diff --git a/Cargo.lock b/Cargo.lock index cf4425b2..02b65371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,26 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "difflib" version = "0.4.0" @@ -1493,7 +1513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0c58a595280bc9d8386a1888aac2264838ce90f29450dfa50d4b9a00cbbcc63" dependencies = [ "ctor", - "derive_more", + "derive_more 0.99.20", "fluent", "gherkin", "hashbrown", @@ -2463,6 +2483,7 @@ name = "weaverd" version = "0.1.0" dependencies = [ "daemonize-me", + "derive_more 1.0.0", "dirs 5.0.1", "nix 0.28.0", "once_cell", diff --git a/crates/weaverd/Cargo.toml b/crates/weaverd/Cargo.toml index 3a1a4a4e..aedcb047 100644 --- a/crates/weaverd/Cargo.toml +++ b/crates/weaverd/Cargo.toml @@ -19,6 +19,7 @@ weaver-config = { path = "../weaver-config", features = ["cli"] } tempfile.workspace = true [dev-dependencies] +derive_more = { version = "1.0", features = ["as_ref", "deref"] } rstest.workspace = true rstest-bdd.workspace = true rstest-bdd-macros.workspace = true diff --git a/crates/weaverd/src/lib.rs b/crates/weaverd/src/lib.rs index a131fad4..93dad9c9 100644 --- a/crates/weaverd/src/lib.rs +++ b/crates/weaverd/src/lib.rs @@ -12,12 +12,30 @@ //! quickly. Semantic fusion backends are started lazily the first time they are //! requested, ensuring the daemon remains lightweight when only a subset of the //! functionality is required. +//! +//! ## Double-Lock Safety Harness +//! +//! All `act` commands pass through the "Double-Lock" safety harness before +//! committing changes to the filesystem. The harness validates proposed edits +//! in two phases: +//! +//! 1. **Syntactic Lock**: Modified files are parsed to ensure they produce +//! valid syntax trees. This catches structural errors before they reach the +//! semantic analysis phase. +//! +//! 2. **Semantic Lock**: Changes are sent to the configured language server, +//! which verifies that no new errors or high-severity warnings are +//! introduced compared to the pre-edit state. +//! +//! Changes that fail either lock are rejected, leaving the filesystem +//! untouched. See the [`safety_harness`] module for details. mod backends; mod bootstrap; mod health; mod placeholder_provider; mod process; +pub mod safety_harness; mod telemetry; pub use backends::{ diff --git a/crates/weaverd/src/safety_harness/edit.rs b/crates/weaverd/src/safety_harness/edit.rs new file mode 100644 index 00000000..6907149d --- /dev/null +++ b/crates/weaverd/src/safety_harness/edit.rs @@ -0,0 +1,218 @@ +//! Types representing file edits and modifications. +//! +//! These types form the input to the Double-Lock safety harness. External tools +//! produce edits that are captured here before being validated and applied. + +use std::path::{Path, PathBuf}; + +/// A position within a text file. +/// +/// Uses zero-based line and column offsets. Column offsets count UTF-8 bytes, +/// matching the convention used by the Language Server Protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Position { + /// Line number (zero-based). + pub line: u32, + /// Column offset (zero-based, UTF-8 bytes). + pub column: u32, +} + +impl Position { + /// Creates a new position. + #[must_use] + pub const fn new(line: u32, column: u32) -> Self { + Self { line, column } + } +} + +/// A range within a text file, defined by start and end positions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + /// Start of the range (inclusive). + pub start: Position, + /// End of the range (exclusive). + pub end: Position, +} + +impl TextRange { + /// Creates a new range from start to end. + #[must_use] + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } + + /// Creates a zero-length range at the given position. + #[must_use] + pub const fn point(position: Position) -> Self { + Self { + start: position, + end: position, + } + } +} + +/// A single text replacement within a file. +/// +/// Range values use zero-based line and column offsets. Column offsets count +/// UTF-8 bytes, matching the convention used by the Language Server Protocol. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextEdit { + /// Range being replaced. + range: TextRange, + /// Replacement text. + new_text: String, +} + +impl TextEdit { + /// Builds a text edit from a range and replacement text. + /// + /// This is the core constructor. All other constructors delegate to this. + #[must_use] + pub fn new(range: TextRange, new_text: impl Into) -> Self { + Self { + range, + new_text: new_text.into(), + } + } + + /// Builds a text edit from Position types and replacement text. + /// + /// This constructor uses the parameter object pattern, accepting [`Position`] + /// objects instead of primitive coordinates. + #[must_use] + pub fn from_positions(start: Position, end: Position, new_text: impl Into) -> Self { + Self::new(TextRange::new(start, end), new_text) + } + + /// Creates an insertion at the specified position. + /// + /// An insertion is a zero-length replacement (start == end) with non-empty text. + #[must_use] + pub fn insert_at(position: Position, new_text: impl Into) -> Self { + Self::new(TextRange::point(position), new_text) + } + + /// Creates a deletion spanning the given range. + /// + /// A deletion is a replacement with empty text. + #[must_use] + pub fn delete_range(start: Position, end: Position) -> Self { + Self::new(TextRange::new(start, end), String::new()) + } + + /// Starting line (zero-based). + #[must_use] + pub const fn start_line(&self) -> u32 { + self.range.start.line + } + + /// Starting column (zero-based, UTF-8 bytes). + #[must_use] + pub const fn start_column(&self) -> u32 { + self.range.start.column + } + + /// Ending line (zero-based). + #[must_use] + pub const fn end_line(&self) -> u32 { + self.range.end.line + } + + /// Ending column (zero-based, UTF-8 bytes). + #[must_use] + pub const fn end_column(&self) -> u32 { + self.range.end.column + } + + /// Replacement text. + #[must_use] + pub fn new_text(&self) -> &str { + &self.new_text + } +} + +/// A collection of edits for a single file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileEdit { + /// Path to the file being edited. + path: PathBuf, + /// Edits to apply, sorted by position. + edits: Vec, +} + +impl FileEdit { + /// Creates a new file edit with no changes. + #[must_use] + pub fn new(path: PathBuf) -> Self { + Self { + path, + edits: Vec::new(), + } + } + + /// Adds a text edit to this file. + pub fn add_edit(&mut self, edit: TextEdit) { + self.edits.push(edit); + } + + /// Builds a file edit from an existing collection of edits. + #[must_use] + pub fn with_edits(path: PathBuf, edits: Vec) -> Self { + Self { path, edits } + } + + /// Path to the file being edited. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Edits to apply. + #[must_use] + pub fn edits(&self) -> &[TextEdit] { + &self.edits + } + + /// Returns true when no edits are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.edits.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_edit_insert_is_zero_length() { + let edit = TextEdit::insert_at(Position::new(5, 10), "hello"); + assert_eq!(edit.start_line(), 5); + assert_eq!(edit.start_column(), 10); + assert_eq!(edit.end_line(), 5); + assert_eq!(edit.end_column(), 10); + assert_eq!(edit.new_text(), "hello"); + } + + #[test] + fn text_edit_delete_has_empty_replacement() { + let edit = TextEdit::delete_range(Position::new(1, 0), Position::new(3, 5)); + assert_eq!(edit.start_line(), 1); + assert_eq!(edit.start_column(), 0); + assert_eq!(edit.end_line(), 3); + assert_eq!(edit.end_column(), 5); + assert!(edit.new_text().is_empty()); + } + + #[test] + fn file_edit_tracks_path_and_edits() { + let path = PathBuf::from("/project/src/main.rs"); + let mut file_edit = FileEdit::new(path.clone()); + assert!(file_edit.is_empty()); + + file_edit.add_edit(TextEdit::insert_at(Position::new(0, 0), "// header\n")); + assert!(!file_edit.is_empty()); + assert_eq!(file_edit.path(), &path); + assert_eq!(file_edit.edits().len(), 1); + } +} diff --git a/crates/weaverd/src/safety_harness/error.rs b/crates/weaverd/src/safety_harness/error.rs new file mode 100644 index 00000000..3ca76bb3 --- /dev/null +++ b/crates/weaverd/src/safety_harness/error.rs @@ -0,0 +1,164 @@ +//! Error types for the Double-Lock safety harness. +//! +//! These errors provide structured information about operational failures. +//! Verification failures (syntactic/semantic lock failures) are returned as +//! `TransactionOutcome` variants, not as errors. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +/// Describes a single problem discovered during verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerificationFailure { + /// Path to the affected file. + file: PathBuf, + /// Optional line number (one-based for display). + line: Option, + /// Optional column number (one-based for display). + column: Option, + /// Human-readable message describing the problem. + message: String, +} + +impl VerificationFailure { + /// Builds a new verification failure. + #[must_use] + pub fn new(file: PathBuf, message: impl Into) -> Self { + Self { + file, + line: None, + column: None, + message: message.into(), + } + } + + /// Attaches a location to this failure. + #[must_use] + pub fn at_location(mut self, line: u32, column: u32) -> Self { + self.line = Some(line); + self.column = Some(column); + self + } + + /// Path to the affected file. + #[must_use] + pub fn file(&self) -> &Path { + &self.file + } + + /// Optional line number (one-based for display). + #[must_use] + pub fn line(&self) -> Option { + self.line + } + + /// Optional column number (one-based for display). + #[must_use] + pub fn column(&self) -> Option { + self.column + } + + /// Human-readable message describing the problem. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for VerificationFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.file.display())?; + if let Some(line) = self.line { + write!(f, ":{line}")?; + if let Some(col) = self.column { + write!(f, ":{col}")?; + } + } + write!(f, ": {}", self.message) + } +} + +/// Errors surfaced by the Double-Lock safety harness. +/// +/// Note: Verification failures (syntactic/semantic lock failures) are returned +/// as `TransactionOutcome` variants, not as errors. This enum only covers +/// unexpected operational errors that prevent the transaction from completing. +#[derive(Debug, Error)] +pub enum SafetyHarnessError { + /// An I/O error occurred while reading original file content. + #[error("failed to read file {path}: {message}")] + FileReadError { + /// Path to the file that could not be read. + path: PathBuf, + /// Description of the I/O error. + message: String, + }, + + /// An I/O error occurred while writing the final output. + #[error("failed to write file {path}: {message}")] + FileWriteError { + /// Path to the file that could not be written. + path: PathBuf, + /// Description of the I/O error. + message: String, + }, + + /// Failed to apply edits to the in-memory buffer. + #[error("edit application failed for {path}: {message}")] + EditApplicationError { + /// Path to the affected file. + path: PathBuf, + /// Description of what went wrong. + message: String, + }, + + /// The semantic backend was unavailable. + #[error("semantic backend unavailable: {message}")] + SemanticBackendUnavailable { + /// Description of why the backend is unavailable. + message: String, + }, + + /// The syntactic backend was unavailable. + #[error("syntactic backend unavailable: {message}")] + SyntacticBackendUnavailable { + /// Description of why the backend is unavailable. + message: String, + }, +} + +impl SafetyHarnessError { + /// Creates a file read error. + pub fn file_read(path: PathBuf, error: std::io::Error) -> Self { + Self::FileReadError { + path, + message: error.to_string(), + } + } + + /// Creates a file write error. + pub fn file_write(path: PathBuf, error: std::io::Error) -> Self { + Self::FileWriteError { + path, + message: error.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verification_failure_displays_location() { + let failure = VerificationFailure::new(PathBuf::from("/src/main.rs"), "unexpected token") + .at_location(42, 17); + + let display = format!("{failure}"); + assert!(display.contains("/src/main.rs")); + assert!(display.contains("42")); + assert!(display.contains("17")); + assert!(display.contains("unexpected token")); + } +} diff --git a/crates/weaverd/src/safety_harness/locks.rs b/crates/weaverd/src/safety_harness/locks.rs new file mode 100644 index 00000000..46da7585 --- /dev/null +++ b/crates/weaverd/src/safety_harness/locks.rs @@ -0,0 +1,107 @@ +//! Lock result types for the two phases of verification. +//! +//! Each lock phase produces a result indicating success or describing the +//! specific failures encountered during validation. + +use super::error::VerificationFailure; + +/// Result from the syntactic lock phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntacticLockResult { + /// All modified files produced valid syntax trees. + Passed, + /// One or more files contain syntax errors. + Failed { + /// Details about each syntax error. + failures: Vec, + }, +} + +impl SyntacticLockResult { + /// Returns true when the syntactic lock passed. + #[must_use] + pub const fn passed(&self) -> bool { + matches!(self, Self::Passed) + } + + /// Returns the failures, if any. + #[must_use] + pub fn failures(&self) -> Option<&[VerificationFailure]> { + match self { + Self::Passed => None, + Self::Failed { failures } => Some(failures), + } + } +} + +/// Result from the semantic lock phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SemanticLockResult { + /// No new errors were introduced. + Passed, + /// New errors or high-severity warnings appeared after the changes. + Failed { + /// Details about each new diagnostic. + failures: Vec, + }, +} + +impl SemanticLockResult { + /// Returns true when the semantic lock passed. + #[must_use] + pub const fn passed(&self) -> bool { + matches!(self, Self::Passed) + } + + /// Returns the failures, if any. + #[must_use] + pub fn failures(&self) -> Option<&[VerificationFailure]> { + match self { + Self::Passed => None, + Self::Failed { failures } => Some(failures), + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn syntactic_passed_has_no_failures() { + let result = SyntacticLockResult::Passed; + assert!(result.passed()); + assert!(result.failures().is_none()); + } + + #[test] + fn syntactic_failed_contains_failures() { + let failures = vec![VerificationFailure::new( + PathBuf::from("a.rs"), + "parse error", + )]; + let result = SyntacticLockResult::Failed { failures }; + assert!(!result.passed()); + assert_eq!(result.failures().map(|f| f.len()), Some(1)); + } + + #[test] + fn semantic_passed_has_no_failures() { + let result = SemanticLockResult::Passed; + assert!(result.passed()); + assert!(result.failures().is_none()); + } + + #[test] + fn semantic_failed_contains_failures() { + let failures = vec![VerificationFailure::new( + PathBuf::from("b.rs"), + "type error", + )]; + let result = SemanticLockResult::Failed { failures }; + assert!(!result.passed()); + assert_eq!(result.failures().map(|f| f.len()), Some(1)); + } +} diff --git a/crates/weaverd/src/safety_harness/mod.rs b/crates/weaverd/src/safety_harness/mod.rs new file mode 100644 index 00000000..5558737b --- /dev/null +++ b/crates/weaverd/src/safety_harness/mod.rs @@ -0,0 +1,42 @@ +//! Double-Lock safety harness for safe code modifications. +//! +//! The safety harness wraps every actuation command in a verifiable transaction. +//! All proposed changes are validated against a two-phase verification process +//! before being committed to the filesystem: +//! +//! 1. **Syntactic Lock**: Modified files are parsed to ensure they produce valid +//! syntax trees. This catches structural errors such as unbalanced braces or +//! broken statements. +//! +//! 2. **Semantic Lock**: Changes are submitted to the configured LSP server, +//! which verifies that no new errors or high-severity warnings are introduced +//! compared to the pre-edit state. +//! +//! Changes that fail either lock are rejected, leaving the filesystem untouched +//! and returning a structured error describing the failure. +//! +//! The harness operates in-memory by applying proposed diffs to virtual file +//! buffers. Only when both locks pass are the changes atomically committed to +//! the real filesystem. +//! +//! # Design +//! +//! The harness follows the broker process pattern described in the design +//! document. Plugin outputs are captured as diffs (or text edits), validated +//! through both locks, and only then written to disk. This zero-trust approach +//! treats all external tool output as potentially problematic until proven safe. + +mod edit; +mod error; +mod locks; +mod transaction; +mod verification; + +pub use edit::{FileEdit, Position, TextEdit, TextRange}; +pub use error::{SafetyHarnessError, VerificationFailure}; +pub use locks::{SemanticLockResult, SyntacticLockResult}; +pub use transaction::{EditTransaction, TransactionOutcome}; +pub use verification::{ + ConfigurableSemanticLock, ConfigurableSyntacticLock, PlaceholderSemanticLock, + PlaceholderSyntacticLock, SemanticLock, SyntacticLock, VerificationContext, +}; diff --git a/crates/weaverd/src/safety_harness/transaction.rs b/crates/weaverd/src/safety_harness/transaction.rs new file mode 100644 index 00000000..69533dbd --- /dev/null +++ b/crates/weaverd/src/safety_harness/transaction.rs @@ -0,0 +1,250 @@ +//! Edit transaction management for the Double-Lock safety harness. +//! +//! An edit transaction collects proposed file edits, applies them to in-memory +//! buffers, validates through both syntactic and semantic locks, and commits +//! only when both checks pass. The commit phase uses two-phase commit with +//! rollback to ensure multi-file atomicity: either all files are updated or +//! none are (barring catastrophic failures during rollback itself). + +#[cfg(test)] +mod tests; + +use std::fs; +use std::io::Write as IoWrite; +use std::path::PathBuf; + +use super::edit::FileEdit; +use super::error::{SafetyHarnessError, VerificationFailure}; +use super::locks::{SemanticLockResult, SyntacticLockResult}; +use super::verification::{SemanticLock, SyntacticLock, VerificationContext, apply_edits}; + +/// Outcome of an edit transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransactionOutcome { + /// All checks passed and changes were committed. + Committed { + /// Number of files modified. + files_modified: usize, + }, + /// Syntactic lock failed; no changes were made. + SyntacticLockFailed { + /// Details about the syntax errors. + failures: Vec, + }, + /// Semantic lock failed; no changes were made. + SemanticLockFailed { + /// Details about the new diagnostics. + failures: Vec, + }, + /// No edits were provided. + NoChanges, +} + +impl TransactionOutcome { + /// Returns true when the transaction committed successfully. + #[must_use] + pub const fn committed(&self) -> bool { + matches!(self, Self::Committed { .. }) + } + + /// Returns the number of files modified, if the transaction committed. + #[must_use] + pub const fn files_modified(&self) -> Option { + match self { + Self::Committed { files_modified } => Some(*files_modified), + _ => None, + } + } +} + +/// Builder for coordinating the Double-Lock verification process. +pub struct EditTransaction<'a> { + file_edits: Vec, + syntactic_lock: &'a dyn SyntacticLock, + semantic_lock: &'a dyn SemanticLock, +} + +impl<'a> EditTransaction<'a> { + /// Creates a new transaction with the specified locks. + #[must_use] + pub fn new(syntactic_lock: &'a dyn SyntacticLock, semantic_lock: &'a dyn SemanticLock) -> Self { + Self { + file_edits: Vec::new(), + syntactic_lock, + semantic_lock, + } + } + + /// Adds a file edit to the transaction. + pub fn add_edit(&mut self, edit: FileEdit) { + if !edit.is_empty() { + self.file_edits.push(edit); + } + } + + /// Adds multiple file edits to the transaction. + pub fn add_edits(&mut self, edits: impl IntoIterator) { + for edit in edits { + self.add_edit(edit); + } + } + + /// Executes the transaction, validating and committing if successful. + /// + /// # Process + /// + /// 1. Reads original content for all affected files. + /// 2. Applies edits in memory to produce modified content. + /// 3. Runs the syntactic lock on modified content. + /// 4. Runs the semantic lock on modified content. + /// 5. Writes modified content to disk if both locks pass. + /// + /// # Errors + /// + /// Returns an error when: + /// - A file cannot be read or written. + /// - Edits cannot be applied to the in-memory buffer. + /// - The semantic backend is unavailable. + pub fn execute(self) -> Result { + if self.file_edits.is_empty() { + return Ok(TransactionOutcome::NoChanges); + } + + // Step 1: Build verification context with original and modified content + let mut context = VerificationContext::new(); + let mut paths_to_write: Vec = Vec::new(); + + for file_edit in &self.file_edits { + let path = file_edit.path(); + let original = read_file(path)?; + + // Step 2: Apply edits to produce modified content + let modified = apply_edits(&original, file_edit)?; + + context.add_original(path.to_path_buf(), original); + context.add_modified(path.to_path_buf(), modified); + paths_to_write.push(path.to_path_buf()); + } + + // Step 3: Syntactic lock + let syntactic_result = self.syntactic_lock.validate(&context); + if let SyntacticLockResult::Failed { failures } = syntactic_result { + return Ok(TransactionOutcome::SyntacticLockFailed { failures }); + } + + // Step 4: Semantic lock + let semantic_result = self.semantic_lock.validate(&context)?; + if let SemanticLockResult::Failed { failures } = semantic_result { + return Ok(TransactionOutcome::SemanticLockFailed { failures }); + } + + // Step 5: Commit changes atomically + commit_changes(&context, &paths_to_write)?; + + Ok(TransactionOutcome::Committed { + files_modified: paths_to_write.len(), + }) + } +} + +/// Reads file content or creates an empty file entry for new files. +fn read_file(path: &std::path::Path) -> Result { + match fs::read_to_string(path) { + Ok(content) => Ok(content), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // New file creation: start with empty content + Ok(String::new()) + } + Err(err) => Err(SafetyHarnessError::file_read(path.to_path_buf(), err)), + } +} + +/// Writes all modified content to the filesystem using two-phase commit. +/// +/// # Atomicity Guarantee +/// +/// Phase 1 (prepare): All modified content is written to temporary files. +/// Phase 2 (commit): Temporary files are atomically renamed to targets. +/// +/// If any rename fails, all previously renamed files are rolled back to +/// their original content. This provides multi-file transaction semantics. +/// +/// # Rollback Limitations +/// +/// Rollback is best-effort: if a catastrophic failure occurs during rollback +/// (e.g., disk removed), some files may remain in an inconsistent state. +fn commit_changes( + context: &VerificationContext, + paths: &[PathBuf], +) -> Result<(), SafetyHarnessError> { + // Phase 1: Prepare all files (write to temps) + let mut prepared: Vec<(PathBuf, tempfile::NamedTempFile, String, bool)> = Vec::new(); + + for path in paths { + let content = context + .modified(path) + .ok_or_else(|| SafetyHarnessError::FileWriteError { + path: path.clone(), + message: "modified content missing from context".to_string(), + })?; + + let original = context.original(path).cloned().unwrap_or_default(); + let existed = path.exists(); + let temp_file = prepare_file(path, content)?; + prepared.push((path.clone(), temp_file, original, existed)); + } + + // Phase 2: Commit all files (atomic renames) + let mut committed: Vec<(PathBuf, String, bool)> = Vec::new(); + + for (path, temp_file, original, existed) in prepared { + if let Err(err) = temp_file.persist(&path) { + rollback(&committed); + return Err(SafetyHarnessError::file_write(path, err.error)); + } + committed.push((path, original, existed)); + } + + Ok(()) +} + +/// Prepares a file for commit by writing content to a temporary file. +/// +/// The temp file is created in the same directory as the target to ensure +/// atomic rename is possible (same filesystem). Parent directories are +/// created if they don't exist. +fn prepare_file( + path: &std::path::Path, + content: &str, +) -> Result { + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + + // Create parent directories if they don't exist (for nested new files) + fs::create_dir_all(parent) + .map_err(|err| SafetyHarnessError::file_write(path.to_path_buf(), err))?; + + let mut temp_file = tempfile::NamedTempFile::new_in(parent) + .map_err(|err| SafetyHarnessError::file_write(path.to_path_buf(), err))?; + + temp_file + .write_all(content.as_bytes()) + .map_err(|err| SafetyHarnessError::file_write(path.to_path_buf(), err))?; + + Ok(temp_file) +} + +/// Rolls back committed files to their original content. +/// +/// This is a best-effort operation: if restoration fails for any file, +/// we continue attempting to restore the remaining files. +fn rollback(committed: &[(PathBuf, String, bool)]) { + for (path, original, existed) in committed { + if !existed { + // File was newly created, remove it + let _ = std::fs::remove_file(path); + } else { + // Restore original content (best effort) + let _ = std::fs::write(path, original); + } + } +} diff --git a/crates/weaverd/src/safety_harness/transaction/tests.rs b/crates/weaverd/src/safety_harness/transaction/tests.rs new file mode 100644 index 00000000..7efbb016 --- /dev/null +++ b/crates/weaverd/src/safety_harness/transaction/tests.rs @@ -0,0 +1,309 @@ +//! Tests for edit transaction management. + +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use rstest::rstest; +use tempfile::TempDir; + +use super::{EditTransaction, SafetyHarnessError, TransactionOutcome}; +use crate::safety_harness::edit::{FileEdit, Position, TextEdit}; +use crate::safety_harness::error::VerificationFailure; +use crate::safety_harness::verification::{ + ConfigurableSemanticLock, ConfigurableSyntacticLock, SemanticLock, SyntacticLock, +}; + +fn temp_file(dir: &TempDir, name: &str, content: &str) -> PathBuf { + let path = dir.path().join(name); + let mut file = fs::File::create(&path).expect("create temp file"); + file.write_all(content.as_bytes()).expect("write temp file"); + path +} + +/// Creates a standard failure scenario builder with a test file and replacement edit. +fn failure_scenario_builder() -> TransactionTestBuilder { + TransactionTestBuilder::new() + .with_file("test.txt", "hello world") + .with_replacement_edit(0, LineReplacement::from_start(5, "greetings")) +} + +/// Asserts that the file at the given path contains "hello world". +fn assert_file_unchanged(path: &PathBuf) { + let content = fs::read_to_string(path).expect("read file"); + assert_eq!(content, "hello world"); +} + +/// Tests lock failure scenarios, eliminating duplication between syntactic and semantic tests. +/// +/// The `configure_locks` closure receives the file path and returns configured locks. +/// The `verify_outcome` closure performs test-specific assertions on the outcome. +fn test_lock_failure(configure_locks: F, verify_outcome: V) +where + F: FnOnce(PathBuf) -> (ConfigurableSyntacticLock, ConfigurableSemanticLock), + V: FnOnce(&TransactionOutcome), +{ + let builder = failure_scenario_builder(); + let path = builder.file_path(0).clone(); + let (syntactic, semantic) = configure_locks(path.clone()); + + let (result, _, _dir) = builder.execute_with_locks(&syntactic, &semantic); + let outcome = result.expect("should succeed"); + + verify_outcome(&outcome); + assert_file_unchanged(&path); +} + +/// Parameter object for line replacement edits. +/// +/// Encapsulates column range and replacement text for a single-line edit, +/// reducing argument count in builder methods. +#[derive(Debug, Clone)] +struct LineReplacement { + start_col: u32, + end_col: u32, + text: String, +} + +impl LineReplacement { + /// Creates a new line replacement with explicit column range. + fn new(start_col: u32, end_col: u32, text: impl Into) -> Self { + Self { + start_col, + end_col, + text: text.into(), + } + } + + /// Creates a replacement starting from column 0. + fn from_start(end_col: u32, text: impl Into) -> Self { + Self::new(0, end_col, text) + } +} + +/// Builder for constructing test transactions with reduced boilerplate. +struct TransactionTestBuilder { + dir: TempDir, + files: Vec<(PathBuf, String)>, + edits: Vec, +} + +impl TransactionTestBuilder { + /// Creates a new builder with a fresh temporary directory. + fn new() -> Self { + Self { + dir: TempDir::new().expect("create temp dir"), + files: Vec::new(), + edits: Vec::new(), + } + } + + /// Creates a file with the given content and adds it to the tracked files. + fn with_file(mut self, name: &str, content: &str) -> Self { + let path = temp_file(&self.dir, name, content); + self.files.push((path, content.to_string())); + self + } + + /// Adds a non-existent file path to the tracked files (for new file creation tests). + fn with_new_file_path(mut self, name: &str) -> Self { + let path = self.dir.path().join(name); + self.files.push((path, String::new())); + self + } + + /// Adds a replacement edit for the file at the given index. + fn with_replacement_edit(mut self, file_idx: usize, replacement: LineReplacement) -> Self { + let path = self.files[file_idx].0.clone(); + let edit = FileEdit::with_edits( + path, + vec![TextEdit::from_positions( + Position::new(0, replacement.start_col), + Position::new(0, replacement.end_col), + replacement.text, + )], + ); + self.edits.push(edit); + self + } + + /// Adds an insert edit for the file at the given index. + fn with_insert_edit(mut self, file_idx: usize, text: &str) -> Self { + let path = self.files[file_idx].0.clone(); + let edit = FileEdit::with_edits(path, vec![TextEdit::insert_at(Position::new(0, 0), text)]); + self.edits.push(edit); + self + } + + /// Returns a reference to a file path by index. + fn file_path(&self, idx: usize) -> &PathBuf { + &self.files[idx].0 + } + + /// Executes the transaction with the given locks and returns the outcome. + /// + /// The builder is consumed but the TempDir is returned to keep the files alive. + /// The TempDir is always returned, even on error. + fn execute_with_locks( + self, + syntactic: &dyn SyntacticLock, + semantic: &dyn SemanticLock, + ) -> ( + Result, + Vec, + TempDir, + ) { + let paths: Vec = self.files.iter().map(|(p, _)| p.clone()).collect(); + let mut transaction = EditTransaction::new(syntactic, semantic); + for edit in self.edits { + transaction.add_edit(edit); + } + let outcome = transaction.execute(); + (outcome, paths, self.dir) + } +} + +#[test] +fn empty_transaction_returns_no_changes() { + let syntactic = ConfigurableSyntacticLock::passing(); + let semantic = ConfigurableSemanticLock::passing(); + let transaction = EditTransaction::new(&syntactic, &semantic); + + let outcome = transaction.execute().expect("should succeed"); + assert!(matches!(outcome, TransactionOutcome::NoChanges)); +} + +#[test] +fn successful_transaction_commits_changes() { + let builder = TransactionTestBuilder::new() + .with_file("test.txt", "hello world") + .with_replacement_edit(0, LineReplacement::from_start(5, "greetings")); + + let syntactic = ConfigurableSyntacticLock::passing(); + let semantic = ConfigurableSemanticLock::passing(); + + let (result, paths, _dir) = builder.execute_with_locks(&syntactic, &semantic); + let outcome = result.expect("should succeed"); + + assert!(outcome.committed()); + assert_eq!(outcome.files_modified(), Some(1)); + + let content = fs::read_to_string(&paths[0]).expect("read file"); + assert_eq!(content, "greetings world"); +} + +/// Lock failure type for parameterised testing. +#[derive(Debug, Clone, Copy)] +enum LockFailureKind { + Syntactic, + Semantic, +} + +#[rstest] +#[case::syntactic(LockFailureKind::Syntactic)] +#[case::semantic(LockFailureKind::Semantic)] +fn lock_failure_prevents_commit(#[case] kind: LockFailureKind) { + let configure_locks = |path: PathBuf| -> (ConfigurableSyntacticLock, ConfigurableSemanticLock) { + let failures = vec![VerificationFailure::new(path, "test error")]; + match kind { + LockFailureKind::Syntactic => ( + ConfigurableSyntacticLock::failing(failures), + ConfigurableSemanticLock::passing(), + ), + LockFailureKind::Semantic => ( + ConfigurableSyntacticLock::passing(), + ConfigurableSemanticLock::failing(failures), + ), + } + }; + + let verify_outcome = |outcome: &TransactionOutcome| match kind { + LockFailureKind::Syntactic => { + assert!(matches!( + outcome, + TransactionOutcome::SyntacticLockFailed { .. } + )); + } + LockFailureKind::Semantic => { + assert!(matches!( + outcome, + TransactionOutcome::SemanticLockFailed { .. } + )); + } + }; + + test_lock_failure(configure_locks, verify_outcome); +} + +#[test] +fn semantic_backend_error_propagates() { + let builder = failure_scenario_builder(); + let path = builder.file_path(0).clone(); + let syntactic = ConfigurableSyntacticLock::passing(); + let semantic = ConfigurableSemanticLock::unavailable("LSP crashed"); + + let (result, _, _dir) = builder.execute_with_locks(&syntactic, &semantic); + assert!(result.is_err()); + assert!(matches!( + result.expect_err("should propagate backend error"), + SafetyHarnessError::SemanticBackendUnavailable { .. } + )); + assert_file_unchanged(&path); +} + +#[test] +fn handles_new_file_creation() { + let builder = TransactionTestBuilder::new() + .with_new_file_path("new_file.txt") + .with_insert_edit(0, "new content"); + + // Path doesn't exist yet + assert!(!builder.file_path(0).exists()); + + let syntactic = ConfigurableSyntacticLock::passing(); + let semantic = ConfigurableSemanticLock::passing(); + + let (result, paths, _dir) = builder.execute_with_locks(&syntactic, &semantic); + let outcome = result.expect("should succeed"); + + assert!(outcome.committed()); + + let content = fs::read_to_string(&paths[0]).expect("read file"); + assert_eq!(content, "new content"); +} + +#[test] +fn handles_multiple_files() { + let dir = TempDir::new().expect("create temp dir"); + let path1 = temp_file(&dir, "file1.txt", "aaa"); + let path2 = temp_file(&dir, "file2.txt", "bbb"); + + let edit1 = FileEdit::with_edits( + path1.clone(), + vec![TextEdit::from_positions( + Position::new(0, 0), + Position::new(0, 3), + "AAA".to_string(), + )], + ); + let edit2 = FileEdit::with_edits( + path2.clone(), + vec![TextEdit::from_positions( + Position::new(0, 0), + Position::new(0, 3), + "BBB".to_string(), + )], + ); + + let syntactic = ConfigurableSyntacticLock::passing(); + let semantic = ConfigurableSemanticLock::passing(); + + let mut transaction = EditTransaction::new(&syntactic, &semantic); + transaction.add_edits([edit1, edit2]); + + let outcome = transaction.execute().expect("should succeed"); + assert_eq!(outcome.files_modified(), Some(2)); + + assert_eq!(fs::read_to_string(&path1).expect("read"), "AAA"); + assert_eq!(fs::read_to_string(&path2).expect("read"), "BBB"); +} diff --git a/crates/weaverd/src/safety_harness/verification.rs b/crates/weaverd/src/safety_harness/verification.rs new file mode 100644 index 00000000..4eb1a559 --- /dev/null +++ b/crates/weaverd/src/safety_harness/verification.rs @@ -0,0 +1,184 @@ +//! Verification implementations for syntactic and semantic locks. +//! +//! This module provides the trait definitions for lock implementations and +//! the context in which verification occurs. Concrete implementations are +//! injected via the trait system to enable testing and pluggable backends. + +mod apply; +mod test_doubles; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub use apply::apply_edits; +pub use test_doubles::{ConfigurableSemanticLock, ConfigurableSyntacticLock}; + +use super::error::SafetyHarnessError; +use super::locks::{SemanticLockResult, SyntacticLockResult}; + +/// Context for verification operations. +/// +/// Holds the in-memory state of modified files and provides access to both +/// original and modified content for comparison during the semantic lock phase. +#[derive(Debug, Clone)] +pub struct VerificationContext { + /// Original file contents keyed by path. + original_content: HashMap, + /// Modified file contents keyed by path. + modified_content: HashMap, +} + +impl VerificationContext { + /// Creates a new empty verification context. + #[must_use] + pub fn new() -> Self { + Self { + original_content: HashMap::new(), + modified_content: HashMap::new(), + } + } + + /// Adds original file content to the context. + pub fn add_original(&mut self, path: PathBuf, content: String) { + self.original_content.insert(path, content); + } + + /// Adds modified file content to the context. + pub fn add_modified(&mut self, path: PathBuf, content: String) { + self.modified_content.insert(path, content); + } + + /// Returns the original content for a path. + #[must_use] + pub fn original(&self, path: &Path) -> Option<&String> { + self.original_content.get(path) + } + + /// Returns the modified content for a path. + #[must_use] + pub fn modified(&self, path: &Path) -> Option<&String> { + self.modified_content.get(path) + } + + /// Returns all paths with modified content. + pub fn modified_paths(&self) -> impl Iterator { + self.modified_content.keys().map(PathBuf::as_path) + } + + /// Returns all modified content as path-content pairs. + pub fn modified_files(&self) -> impl Iterator { + self.modified_content.iter().map(|(p, c)| (p.as_path(), c)) + } + + /// Returns the number of files in the modified set. + #[must_use] + pub fn modified_count(&self) -> usize { + self.modified_content.len() + } + + /// Returns true when no files are in the modified set. + #[must_use] + pub fn is_empty(&self) -> bool { + self.modified_content.is_empty() + } +} + +impl Default for VerificationContext { + fn default() -> Self { + Self::new() + } +} + +/// Trait for syntactic validation implementations. +/// +/// Implementors parse the modified files and report any syntax errors. The +/// default implementation passes all files (placeholder for future Tree-sitter +/// integration). +pub trait SyntacticLock: Send + Sync { + /// Validates that all modified files produce valid syntax trees. + fn validate(&self, context: &VerificationContext) -> SyntacticLockResult; +} + +/// Trait for semantic validation implementations. +/// +/// Implementors compare diagnostics before and after the proposed changes, +/// reporting any new errors or high-severity warnings. +pub trait SemanticLock: Send + Sync { + /// Validates that no new errors were introduced by the changes. + fn validate( + &self, + context: &VerificationContext, + ) -> Result; +} + +/// Placeholder syntactic lock that always passes. +/// +/// This implementation is used until the `weaver-syntax` crate provides +/// Tree-sitter integration. It serves as a no-op pass-through for testing +/// the overall harness flow. +#[derive(Debug, Default, Clone, Copy)] +pub struct PlaceholderSyntacticLock; + +impl SyntacticLock for PlaceholderSyntacticLock { + fn validate(&self, _context: &VerificationContext) -> SyntacticLockResult { + // Placeholder: always pass until Tree-sitter is integrated. + SyntacticLockResult::Passed + } +} + +/// Placeholder semantic lock that always passes. +/// +/// This implementation is used until the full LSP integration is complete. +/// It serves as a no-op pass-through for testing the overall harness flow. +#[derive(Debug, Default, Clone, Copy)] +pub struct PlaceholderSemanticLock; + +impl SemanticLock for PlaceholderSemanticLock { + fn validate( + &self, + _context: &VerificationContext, + ) -> Result { + // Placeholder: always pass until LSP diagnostic comparison is integrated. + Ok(SemanticLockResult::Passed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verification_context_tracks_content() { + let mut ctx = VerificationContext::new(); + let path = PathBuf::from("/test.rs"); + + ctx.add_original(path.clone(), "fn main() {}".to_string()); + ctx.add_modified(path.clone(), "fn main() { todo!() }".to_string()); + + assert_eq!( + ctx.original(&path).map(String::as_str), + Some("fn main() {}") + ); + assert_eq!( + ctx.modified(&path).map(String::as_str), + Some("fn main() { todo!() }") + ); + assert_eq!(ctx.modified_count(), 1); + } + + #[test] + fn placeholder_syntactic_lock_always_passes() { + let lock = PlaceholderSyntacticLock; + let ctx = VerificationContext::new(); + let result = lock.validate(&ctx); + assert!(result.passed()); + } + + #[test] + fn placeholder_semantic_lock_always_passes() { + let lock = PlaceholderSemanticLock; + let ctx = VerificationContext::new(); + let result = lock.validate(&ctx).expect("should not error"); + assert!(result.passed()); + } +} diff --git a/crates/weaverd/src/safety_harness/verification/apply.rs b/crates/weaverd/src/safety_harness/verification/apply.rs new file mode 100644 index 00000000..c0ae0f50 --- /dev/null +++ b/crates/weaverd/src/safety_harness/verification/apply.rs @@ -0,0 +1,193 @@ +//! Text edit application utilities. +//! +//! This module provides functions to apply text edits to file content, handling +//! both LF and CRLF line endings correctly. + +use std::path::Path; + +use crate::safety_harness::edit::FileEdit; +use crate::safety_harness::error::SafetyHarnessError; + +/// Applies text edits to the original content to produce modified content. +/// +/// Edits are applied in reverse order (from end of file to start) to avoid +/// invalidating positions as text is inserted or deleted. +/// +/// Handles both LF (`\n`) and CRLF (`\r\n`) line endings correctly by computing +/// byte offsets from the original content rather than assuming fixed newline lengths. +pub fn apply_edits(original: &str, file_edit: &FileEdit) -> Result { + let line_starts = compute_line_start_offsets(original); + let mut result = original.to_string(); + + // Sort edits in reverse order by position to avoid offset shifts + let mut edits: Vec<_> = file_edit.edits().iter().collect(); + edits.sort_by(|a, b| { + b.start_line() + .cmp(&a.start_line()) + .then_with(|| b.start_column().cmp(&a.start_column())) + }); + + for edit in edits { + let start_offset = line_column_to_offset( + &line_starts, + original, + edit.start_line(), + edit.start_column(), + ) + .ok_or_else(|| edit_error(file_edit.path(), edit.start_line(), edit.start_column()))?; + + let end_offset = + line_column_to_offset(&line_starts, original, edit.end_line(), edit.end_column()) + .ok_or_else(|| edit_error(file_edit.path(), edit.end_line(), edit.end_column()))?; + + result.replace_range(start_offset..end_offset, edit.new_text()); + } + + Ok(result) +} + +/// Creates an edit application error for an invalid position. +fn edit_error(path: &Path, line: u32, column: u32) -> SafetyHarnessError { + SafetyHarnessError::EditApplicationError { + path: path.to_path_buf(), + message: format!("invalid position: line {line}, column {column}"), + } +} + +/// Computes the byte offset of each line start in the original content. +/// +/// Handles both LF (`\n`) and CRLF (`\r\n`) line endings by scanning for actual +/// newline positions rather than assuming fixed lengths. +fn compute_line_start_offsets(content: &str) -> Vec { + let mut offsets = vec![0]; // Line 0 starts at byte 0 + for (idx, byte) in content.bytes().enumerate() { + if byte == b'\n' { + offsets.push(idx + 1); // Next line starts after the newline + } + } + offsets +} + +/// Returns the content length of a line, excluding trailing newline characters. +fn line_content_length(content: &str, line_start: usize, line_end: usize) -> usize { + let adjusted_end = if line_end > 0 && content.as_bytes().get(line_end - 1) == Some(&b'\n') { + if line_end > 1 && content.as_bytes().get(line_end - 2) == Some(&b'\r') { + line_end - 2 // CRLF + } else { + line_end - 1 // LF + } + } else { + line_end // Last line without trailing newline + }; + adjusted_end.saturating_sub(line_start) +} + +/// Converts a line and column pair to a byte offset in the original text. +/// +/// Uses pre-computed line start offsets for correct handling of any newline style. +fn line_column_to_offset( + line_starts: &[usize], + content: &str, + line: u32, + column: u32, +) -> Option { + let line_idx = line as usize; + let col_offset = column as usize; + + // Get the byte offset where this line starts + let line_start = *line_starts.get(line_idx)?; + + // Calculate line length to validate column + let line_end = line_starts + .get(line_idx + 1) + .copied() + .unwrap_or(content.len()); + + let line_len = line_content_length(content, line_start, line_end); + + // Allow column to be at most line_len (for end-of-line positions) + if col_offset > line_len { + return None; + } + + line_start.checked_add(col_offset) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use rstest::rstest; + + use super::*; + use crate::safety_harness::edit::{FileEdit, Position, TextEdit}; + + /// Helper for testing successful edit application scenarios. + fn assert_edits_produce(original: &str, edits: Vec, expected: &str) { + let path = PathBuf::from("test.txt"); + let edit = FileEdit::with_edits(path, edits); + let result = apply_edits(original, &edit).expect("edit should succeed"); + assert_eq!(result, expected); + } + + #[rstest] + #[case::insert( + "hello world", + vec![TextEdit::insert_at(Position::new(0, 6), "beautiful ")], + "hello beautiful world" + )] + #[case::delete( + "hello beautiful world", + vec![TextEdit::delete_range(Position::new(0, 6), Position::new(0, 16))], + "hello world" + )] + #[case::replace( + "fn foo() {}", + vec![TextEdit::from_positions(Position::new(0, 3), Position::new(0, 6), "bar".to_string())], + "fn bar() {}" + )] + #[case::multiple_edits( + "aaa bbb ccc", + vec![ + TextEdit::from_positions(Position::new(0, 0), Position::new(0, 3), "AAA".to_string()), + TextEdit::from_positions(Position::new(0, 8), Position::new(0, 11), "CCC".to_string()), + ], + "AAA bbb CCC" + )] + #[case::crlf_line_endings( + "line one\r\nline two\r\nline three", + vec![TextEdit::from_positions(Position::new(1, 5), Position::new(1, 8), "TWO".to_string())], + "line one\r\nline TWO\r\nline three" + )] + #[case::lf_multiline( + "first\nsecond\nthird", + vec![TextEdit::from_positions(Position::new(1, 0), Position::new(1, 6), "SECOND".to_string())], + "first\nSECOND\nthird" + )] + #[case::end_of_file_position( + "hello", + vec![TextEdit::insert_at(Position::new(0, 5), " world")], + "hello world" + )] + fn apply_edits_succeeds( + #[case] original: &str, + #[case] edits: Vec, + #[case] expected: &str, + ) { + assert_edits_produce(original, edits, expected); + } + + #[test] + fn apply_edits_rejects_past_eof_line() { + let original = "hello"; + let path = PathBuf::from("test.txt"); + + // Try to insert at line 1 (past EOF on a file with no trailing newline) + let edit = FileEdit::with_edits( + path, + vec![TextEdit::insert_at(Position::new(1, 0), "world")], + ); + let result = apply_edits(original, &edit); + assert!(result.is_err(), "should reject past-EOF line"); + } +} diff --git a/crates/weaverd/src/safety_harness/verification/test_doubles.rs b/crates/weaverd/src/safety_harness/verification/test_doubles.rs new file mode 100644 index 00000000..9a27a57e --- /dev/null +++ b/crates/weaverd/src/safety_harness/verification/test_doubles.rs @@ -0,0 +1,129 @@ +//! Test double implementations for verification locks. +//! +//! These configurable lock types exist for tests and behavioural specs, +//! allowing test scenarios to specify exact pass/fail behaviour. + +use crate::safety_harness::error::{SafetyHarnessError, VerificationFailure}; +use crate::safety_harness::locks::{SemanticLockResult, SyntacticLockResult}; + +use super::{SemanticLock, SyntacticLock, VerificationContext}; + +/// Configurable syntactic lock for testing purposes. +/// +/// Allows test scenarios to specify exact pass/fail behaviour. +#[derive(Debug, Default, Clone)] +pub struct ConfigurableSyntacticLock { + failures: Vec, +} + +impl ConfigurableSyntacticLock { + /// Creates a lock that always passes. + #[must_use] + pub fn passing() -> Self { + Self { failures: vec![] } + } + + /// Creates a lock that fails with the specified failures. + #[must_use] + pub fn failing(failures: Vec) -> Self { + Self { failures } + } +} + +impl SyntacticLock for ConfigurableSyntacticLock { + fn validate(&self, _context: &VerificationContext) -> SyntacticLockResult { + if self.failures.is_empty() { + SyntacticLockResult::Passed + } else { + SyntacticLockResult::Failed { + failures: self.failures.clone(), + } + } + } +} + +/// Configurable semantic lock for testing purposes. +/// +/// Allows test scenarios to specify exact pass/fail behaviour. +#[derive(Debug, Default, Clone)] +pub struct ConfigurableSemanticLock { + failures: Vec, + error: Option, +} + +impl ConfigurableSemanticLock { + /// Creates a lock that always passes. + #[must_use] + pub fn passing() -> Self { + Self { + failures: vec![], + error: None, + } + } + + /// Creates a lock that fails with the specified failures. + #[must_use] + pub fn failing(failures: Vec) -> Self { + Self { + failures, + error: None, + } + } + + /// Creates a lock that returns an error (backend unavailable). + #[must_use] + pub fn unavailable(message: impl Into) -> Self { + Self { + failures: vec![], + error: Some(message.into()), + } + } +} + +impl SemanticLock for ConfigurableSemanticLock { + fn validate( + &self, + _context: &VerificationContext, + ) -> Result { + if let Some(ref message) = self.error { + return Err(SafetyHarnessError::SemanticBackendUnavailable { + message: message.clone(), + }); + } + + if self.failures.is_empty() { + Ok(SemanticLockResult::Passed) + } else { + Ok(SemanticLockResult::Failed { + failures: self.failures.clone(), + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn configurable_syntactic_lock_can_fail() { + let failures = vec![VerificationFailure::new( + PathBuf::from("test.rs"), + "syntax error", + )]; + let lock = ConfigurableSyntacticLock::failing(failures); + let ctx = VerificationContext::new(); + let result = lock.validate(&ctx); + assert!(!result.passed()); + } + + #[test] + fn configurable_semantic_lock_can_be_unavailable() { + let lock = ConfigurableSemanticLock::unavailable("LSP server crashed"); + let ctx = VerificationContext::new(); + let result = lock.validate(&ctx); + assert!(result.is_err()); + } +} diff --git a/crates/weaverd/src/tests/mod.rs b/crates/weaverd/src/tests/mod.rs index 258831d8..92a8c399 100644 --- a/crates/weaverd/src/tests/mod.rs +++ b/crates/weaverd/src/tests/mod.rs @@ -3,5 +3,7 @@ mod behaviour; mod lib_api; mod process_behaviour; +mod safety_harness_behaviour; +mod safety_harness_types; mod support; mod unit; diff --git a/crates/weaverd/src/tests/safety_harness_behaviour.rs b/crates/weaverd/src/tests/safety_harness_behaviour.rs new file mode 100644 index 00000000..8b0eff3c --- /dev/null +++ b/crates/weaverd/src/tests/safety_harness_behaviour.rs @@ -0,0 +1,304 @@ +//! Behavioural tests for the Double-Lock safety harness. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; +use tempfile::TempDir; + +use super::safety_harness_types::{DiagnosticMessage, FileContent, FileName, TextPattern}; +use crate::safety_harness::{ + ConfigurableSemanticLock, ConfigurableSyntacticLock, EditTransaction, FileEdit, Position, + SafetyHarnessError, TextEdit, TransactionOutcome, VerificationFailure, +}; + +/// Test world for safety harness BDD scenarios. +pub struct SafetyHarnessWorld { + temp_dir: TempDir, + files: HashMap, + /// Original content of files when created, for unchanged assertions. + original_content: HashMap, + syntactic_lock: ConfigurableSyntacticLock, + semantic_lock: ConfigurableSemanticLock, + pending_edits: Vec, + outcome: Option>, +} + +impl SafetyHarnessWorld { + /// Creates a new test world. + fn new() -> Self { + Self { + temp_dir: TempDir::new().expect("create temp dir"), + files: HashMap::new(), + original_content: HashMap::new(), + syntactic_lock: ConfigurableSyntacticLock::passing(), + semantic_lock: ConfigurableSemanticLock::passing(), + pending_edits: Vec::new(), + outcome: None, + } + } + + /// Creates a file with the given content. + fn create_file(&mut self, name: &FileName, content: &FileContent) { + let path = name.to_path(self.temp_dir.path()); + let mut file = fs::File::create(&path).expect("create file"); + file.write_all(content.as_bytes()).expect("write content"); + let name_str = name.as_str().to_string(); + self.files.insert(name_str.clone(), path); + self.original_content + .insert(name_str, content.as_str().to_string()); + } + + /// Returns the original content for a named file. + fn original_content(&self, name: &FileName) -> Option<&str> { + self.original_content.get(name.as_str()).map(String::as_str) + } + + /// Returns the path for a named file. + fn file_path(&self, name: &FileName) -> PathBuf { + self.files + .get(name.as_str()) + .cloned() + .unwrap_or_else(|| name.to_path(self.temp_dir.path())) + } + + /// Reads the current content of a file. + fn read_file(&self, name: &FileName) -> String { + let path = self.file_path(name); + fs::read_to_string(&path).expect("read file") + } + + /// Adds an edit that replaces text. + fn add_replacement_edit(&mut self, name: &FileName, old: &TextPattern, new: &TextPattern) { + let path = self.file_path(name); + let content = if path.exists() { + fs::read_to_string(&path).expect("read file") + } else { + String::new() + }; + + // Find the position of the old text + if let Some(pos) = content.find(old.as_str()) { + let line = content[..pos].matches('\n').count() as u32; + let line_start = content[..pos].rfind('\n').map_or(0, |i| i + 1); + let column = (pos - line_start) as u32; + let old_end_col = column + old.len() as u32; + + let edit = TextEdit::from_positions( + Position::new(line, column), + Position::new(line, old_end_col), + new.as_str().to_string(), + ); + let file_edit = FileEdit::with_edits(path, vec![edit]); + self.pending_edits.push(file_edit); + } + } + + /// Adds an edit that creates a new file with content. + fn add_creation_edit(&mut self, name: &FileName, content: &FileContent) { + let path = self.file_path(name); + let edit = TextEdit::insert_at(Position::new(0, 0), content.as_str()); + let file_edit = FileEdit::with_edits(path.clone(), vec![edit]); + self.pending_edits.push(file_edit); + self.files.insert(name.as_str().to_string(), path); + } + + /// Executes the transaction with pending edits. + fn execute_transaction(&mut self) { + let mut transaction = EditTransaction::new(&self.syntactic_lock, &self.semantic_lock); + for edit in self.pending_edits.drain(..) { + transaction.add_edit(edit); + } + self.outcome = Some(transaction.execute()); + } + + /// Returns the transaction outcome. + fn outcome(&self) -> Option<&Result> { + self.outcome.as_ref() + } +} + +#[fixture] +fn world() -> RefCell { + RefCell::new(SafetyHarnessWorld::new()) +} + +// ---- Given steps ---- + +#[given("a source file {name} with content {content}")] +fn given_source_file(world: &RefCell, name: FileName, content: FileContent) { + world.borrow_mut().create_file(&name, &content); +} + +#[given("no existing file {name}")] +fn given_no_file(world: &RefCell, name: FileName) { + let path = world.borrow().file_path(&name); + assert!(!path.exists(), "file should not exist: {path:?}"); +} + +#[given("a syntactic lock that passes")] +fn given_syntactic_passes(world: &RefCell) { + world.borrow_mut().syntactic_lock = ConfigurableSyntacticLock::passing(); +} + +#[given("a syntactic lock that fails with {message}")] +fn given_syntactic_fails(world: &RefCell, message: DiagnosticMessage) { + let failure = VerificationFailure::new(PathBuf::from("test"), message.as_str()); + world.borrow_mut().syntactic_lock = ConfigurableSyntacticLock::failing(vec![failure]); +} + +#[given("a semantic lock that passes")] +fn given_semantic_passes(world: &RefCell) { + world.borrow_mut().semantic_lock = ConfigurableSemanticLock::passing(); +} + +#[given("a semantic lock that fails with {message}")] +fn given_semantic_fails(world: &RefCell, message: DiagnosticMessage) { + let failure = VerificationFailure::new(PathBuf::from("test"), message.as_str()); + world.borrow_mut().semantic_lock = ConfigurableSemanticLock::failing(vec![failure]); +} + +#[given("a semantic lock that is unavailable with {message}")] +fn given_semantic_unavailable(world: &RefCell, message: DiagnosticMessage) { + world.borrow_mut().semantic_lock = ConfigurableSemanticLock::unavailable(message.as_str()); +} + +// ---- When steps ---- + +#[when("an edit replaces {old} with {new}")] +fn when_edit_replaces(world: &RefCell, old: TextPattern, new: TextPattern) { + // Use default file name "test.txt" + let default_name: FileName = "test.txt".into(); + world + .borrow_mut() + .add_replacement_edit(&default_name, &old, &new); + world.borrow_mut().execute_transaction(); +} + +#[when("an edit replaces {old} with {new} in {name}")] +fn when_edit_replaces_in_file( + world: &RefCell, + old: TextPattern, + new: TextPattern, + name: FileName, +) { + world.borrow_mut().add_replacement_edit(&name, &old, &new); +} + +#[when("no edits are submitted")] +fn when_no_edits(_world: &RefCell) { + // No edits to add +} + +#[when("an edit creates {name} with content {content}")] +fn when_edit_creates(world: &RefCell, name: FileName, content: FileContent) { + world.borrow_mut().add_creation_edit(&name, &content); + world.borrow_mut().execute_transaction(); +} + +// ---- Then steps ---- + +/// Helper for outcome assertion steps that execute the transaction if needed. +fn assert_outcome(world: &RefCell, assertion: F) +where + F: FnOnce(&Result), +{ + if world.borrow().outcome().is_none() { + world.borrow_mut().execute_transaction(); + } + let world = world.borrow(); + let outcome = world.outcome().expect("outcome should exist"); + assertion(outcome); +} + +#[then("the transaction commits successfully")] +fn then_commits(world: &RefCell) { + assert_outcome(world, |outcome| { + assert!( + outcome.as_ref().is_ok_and(|o| o.committed()), + "transaction should commit: {outcome:?}" + ); + }); +} + +#[then("the transaction fails with a syntactic lock error")] +fn then_syntactic_fails(world: &RefCell) { + assert_outcome(world, |outcome| match outcome { + Ok(TransactionOutcome::SyntacticLockFailed { .. }) => {} + other => panic!("expected syntactic lock failure, got {other:?}"), + }); +} + +#[then("the transaction fails with a semantic lock error")] +fn then_semantic_fails(world: &RefCell) { + assert_outcome(world, |outcome| match outcome { + Ok(TransactionOutcome::SemanticLockFailed { .. }) => {} + other => panic!("expected semantic lock failure, got {other:?}"), + }); +} + +#[then("the transaction fails with a backend error")] +fn then_backend_error(world: &RefCell) { + assert_outcome(world, |outcome| match outcome { + Err(SafetyHarnessError::SemanticBackendUnavailable { .. }) => {} + other => panic!("expected backend error, got {other:?}"), + }); +} + +#[then("the transaction reports no changes")] +fn then_no_changes(world: &RefCell) { + assert_outcome(world, |outcome| match outcome { + Ok(TransactionOutcome::NoChanges) => {} + other => panic!("expected no changes, got {other:?}"), + }); +} + +#[then("the file contains {expected}")] +fn then_file_contains(world: &RefCell, expected: TextPattern) { + let default_name: FileName = "test.txt".into(); + let content = world.borrow().read_file(&default_name); + assert!( + content.contains(expected.as_str()), + "expected file to contain '{}', got '{content}'", + expected.as_str() + ); +} + +#[then("the file {name} contains {expected}")] +fn then_named_file_contains( + world: &RefCell, + name: FileName, + expected: TextPattern, +) { + let content = world.borrow().read_file(&name); + assert!( + content.contains(expected.as_str()), + "expected {} to contain '{}', got '{content}'", + name.as_str(), + expected.as_str() + ); +} + +#[then("the file is unchanged")] +fn then_file_unchanged(world: &RefCell) { + let default_name: FileName = "test.txt".into(); + let content = world.borrow().read_file(&default_name); + assert_eq!(content, "hello world", "file should be unchanged"); +} + +#[then("the file {name} is unchanged")] +fn then_named_file_unchanged(world: &RefCell, name: FileName) { + let world = world.borrow(); + let content = world.read_file(&name); + let expected = world + .original_content(&name) + .unwrap_or_else(|| panic!("no original content recorded for {}", name.as_str())); + assert_eq!(content, expected, "{} should be unchanged", name.as_str()); +} + +#[scenario(path = "tests/features/safety_harness.feature")] +fn safety_harness(#[from(world)] _: RefCell) {} diff --git a/crates/weaverd/src/tests/safety_harness_types.rs b/crates/weaverd/src/tests/safety_harness_types.rs new file mode 100644 index 00000000..28f4c754 --- /dev/null +++ b/crates/weaverd/src/tests/safety_harness_types.rs @@ -0,0 +1,147 @@ +//! Domain-specific NewTypes for safety harness behavioural tests. +//! +//! These types eliminate string-heavy function arguments and make the test +//! domain model explicit and type-safe. + +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use derive_more::{AsRef, Deref}; + +/// Wraps file name strings. +#[derive(Debug, Clone, PartialEq, Eq, Deref, AsRef)] +#[as_ref(forward)] +pub struct FileName(String); + +impl From for FileName { + fn from(s: String) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl From<&str> for FileName { + fn from(s: &str) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl FileName { + /// Returns the inner string as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Joins this file name to a base path. + pub fn to_path(&self, base: &Path) -> PathBuf { + base.join(&self.0) + } +} + +impl FromStr for FileName { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} + +/// Wraps file content strings. +#[derive(Debug, Clone, PartialEq, Eq, Deref, AsRef)] +#[as_ref(forward)] +pub struct FileContent(String); + +impl From for FileContent { + fn from(s: String) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl From<&str> for FileContent { + fn from(s: &str) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl FileContent { + /// Returns the inner string as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the content as bytes. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl FromStr for FileContent { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} + +/// Wraps text patterns for search/replace/assertion. +#[derive(Debug, Clone, PartialEq, Eq, Deref, AsRef)] +#[as_ref(forward)] +pub struct TextPattern(String); + +impl From for TextPattern { + fn from(s: String) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl From<&str> for TextPattern { + fn from(s: &str) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl TextPattern { + /// Returns the inner string as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for TextPattern { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} + +/// Wraps diagnostic messages for lock configuration. +#[derive(Debug, Clone, PartialEq, Eq, Deref, AsRef)] +#[as_ref(forward)] +pub struct DiagnosticMessage(String); + +impl From for DiagnosticMessage { + fn from(s: String) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl From<&str> for DiagnosticMessage { + fn from(s: &str) -> Self { + Self(s.trim_matches('"').to_string()) + } +} + +impl DiagnosticMessage { + /// Returns the inner string as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for DiagnosticMessage { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} diff --git a/crates/weaverd/tests/features/safety_harness.feature b/crates/weaverd/tests/features/safety_harness.feature new file mode 100644 index 00000000..03364b14 --- /dev/null +++ b/crates/weaverd/tests/features/safety_harness.feature @@ -0,0 +1,73 @@ +Feature: Double-Lock safety harness + + The Double-Lock safety harness ensures that all code modifications pass + syntactic and semantic validation before being committed to the filesystem. + This protects the codebase from corrupted or broken changes. + + Scenario: Successful edit passes both locks and commits changes + Given a source file "test.txt" with content "hello world" + And a syntactic lock that passes + And a semantic lock that passes + When an edit replaces "hello" with "greetings" + Then the transaction commits successfully + And the file contains "greetings world" + + Scenario: Syntactic lock failure prevents commit + Given a source file "test.txt" with content "hello world" + And a syntactic lock that fails with "parse error at line 1" + And a semantic lock that passes + When an edit replaces "hello" with "greetings" + Then the transaction fails with a syntactic lock error + And the file is unchanged + + Scenario: Semantic lock failure prevents commit + Given a source file "test.txt" with content "hello world" + And a syntactic lock that passes + And a semantic lock that fails with "type error at line 1" + When an edit replaces "hello" with "greetings" + Then the transaction fails with a semantic lock error + And the file is unchanged + + Scenario: Semantic backend unavailability surfaces error + Given a source file "test.txt" with content "hello world" + And a syntactic lock that passes + And a semantic lock that is unavailable with "LSP server crashed" + When an edit replaces "hello" with "greetings" + Then the transaction fails with a backend error + And the file is unchanged + + Scenario: Empty transaction returns no changes + Given a syntactic lock that passes + And a semantic lock that passes + When no edits are submitted + Then the transaction reports no changes + + Scenario: Multiple file edits are committed atomically + Given a source file "file1.txt" with content "aaa" + And a source file "file2.txt" with content "bbb" + And a syntactic lock that passes + And a semantic lock that passes + When an edit replaces "aaa" with "AAA" in "file1.txt" + And an edit replaces "bbb" with "BBB" in "file2.txt" + Then the transaction commits successfully + And the file "file1.txt" contains "AAA" + And the file "file2.txt" contains "BBB" + + Scenario: Multi-file transaction fails if any file has syntactic errors + Given a source file "file1.txt" with content "aaa" + And a source file "file2.txt" with content "bbb" + And a syntactic lock that fails with "syntax error in file2.txt" + And a semantic lock that passes + When an edit replaces "aaa" with "AAA" in "file1.txt" + And an edit replaces "bbb" with "BBB" in "file2.txt" + Then the transaction fails with a syntactic lock error + And the file "file1.txt" is unchanged + And the file "file2.txt" is unchanged + + Scenario: New file creation passes validation + Given no existing file "new_file.txt" + And a syntactic lock that passes + And a semantic lock that passes + When an edit creates "new_file.txt" with content "fresh content" + Then the transaction commits successfully + And the file "new_file.txt" contains "fresh content" diff --git a/docs/roadmap.md b/docs/roadmap.md index 1dae183d..a4313bc5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,13 +57,21 @@ design contract in `docs/weaver-design.md` and expose the lifecycle expected by `birdcage` for its focused scope and production usage, prioritising robust Linux support via namespaces and seccomp-bpf. -- [ ] Implement the full "Double-Lock" safety harness logic in `weaverd`. +- [x] Implement the full "Double-Lock" safety harness logic in `weaverd`. This is a critical, non-negotiable feature for the MVP. All `act` commands must pass through this verification layer before committing to the filesystem. + - Acceptance criteria: Edit transactions pass through syntactic and semantic + lock validation before commit, failures leave the filesystem untouched, + and BDD scenarios cover success, syntactic failure, semantic failure, and + backend unavailable error paths. -- [ ] Implement atomic edits to ensure that multi-file changes either succeed +- [x] Implement atomic edits to ensure that multi-file changes either succeed or fail as a single transaction. + - Acceptance criteria: Two-phase commit with prepare (temp files) and commit + (atomic renames) phases, rollback restores original content on partial + failure, and new file creation properly tracks file existence for + rollback. ## Phase 2: Syntactic & Relational Intelligence @@ -77,6 +85,10 @@ and relational understanding of code.* - [ ] Integrate the "Syntactic Lock" from `weaver-syntax` into the "Double-Lock" harness. +- [ ] Extend the `LanguageServer` trait with document sync methods + (`did_open`, `did_change`, `did_close`) to enable semantic validation + of modified content at real file paths without writing to disk. + - [ ] Create the `weaver-graph` crate and implement the LSP Provider for call graph generation, using the `textDocument/callHierarchy` request as the initial data source. diff --git a/docs/users-guide.md b/docs/users-guide.md index e374c34b..3e0b500a 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -237,3 +237,67 @@ weaver --capabilities The CLI loads the shared configuration, applies any override directives, and prints the resulting matrix as pretty-printed JSON. The probe does not contact `weaverd`, making it safe to run during planning stages or health checks. + +## Double-Lock safety harness + +All `act` commands pass through a "Double-Lock" safety harness before any +changes are committed to the filesystem. This verification layer ensures that +agent-generated modifications do not corrupt the codebase by introducing syntax +errors or type mismatches. + +### Two-phase verification + +The harness validates proposed edits in two sequential phases: + +1. **Syntactic Lock**: Each modified file is parsed to ensure it produces a + valid syntax tree. Structural errors such as unbalanced braces, missing + semicolons, or malformed declarations are caught at this stage. Files that + fail parsing are rejected immediately, and the filesystem remains untouched. + +2. **Semantic Lock**: If the syntactic lock passes, the modified content is + submitted to the configured language server. The daemon requests fresh + diagnostics and compares them against the pre-edit baseline. Any new errors + or high-severity warnings cause the semantic lock to fail. Only when both + locks pass are the changes atomically written to disk. + +### In-memory application + +Edits are first applied to in-memory copies of the affected files. The original +content is preserved until both verification phases succeed. This allows the +harness to reject problematic changes without leaving partially written files +on disk. + +### Atomic commits + +When both locks pass, the harness writes each modified file atomically by +creating a temporary file and renaming it into place. This guarantees that a +crash or power loss during the commit phase does not leave files in a corrupted +intermediate state. + +### Error reporting + +When verification fails, the harness returns a structured error describing: + +- **Lock phase**: Whether the failure occurred during syntactic or semantic + validation. +- **Affected files**: Paths to the files that triggered the failure. +- **Locations**: Optional line and column numbers pinpointing each issue. +- **Messages**: Human-readable descriptions of what went wrong. + +Agents can use this information to diagnose problems and regenerate corrected +edits. The structured format also enables tooling to present failures in IDE +integrations or CI pipelines. + +### Backend unavailability + +If a language server is not running or crashes mid-request, the semantic lock +returns a backend-unavailable error rather than silently passing. Operators +should ensure the appropriate language servers are healthy before executing +`act` commands. + +### Placeholder implementation note + +The current syntactic lock uses a placeholder implementation that always passes. +Full Tree-sitter integration will be delivered in a future phase. The semantic +lock relies on the `weaver-lsp-host` infrastructure, which requires language +servers to be registered and initialized for the relevant languages. diff --git a/docs/weaver-design.md b/docs/weaver-design.md index 2cbeda4e..80f5ba78 100644 --- a/docs/weaver-design.md +++ b/docs/weaver-design.md @@ -898,6 +898,88 @@ leverage the most advanced and specialized refactoring tools available, knowing that `Weaver` provides a safety net that protects the codebase from corruption and regressions. +#### 4.2.1. Implementation decisions + +The initial Double-Lock implementation resides in the `weaverd::safety_harness` +module. The architecture uses trait-based abstraction for both lock phases, +enabling straightforward unit and integration testing without requiring real +language servers or parsers during development. + +**Core types**: + +- `FileEdit` and `TextEdit` represent proposed changes using zero-based line + and column offsets following LSP conventions. +- `VerificationContext` maintains in-memory copies of both original and + modified file content, isolating the verification process from the real + filesystem. +- `EditTransaction` orchestrates the full workflow: reading original files, + applying edits in-memory, validating through both locks, and committing + atomically on success. + +**Lock traits**: + +- `SyntacticLock::validate(&self, context: &VerificationContext) -> + SyntacticLockResult` returns either `Passed` or `Failed { failures }`. +- `SemanticLock::validate(&self, context: &VerificationContext) -> + Result` permits the semantic backend + to surface unavailability errors separately from verification failures. + +**Placeholder implementations**: + +Until `weaver-syntax` delivers Tree-sitter parsing, the +`PlaceholderSyntacticLock` unconditionally passes all files. This maintains the +contract while deferring parser integration. `PlaceholderSemanticLock` likewise +passes until the full LSP diagnostic comparison pipeline is wired through +`weaver-lsp-host`. + +**Configurable test doubles**: + +`ConfigurableSyntacticLock` and `ConfigurableSemanticLock` accept +pre-determined results, enabling BDD scenarios to exercise pass, fail, and +backend-unavailable paths without external dependencies. These doubles power +the `safety_harness.feature` behavioural tests. + +**Atomic commit strategy**: + +Successful transactions use two-phase commit with rollback: + +1. **Prepare phase**: All modified content is written to temporary files in + the same directory as the target files. +2. **Commit phase**: Temporary files are atomically renamed to their targets. +3. **Rollback**: If any rename fails, all previously committed files are + restored to their original content from the `VerificationContext`. + +This ensures multi-file atomicity: either all files are updated or none are. +Rollback is best-effort; catastrophic failures during rollback (e.g., disk +removal) may leave some files in an inconsistent state. + +**Structured error reporting**: + +`SafetyHarnessError` captures the lock phase, affected files, optional line and +column locations, and human-readable messages. Agents can parse this structure +to diagnose failures and regenerate corrected edits without manual intervention. + +#### 4.2.2. Future: LSP Document Sync for Semantic Validation + +For operations spanning multiple files (renames, signature changes), the +semantic lock must validate cross-file references. Rather than writing modified +content to temporary files (which would break import resolution), the semantic +lock will use LSP's document synchronization protocol: + +1. **`textDocument/didOpen`**: Open each affected file at its real URI, + sending the modified content as the document text. +2. **Request diagnostics**: The LSP validates the in-memory content as if + it were at the actual file path, allowing imports and references to resolve + correctly. +3. **Compare diagnostics**: Check for new errors compared to the pre-edit + baseline. +4. **`textDocument/didClose`**: Clean up the virtual document state. + +This approach leverages the standard LSP document lifecycle that editors use, +where the LSP always validates in-memory content rather than disk content. The +`LanguageServer` trait in `weaver-lsp-host` will be extended with `did_open`, +`did_change`, and `did_close` methods to support this workflow. + ## 5. Security by Design: A Zero-Trust Sandboxing Model Given that `Weaver` is designed to be programmatically controlled by AI agents