Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

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

14 changes: 14 additions & 0 deletions crates/wright-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ const TOOLS: &[ToolSpec] = &[
drop_fields: &["sources"],
description: "Validate and preview a source-edit transaction atomically against the session's project; no filesystem writes. Sources default to the on-disk text.",
},
ToolSpec {
op: "providerSemanticRename",
request_def: "ProviderSemanticRenameRequest",
drop_fields: &[],
description: "Provider-owned rename for source-language projects (e.g. OverPy): the configured provider computes the edits, Wright verifies document versions and source preconditions, the provider validates the transaction, and the edited project is rechecked — returning validated edits or a structured refusal. `documents` and `sources` are supplied by the caller.",
},
ToolSpec {
op: "providerValidateEdit",
request_def: "ProviderValidateEditRequest",
drop_fields: &[],
description: "Validate a caller-proposed source-edit transaction for a provider-owned language through the same provider-backed pipeline as providerSemanticRename; no filesystem writes. `documents` and `sources` are supplied by the caller.",
},
];

/// A tool definition as listed by `tools/list`.
Expand Down Expand Up @@ -359,6 +371,8 @@ mod tests {
assert_eq!(names[7], "wright_cost_estimate");
assert_eq!(names[8], "wright_semantic_rename");
assert_eq!(names[9], "wright_validate_edit_transaction");
assert_eq!(names[10], "wright_provider_semantic_rename");
assert_eq!(names[11], "wright_provider_validate_edit");
assert!(names.iter().all(|name| name.starts_with("wright_")));
}

Expand Down
6 changes: 6 additions & 0 deletions crates/wright-cli/tests/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ fn mcp_transport_lists_the_initial_tool_set_within_capabilities() {
"wright_cost_estimate",
"wright_semantic_rename",
"wright_validate_edit_transaction",
"wright_provider_semantic_rename",
"wright_provider_validate_edit",
]
);
// tools/list is a subset of the contract's advertised operations.
Expand All @@ -667,6 +669,8 @@ fn mcp_transport_lists_the_initial_tool_set_within_capabilities() {
"cost_estimate" => "costEstimate",
"semantic_rename" => "semanticRename",
"validate_edit_transaction" => "validateEditTransaction",
"provider_semantic_rename" => "providerSemanticRename",
"provider_validate_edit" => "providerValidateEdit",
other => other,
};
assert!(
Expand Down Expand Up @@ -739,6 +743,8 @@ fn mcp_transport_results_match_the_service_contract() {
"cost_estimate" => "costEstimate",
"semantic_rename" => "semanticRename",
"validate_edit_transaction" => "validateEditTransaction",
"provider_semantic_rename" => "providerSemanticRename",
"provider_validate_edit" => "providerValidateEdit",
other => other,
};
let mut request = arguments.clone();
Expand Down
177 changes: 173 additions & 4 deletions crates/wright-driver/src/provider_edit.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -112,12 +112,29 @@ pub fn semantic_rename(
Err(diag) => return refusal(vec![diag], None),
};

// The supplied document set is deliberately wider than the target
// project: only the provider can compute project membership (include
// resolution, entry selection), and `lpp/rename` needs every open
// document to spot references the project must account for. The
// post-edit check verdict is therefore scoped to the mutation the
// provider actually produced — the edit sites plus the position
// document — so an unrelated open document cannot block a valid
// rename. Diagnostics in member files that were never supplied still
// attribute to the entry document's check view and remain blocking.
let mut edit_scope: BTreeSet<String> = transaction
.edits
.iter()
.map(|edit| edit.source.clone())
.collect();
edit_scope.insert(request.position_document_uri.clone());

finish_transaction(
provider,
&request.documents,
transaction,
&request.sources,
request.project_root.as_deref(),
&edit_scope,
)
}

Expand All @@ -129,12 +146,17 @@ pub fn validate_transaction(
Ok(transaction) => transaction,
Err(diagnostic) => return refusal(vec![diagnostic], None),
};
// A caller-supplied document set is the declared project: every
// supplied document's errors stay blocking, including unedited
// members — the post-edit check is the only project-level gate here.
let edit_scope: BTreeSet<String> = request.documents.keys().cloned().collect();
finish_transaction(
provider,
&request.documents,
transaction,
&request.sources,
request.project_root.as_deref(),
&edit_scope,
)
}

