From 52a917c353951cccce14896894f1634a4e527a82 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 9 Oct 2026 10:38:48 +0200 Subject: [PATCH] fix: rewrite import and decision node paths when documents move --- bindings/nodejs/dts-header.d.ts | 10 ++ bindings/nodejs/src/policy.rs | 19 +++ core/engine/src/policy/mod.rs | 2 +- core/engine/src/workspace/mod.rs | 11 +- core/engine/src/workspace/paths.rs | 63 ++++++++ core/engine/src/workspace/types/edit.rs | 20 +++ core/engine/src/workspace/types/mod.rs | 2 +- core/engine/tests/workspace_move_paths.rs | 170 ++++++++++++++++++++++ 8 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 core/engine/src/workspace/paths.rs create mode 100644 core/engine/tests/workspace_move_paths.rs diff --git a/bindings/nodejs/dts-header.d.ts b/bindings/nodejs/dts-header.d.ts index 2ca229a6..efaf6585 100644 --- a/bindings/nodejs/dts-header.d.ts +++ b/bindings/nodejs/dts-header.d.ts @@ -236,6 +236,16 @@ export type PolicyEngineEdit = newNode: unknown; }; +export type PolicyPathEdit = + | { kind: 'replaceImport'; document: string; from: string; to: string } + | { + kind: 'replaceDecisionKey'; + document: string; + nodeId: string; + from: string; + to: string; + }; + /** * What kind of usage site a `PolicyReferenceSite` represents. * diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs index ccad4648..5d3dc156 100644 --- a/bindings/nodejs/src/policy.rs +++ b/bindings/nodejs/src/policy.rs @@ -340,6 +340,12 @@ pub struct PolicyRenameRequest { pub origin: Option, } +#[napi(object)] +pub struct PolicyPathMove { + pub from: String, + pub to: String, +} + #[napi(object)] pub struct PolicyUpdateBlockRequest { pub policy_path: String, @@ -888,6 +894,19 @@ impl Workspace { .collect()) } + #[napi(ts_return_type = "PolicyPathEdit[]")] + pub fn move_paths(&self, moves: Vec) -> Vec { + let moves: Vec<(Arc, Arc)> = moves + .into_iter() + .map(|m| (Arc::from(m.from), Arc::from(m.to))) + .collect(); + self.inner + .move_paths(&moves) + .into_iter() + .map(|e| serde_json::to_value(e).expect("PathEdit serializes")) + .collect() + } + #[napi(ts_return_type = "PolicyReferenceSite[]")] pub fn references( &self, diff --git a/core/engine/src/policy/mod.rs b/core/engine/src/policy/mod.rs index 55d11074..2ec250b0 100644 --- a/core/engine/src/policy/mod.rs +++ b/core/engine/src/policy/mod.rs @@ -19,7 +19,7 @@ pub use crate::workspace::{ EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult, ExpressionFacts, ExpressionKind, FieldOrigin, FunctionResolutionRequest, FunctionTypeResolver, GraphAnalysis, GraphNodeAnalysis, GraphSignature, GraphTraceMap, GuardedProperty, - InputProperty, InputValidationError, InspectResult, OutputProperty, PrepareRename, + InputProperty, InputValidationError, InspectResult, OutputProperty, PathEdit, PrepareRename, PropertyKind, ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, Severity, SlotResponse, SlotRole, Span, Trace, Workspace, WriteConflict, WriteTrace, diff --git a/core/engine/src/workspace/mod.rs b/core/engine/src/workspace/mod.rs index a5519af2..cc59adf5 100644 --- a/core/engine/src/workspace/mod.rs +++ b/core/engine/src/workspace/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod db; pub mod document_dependencies; pub(crate) mod editor; pub(crate) mod graph; +pub(crate) mod paths; pub(crate) mod reads; pub(crate) mod search; pub(crate) mod slot; @@ -27,9 +28,9 @@ pub use types::{ DiagnosticLocation, Dictionary, DictionaryEntryInfo, DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult, ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, InputValidationError, - InspectResult, OutputProperty, PrepareRename, PropertyKind, ReferenceKind, ReferenceSite, - RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, SearchHit, SearchHitKind, Severity, - SlotRole, Span, Trace, WriteConflict, WriteTrace, + InspectResult, OutputProperty, PathEdit, PrepareRename, PropertyKind, ReferenceKind, + ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, SearchHit, + SearchHitKind, Severity, SlotRole, Span, Trace, WriteConflict, WriteTrace, }; use types::Global; @@ -221,6 +222,10 @@ impl Workspace { self.db.rename(target, new_name) } + pub fn move_paths(&self, moves: &[(Arc, Arc)]) -> Vec { + self.db.move_paths(moves) + } + pub fn references(&self, target: &RenameTarget) -> Vec { self.db.references(target) } diff --git a/core/engine/src/workspace/paths.rs b/core/engine/src/workspace/paths.rs new file mode 100644 index 00000000..0747b37d --- /dev/null +++ b/core/engine/src/workspace/paths.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use ahash::HashSet; +use zen_types::decision::DecisionNodeKind; + +use crate::model::DecisionContent; +use crate::workspace::db::Db; +use crate::workspace::document_dependencies::DependencyKind; +use crate::workspace::types::PathEdit; + +impl Db { + pub(crate) fn move_paths(&self, moves: &[(Arc, Arc)]) -> Vec { + let mut seen: HashSet<&str> = HashSet::default(); + let mut edits = Vec::new(); + for (from, to) in moves { + if from == to || !seen.insert(from.as_ref()) { + continue; + } + for (document, kind) in self.document_dependencies().used_by(from) { + match kind { + DependencyKind::Import | DependencyKind::Dictionaries => { + edits.push(PathEdit::ReplaceImport { + document, + from: from.clone(), + to: to.clone(), + }); + } + DependencyKind::Signature => { + edits.extend(self.decision_key_edits(&document, from, to)); + } + } + } + } + edits + } + + fn decision_key_edits( + &self, + document: &Arc, + from: &Arc, + to: &Arc, + ) -> Vec { + let Some(content) = self.raw_document(document) else { + return Vec::new(); + }; + let DecisionContent::Graph(graph) = content.as_ref() else { + return Vec::new(); + }; + graph + .nodes + .iter() + .filter(|node| { + matches!(&node.kind, DecisionNodeKind::DecisionNode { content } if content.key == *from) + }) + .map(|node| PathEdit::ReplaceDecisionKey { + document: document.clone(), + node_id: node.id.clone(), + from: from.clone(), + to: to.clone(), + }) + .collect() + } +} diff --git a/core/engine/src/workspace/types/edit.rs b/core/engine/src/workspace/types/edit.rs index b85e5b10..e14e0e2f 100644 --- a/core/engine/src/workspace/types/edit.rs +++ b/core/engine/src/workspace/types/edit.rs @@ -31,3 +31,23 @@ pub enum EngineEdit { new_node: Value, }, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum PathEdit { + ReplaceImport { + document: Arc, + from: Arc, + to: Arc, + }, + ReplaceDecisionKey { + document: Arc, + node_id: Arc, + from: Arc, + to: Arc, + }, +} diff --git a/core/engine/src/workspace/types/mod.rs b/core/engine/src/workspace/types/mod.rs index 2a946dac..9ba8399c 100644 --- a/core/engine/src/workspace/types/mod.rs +++ b/core/engine/src/workspace/types/mod.rs @@ -14,7 +14,7 @@ pub(crate) use diagnostic::SpanOps; pub use diagnostic::{ Diagnostic, DiagnosticArgs, DiagnosticCode, DiagnosticLocation, Severity, Span, }; -pub use edit::EngineEdit; +pub use edit::{EngineEdit, PathEdit}; pub use error::{EvaluationError, InputValidationError}; pub use request::{EvaluateRequest, ScopeRequest}; pub use result::{ diff --git a/core/engine/tests/workspace_move_paths.rs b/core/engine/tests/workspace_move_paths.rs new file mode 100644 index 00000000..73f24df2 --- /dev/null +++ b/core/engine/tests/workspace_move_paths.rs @@ -0,0 +1,170 @@ +use std::sync::Arc; + +use serde_json::{json, Value}; +use zen_engine::model::DecisionContent; +use zen_engine::policy::Workspace; + +fn document(value: Value) -> DecisionContent { + serde_json::from_value(value).expect("valid decision content") +} + +fn node(id: &str, kind: &str, content: Value) -> Value { + json!({ "id": id, "name": id, "type": kind, "content": content }) +} + +fn graph(imports: &[&str], decision_keys: &[&str]) -> Value { + let mut nodes = vec![node("in", "inputNode", json!({}))]; + for (i, key) in decision_keys.iter().enumerate() { + nodes.push(node( + &format!("call{i}"), + "decisionNode", + json!({ "key": key }), + )); + } + json!({ "imports": imports, "nodes": nodes, "edges": [] }) +} + +fn policy(imports: &[&str]) -> Value { + json!({ "imports": imports, "blocks": [] }) +} + +fn moves(pairs: &[(&str, &str)]) -> Vec<(Arc, Arc)> { + pairs + .iter() + .map(|(from, to)| (Arc::from(*from), Arc::from(*to))) + .collect() +} + +fn edits(ws: &Workspace, pairs: &[(&str, &str)]) -> Vec { + ws.move_paths(&moves(pairs)) + .into_iter() + .map(|edit| serde_json::to_value(edit).unwrap()) + .collect() +} + +#[test] +fn graph_imports_follow_a_moved_policy() { + let mut ws = Workspace::new(); + ws.set_document("loan-dictionaries", document(policy(&[]))); + ws.set_document("loan", document(graph(&["loan-dictionaries"], &[]))); + + assert_eq!( + edits(&ws, &[("loan-dictionaries", "shared/loan-dictionaries")]), + vec![json!({ + "kind": "replaceImport", + "document": "loan", + "from": "loan-dictionaries", + "to": "shared/loan-dictionaries", + })] + ); +} + +#[test] +fn policy_imports_follow_a_moved_policy() { + let mut ws = Workspace::new(); + ws.set_document("base", document(policy(&[]))); + ws.set_document("pricing", document(policy(&["base"]))); + + assert_eq!( + edits(&ws, &[("base", "core/base")]), + vec![json!({ + "kind": "replaceImport", + "document": "pricing", + "from": "base", + "to": "core/base", + })] + ); +} + +#[test] +fn decision_nodes_follow_a_moved_graph() { + let mut ws = Workspace::new(); + ws.set_document("risk", document(graph(&[], &[]))); + ws.set_document("main", document(graph(&[], &["risk", "other", "risk"]))); + + assert_eq!( + edits(&ws, &[("risk", "checks/risk")]), + vec![ + json!({ + "kind": "replaceDecisionKey", + "document": "main", + "nodeId": "call0", + "from": "risk", + "to": "checks/risk", + }), + json!({ + "kind": "replaceDecisionKey", + "document": "main", + "nodeId": "call2", + "from": "risk", + "to": "checks/risk", + }), + ] + ); +} + +#[test] +fn folder_move_rewrites_references_between_moved_documents() { + let mut ws = Workspace::new(); + ws.set_document("loan/dictionaries", document(policy(&[]))); + ws.set_document( + "loan/approval", + document(graph(&["loan/dictionaries"], &[])), + ); + ws.set_document("main", document(graph(&[], &["loan/approval"]))); + + assert_eq!( + edits( + &ws, + &[ + ("loan/approval", "lending/approval"), + ("loan/dictionaries", "lending/dictionaries"), + ] + ), + vec![ + json!({ + "kind": "replaceDecisionKey", + "document": "main", + "nodeId": "call0", + "from": "loan/approval", + "to": "lending/approval", + }), + json!({ + "kind": "replaceImport", + "document": "loan/approval", + "from": "loan/dictionaries", + "to": "lending/dictionaries", + }), + ] + ); +} + +#[test] +fn references_to_missing_documents_are_still_rewritten() { + let mut ws = Workspace::new(); + ws.set_document("loan", document(graph(&["loan-dictionaries"], &[]))); + + assert_eq!(edits(&ws, &[("loan-dictionaries", "renamed")]).len(), 1); +} + +#[test] +fn unrelated_and_identity_moves_produce_no_edits() { + let mut ws = Workspace::new(); + ws.set_document("a", document(policy(&[]))); + ws.set_document("b", document(graph(&["a"], &["a"]))); + + assert!(edits(&ws, &[("a", "a"), ("c", "d")]).is_empty()); +} + +#[test] +fn graph_importing_and_calling_the_same_path_gets_both_edits() { + let mut ws = Workspace::new(); + ws.set_document("a", document(policy(&[]))); + ws.set_document("b", document(graph(&["a"], &["a"]))); + + let kinds: Vec = edits(&ws, &[("a", "z")]) + .into_iter() + .map(|edit| edit["kind"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(kinds, vec!["replaceImport", "replaceDecisionKey"]); +}