Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
21d8341
feat(safety_harness): implement Double-Lock safety harness for transa…
leynos Dec 8, 2025
442a5be
refactor(tests): introduce domain-specific newtypes for safety harnes…
leynos Dec 10, 2025
76b2828
feat(safety_harness): add typed ReplacementText and transaction test …
leynos Dec 10, 2025
7dfd3f1
refactor(safety_harness/edit): deprecate from_coords; add safer posit…
leynos Dec 10, 2025
1cb849a
refactor(safety_harness): replace deprecated coordinate API with Posi…
leynos Dec 10, 2025
c1ff0e8
refactor(tests): use derive_more to simplify wrapper type implementat…
leynos Dec 10, 2025
a71daed
refactor(tests): reduce duplication in failure scenario tests
leynos Dec 10, 2025
8dabe1d
test(safety_harness): refactor lock failure tests to remove duplication
leynos Dec 10, 2025
91b4f13
refactor(tests): introduce LineReplacement struct to simplify test edits
leynos Dec 10, 2025
3f7b73d
feat(safety_harness): implement two-phase commit with rollback
leynos Dec 10, 2025
b2e87ed
refactor(safety_harness): remove deprecated coordinate-based TextEdit…
leynos Dec 10, 2025
e3f9c7a
fix(safety_harness): handle CRLF line endings and remove unused error…
leynos Dec 10, 2025
abbec3d
refactor(safety_harness): simplify TextEdit API and fix const fn issue
leynos Dec 10, 2025
b8017b2
test(safety_harness): add tests for past-EOF line handling in apply_e…
leynos Dec 10, 2025
32918ad
feat(safety_harness/transaction): implement atomic multi-file edit tr…
leynos Dec 10, 2025
56b863d
test(safety_harness/verification): simplify edit application tests wi…
leynos Dec 10, 2025
b037eab
refactor(safety_harness): refine path handling and ensure test transa…
leynos Dec 11, 2025
67e9334
refactor(safety_harness): remove ReplacementText newtype and improve …
leynos Dec 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/weaverd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions crates/weaverd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
218 changes: 218 additions & 0 deletions crates/weaverd/src/safety_harness/edit.rs
Original file line number Diff line number Diff line change
@@ -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 }
}
Comment on lines +23 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Format as single-line for consistency with guidelines.

The constructor has a simple body and should be formatted as a single line per the project guidelines.

Apply this diff:

-    pub const fn new(line: u32, column: u32) -> Self {
-        Self { line, column }
-    }
+    pub const fn new(line: u32, column: u32) -> Self { Self { line, column } }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub const fn new(line: u32, column: u32) -> Self {
Self { line, column }
}
pub const fn new(line: u32, column: u32) -> Self { Self { line, column } }
🤖 Prompt for AI Agents
In crates/weaverd/src/safety_harness/edit.rs around lines 23 to 25, the pub
const fn new(...) constructor is written on multiple lines but should be
formatted as a single-line function per project guidelines; replace the current
multi-line body with a single-line definition (e.g., pub const fn new(line: u32,
column: u32) -> Self { Self { line, column } }) so the entire fn signature and
body reside on one line.

}

/// 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 }
}
Comment on lines +40 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Format constructors as single-line for consistency.

Both constructors have simple bodies and should be formatted as single lines per the project guidelines.

Apply this diff:

-    pub const fn new(start: Position, end: Position) -> Self {
-        Self { start, end }
-    }
+    pub const fn new(start: Position, end: Position) -> Self { Self { start, end } }
-    pub const fn point(position: Position) -> Self {
-        Self {
-            start: position,
-            end: position,
-        }
-    }
+    pub const fn point(position: Position) -> Self { Self { start: position, end: position } }

Also applies to: 47-51

🤖 Prompt for AI Agents
In crates/weaverd/src/safety_harness/edit.rs around lines 40-42 and 47-51, the
two constructors are written as multi-line functions but the project guideline
requires simple constructors be formatted as single-line. Convert each pub const
fn block with its body to a single line (e.g., pub const fn new(start: Position,
end: Position) -> Self { Self { start, end } }) and similarly collapse the
second constructor on lines 47-51 into one line to match style.


/// 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 {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
/// 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<String>) -> 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<String>) -> 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<String>) -> 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<TextEdit>,
}

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<TextEdit>) -> Self {
Self { path, edits }
}

/// Path to the file being edited.
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// 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);
}
}
Loading
Loading