Expand All @@ -144,6 +166,7 @@ fn finish_transaction(
transaction: EditTransaction,
sources: &BTreeMap<String, String>,
project_root: Option<&str>,
edit_scope: &BTreeSet<String>,
) -> ProviderMutation {
for edit in &transaction.edits {
if let Some(diagnostic) = crate::edit::source_precondition(edit, sources) {
Expand All @@ -154,9 +177,14 @@ fn finish_transaction(
Ok(previews) => previews,
Err(diag) => return refusal(vec![diag], None),
};
if let Err((diagnostics, provider)) =
validate_pipeline(provider, documents, &transaction, &previews, project_root)
{
if let Err((diagnostics, provider)) = validate_pipeline(
provider,
documents,
&transaction,
&previews,
project_root,
edit_scope,
) {
return refusal(diagnostics, provider);
}
ProviderMutation {
Expand All @@ -175,6 +203,7 @@ fn validate_pipeline(
transaction: &EditTransaction,
previews: &[SourcePreview],
project_root: Option<&str>,
edit_scope: &BTreeSet<String>,
) -> Result<(), (Vec<Diagnostic>, Option<ProviderInfo>)> {
let mut by_source: BTreeMap<&str, Vec<&SourceEdit>> = BTreeMap::new();
for edit in &transaction.edits {
Expand Down Expand Up @@ -261,6 +290,13 @@ fn validate_pipeline(
.check(&edited, project_root)
.map_err(provider_failure_tuple)?;
for doc in &checked.documents {
// Only documents the mutation is allowed to affect can block it:
// the request set may intentionally carry unrelated open documents
// (see `semantic_rename`), whose diagnostics are not this project's
// problem.
if !edit_scope.contains(&doc.uri) {
continue;
}
for diag in &doc.diagnostics {
if diag.severity == wright_lpp::DiagnosticSeverity::Error {
return Err((
Expand Down Expand Up @@ -445,6 +481,7 @@ mod tests {
};

const URI: &str = "file:///project/puzzle.xdl";
const UNRELATED_URI: &str = "file:///project/unrelated.xdl";
const CLEAN: &str = "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}";

fn document(text: &str) -> Document {
Expand Down Expand Up @@ -938,6 +975,138 @@ mod tests {
assert!(mutation.preview.is_none());
}

#[test]
fn rename_post_edit_check_drops_unrelated_open_document_errors() {
// The rename document set is language-wide because only the
// provider can compute project membership; a supplied document the
// mutation never touched must not block it.
let mut request = rename_request();
request.documents.insert(
UNRELATED_URI.to_string(),
wright_lpp::Document {
uri: UNRELATED_URI.to_string(),
language_id: "x-demo-lang".to_string(),
version: 1,
text: "puzzle broken {\n".to_string(),
},
);
request
.sources
.insert(UNRELATED_URI.to_string(), "puzzle broken {\n".to_string());
let mut provider = ScriptedProvider {
rename: Ok(clean_rename_result()),
validate_edits: Ok(ValidateEditsResult {
valid: true,
version: 3,
reason: None,
failing_edit_index: None,
}),
check: Ok(CheckResult {
documents: vec![wright_lpp::DocumentDiagnostics {
uri: UNRELATED_URI.to_string(),
version: 1,
diagnostics: vec![wright_lpp::Diagnostic {
range: wright_lpp::Range {
start: wright_lpp::Position {
line: 0,
character: 0,
},
end: wright_lpp::Position {
line: 0,
character: 15,
},
},
severity: wright_lpp::DiagnosticSeverity::Error,
code: Some("x-demo/unterminated".to_string()),
message: "unterminated puzzle".to_string(),
source: Some("x-demo-lang".to_string()),
}],
}],
}),
};
let mutation = semantic_rename(&mut provider, &request);
assert!(
mutation.ok,
"an unrelated document's error cannot block the rename: {:?}",
mutation.diagnostics
);
assert!(mutation.transaction.is_some());
}

#[test]
fn validate_transaction_check_scope_covers_the_whole_caller_set() {
// A caller-supplied document set is the declared project — unlike
// the rename-collected set above, an error in an unedited document
// still blocks because the post-edit check is the only project gate.
let transaction = EditTransaction::new(vec![SourceEdit {
edit_kind: "rename".to_string(),
source: URI.to_string(),
source_identity: crate::input_identity(CLEAN),
range: EditRange {
start_line: 5,
start_col: 5,
end_line: 5,
end_col: 11,
},
new_text: "twice".to_string(),
}])
.expect("transaction");
let mut request = ProviderValidateRequest {
documents: document_set(),
transaction,
sources: sources(CLEAN),
project_root: None,
};
request.documents.insert(
UNRELATED_URI.to_string(),
wright_lpp::Document {
uri: UNRELATED_URI.to_string(),
language_id: "x-demo-lang".to_string(),
version: 1,
text: "puzzle broken {\n".to_string(),
},
);
request
.sources
.insert(UNRELATED_URI.to_string(), "puzzle broken {\n".to_string());
let mut provider = ScriptedProvider {
rename: Ok(RenameResult { edits: vec![] }),
validate_edits: Ok(ValidateEditsResult {
valid: true,
version: 3,
reason: None,
failing_edit_index: None,
}),
check: Ok(CheckResult {
documents: vec![wright_lpp::DocumentDiagnostics {
uri: UNRELATED_URI.to_string(),
version: 1,
diagnostics: vec![wright_lpp::Diagnostic {
range: wright_lpp::Range {
start: wright_lpp::Position {
line: 0,
character: 0,
},
end: wright_lpp::Position {
line: 0,
character: 15,
},
},
severity: wright_lpp::DiagnosticSeverity::Error,
code: Some("x-demo/unterminated".to_string()),
message: "unterminated puzzle".to_string(),
source: Some("x-demo-lang".to_string()),
}],
}],
}),
};
let mutation = validate_transaction(&mut provider, &request);
assert!(!mutation.ok);
assert_eq!(mutation.diagnostics[0].code, "provider-semantic-error");
assert!(mutation.transaction.is_none());
assert!(mutation.preview.is_none());
}

#[test]
fn validate_transaction_runs_the_provider_gates_on_a_caller_transaction() {
// The caller proposes a Wright transaction; the flow re-asserts the
Expand Down
33 changes: 10 additions & 23 deletions crates/wright-driver/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,36 +600,23 @@ impl<'a> ToolService<'a> {
self.session.language_provider(language_id)
}

/// Run a provider-driven mutation flow (#139) over a fresh provider
/// session: spawn by opaque language id, initialize, run the flow, and
/// terminate gracefully.
///
/// Any failure before the flow — an unconfigured language id, a spawn
/// failure, a failed handshake — is the same structured
/// [`crate::provider_edit::ProviderMutation`] refusal surface the flow
/// itself uses, so callers handle one refusal contract. The provider
/// process never outlives the request: graceful shutdown when possible,
/// and the session's drop guard terminates it otherwise.
/// Run a provider-driven mutation flow (#139) through the session's
/// shared provider lifecycle path.
fn run_provider_flow(
&self,
language_id: &str,
flow: impl FnOnce(
&mut dyn wright_lpp::LanguageProvider,
) -> crate::provider_edit::ProviderMutation,
) -> crate::provider_edit::ProviderMutation {
let mut provider = match self.session.language_provider(language_id) {
Ok(provider) => provider,
Err(error) => return crate::provider_edit::provider_failure(&error),
};
if let Err(error) = provider.initialize(Some(&wright_lpp::ClientInfo {
name: SERVICE_NAME.to_string(),
version: SERVICE_VERSION.to_string(),
})) {
return crate::provider_edit::provider_failure(&error);
}
let mutation = flow(provider.as_mut());
let _ = provider.shutdown();
mutation
self.session.run_provider_flow(
language_id,
&wright_lpp::ClientInfo {
name: SERVICE_NAME.to_string(),
version: SERVICE_VERSION.to_string(),
},
flow,
)
}

fn ok(&self, result: serde_json::Value) -> ToolResponse {
Expand Down
30 changes: 30 additions & 0 deletions crates/wright-driver/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,36 @@ impl CompilerSession {
.map(|b| Box::new(b) as Box<dyn wright_lpp::LanguageProvider>)
}

/// Run a provider-driven mutation flow (#139) over a fresh provider
/// session: spawn by opaque language id, initialize with `client`, run
/// the flow, and terminate gracefully.
///
/// Any failure before the flow — an unconfigured language id, a spawn
/// failure, a failed handshake — is the same structured
/// [`crate::provider_edit::ProviderMutation`] refusal surface the flow
/// itself uses, so callers handle one refusal contract. The provider
/// process never outlives the request: graceful shutdown when possible,
/// and the provider's drop guard terminates it otherwise.
pub fn run_provider_flow(
&self,
language_id: &str,
client: &wright_lpp::ClientInfo,
flow: impl FnOnce(
&mut dyn wright_lpp::LanguageProvider,
) -> crate::provider_edit::ProviderMutation,
) -> crate::provider_edit::ProviderMutation {
let mut provider = match self.language_provider(language_id) {
Ok(provider) => provider,
Err(error) => return crate::provider_edit::provider_failure(&error),
};
if let Err(error) = provider.initialize(Some(client)) {
return crate::provider_edit::provider_failure(&error);
}
let mutation = flow(provider.as_mut());
let _ = provider.shutdown();
mutation
}

/// The locale a Workshop input resolved to, if the last load was
/// Workshop-origin.
pub fn resolved_locale(&self) -> Option<String> {
Expand Down
Loading
Loading