Skip to main content

airl_patch/
lib.rs

1//! AIRL Patch Engine - Semantic patch operations on IR graphs.
2//!
3//! Provides the core editing interface for AI agents: instead of rewriting
4//! entire modules, agents produce small semantic patches that are validated,
5//! applied, and can be inverted.
6//!
7//! # Key property
8//!
9//! Every patch can be inverted exactly:
10//!
11//! ```text
12//! apply(inverse(p), apply(p, module)) == module
13//! ```
14//!
15//! This is verified by property tests.
16//!
17//! # Modules
18//!
19//! - [`ops`] — [`Patch`] and [`PatchOp`] type definitions
20//! - [`apply`] — forward application of patches
21//! - [`inverse`] — compute undo patches before applying
22//! - [`validate`] — structural checks before application
23//! - [`traverse`] — IR tree walking helpers
24//! - [`impact`] — analysis of which functions a patch affects
25
26#![deny(missing_docs)]
27
28pub mod apply;
29pub mod impact;
30pub mod inverse;
31pub mod ops;
32pub mod traverse;
33pub mod validate;
34
35use thiserror::Error;
36
37// Re-export key types
38pub use apply::{apply_patch, PatchResult};
39pub use impact::Impact;
40pub use inverse::invert_patch;
41pub use ops::{Patch, PatchOp};
42pub use validate::validate_patch;
43
44/// Errors that can occur during patch operations.
45#[derive(Debug, Error)]
46pub enum PatchError {
47    /// The patch targets a node ID that doesn't exist in the module.
48    #[error("node not found: {node_id}")]
49    NodeNotFound {
50        /// The node ID that was not found.
51        node_id: String,
52    },
53
54    /// The patch targets a function ID that doesn't exist.
55    #[error("function not found: {func_id}")]
56    FunctionNotFound {
57        /// The function ID that was not found.
58        func_id: String,
59    },
60
61    /// Adding a function with a name that already exists in the module.
62    #[error("duplicate function name: {name}")]
63    DuplicateFunction {
64        /// The duplicate name.
65        name: String,
66    },
67
68    /// Structural validation of one of the patch operations failed.
69    #[error("validation failed at operation {op_index}: {message}")]
70    ValidationFailed {
71        /// Index into [`Patch::operations`] where validation failed.
72        op_index: usize,
73        /// Validation error message.
74        message: String,
75    },
76
77    /// The module produced by the patch fails type checking.
78    #[error("type check failed after patch: {message}")]
79    TypeCheckFailed {
80        /// Summary of type errors.
81        message: String,
82    },
83
84    /// The patch was written against a different module version than the current state.
85    #[error("version mismatch: expected {expected}, got {actual}")]
86    VersionMismatch {
87        /// The version the patch was written against.
88        expected: String,
89        /// The actual current module version.
90        actual: String,
91    },
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use airl_ir::effects::Effect;
98    use airl_ir::ids::{FuncId, NodeId};
99    use airl_ir::module::{FuncDef, Import, Module};
100    use airl_ir::node::{LiteralValue, Node};
101    use airl_ir::types::Type;
102
103    fn load_module(json: &str) -> Module {
104        serde_json::from_str(json).unwrap()
105    }
106
107    fn hello_module() -> Module {
108        load_module(
109            r#"{
110            "format_version": "0.1.0",
111            "module": {
112                "id": "mod_main", "name": "main",
113                "metadata": {"version": "1.0.0", "description": "", "author": "", "created_at": ""},
114                "imports": [{"module": "std::io", "items": ["println"]}],
115                "exports": [],
116                "types": [],
117                "functions": [{
118                    "id": "f_main", "name": "main", "params": [], "returns": "Unit",
119                    "effects": ["IO"],
120                    "body": {"id": "n_1", "kind": "Call", "type": "Unit",
121                        "target": "std::io::println",
122                        "args": [{"id": "n_2", "kind": "Literal", "type": "String", "value": "hello world"}]
123                    }
124                }]
125            }
126        }"#,
127        )
128    }
129
130    fn make_patch(ops: Vec<PatchOp>) -> Patch {
131        Patch {
132            id: "test_patch".to_string(),
133            parent_version: String::new(),
134            operations: ops,
135            rationale: "test".to_string(),
136            author: "test-agent".to_string(),
137        }
138    }
139
140    // ---- ReplaceNode tests ----
141
142    #[test]
143    fn test_replace_leaf_node() {
144        let module = hello_module();
145        let patch = make_patch(vec![PatchOp::ReplaceNode {
146            target: NodeId::new("n_2"),
147            replacement: Node::Literal {
148                id: NodeId::new("n_2"),
149                node_type: Type::String,
150                value: LiteralValue::Str("goodbye world".to_string()),
151            },
152        }]);
153
154        let result = apply_patch(&module, &patch).unwrap();
155
156        // Verify the replacement took effect
157        let func = result.new_module.find_function("main").unwrap();
158        match &func.body {
159            Node::Call { args, .. } => match &args[0] {
160                Node::Literal { value, .. } => {
161                    assert_eq!(*value, LiteralValue::Str("goodbye world".to_string()));
162                }
163                other => panic!("Expected Literal, got: {other:?}"),
164            },
165            other => panic!("Expected Call, got: {other:?}"),
166        }
167    }
168
169    #[test]
170    fn test_replace_node_runs_correctly() {
171        let module = hello_module();
172        let patch = make_patch(vec![PatchOp::ReplaceNode {
173            target: NodeId::new("n_2"),
174            replacement: Node::Literal {
175                id: NodeId::new("n_2"),
176                node_type: Type::String,
177                value: LiteralValue::Str("patched!".to_string()),
178            },
179        }]);
180
181        let result = apply_patch(&module, &patch).unwrap();
182
183        // Run the patched module through the interpreter
184        let output = airl_interp::interpret(&result.new_module).unwrap();
185        assert_eq!(output.stdout, "patched!\n");
186    }
187
188    #[test]
189    fn test_replace_nonexistent_node_fails() {
190        let module = hello_module();
191        let patch = make_patch(vec![PatchOp::ReplaceNode {
192            target: NodeId::new("n_999"),
193            replacement: Node::Literal {
194                id: NodeId::new("n_999"),
195                node_type: Type::Unit,
196                value: LiteralValue::Unit,
197            },
198        }]);
199
200        let result = apply_patch(&module, &patch);
201        assert!(result.is_err());
202    }
203
204    // ---- AddFunction / RemoveFunction tests ----
205
206    #[test]
207    fn test_add_function() {
208        let module = hello_module();
209        let new_func = FuncDef {
210            id: FuncId::new("f_greet"),
211            name: "greet".to_string(),
212            params: vec![],
213            returns: Type::String,
214            effects: vec![Effect::Pure],
215            body: Node::Literal {
216                id: NodeId::new("g_1"),
217                node_type: Type::String,
218                value: LiteralValue::Str("hi".to_string()),
219            },
220        };
221
222        let patch = make_patch(vec![PatchOp::AddFunction { func: new_func }]);
223        let result = apply_patch(&module, &patch).unwrap();
224
225        assert_eq!(result.new_module.functions().len(), 2);
226        assert!(result.new_module.find_function("greet").is_some());
227    }
228
229    #[test]
230    fn test_remove_function() {
231        let module = hello_module();
232        let patch = make_patch(vec![PatchOp::RemoveFunction {
233            func_id: FuncId::new("f_main"),
234        }]);
235
236        let result = apply_patch(&module, &patch).unwrap();
237        assert_eq!(result.new_module.functions().len(), 0);
238    }
239
240    #[test]
241    fn test_add_duplicate_function_fails() {
242        let module = hello_module();
243        let dup_func = FuncDef {
244            id: FuncId::new("f_main2"),
245            name: "main".to_string(), // same name!
246            params: vec![],
247            returns: Type::Unit,
248            effects: vec![],
249            body: Node::Literal {
250                id: NodeId::new("d_1"),
251                node_type: Type::Unit,
252                value: LiteralValue::Unit,
253            },
254        };
255
256        let patch = make_patch(vec![PatchOp::AddFunction { func: dup_func }]);
257        let result = apply_patch(&module, &patch);
258        assert!(result.is_err());
259    }
260
261    // ---- RenameSymbol tests ----
262
263    #[test]
264    fn test_rename_call_target() {
265        let module = hello_module();
266        let patch = make_patch(vec![PatchOp::RenameSymbol {
267            old_name: "std::io::println".to_string(),
268            new_name: "std::io::print".to_string(),
269            scope: None,
270        }]);
271
272        let result = apply_patch(&module, &patch).unwrap();
273        let func = result.new_module.find_function("main").unwrap();
274        match &func.body {
275            Node::Call { target, .. } => {
276                assert_eq!(target, "std::io::print");
277            }
278            other => panic!("Expected Call, got: {other:?}"),
279        }
280    }
281
282    // ---- AddEffect / RemoveEffect tests ----
283
284    #[test]
285    fn test_add_effect() {
286        let module = hello_module();
287        let patch = make_patch(vec![PatchOp::AddEffect {
288            func_id: FuncId::new("f_main"),
289            effect: Effect::Fail {
290                error_type: "IOError".to_string(),
291            },
292        }]);
293
294        let result = apply_patch(&module, &patch).unwrap();
295        let func = result.new_module.find_function("main").unwrap();
296        assert!(func.effects.contains(&Effect::Fail {
297            error_type: "IOError".to_string()
298        }));
299        assert!(func.effects.contains(&Effect::IO));
300    }
301
302    #[test]
303    fn test_remove_effect() {
304        let module = hello_module();
305        let patch = make_patch(vec![PatchOp::RemoveEffect {
306            func_id: FuncId::new("f_main"),
307            effect: Effect::IO,
308        }]);
309
310        let result = apply_patch(&module, &patch).unwrap();
311        let func = result.new_module.find_function("main").unwrap();
312        assert!(!func.effects.contains(&Effect::IO));
313    }
314
315    // ---- AddImport / RemoveImport tests ----
316
317    #[test]
318    fn test_add_import() {
319        let module = hello_module();
320        let patch = make_patch(vec![PatchOp::AddImport {
321            import: Import {
322                module: "std::math".to_string(),
323                items: vec!["abs".to_string()],
324            },
325        }]);
326
327        let result = apply_patch(&module, &patch).unwrap();
328        assert_eq!(result.new_module.module.imports.len(), 2);
329    }
330
331    #[test]
332    fn test_remove_import() {
333        let module = hello_module();
334        let patch = make_patch(vec![PatchOp::RemoveImport {
335            import: Import {
336                module: "std::io".to_string(),
337                items: vec!["println".to_string()],
338            },
339        }]);
340
341        let result = apply_patch(&module, &patch).unwrap();
342        assert_eq!(result.new_module.module.imports.len(), 0);
343    }
344
345    // ---- Patch inversion tests ----
346
347    #[test]
348    fn test_inversion_replace_node() {
349        let module = hello_module();
350        let patch = make_patch(vec![PatchOp::ReplaceNode {
351            target: NodeId::new("n_2"),
352            replacement: Node::Literal {
353                id: NodeId::new("n_2"),
354                node_type: Type::String,
355                value: LiteralValue::Str("changed".to_string()),
356            },
357        }]);
358
359        // Generate inverse BEFORE applying
360        let inverse = invert_patch(&module, &patch).unwrap();
361
362        // Apply forward patch
363        let patched = apply_patch(&module, &patch).unwrap();
364
365        // Apply inverse patch
366        let restored = apply_patch(&patched.new_module, &inverse).unwrap();
367
368        // Original and restored should be equal
369        assert_eq!(
370            serde_json::to_string(&module).unwrap(),
371            serde_json::to_string(&restored.new_module).unwrap(),
372        );
373    }
374
375    #[test]
376    fn test_inversion_add_remove_function() {
377        let module = hello_module();
378        let new_func = FuncDef {
379            id: FuncId::new("f_helper"),
380            name: "helper".to_string(),
381            params: vec![],
382            returns: Type::Unit,
383            effects: vec![Effect::Pure],
384            body: Node::Literal {
385                id: NodeId::new("h_1"),
386                node_type: Type::Unit,
387                value: LiteralValue::Unit,
388            },
389        };
390
391        let patch = make_patch(vec![PatchOp::AddFunction {
392            func: new_func.clone(),
393        }]);
394
395        let inverse = invert_patch(&module, &patch).unwrap();
396        let patched = apply_patch(&module, &patch).unwrap();
397        assert_eq!(patched.new_module.functions().len(), 2);
398
399        let restored = apply_patch(&patched.new_module, &inverse).unwrap();
400        assert_eq!(restored.new_module.functions().len(), 1);
401    }
402
403    #[test]
404    fn test_inversion_rename_symbol() {
405        let module = hello_module();
406        let patch = make_patch(vec![PatchOp::RenameSymbol {
407            old_name: "std::io::println".to_string(),
408            new_name: "std::io::print".to_string(),
409            scope: None,
410        }]);
411
412        let inverse = invert_patch(&module, &patch).unwrap();
413        let patched = apply_patch(&module, &patch).unwrap();
414        let restored = apply_patch(&patched.new_module, &inverse).unwrap();
415
416        assert_eq!(
417            serde_json::to_string(&module).unwrap(),
418            serde_json::to_string(&restored.new_module).unwrap(),
419        );
420    }
421
422    // ---- Impact analysis tests ----
423
424    #[test]
425    fn test_impact_replace_node() {
426        let module = hello_module();
427        let patch = make_patch(vec![PatchOp::ReplaceNode {
428            target: NodeId::new("n_2"),
429            replacement: Node::Literal {
430                id: NodeId::new("n_2"),
431                node_type: Type::String,
432                value: LiteralValue::Str("x".to_string()),
433            },
434        }]);
435
436        let result = apply_patch(&module, &patch).unwrap();
437        assert!(result
438            .impact
439            .affected_functions
440            .contains(&FuncId::new("f_main")));
441    }
442
443    // ---- Version tracking tests ----
444
445    #[test]
446    fn test_version_changes_after_patch() {
447        let module = hello_module();
448        let v1 = airl_ir::version::VersionId::compute(&module).to_hex();
449
450        let patch = make_patch(vec![PatchOp::ReplaceNode {
451            target: NodeId::new("n_2"),
452            replacement: Node::Literal {
453                id: NodeId::new("n_2"),
454                node_type: Type::String,
455                value: LiteralValue::Str("different".to_string()),
456            },
457        }]);
458
459        let result = apply_patch(&module, &patch).unwrap();
460        assert_ne!(v1, result.new_version);
461    }
462
463    // ---- Multi-op patch tests ----
464
465    #[test]
466    fn test_multiple_ops_in_one_patch() {
467        let module = hello_module();
468        let patch = make_patch(vec![
469            // Change the message
470            PatchOp::ReplaceNode {
471                target: NodeId::new("n_2"),
472                replacement: Node::Literal {
473                    id: NodeId::new("n_2"),
474                    node_type: Type::String,
475                    value: LiteralValue::Str("multi-patched".to_string()),
476                },
477            },
478            // Add Fail effect
479            PatchOp::AddEffect {
480                func_id: FuncId::new("f_main"),
481                effect: Effect::Fail {
482                    error_type: "E".to_string(),
483                },
484            },
485        ]);
486
487        let result = apply_patch(&module, &patch).unwrap();
488        let func = result.new_module.find_function("main").unwrap();
489        assert!(func.has_effect(&Effect::Fail {
490            error_type: "E".to_string()
491        }));
492
493        let output = airl_interp::interpret(&result.new_module).unwrap();
494        assert_eq!(output.stdout, "multi-patched\n");
495    }
496
497    // ---- Traverse utility tests ----
498
499    #[test]
500    fn test_collect_node_ids() {
501        let module = hello_module();
502        let func = module.find_function("main").unwrap();
503        let ids = traverse::collect_node_ids(&func.body);
504        assert!(ids.contains(&NodeId::new("n_1")));
505        assert!(ids.contains(&NodeId::new("n_2")));
506        assert_eq!(ids.len(), 2);
507    }
508
509    #[test]
510    fn test_find_node() {
511        let module = hello_module();
512        let func = module.find_function("main").unwrap();
513        let node = traverse::find_node(&func.body, &NodeId::new("n_2"));
514        assert!(node.is_some());
515        match node.unwrap() {
516            Node::Literal { value, .. } => {
517                assert_eq!(*value, LiteralValue::Str("hello world".to_string()));
518            }
519            other => panic!("Expected Literal, got: {other:?}"),
520        }
521    }
522}