diff --git a/crates/wright-cli/src/bin/wright-serve.rs b/crates/wright-cli/src/bin/wright-serve.rs index fc60b991..87cb412a 100644 --- a/crates/wright-cli/src/bin/wright-serve.rs +++ b/crates/wright-cli/src/bin/wright-serve.rs @@ -1,176 +1,15 @@ -//! Exposes the same operations as [`wright_driver::service::ToolService`] over two transports. +#[path = "../serve.rs"] +mod serve; -use std::io::{BufRead, Write}; +use clap::Parser; use std::process::ExitCode; -use serde_json::{Value, json}; -use wright_driver::config::{InputSpec, SessionConfig, SourceKind}; -use wright_driver::service::{ToolRequest, ToolService}; - -fn usage() -> &'static str { - "usage: wright-serve --transport stdio|jsonrpc [--kind opy|ostw|workshop|protocol] [--locale LOC] [--profile off|compat|aggressive] [INPUT]\n\ - \n\ - Serves the Wright tool service over stdin/stdout. With no INPUT, reads a\n\ - protocol payload or Workshop text from stdin (auto-detected)." +#[derive(Debug, Parser)] +struct Cli { + #[command(flatten)] + serve: serve::ServeArgs, } fn main() -> ExitCode { - let mut args = std::env::args().skip(1); - let mut transport = "stdio".to_string(); - let mut config = SessionConfig::default(); - let mut positional: Option = None; - while let Some(arg) = args.next() { - match arg.as_str() { - "--transport" => transport = args.next().unwrap_or_else(|| "stdio".to_string()), - "--kind" => { - config.kind = - SourceKind::parse(&args.next().unwrap_or_default()).unwrap_or(SourceKind::Auto) - } - "--locale" => config.locale = args.next(), - "--profile" => { - config.profile = wright_driver::Profile::parse(&args.next().unwrap_or_default()) - .unwrap_or_default() - } - "--help" | "-h" => { - println!("{}", usage()); - return ExitCode::SUCCESS; - } - other if other.starts_with('-') => { - eprintln!("wright-serve: unknown argument '{other}'\n{}", usage()); - return ExitCode::from(2); - } - other => positional = Some(std::path::PathBuf::from(other)), - } - } - - config.input = positional.map(InputSpec::Path).unwrap_or(InputSpec::Stdin); - let mut session = match wright_driver::CompilerSession::new(config) { - Ok(s) => s, - Err(d) => { - eprintln!("wright-serve: {}", d.message); - return ExitCode::from(1); - } - }; - let mut service = match ToolService::new(&mut session) { - Ok(s) => s, - Err(d) => { - eprintln!("wright-serve: {}", d.message); - return ExitCode::from(1); - } - }; - - match transport.as_str() { - "stdio" => serve_stdio(&mut service), - "jsonrpc" => serve_jsonrpc(&mut service), - other => { - eprintln!("wright-serve: unknown transport '{other}'\n{}", usage()); - ExitCode::from(2) - } - } -} - -fn serve_stdio(service: &mut ToolService<'_>) -> ExitCode { - serve_lines(|line| Some(dispatch(service, line))) -} - -fn serve_jsonrpc(service: &mut ToolService<'_>) -> ExitCode { - serve_lines(|line| { - match serde_json::from_str(line) { - Ok(value) => jsonrpc_dispatch(service, value), - Err(_) => Some(jsonrpc_error(Value::Null, -32700, "Parse error")), - } - .map(|response| response.to_string()) - }) -} - -fn serve_lines(mut dispatch: impl FnMut(&str) -> Option) -> ExitCode { - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - for line in std::io::stdin().lock().lines().map_while(Result::ok) { - if line.trim().is_empty() { - continue; - } - if dispatch(&line).is_some_and(|response| writeln!(out, "{response}").is_err()) { - break; - } - } - ExitCode::SUCCESS -} - -fn jsonrpc_dispatch(service: &mut ToolService<'_>, value: Value) -> Option { - if let Value::Array(batch) = value { - if batch.is_empty() { - return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); - } - let responses: Vec = batch - .into_iter() - .filter_map(|v| jsonrpc_dispatch_request(service, v)) - .collect(); - return (!responses.is_empty()).then_some(Value::Array(responses)); - } - jsonrpc_dispatch_request(service, value) -} - -fn jsonrpc_dispatch_request(service: &mut ToolService<'_>, value: Value) -> Option { - let Some(object) = value.as_object() else { - return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); - }; - if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); - } - let method = match object.get("method").and_then(Value::as_str) { - Some(m) => m, - None => return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")), - }; - let has_id = object.contains_key("id"); - let id = object.get("id").cloned().unwrap_or(Value::Null); - if has_id && !matches!(id, Value::Null | Value::String(_) | Value::Number(_)) { - return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); - } - - if matches!(method, "compile" | "check" | "analyze" | "inspect") - && object.contains_key("params") - { - return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")); - } - - let response = match method { - "request" => { - let params = match object.get("params") { - Some(Value::Object(p)) if p.get("op").is_some() => Value::Object(p.clone()), - _ => return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")), - }; - let Ok(req) = serde_json::from_value::(params) else { - return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")); - }; - match service.handle(&req) { - wright_driver::service::ToolResponse::Ok { result } => result, - wright_driver::service::ToolResponse::Error { error } => json!({ "error": error }), - } - } - "compile" => serde_json::to_value(service.compile()).expect("serializes"), - "check" => serde_json::to_value(service.check()).expect("serializes"), - "analyze" => serde_json::to_value(service.analyze()).expect("serializes"), - "inspect" => serde_json::to_value(service.inspect()).expect("serializes"), - other => { - return has_id.then(|| jsonrpc_error(id, -32601, format!("Method not found: {other}"))); - } - }; - - has_id.then(|| json!({ "jsonrpc": "2.0", "id": id, "result": response })) -} - -fn jsonrpc_error(id: Value, code: i64, message: impl Into) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message.into() } }) -} - -fn dispatch(service: &mut ToolService<'_>, line: &str) -> String { - let req: ToolRequest = match serde_json::from_str(line) { - Ok(r) => r, - Err(e) => { - return json!({ "error": { "code": "malformed-request", "message": e.to_string() } }) - .to_string(); - } - }; - serde_json::to_string(&service.handle(&req)).expect("serializes") + serve::run(Cli::parse().serve) } diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index c787e495..4eff5886 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crate::serve::ServeArgs; use clap::{Args, Parser, Subcommand, ValueEnum}; /// The top-level command model used by parsing, help, and completion. @@ -25,7 +26,8 @@ pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI. Commands check correctness, summarize semantic hotspots, lint, inspect exhaustive facts, compile, or reconstruct source through the typed wright-driver result envelope. `compile` and `convert` keep their source artifact stdout contracts; JSON mode prints only one -wright-result/v1 envelope to stdout. +wright-result/v1 envelope to stdout. `serve` exposes the versioned +wright-agent/v1 session contract over stdio or JSON-RPC 2.0. EXIT CODES: 0 success @@ -78,6 +80,8 @@ pub(crate) enum Command { Update(UpdateArgs), /// Manage first-party language providers. Provider(ProviderArgs), + /// Serve the versioned agent contract over stdio or JSON-RPC 2.0. + Serve(ServeArgs), /// Show the top-level help. Help, /// Show version and result-contract metadata. diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index ba813289..50f1e8f3 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod completion; mod present; mod provider; +mod serve; mod update; use std::io::Read; @@ -62,6 +63,7 @@ fn main() -> ExitCode { ExitCode::SUCCESS } Some(Command::SemanticCompare(args)) => run_semantic_compare(args), + Some(Command::Serve(args)) => serve::run(args), Some(Command::Completion(args)) => match args.subcommand { Some(cli::CompletionSubcommand::Install(install_args)) => { match completion::run_install(&install_args) { @@ -194,6 +196,7 @@ fn run_workflow(command: Command) -> ExitCode { Command::Completion(_) | Command::Update(_) | Command::Provider(_) + | Command::Serve(_) | Command::Help | Command::Version | Command::SemanticCompare(_) => { diff --git a/crates/wright-cli/src/serve.rs b/crates/wright-cli/src/serve.rs new file mode 100644 index 00000000..deacb50b --- /dev/null +++ b/crates/wright-cli/src/serve.rs @@ -0,0 +1,180 @@ +use std::io::{BufRead, Write}; +use std::process::ExitCode; + +use clap::Args; +use serde_json::{Value, json}; +use wright_driver::config::{InputSpec, SessionConfig, SourceKind}; +use wright_driver::service::{ToolRequest, ToolService}; + +#[derive(Debug, Args)] +pub(crate) struct ServeArgs { + #[arg(long, default_value = "stdio")] + transport: String, + #[arg(long, default_value = "auto")] + kind: String, + #[arg(long)] + locale: Option, + #[arg(long)] + profile: Option, + #[arg(value_name = "INPUT")] + input: Option, +} + +pub(crate) fn run(args: ServeArgs) -> ExitCode { + if args + .input + .as_deref() + .is_some_and(|input| input == std::path::Path::new("-")) + { + eprintln!( + "wright: serve reserves stdin for requests; pass a source file or project directory" + ); + return ExitCode::from(2); + } + let config = SessionConfig { + kind: SourceKind::parse(&args.kind).unwrap_or(SourceKind::Auto), + locale: args.locale, + profile: args + .profile + .as_deref() + .map(|profile| wright_driver::Profile::parse(profile).unwrap_or_default()) + .unwrap_or_default(), + input: InputSpec::Path(args.input.unwrap_or_else(|| ".".into())), + ..SessionConfig::default() + }; + let mut session = match wright_driver::CompilerSession::new(config) { + Ok(session) => session, + Err(diagnostic) => { + eprintln!("wright: {}", diagnostic.message); + return ExitCode::from(1); + } + }; + let mut service = match ToolService::new(&mut session) { + Ok(service) => service, + Err(diagnostic) => { + eprintln!("wright: {}", diagnostic.message); + return ExitCode::from(1); + } + }; + + match args.transport.as_str() { + "stdio" => serve_stdio(&mut service), + "jsonrpc" => serve_jsonrpc(&mut service), + other => { + eprintln!("wright: unknown transport '{other}'"); + ExitCode::from(2) + } + } +} + +fn serve_stdio(service: &mut ToolService<'_>) -> ExitCode { + serve_lines(|line| Some(dispatch(service, line))) +} + +fn serve_jsonrpc(service: &mut ToolService<'_>) -> ExitCode { + serve_lines(|line| { + match serde_json::from_str(line) { + Ok(value) => jsonrpc_dispatch(service, value), + Err(_) => Some(jsonrpc_error(Value::Null, -32700, "Parse error")), + } + .map(|response| response.to_string()) + }) +} + +fn serve_lines(mut dispatch: impl FnMut(&str) -> Option) -> ExitCode { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for line in std::io::stdin().lock().lines().map_while(Result::ok) { + if line.trim().is_empty() { + continue; + } + if dispatch(&line).is_some_and(|response| writeln!(out, "{response}").is_err()) { + break; + } + } + ExitCode::SUCCESS +} + +fn jsonrpc_dispatch(service: &mut ToolService<'_>, value: Value) -> Option { + if let Value::Array(batch) = value { + if batch.is_empty() { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + } + let responses: Vec = batch + .into_iter() + .filter_map(|value| jsonrpc_dispatch_request(service, value)) + .collect(); + return (!responses.is_empty()).then_some(Value::Array(responses)); + } + jsonrpc_dispatch_request(service, value) +} + +fn jsonrpc_dispatch_request(service: &mut ToolService<'_>, value: Value) -> Option { + let Some(object) = value.as_object() else { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + }; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + } + let method = match object.get("method").and_then(Value::as_str) { + Some(method) => method, + None => return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")), + }; + let has_id = object.contains_key("id"); + let id = object.get("id").cloned().unwrap_or(Value::Null); + if has_id && !matches!(id, Value::Null | Value::String(_) | Value::Number(_)) { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + } + + if matches!(method, "compile" | "check" | "analyze" | "inspect") + && object.contains_key("params") + { + return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")); + } + + let response = match method { + "request" => { + let params = match object.get("params") { + Some(Value::Object(params)) if params.get("op").is_some() => { + Value::Object(params.clone()) + } + _ => return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")), + }; + let Ok(request) = serde_json::from_value::(params) else { + return has_id.then(|| jsonrpc_error(id, -32602, "Invalid params")); + }; + operation_result(service, request) + } + "compile" => operation_result(service, ToolRequest::Compile), + "check" => operation_result(service, ToolRequest::Check), + "analyze" => operation_result(service, ToolRequest::Analyze), + "inspect" => operation_result(service, ToolRequest::Inspect), + other => { + return has_id.then(|| jsonrpc_error(id, -32601, format!("Method not found: {other}"))); + } + }; + + has_id.then(|| json!({ "jsonrpc": "2.0", "id": id, "result": response })) +} + +fn operation_result(service: &mut ToolService<'_>, request: ToolRequest) -> Value { + match service.handle(&request) { + wright_driver::service::ToolResponse::Ok { result } => result, + wright_driver::service::ToolResponse::Error { error } => json!({ "error": error }), + } +} + +fn jsonrpc_error(id: Value, code: i64, message: impl Into) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message.into() } }) +} + +fn dispatch(service: &mut ToolService<'_>, line: &str) -> String { + let request: ToolRequest = match serde_json::from_str(line) { + Ok(request) => request, + Err(error) => { + return json!({ "error": { "code": "malformed-request", "message": error.to_string() } }) + .to_string(); + } + }; + serde_json::to_string(&service.handle(&request)).expect("serializes") +} diff --git a/crates/wright-cli/tests/agent_contract.rs b/crates/wright-cli/tests/agent_contract.rs new file mode 100644 index 00000000..b300429b --- /dev/null +++ b/crates/wright-cli/tests/agent_contract.rs @@ -0,0 +1,303 @@ +use std::collections::BTreeSet; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use jsonschema::JSONSchema; +use serde_json::{Value, json}; +use wright_driver::service::{AGENT_CONTRACT, ToolRequest, ToolResponse, ToolService}; +use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceKind}; + +const EXPECTED_V1_OPERATIONS: &[&str] = &[ + "capabilities", + "compile", + "check", + "analyze", + "inspect", + "project", + "rules", + "symbols", + "references", + "usage", + "cfg", + "findings", + "persistentObjects", + "lint", + "lintRules", + "callGraph", + "costEstimate", + "targetMetadata", + "validateEditTransaction", + "semanticRename", + "providerSemanticRename", + "providerValidateEdit", +]; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") +} + +fn capabilities() -> Value { + let input = workspace_root().join("tests/fixtures/workshop/synthetic/basic-rule.ws"); + let mut child = Command::new(env!("CARGO_BIN_EXE_wright")) + .args(["serve", "--transport", "stdio"]) + .arg(input) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("wright serve starts"); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"op\":\"capabilities\"}\n") + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + serde_json::from_slice::(output.stdout.split(|byte| *byte == b'\n').next().unwrap()) + .unwrap()["result"] + .clone() +} + +fn requests() -> Vec { + vec![ + json!({"op":"capabilities"}), + json!({"op":"compile"}), + json!({"op":"check"}), + json!({"op":"analyze"}), + json!({"op":"inspect"}), + json!({"op":"project"}), + json!({"op":"rules"}), + json!({"op":"symbols","kind":null}), + json!({"op":"references","symbol":1}), + json!({"op":"usage","symbol":1}), + json!({"op":"cfg","rule":1}), + json!({"op":"findings"}), + json!({"op":"persistentObjects"}), + json!({"op":"lint"}), + json!({"op":"lintRules"}), + json!({"op":"callGraph"}), + json!({"op":"costEstimate"}), + json!({"op":"targetMetadata"}), + json!({ + "op":"validateEditTransaction", + "sources":{"file:///project.ws":"rule(\"Example\") {}"}, + "transaction":{"edits":[{ + "kind":"edit", + "source":"file:///project.ws", + "source_identity":"sha256:example", + "range":{"start_line":1,"start_col":1,"end_line":1,"end_col":1}, + "new_text":"" + }]} + }), + json!({ + "op":"semanticRename", + "sources":{"file:///project.ws":"rule(\"Example\") {}"}, + "target":{"source":"file:///project.ws","line":1,"col":1,"to":"Renamed"} + }), + json!({ + "op":"providerSemanticRename", + "language_id":"opy", + "documents":{"file:///project.opy":{ + "uri":"file:///project.opy","languageId":"opy","version":1,"text":"rule(\"Example\") {}" + }}, + "position_document_uri":"file:///project.opy", + "position":{"line":0,"character":0}, + "new_name":"Renamed", + "project_root":null, + "sources":{"file:///project.opy":"rule(\"Example\") {}"} + }), + json!({ + "op":"providerValidateEdit", + "language_id":"opy", + "documents":{"file:///project.opy":{ + "uri":"file:///project.opy","languageId":"opy","version":1,"text":"rule(\"Example\") {}" + }}, + "transaction":{"edits":[{ + "kind":"edit", + "source":"file:///project.opy", + "source_identity":"sha256:example", + "range":{"start_line":1,"start_col":1,"end_line":1,"end_col":1}, + "new_text":"" + }]}, + "sources":{"file:///project.opy":"rule(\"Example\") {}"}, + "project_root":null + }), + ] +} + +fn service_responses() -> Vec<(String, Value)> { + let input = workspace_root().join("tests/fixtures/workshop/synthetic/control-flow.ws"); + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(input), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .expect("session starts"); + let mut service = ToolService::new(&mut session).expect("service loads the project"); + + let rule = match service.handle(&ToolRequest::Rules) { + ToolResponse::Ok { result } => result[0]["id"].as_u64().unwrap(), + ToolResponse::Error { error } => panic!("rules failed: {error:?}"), + }; + let symbol = match service.handle(&ToolRequest::Symbols { kind: None }) { + ToolResponse::Ok { result } => result[0]["id"].as_u64().unwrap(), + ToolResponse::Error { error } => panic!("symbols failed: {error:?}"), + }; + + requests() + .into_iter() + .map(|mut request| { + let operation = request["op"].as_str().unwrap().to_string(); + match operation.as_str() { + "references" | "usage" => request["symbol"] = json!(symbol), + "cfg" => request["rule"] = json!(rule), + _ => {} + } + let parsed = + serde_json::from_value::(request).expect("request deserializes"); + let response = service.handle(&parsed); + let ToolResponse::Ok { result } = response else { + panic!("{operation} returned a service error: {response:?}"); + }; + (operation, result) + }) + .collect() +} + +#[test] +fn agent_v1_schema_covers_every_advertised_request_and_response() { + let root = workspace_root(); + let schema: Value = serde_json::from_str( + &std::fs::read_to_string(root.join("schemas/wright-agent-v1.schema.json")).unwrap(), + ) + .unwrap(); + let request_schema = JSONSchema::compile(&schema).expect("request schema compiles"); + let mut response_document = schema.clone(); + response_document["$ref"] = Value::String("#/$defs/ToolResponse".to_string()); + let response_schema = + JSONSchema::compile(&response_document).expect("response schema compiles"); + let mut capabilities_document = schema.clone(); + capabilities_document["$ref"] = Value::String("#/$defs/Capabilities".to_string()); + let capabilities_schema = + JSONSchema::compile(&capabilities_document).expect("capability schema compiles"); + let mut edit_range_document = schema.clone(); + edit_range_document["$ref"] = Value::String("#/$defs/EditRange".to_string()); + let edit_range_schema = + JSONSchema::compile(&edit_range_document).expect("range schema compiles"); + + assert_eq!(AGENT_CONTRACT, "wright-agent/v1"); + assert_eq!( + schema["$id"], + "https://wrightkit.dev/schemas/wright-agent-v1.schema.json" + ); + let current = capabilities(); + assert_eq!(current["agent_contract"], AGENT_CONTRACT); + let advertised = current["operations"] + .as_array() + .unwrap() + .iter() + .map(|operation| operation.as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + schema["$defs"]["Capabilities"]["properties"]["operations"]["items"]["type"], + "string" + ); + let request_examples = requests(); + let request_operations = request_examples + .iter() + .map(|request| request["op"].as_str().unwrap().to_string()) + .collect::>(); + + assert_eq!(advertised, request_operations); + assert_eq!( + advertised, + EXPECTED_V1_OPERATIONS + .iter() + .map(|operation| (*operation).to_string()) + .collect() + ); + assert!(capabilities_schema.is_valid(¤t)); + let mut future_capabilities = current.clone(); + future_capabilities["operations"] + .as_array_mut() + .unwrap() + .push(json!("futureOperation")); + assert!(capabilities_schema.is_valid(&future_capabilities)); + for request in request_examples { + assert!( + request_schema.is_valid(&request), + "invalid request: {request}" + ); + serde_json::from_value::(request).expect("request deserializes"); + } + + let capabilities_response = json!({"result":current}); + assert!(response_schema.is_valid(&capabilities_response)); + assert!(response_schema.is_valid(&json!({ + "result":current, + "future_metadata":{"version":2} + }))); + assert!(response_schema.is_valid(&json!({ + "error":{"code":"unsupported","message":"not available","details":{}}, + "future_metadata":{"version":2} + }))); + assert!(!response_schema.is_valid(&json!({"result":{},"error":{"code":"x","message":"y"}}))); + assert!(!request_schema.is_valid(&json!({"op":"check","unexpected":true}))); + + let operation_results = [ + ("capabilities", "CapabilitiesResult"), + ("compile", "CompileResult"), + ("check", "CheckResult"), + ("analyze", "AnalyzeResult"), + ("inspect", "InspectResult"), + ("project", "ProjectResult"), + ("rules", "RulesResult"), + ("symbols", "SymbolsResult"), + ("references", "ReferencesResult"), + ("usage", "UsageResult"), + ("cfg", "CfgResult"), + ("findings", "FindingsResult"), + ("persistentObjects", "PersistentObjectsResult"), + ("lint", "LintResult"), + ("lintRules", "LintRulesResult"), + ("callGraph", "CallGraphResult"), + ("costEstimate", "CostEstimateResult"), + ("targetMetadata", "TargetMetadataResult"), + ("validateEditTransaction", "ValidateEditTransactionResult"), + ("semanticRename", "SemanticRenameResult"), + ("providerSemanticRename", "ProviderSemanticRenameResult"), + ("providerValidateEdit", "ProviderValidateEditResult"), + ]; + let live_responses = service_responses(); + assert_eq!(live_responses.len(), operation_results.len()); + for ((operation, result), (expected_operation, definition)) in + live_responses.iter().zip(operation_results) + { + assert_eq!(operation, expected_operation); + let response = json!({"result":result}); + assert!( + response_schema.is_valid(&response), + "ToolResponse schema rejected {operation}: {response}" + ); + let mut operation_document = schema.clone(); + operation_document["$ref"] = Value::String(format!("#/$defs/{definition}")); + let operation_schema = + JSONSchema::compile(&operation_document).expect("operation result schema compiles"); + assert!( + operation_schema.is_valid(result), + "{operation} result does not match {definition}: {result}" + ); + } + + let valid_range = json!({"start_line":1,"start_col":1,"end_line":1,"end_col":1}); + assert!(edit_range_schema.is_valid(&valid_range)); + for coordinate in ["start_line", "start_col", "end_line", "end_col"] { + let mut zero_range = valid_range.clone(); + zero_range[coordinate] = json!(0); + assert!( + !edit_range_schema.is_valid(&zero_range), + "EditRange accepted 0 for {coordinate}" + ); + } +} diff --git a/crates/wright-cli/tests/serve.rs b/crates/wright-cli/tests/serve.rs index 59bdc928..0f7ad17b 100644 --- a/crates/wright-cli/tests/serve.rs +++ b/crates/wright-cli/tests/serve.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +fn wright() -> &'static str { + env!("CARGO_BIN_EXE_wright") +} + fn wright_serve() -> &'static str { env!("CARGO_BIN_EXE_wright-serve") } @@ -22,8 +26,17 @@ fn corpus_workshop(id: &str) -> PathBuf { } fn run_lines(transport: &str, input: &Path, lines: &[&str]) -> Vec { - let mut child = Command::new(wright_serve()) - .args(["--transport", transport]) + run_lines_with(wright(), &["serve", "--transport", transport], input, lines) +} + +fn run_lines_with( + executable: &str, + args: &[&str], + input: &Path, + lines: &[&str], +) -> Vec { + let mut child = Command::new(executable) + .args(args) .arg(input) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -62,6 +75,7 @@ fn stdio_transport_serves_structured_queries() { ); assert_eq!(responses.len(), 4); assert_eq!(responses[0]["result"]["contract"], "wright-result/v1"); + assert_eq!(responses[0]["result"]["agent_contract"], "wright-agent/v1"); assert_eq!(responses[1]["result"]["origin"]["kind"], "workshop"); assert!(responses[2]["result"].as_array().unwrap().is_empty()); assert!( @@ -105,6 +119,73 @@ fn jsonrpc_transport_serves_requests_and_workflows() { } } +#[test] +fn workflows_have_equivalent_results_through_agent_requests_and_legacy_methods() { + let input = corpus_workshop("synthetic/control-flow"); + let stdio = run_lines( + "stdio", + &input, + &[ + r#"{"op":"compile"}"#, + r#"{"op":"check"}"#, + r#"{"op":"analyze"}"#, + r#"{"op":"inspect"}"#, + ], + ); + let jsonrpc_requests = run_lines( + "jsonrpc", + &input, + &[ + r#"{"jsonrpc":"2.0","id":1,"method":"request","params":{"op":"compile"}}"#, + r#"{"jsonrpc":"2.0","id":2,"method":"request","params":{"op":"check"}}"#, + r#"{"jsonrpc":"2.0","id":3,"method":"request","params":{"op":"analyze"}}"#, + r#"{"jsonrpc":"2.0","id":4,"method":"request","params":{"op":"inspect"}}"#, + ], + ); + let jsonrpc_methods = run_lines( + "jsonrpc", + &input, + &[ + r#"{"jsonrpc":"2.0","id":1,"method":"compile"}"#, + r#"{"jsonrpc":"2.0","id":2,"method":"check"}"#, + r#"{"jsonrpc":"2.0","id":3,"method":"analyze"}"#, + r#"{"jsonrpc":"2.0","id":4,"method":"inspect"}"#, + ], + ); + for (index, command) in ["compile", "check", "analyze", "inspect"] + .iter() + .enumerate() + { + let result = &stdio[index]["result"]; + assert_eq!(result["command"], *command); + assert_eq!(result, &jsonrpc_requests[index]["result"]); + assert_eq!(result, &jsonrpc_methods[index]["result"]); + } +} + +#[test] +fn standalone_wright_serve_binary_matches_the_installed_subcommand() { + let input = corpus_workshop("synthetic/basic-rule"); + let cli = run_lines("stdio", &input, &[r#"{"op":"capabilities"}"#]); + let standalone = run_lines_with( + wright_serve(), + &["--transport", "stdio"], + &input, + &[r#"{"op":"capabilities"}"#], + ); + assert_eq!(cli[0], standalone[0]); +} + +#[test] +fn serve_reserves_stdin_for_session_requests() { + let output = Command::new(wright()) + .args(["serve", "-"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("reserves stdin for requests")); +} + #[test] fn transports_match_in_process_semantics() { // The same query through both transports yields equivalent results. @@ -252,6 +333,7 @@ fn capability_negotiation_is_preserved() { .unwrap() .starts_with("wright-result/") ); + assert_eq!(capabilities["agent_contract"], "wright-agent/v1"); } #[test] diff --git a/crates/wright-consumer/src/workflow.rs b/crates/wright-consumer/src/workflow.rs index 2968c1c5..2bb79be8 100644 --- a/crates/wright-consumer/src/workflow.rs +++ b/crates/wright-consumer/src/workflow.rs @@ -40,11 +40,12 @@ pub fn run_consumer(input: &str) -> Result<(), String> { lint.result.rules.as_array().unwrap().len() ); - let service = ToolService::new(&mut session).map_err(|e| e.message)?; + let mut service = ToolService::new(&mut session).map_err(|e| e.message)?; let capabilities = service.handle(&ToolRequest::Capabilities); match capabilities { wright_driver::service::ToolResponse::Ok { result } => { assert_eq!(result["contract"], "wright-result/v1"); + assert_eq!(result["agent_contract"], "wright-agent/v1"); assert!(result["operations"].as_array().unwrap().len() >= 10); println!( "service: {} v{}, {} operations", diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 0ecfdb22..e1514eeb 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -26,6 +26,7 @@ use wright_analyzer::service::{Request, Response}; /// The tool-service name and version. pub const SERVICE_NAME: &str = "wright-tool-service"; pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const AGENT_CONTRACT: &str = "wright-agent/v1"; /// A tool request: the owned query surface plus agent-oriented operations. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,6 +34,14 @@ pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); pub enum ToolRequest { /// Service identity, contract, and supported operations. Capabilities, + /// Compile the loaded project. + Compile, + /// Check the loaded project. + Check, + /// Analyze the loaded project. + Analyze, + /// Inspect the loaded project. + Inspect, /// The loaded canonical program summary (origin, files, counts, findings). Project, /// Every rule. @@ -144,6 +153,7 @@ pub struct Capabilities { pub name: String, pub version: String, pub contract: String, + pub agent_contract: String, pub operations: Vec, pub languages: Vec, pub profiles: Vec, @@ -184,6 +194,7 @@ impl<'a> ToolService<'a> { name: SERVICE_NAME.to_string(), version: SERVICE_VERSION.to_string(), contract: RESULT_CONTRACT.to_string(), + agent_contract: AGENT_CONTRACT.to_string(), operations: vec![ "capabilities", "project", @@ -221,11 +232,30 @@ impl<'a> ToolService<'a> { } /// Handle one tool request, returning a structured owned response. - pub fn handle(&self, request: &ToolRequest) -> ToolResponse { + pub fn handle(&mut self, request: &ToolRequest) -> ToolResponse { match request { ToolRequest::Capabilities => ToolResponse::Ok { result: serde_json::to_value(self.capabilities()).expect("capabilities serialize"), }, + ToolRequest::Compile => { + let result = + serde_json::to_value(self.compile()).expect("compile result serializes"); + ToolResponse::Ok { result } + } + ToolRequest::Check => { + let result = serde_json::to_value(self.check()).expect("check result serializes"); + ToolResponse::Ok { result } + } + ToolRequest::Analyze => { + let result = + serde_json::to_value(self.analyze()).expect("analyze result serializes"); + ToolResponse::Ok { result } + } + ToolRequest::Inspect => { + let result = + serde_json::to_value(self.inspect()).expect("inspect result serializes"); + ToolResponse::Ok { result } + } ToolRequest::Project => self.ok(self.project()), ToolRequest::Rules => self.semantic_query(Request::ListRules), ToolRequest::Symbols { kind } => { diff --git a/crates/wright-driver/tests/provider_edit.rs b/crates/wright-driver/tests/provider_edit.rs index 0465201b..2049777a 100644 --- a/crates/wright-driver/tests/provider_edit.rs +++ b/crates/wright-driver/tests/provider_edit.rs @@ -158,7 +158,7 @@ const DOUBLE_POSITION: Position = Position { character: 6, }; -fn handle(service: &ToolService<'_>, request: &ToolRequest) -> ProviderMutation { +fn handle(service: &mut ToolService<'_>, request: &ToolRequest) -> ProviderMutation { match service.handle(request) { ToolResponse::Ok { result } => { serde_json::from_value(result).expect("provider mutation deserializes") @@ -176,10 +176,10 @@ fn single_file_rename_routes_through_the_provider() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -230,10 +230,10 @@ fn multi_file_rename_edits_every_received_document_consistently() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = two_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -282,10 +282,10 @@ fn rename_collision_is_a_structured_atomic_refusal() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -310,10 +310,10 @@ fn invalid_new_name_is_a_structured_atomic_refusal() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -336,7 +336,7 @@ fn stale_current_sources_refuse_with_no_partial_edit_set() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); // The caller's current text no longer matches the snapshot the provider // computed the rename against: the Wright-owned identity precondition @@ -344,7 +344,7 @@ fn stale_current_sources_refuse_with_no_partial_edit_set() { let mut stale_sources = sources_of(&documents); stale_sources.insert(URI.to_string(), format!("{CLEAN_PUZZLE}\n")); let mutation = handle( - &service, + &mut service, &rename_request(documents, URI, DOUBLE_POSITION, "twice", stale_sources), ); assert!(!mutation.ok); @@ -362,10 +362,10 @@ fn missing_rename_capability_refuses_explicitly() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&["rename"]); + let mut service = tool_service(&["rename"]); let documents = single_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -394,10 +394,10 @@ fn provider_failure_mid_rename_applies_nothing() { // is not: the provider computes the rename, then the mandatory semantic // gate refuses. The failure happens after edit generation, so this // proves nothing partial is ever returned. - let service = tool_service(&["editValidation"]); + let mut service = tool_service(&["editValidation"]); let documents = single_document_set(); let mutation = handle( - &service, + &mut service, &rename_request( documents, URI, @@ -422,7 +422,7 @@ fn semantic_validation_failure_is_atomic_across_documents() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); // Document 1 declares only `double`; document 2 declares `double` and // `plus1`. Renaming `double` to `plus1` in document 1 passes the // provider's rename-time collision check (which is scoped to the @@ -437,7 +437,7 @@ fn semantic_validation_failure_is_atomic_across_documents() { ); let sources = sources_of(&documents); let mutation = handle( - &service, + &mut service, &rename_request(documents, URI, DOUBLE_POSITION, "plus1", sources), ); assert!(!mutation.ok); @@ -518,7 +518,7 @@ fn caller_transaction_validates_through_the_provider() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); let request = ToolRequest::ProviderValidateEdit { language_id: DEMO_LANGUAGE_ID.to_string(), @@ -527,7 +527,7 @@ fn caller_transaction_validates_through_the_provider() { sources: sources_of(&single_document_set()), project_root: Some("file:///project".to_string()), }; - let mutation = handle(&service, &request); + let mutation = handle(&mut service, &request); assert!( mutation.ok, "provider accepts the caller transaction: {:?}", @@ -543,7 +543,7 @@ fn caller_transaction_that_breaks_the_source_refuses() { let Some(_) = mock_provider_path() else { return; }; - let service = tool_service(&[]); + let mut service = tool_service(&[]); let documents = single_document_set(); // Deleting the target value produces a syntax error; the provider's // normative edit validation (apply, then re-parse) catches it. @@ -567,7 +567,7 @@ fn caller_transaction_that_breaks_the_source_refuses() { sources: sources_of(&single_document_set()), project_root: None, }; - let mutation = handle(&service, &request); + let mutation = handle(&mut service, &request); assert!(!mutation.ok); assert_eq!(mutation.diagnostics[0].code, "provider-validation-failed"); assert!(mutation.transaction.is_none()); diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index fefc0fcb..7881cd79 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -22,7 +22,7 @@ fn tool_service_queries_canonical_workshop() { ..SessionConfig::default() }) .unwrap(); - let service = ToolService::new(&mut session).unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); for request in [ ToolRequest::Capabilities, ToolRequest::Project, @@ -64,7 +64,7 @@ fn tool_service_lint_queries_keep_the_session_configuration() { ..SessionConfig::default() }) .unwrap(); - let service = ToolService::new(&mut session).unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); let lint_rules = match service.handle(&ToolRequest::LintRules) { ToolResponse::Ok { result } => result, @@ -101,6 +101,32 @@ fn tool_service_lint_queries_keep_the_session_configuration() { assert_eq!(default_finding["severity"], "warning"); } +#[test] +fn tool_service_routes_workflows_through_the_agent_request_contract() { + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(workshop_path()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); + + let capabilities = service.capabilities(); + assert_eq!(capabilities.agent_contract, "wright-agent/v1"); + for (request, command) in [ + (ToolRequest::Compile, "compile"), + (ToolRequest::Check, "check"), + (ToolRequest::Analyze, "analyze"), + (ToolRequest::Inspect, "inspect"), + ] { + let ToolResponse::Ok { result } = service.handle(&request) else { + panic!("{command} returns its structured envelope"); + }; + assert_eq!(result["command"], command); + assert_eq!(result["wright"]["contract"], "wright-result/v1"); + } +} + #[test] fn tool_service_keeps_provider_refusals_structured() { let path = workspace_root().join("tests/fixtures/opy/basic-rule.opy"); diff --git a/docs/README.md b/docs/README.md index e1022560..636dd5a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,8 @@ Issue contract. - [CLI & driver](cli.md): commands, exit behavior, and machine-readable result contracts. - [Embedding/tool API](embedding.md): programmatic session/query/edit services. +- [Agent contract](agent-contract.md): versioned session requests, results, and + transport mappings for coding agents and embedding consumers. - [Language services & LSP](language-services.md): editor-neutral language services and LSP framing. - [Integration verification](compatibility.md): owner boundaries and diff --git a/docs/agent-contract.md b/docs/agent-contract.md new file mode 100644 index 00000000..cf9633e2 --- /dev/null +++ b/docs/agent-contract.md @@ -0,0 +1,133 @@ +# Wright Agent Contract + +- Status: stable, versioned session contract +- Contract: `wright-agent/v1` +- Schema: [`schemas/wright-agent-v1.schema.json`](../schemas/wright-agent-v1.schema.json) + +`wright-agent/v1` is the transport-neutral request and response contract of +`wright_driver::service::ToolService`. Its Rust `ToolRequest` and `ToolResponse` +types are the executable definition. `capabilities` reports the contract +identity and supported operations before a client uses them. + +The separate `wright-result/v1` contract describes command result envelopes. +`Capabilities.contract` continues to identify that result envelope; +`Capabilities.agent_contract` identifies this service contract. Results from +`compile`, `check`, `analyze`, and `inspect` carry a `wright-result/v1` +envelope inside the agent response. + +## Session transports + +Start a session with `wright serve [INPUT]`. `INPUT` is a source file or +project directory; omission uses the current directory. Standard input is +reserved for requests, so `-` is not a valid source input. The server loads one +project and processes requests until standard input closes. The default +transport is one JSON request per line on stdio: + +```json +{"op":"capabilities"} +{"op":"findings"} +{"op":"check"} +``` + +Stdio returns one `ToolResponse` per request. A successful response has a +`result` member; an application-level refusal has an `error` member containing +`code` and `message`. The schema defines supported request fields; current +deserialization ignores unknown fields for forward compatibility. Clients +should send only fields defined by the negotiated schema. + +Use `wright serve --transport jsonrpc [INPUT]` for JSON-RPC 2.0. The canonical +agent method is `request`, with a `ToolRequest` in `params`: + +```json +{"jsonrpc":"2.0","id":1,"method":"request","params":{"op":"findings"}} +``` + +Its JSON-RPC `result` contains the same successful service result as the stdio +`ToolResponse.result`. A service refusal is returned in that `result` as +`{"error":{"code":"...","message":"..."}}`. JSON-RPC framing errors use +the top-level `error` member and standard JSON-RPC error codes. The direct +JSON-RPC methods `compile`, `check`, `analyze`, and `inspect` remain aliases for +the corresponding operations and return the same `wright-result/v1` envelope +they returned before this contract was introduced. + +One-shot CLI workflows such as `wright check --format json` continue to return +their `wright-result/v1` envelope. They use the same `CompilerSession` +workflows as the session service. The CLI does not add agent-specific semantic +results. The in-process embedding API can call `ToolService::handle` directly. + +The released `wright` binary includes `serve`, so each supported installation +channel can use the session contract without a separate runtime. MCP is not a +currently shipped transport. If added later, it must map this contract without +adding operations or changing their meaning. + +## Operations + +All requests are JSON objects with a required `op` string. Request fields and +the common response/error shapes are defined by the committed JSON Schema. The +table lists every operation advertised by `capabilities.operations` and names +the successful `result` payload. + +| Operation | Request fields | Successful `result` | +| --- | --- | --- | +| `capabilities` | none | Service name/version, `wright-agent/v1`, result contract, operation names, languages, and profiles | +| `compile` | none | `wright-result/v1` compile envelope | +| `check` | none | `wright-result/v1` check envelope | +| `analyze` | none | `wright-result/v1` analysis envelope | +| `inspect` | none | `wright-result/v1` inspection envelope | +| `project` | none | Loaded program origin, files, counts, and findings summary | +| `rules` | none | Canonical Workshop rules | +| `symbols` | optional `kind` | Symbols, optionally filtered by kind | +| `references` | required `symbol` | References for the symbol id | +| `usage` | required `symbol` | Usage counts for the symbol id | +| `cfg` | required `rule` | Control-flow graph for the rule id | +| `findings` | none | Wright static-analysis findings | +| `persistentObjects` | none | Persistent Workshop object facts | +| `lint` | none | Lint findings, rule metadata, and effective configuration | +| `lintRules` | none | Registered lint rules and effective configuration | +| `callGraph` | none | Subroutine call graph | +| `costEstimate` | none | Exact generated-resource counts and separate static findings | +| `targetMetadata` | none | Canonical target/catalog metadata | +| `validateEditTransaction` | `sources`, `transaction` | Atomic validation status, diagnostics, and previews when valid | +| `semanticRename` | `sources`, `target` | Validated rename transaction or structured refusal | +| `providerSemanticRename` | `language_id`, `documents`, `position_document_uri`, `position`, `new_name`, optional `project_root`, `sources` | Provider-resolved rename transaction or structured refusal | +| `providerValidateEdit` | `language_id`, `documents`, `transaction`, `sources`, optional `project_root` | Provider-validated transaction or structured refusal | + +Edit transactions use source identities and half-open, 1-based line/column +ranges. Provider positions use 0-based line/character coordinates. Wright +proposes and validates edits; a caller remains responsible for applying them. +Stale, overlapping, unsupported, or semantically invalid edits return an +explicit refusal without a partial edit set. + +## Results, diagnostics, and errors + +The service response is either `{ "result": value }` or +`{ "error": { "code": string, "message": string } }`. Error codes are the +machine-readable discriminator; error message wording is for people and is not +stable. Transport framing errors are separate from service refusals. + +Workflow envelopes retain `wright-result/v1` fields, including structured +diagnostics. Provider-owned diagnostics remain distinguishable through their +provider status and origin metadata. Wright findings are returned by the +findings/lint operations rather than converted into owner diagnostics. Source +spans retain their source path when mapped; missing or invalid source mapping +is reported as unmapped. Unsupported provider operations remain explicit +diagnostics or mutation refusals, not guessed source locations or textual +fallbacks. + +`capabilities` is the version negotiation step. Clients should check +`agent_contract` and the advertised operation list before sending requests. +Clients should ignore unknown response fields and must not assume an operation +exists unless it is advertised. + +## Versioning + +`wright-agent/v1` permits additive optional response fields and additional +operations whose requests remain valid for existing clients. Removing or +renaming an operation or field, changing a field's type or meaning, or changing +the response/error model requires a new major contract such as +`wright-agent/v2`; the v1 schema and its compatibility tests remain in place. +The CLI result envelope has its independent `wright-result/v1` version. + +The optional guide distributed by `wrightkit/skills` teaches clients to +discover and use these capabilities. It is not required to expose, execute, or +validate any semantic operation. diff --git a/docs/cli.md b/docs/cli.md index e4387861..7d05daf0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,6 +44,10 @@ See [machine-readable CLI contracts](cli/machine-contract.md). See [machine-readable CLI contracts](cli/machine-contract.md). +## `wright serve` agent session + +See the [versioned Wright Agent Contract](agent-contract.md). + ## Determinism See [machine-readable CLI contracts](cli/machine-contract.md). diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 62c49254..97730e40 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -35,6 +35,7 @@ result. | `wright analyze [INPUT]` | Summarize project structure, ranked CFG hotspots, and cross-cutting state | bounded semantic report with static evidence labels | | `wright lint [INPUT]` | Parse, lower, lint; report findings | findings, rule metadata, and effective-configuration summary | | `wright inspect [INPUT]` | Parse, lower, and inspect exhaustive semantic facts | rules, symbols, references summary | +| `wright serve [INPUT]` | Serve `wright-agent/v1` over stdio or JSON-RPC 2.0 | one structured response per request | | `wright completion ` | Generate static completion script for bash, zsh, fish, or powershell | the generated completion script | | `wright completion install [SHELL]` | Install generated completion into standard user-local directory | installation progress and guidance | | `wright update` | Self-update a standalone installation | update progress (text only) | diff --git a/docs/embedding.md b/docs/embedding.md index 793e1e67..21163115 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -11,11 +11,11 @@ safe source-edit contracts, and the transport adapters | `wright_driver::{CompilerSession, SessionConfig, InputSpec, SourceKind, OutputFormat, Profile}` | **stable** | One driver for compile/check/analyze/inspect/lint; `load()` is idempotent | | `wright_driver::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}` | **stable** | Transport-neutral workflow phase events; no terminal presentation or machine-result mutation | | `wright_driver::{Envelope, CompileResult, CheckResult, AnalyzeResult, InspectResult, LintResult, Diagnostic, CompiledOutput}` | **stable** | `wright-result/v1` machine contract ([`docs/cli.md`](cli.md)) | -| `wright_driver::service::{ToolService, ToolRequest, ToolResponse, Capabilities}` | **stable** | Session-aware tool queries (project/rules/symbols/references/usage/CFG/findings/lint/lintRules/callGraph/costEstimate/targetMetadata/capabilities) plus validated mutation (`validateEditTransaction`, `semanticRename`, #130) | +| `wright_driver::service::{ToolService, ToolRequest, ToolResponse, Capabilities}` | **stable** | Session-aware tool contract `wright-agent/v1` ([`docs/agent-contract.md`](agent-contract.md)); capability discovery, queries, workflows, and validated mutation | | `wright_driver::edit::{SourceEdit, EditRange, EditTransaction, SourcePreview, EditValidation, RenameRequest, rename_symbol, validate_transaction}` | **stable** | Source-edit transactions; validated through the correct owner-backed project semantics (#128); `EditTransaction::apply` applies ranges against one original source snapshot | | `wright_driver::{input_identity, EMBEDDING_CONTRACT}` | **stable** | `wright-embedding/v1` | | Internal HIR/WIR arenas, parser/CST, emitter internals | **internal** | Never part of the public contract | -| `wright-serve` stdio/JSON-RPC adapters | **stable** | Thin mappings over `ToolService`; MCP not implemented (no agent evidence) | +| `wright serve` stdio/JSON-RPC adapters | **stable** | Thin mappings over `ToolService` and `wright-agent/v1`; `wright-serve` remains a workspace binary alias | | `wright-transform` passes | experimental per pass | Only evidence-backed passes ship in `compat`; `aggressive` is an explicit experimental marker | The Rust packages in this workspace are implementation packages for the Wright @@ -69,8 +69,11 @@ strings, ANSI, percentages, or fabricated completion estimates. `ToolService::new(&mut session)` loads the program eagerly and answers typed [`ToolRequest`]s with owned [`ToolResponse`]s. `Capabilities` negotiates the -service version, contract (`wright-result/v1`), operations, languages, and -profiles. Cost inspection (`costEstimate`) distinguishes exact +service version, `wright-agent/v1` request/response contract, +`wright-result/v1` workflow envelope, operations, languages, and profiles. +The operation schemas, error model, and transport mapping are specified in the +[Wright Agent Contract](agent-contract.md). Cost inspection (`costEstimate`) +distinguishes exact target-resource counts (emitted bytes, WIR nodes, waits) from static findings and from compiler-host performance (measured by `wright-bench`, not in-process). Target/catalog metadata enables reasoning about Workshop @@ -129,20 +132,21 @@ explicit caller responsibility. ## Transports -`wright-serve` exposes the same operations over stdio JSON-lines and -JSON-RPC 2.0; both are thin mappings with identical semantics to in-process -consumers (equivalence tested). JSON-RPC protocol failures use the standard -top-level `error` member, while a `ToolResponse::Error` remains an application +`wright serve` exposes the same operations over stdio JSON-lines and JSON-RPC +2.0; both map to the same `ToolService` results as in-process consumers +(equivalence tested). The separate `wright-serve` workspace binary remains an +alias for the same adapter. JSON-RPC protocol failures use the standard +top-level `error` member, while a service refusal remains an application result under the top-level `result` member. Non-empty JSON-RPC arrays are handled as batches, with notification responses omitted. ## Versioning -* `wright-result/v1` and `wright-embedding/v1` are additive within major - version 1: new optional fields and new operations are allowed; removed or - renamed fields/ops require a major version. -* Envelope `wright.version` + `wright.contract` identify the producer; - `ToolService::capabilities()` identifies the service. +* `wright-agent/v1`, `wright-result/v1`, and `wright-embedding/v1` are + additive within major version 1: new optional fields and operations are + allowed; removed or renamed fields/ops require a major version. +* Envelope `wright.version` + `wright.contract` identify the result producer; + `ToolService::capabilities()` identifies the service and agent contract. * The release tarball's `version.json` is the authoritative artifact stamp. ## External consumer evidence diff --git a/docs/release.md b/docs/release.md index 8b92aa32..f704a07a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,7 +14,8 @@ immutable artifact, and latest-pointer contract is recorded in `scripts/release.sh [version]` (default `0.1.0`) produces `target/wright-.tar.gz` containing: -* the standalone `wright` and `wright-lsp` release binaries; +* the standalone `wright` and `wright-lsp` release binaries; `wright serve` + is included in the `wright` executable; * `version.json` with the version, `wright-result/v1` contract identity, git commit, build timestamp, and the runtime-dependency claim (`"requires": { "node": false, "overpy": false }`). @@ -33,8 +34,9 @@ channels. 3. **Benchmarks**: `wright-bench` with declared regression thresholds (`target/wright-bench-report.json`). 4. **Standalone proof**: the packaged binaries run `compile`/`check` over the - retained Wright test inputs with `PATH=/usr/bin:/bin` (Node and OverPy absent), and - `wright-lsp --version` reports the release version. + retained Wright test inputs with `PATH=/usr/bin:/bin` (Node and OverPy + absent), `wright serve` reports the negotiated `wright-agent/v1` contract, + and `wright-lsp --version` reports the release version. Any gate failure aborts the release before the version is stamped. @@ -217,10 +219,11 @@ terminal. It does not require Cargo, npm, or a source checkout. Each build leg smoke-tests its **packaged archive** (not workspace binaries): it extracts the archive and runs the shared `scripts/smoke-native.py` contract against the extracted binaries. The contract checks both version banners, -representative OPY compile/check paths, and first-party OPY provider bootstrap -from an empty provider store. The upload job re-verifies that every declared -target's archive and checksum are present before attaching them to the final -GitHub Release. +representative OPY compile/check paths, `wright-agent/v1` capability +negotiation and a structured check request, and first-party OPY provider +bootstrap from an empty provider store. The upload job re-verifies that every +declared target's archive and checksum are present before attaching them to the +final GitHub Release. The normal CI distribution validation uses independent channel legs. It stages a canonical-shaped local release archive and generated local metadata for diff --git a/schemas/wright-agent-v1.schema.json b/schemas/wright-agent-v1.schema.json new file mode 100644 index 00000000..04c21496 --- /dev/null +++ b/schemas/wright-agent-v1.schema.json @@ -0,0 +1,758 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wrightkit.dev/schemas/wright-agent-v1.schema.json", + "title": "Wright agent contract v1", + "$defs": { + "ToolRequest": { + "oneOf": [ + { "$ref": "#/$defs/CapabilitiesRequest" }, + { "$ref": "#/$defs/CompileRequest" }, + { "$ref": "#/$defs/CheckRequest" }, + { "$ref": "#/$defs/AnalyzeRequest" }, + { "$ref": "#/$defs/InspectRequest" }, + { "$ref": "#/$defs/ProjectRequest" }, + { "$ref": "#/$defs/RulesRequest" }, + { "$ref": "#/$defs/SymbolsRequest" }, + { "$ref": "#/$defs/ReferencesRequest" }, + { "$ref": "#/$defs/UsageRequest" }, + { "$ref": "#/$defs/CfgRequest" }, + { "$ref": "#/$defs/FindingsRequest" }, + { "$ref": "#/$defs/PersistentObjectsRequest" }, + { "$ref": "#/$defs/LintRequest" }, + { "$ref": "#/$defs/LintRulesRequest" }, + { "$ref": "#/$defs/CallGraphRequest" }, + { "$ref": "#/$defs/CostEstimateRequest" }, + { "$ref": "#/$defs/TargetMetadataRequest" }, + { "$ref": "#/$defs/ValidateEditTransactionRequest" }, + { "$ref": "#/$defs/SemanticRenameRequest" }, + { "$ref": "#/$defs/ProviderSemanticRenameRequest" }, + { "$ref": "#/$defs/ProviderValidateEditRequest" } + ] + }, + "ToolResponse": { + "oneOf": [ + { + "type": "object", + "required": ["result"], + "properties": { "result": { "$ref": "#/$defs/AgentResult" } }, + "not": { "required": ["error"] }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["error"], + "properties": { "error": { "$ref": "#/$defs/ToolErrorInfo" } }, + "not": { "required": ["result"] }, + "additionalProperties": true + } + ] + }, + "ToolErrorInfo": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "additionalProperties": true + }, + "AgentResult": { + "anyOf": [ + { "$ref": "#/$defs/Capabilities" }, + { "$ref": "#/$defs/CompileResult" }, + { "$ref": "#/$defs/CheckResult" }, + { "$ref": "#/$defs/AnalyzeResult" }, + { "$ref": "#/$defs/InspectResult" }, + { "$ref": "#/$defs/ProjectResult" }, + { "$ref": "#/$defs/RulesResult" }, + { "$ref": "#/$defs/SymbolsResult" }, + { "$ref": "#/$defs/ReferencesResult" }, + { "$ref": "#/$defs/UsageResult" }, + { "$ref": "#/$defs/CfgResult" }, + { "$ref": "#/$defs/FindingsResult" }, + { "$ref": "#/$defs/PersistentObjectsResult" }, + { "$ref": "#/$defs/LintResult" }, + { "$ref": "#/$defs/LintRulesResult" }, + { "$ref": "#/$defs/CallGraphResult" }, + { "$ref": "#/$defs/CostEstimateResult" }, + { "$ref": "#/$defs/TargetMetadataResult" }, + { "$ref": "#/$defs/ValidateEditTransactionResult" }, + { "$ref": "#/$defs/SemanticRenameResult" }, + { "$ref": "#/$defs/ProviderSemanticRenameResult" }, + { "$ref": "#/$defs/ProviderValidateEditResult" } + ] + }, + "WorkflowEnvelope": { + "type": "object", + "required": ["wright", "command", "ok", "exit", "diagnostics", "result"], + "properties": { + "wright": { "$ref": "#/$defs/ResultContract" }, + "command": { "type": "string" }, + "ok": { "type": "boolean" }, + "exit": { "type": "integer", "minimum": 0 }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "result": { "type": "object" } + }, + "additionalProperties": true + }, + "ResultContract": { + "type": "object", + "required": ["version", "contract"], + "properties": { + "version": { "type": "string" }, + "contract": { "const": "wright-result/v1" } + }, + "additionalProperties": true + }, + "Diagnostic": { + "type": "object", + "required": ["code", "stage", "severity", "message"], + "properties": { + "code": { "type": "string" }, + "stage": { "enum": ["discovery", "frontend", "lowering", "validation", "emission", "analysis", "reconstruction", "internal"] }, + "severity": { "enum": ["error", "warning", "info"] }, + "message": { "type": "string" }, + "status": { "enum": ["supported", "partial", "unsupported"] }, + "span": { "$ref": "#/$defs/SourceSpan" }, + "source": { "$ref": "#/$defs/Origin" } + }, + "additionalProperties": true + }, + "Position1Based": { + "type": "object", + "required": ["line", "col"], + "properties": { + "line": { "type": "integer", "minimum": 1 }, + "col": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": true + }, + "Origin": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "type": "string" }, + "locale": { "type": "string" } + }, + "additionalProperties": true + }, + "SourceSpan": { + "type": "object", + "required": ["file", "start", "end"], + "properties": { + "file": { "type": "integer", "minimum": 0 }, + "path": { "type": "string" }, + "start": { "$ref": "#/$defs/Position1Based" }, + "end": { "$ref": "#/$defs/Position1Based" } + }, + "additionalProperties": true + }, + "Span": { + "anyOf": [ + { "type": "null" }, + { "$ref": "#/$defs/SourceSpan" } + ] + }, + "ProgramSummary": { + "type": "object", + "required": ["origin", "files", "globalVariables", "playerVariables", "subroutines", "rules", "findings"], + "properties": { + "origin": { "$ref": "#/$defs/Origin" }, + "files": { "type": "integer", "minimum": 0 }, + "globalVariables": { "type": "integer", "minimum": 0 }, + "playerVariables": { "type": "integer", "minimum": 0 }, + "subroutines": { "type": "integer", "minimum": 0 }, + "rules": { "type": "integer", "minimum": 0 }, + "findings": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }, + "AnalyzedProgramSummary": { + "type": "object", + "required": ["origin", "files", "globalVariables", "playerVariables", "subroutines", "rules"], + "properties": { + "origin": { "$ref": "#/$defs/Origin" }, + "files": { "type": "integer", "minimum": 0 }, + "globalVariables": { "type": "integer", "minimum": 0 }, + "playerVariables": { "type": "integer", "minimum": 0 }, + "subroutines": { "type": "integer", "minimum": 0 }, + "rules": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }, + "RuleSummary": { + "type": "object", + "required": ["id", "name", "span"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "name": { "type": "string" }, + "span": { "$ref": "#/$defs/Span" } + }, + "additionalProperties": true + }, + "Symbol": { + "type": "object", + "required": ["id", "kind", "name", "span"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "kind": { "type": "string" }, + "name": { "type": "string" }, + "span": { "$ref": "#/$defs/Span" } + }, + "additionalProperties": true + }, + "Reference": { + "type": "object", + "required": ["kind", "span", "rule", "action", "value"], + "properties": { + "kind": { "enum": ["declaration", "definition", "read", "write", "call"] }, + "span": { "$ref": "#/$defs/Span" }, + "rule": { "type": ["integer", "null"], "minimum": 0 }, + "action": { "type": ["integer", "null"], "minimum": 0 }, + "value": { "type": ["integer", "null"], "minimum": 0 } + }, + "additionalProperties": true + }, + "Finding": { + "type": "object", + "required": ["code", "severity", "message", "span", "rule", "action", "value", "evidence", "boundedness"], + "properties": { + "code": { "type": "string" }, + "severity": { "enum": ["error", "warning", "info"] }, + "message": { "type": "string" }, + "span": { "$ref": "#/$defs/Span" }, + "rule": { "type": ["integer", "null"], "minimum": 0 }, + "action": { "type": ["integer", "null"], "minimum": 0 }, + "value": { "type": ["integer", "null"], "minimum": 0 }, + "evidence": { "type": "string" }, + "boundedness": { "type": ["string", "null"] } + }, + "additionalProperties": true + }, + "LintRule": { + "type": "object", + "required": ["id", "defaultSeverity", "effectiveSeverity", "enabled", "summary", "rationale", "documentation", "knownLimits", "evidence", "tags", "kind"], + "properties": { + "id": { "type": "string" }, + "defaultSeverity": { "type": "string" }, + "effectiveSeverity": { "type": "string" }, + "enabled": { "type": "boolean" }, + "summary": { "type": "string" }, + "rationale": { "type": "string" }, + "documentation": { "type": "string" }, + "knownLimits": { "type": "string" }, + "evidence": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "kind": { "type": "string" } + }, + "additionalProperties": true + }, + "LintConfiguration": { + "type": "object", + "required": ["rules"], + "properties": { + "rules": { "type": "object", "additionalProperties": { "type": "object" } } + }, + "additionalProperties": true + }, + "SkippedRule": { + "type": "object", + "required": ["id", "rule", "reason"], + "properties": { + "id": { "type": "string" }, + "rule": { "type": "integer", "minimum": 0 }, + "reason": { "type": "string" } + }, + "additionalProperties": true + }, + "SourceEdit": { + "type": "object", + "required": ["kind", "source", "source_identity", "range", "new_text"], + "properties": { + "kind": { "type": "string" }, + "source": { "type": "string" }, + "source_identity": { "type": "string" }, + "range": { "$ref": "#/$defs/EditRange" }, + "new_text": { "type": "string" } + }, + "additionalProperties": true + }, + "SourcePreview": { + "type": "object", + "required": ["source", "source_identity", "new_text"], + "properties": { + "source": { "type": "string" }, + "source_identity": { "type": "string" }, + "new_text": { "type": "string" } + }, + "additionalProperties": true + }, + "EditValidationPayload": { + "type": "object", + "required": ["ok", "exit", "diagnostics"], + "properties": { + "ok": { "type": "boolean" }, + "exit": { "type": "integer", "minimum": 0 }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "preview": { "type": "array", "items": { "$ref": "#/$defs/SourcePreview" } } + }, + "additionalProperties": true + }, + "SemanticRenamePayload": { + "type": "object", + "required": ["ok", "transaction", "diagnostics"], + "properties": { + "ok": { "type": "boolean" }, + "transaction": { "anyOf": [{ "type": "null" }, { "$ref": "#/$defs/EditTransaction" }] }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "preview": { "type": "array", "items": { "$ref": "#/$defs/SourcePreview" } } + }, + "additionalProperties": true + }, + "ProviderMutationPayload": { + "type": "object", + "required": ["ok", "diagnostics"], + "properties": { + "ok": { "type": "boolean" }, + "transaction": { "$ref": "#/$defs/EditTransaction" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "preview": { "type": "array", "items": { "$ref": "#/$defs/SourcePreview" } }, + "provider_code": { "type": "string" }, + "provider_message": { "type": "string" } + }, + "additionalProperties": true + }, + "CapabilitiesResult": { "$ref": "#/$defs/Capabilities" }, + "CompileResult": { + "allOf": [ + { "$ref": "#/$defs/WorkflowEnvelope" }, + { "type": "object", "properties": { "command": { "const": "compile" }, "result": { "$ref": "#/$defs/CompilePayload" } } } + ] + }, + "CheckResult": { + "allOf": [ + { "$ref": "#/$defs/WorkflowEnvelope" }, + { "type": "object", "properties": { "command": { "const": "check" }, "result": { "$ref": "#/$defs/CheckPayload" } } } + ] + }, + "AnalyzeResult": { + "allOf": [ + { "$ref": "#/$defs/WorkflowEnvelope" }, + { "type": "object", "properties": { "command": { "const": "analyze" }, "result": { "$ref": "#/$defs/AnalyzePayload" } } } + ] + }, + "InspectResult": { + "allOf": [ + { "$ref": "#/$defs/WorkflowEnvelope" }, + { "type": "object", "properties": { "command": { "const": "inspect" }, "result": { "$ref": "#/$defs/InspectPayload" } } } + ] + }, + "ProjectResult": { + "type": "object", + "required": ["origin", "inputIdentity", "files", "globalVariables", "playerVariables", "subroutines", "rules", "symbols", "findings"], + "properties": { + "origin": { "$ref": "#/$defs/Origin" }, + "inputIdentity": { "type": "string" }, + "files": { "type": "integer", "minimum": 0 }, + "globalVariables": { "type": "integer", "minimum": 0 }, + "playerVariables": { "type": "integer", "minimum": 0 }, + "subroutines": { "type": "integer", "minimum": 0 }, + "rules": { "type": "integer", "minimum": 0 }, + "symbols": { "type": "integer", "minimum": 0 }, + "findings": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }, + "RulesResult": { "type": "array", "items": { "$ref": "#/$defs/RuleSummary" } }, + "SymbolsResult": { "type": "array", "items": { "$ref": "#/$defs/Symbol" } }, + "ReferencesResult": { "type": "array", "items": { "$ref": "#/$defs/Reference" } }, + "UsageResult": { + "type": "object", + "required": ["symbol", "reads", "writes", "calls", "rules"], + "properties": { + "symbol": { "type": "string" }, + "reads": { "type": "integer", "minimum": 0 }, + "writes": { "type": "integer", "minimum": 0 }, + "calls": { "type": "integer", "minimum": 0 }, + "rules": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }, + "CfgResult": { + "type": "object", + "required": ["entry", "exit", "blocks"], + "properties": { + "entry": { "type": "integer", "minimum": 0 }, + "exit": { "type": "integer", "minimum": 0 }, + "blocks": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "kind", "waits", "calls", "actions", "successors"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "kind": { "type": "string" }, + "waits": { "type": "boolean" }, + "calls": { "type": "array", "items": { "type": "integer", "minimum": 0 } }, + "actions": { "type": "array", "items": { "type": "integer", "minimum": 0 } }, + "successors": { + "type": "array", + "items": { + "type": "object", + "required": ["to", "kind"], + "properties": { "to": { "type": "integer", "minimum": 0 }, "kind": { "type": "string" } }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "FindingsResult": { "type": "array", "items": { "$ref": "#/$defs/Finding" } }, + "PersistentObjectsResult": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "rule", "action", "executionScope", "visibility", "reevaluation", "identityRetained", "sameKindCleanupInRule", "span"], + "properties": { + "kind": { "type": "string" }, + "rule": { "type": "integer", "minimum": 0 }, + "action": { "type": "integer", "minimum": 0 }, + "executionScope": { "type": "string" }, + "visibility": { "type": "string" }, + "reevaluation": { "anyOf": [{ "type": "null" }, { "type": "object", "required": ["domain", "mode"], "properties": { "domain": { "type": "string" }, "mode": { "type": "string" } }, "additionalProperties": true }] }, + "identityRetained": { "type": "boolean" }, + "sameKindCleanupInRule": { "type": "boolean" }, + "span": { "$ref": "#/$defs/Span" } + }, + "additionalProperties": true + } + }, + "LintResult": { + "type": "object", + "required": ["inputIdentity", "rules", "config", "findings", "skipped"], + "properties": { + "inputIdentity": { "type": "string" }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/LintRule" } }, + "config": { "$ref": "#/$defs/LintConfiguration" }, + "findings": { "type": "array", "items": { "$ref": "#/$defs/Finding" } }, + "skipped": { "type": "array", "items": { "$ref": "#/$defs/SkippedRule" } } + }, + "additionalProperties": true + }, + "LintRulesResult": { + "type": "object", + "required": ["rules", "config", "skipped"], + "properties": { + "rules": { "type": "array", "items": { "$ref": "#/$defs/LintRule" } }, + "config": { "$ref": "#/$defs/LintConfiguration" }, + "skipped": { "type": "array", "items": { "$ref": "#/$defs/SkippedRule" } } + }, + "additionalProperties": true + }, + "CallGraphResult": { + "type": "array", + "items": { "type": "object", "required": ["caller", "callee"], "properties": { "caller": { "type": "string" }, "callee": { "type": "string" } }, "additionalProperties": true } + }, + "CostEstimateResult": { + "type": "object", + "required": ["exact", "findings", "kind"], + "properties": { + "exact": { + "type": "object", + "required": ["emittedBytes", "programActions", "programRules", "waitActions"], + "properties": { + "emittedBytes": { "type": "integer", "minimum": 0 }, + "programActions": { "type": "integer", "minimum": 0 }, + "programRules": { "type": "integer", "minimum": 0 }, + "waitActions": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + }, + "findings": { "type": "array", "items": { "type": "object", "required": ["code", "severity", "message"], "properties": { "code": { "type": "string" }, "severity": { "type": "string" }, "message": { "type": "string" } }, "additionalProperties": true } }, + "kind": { "type": "object", "required": ["exact", "findings", "performance"], "properties": { "exact": { "type": "string" }, "findings": { "type": "string" }, "performance": { "type": "string" } }, "additionalProperties": true } + }, + "additionalProperties": true + }, + "TargetMetadataResult": { + "type": "object", + "required": ["catalogVersion", "locales", "actions", "values", "events", "operators", "enumDomains"], + "properties": { + "catalogVersion": { "type": "string" }, + "locales": { "type": "array", "items": { "type": "string" } }, + "actions": { "type": "integer", "minimum": 0 }, + "values": { "type": "integer", "minimum": 0 }, + "events": { "type": "integer", "minimum": 0 }, + "operators": { "type": "integer", "minimum": 0 }, + "enumDomains": { "type": "array", "items": { "type": "object", "required": ["domain", "members"], "properties": { "domain": { "type": "string" }, "members": { "type": "array", "items": { "type": "string" } } }, "additionalProperties": true } } + }, + "additionalProperties": true + }, + "ValidateEditTransactionResult": { "$ref": "#/$defs/EditValidationPayload" }, + "SemanticRenameResult": { "$ref": "#/$defs/SemanticRenamePayload" }, + "ProviderSemanticRenameResult": { "$ref": "#/$defs/ProviderMutationPayload" }, + "ProviderValidateEditResult": { "$ref": "#/$defs/ProviderMutationPayload" }, + "CompilePayload": { + "type": "object", + "properties": { + "output": { + "type": "object", + "required": ["text", "sha256", "locale", "written_to", "input_identity"], + "properties": { + "text": { "type": "string" }, + "sha256": { "type": "string" }, + "locale": { "type": "string" }, + "written_to": { "type": "string" }, + "input_identity": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "CheckPayload": { "type": "object", "additionalProperties": true }, + "AnalyzePayload": { + "type": "object", + "required": ["program", "facts"], + "properties": { + "program": { "$ref": "#/$defs/AnalyzedProgramSummary" }, + "facts": { + "type": "object", + "required": ["symbols", "rules"], + "properties": { + "symbols": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "kind", "name", "span", "usage"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "kind": { "type": "string" }, + "name": { "type": "string" }, + "span": { "$ref": "#/$defs/Span" }, + "usage": { "$ref": "#/$defs/UsageResult" } + }, + "additionalProperties": true + } + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "span", "controlFlow"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "name": { "type": "string" }, + "span": { "$ref": "#/$defs/Span" }, + "controlFlow": { + "type": "object", + "required": ["blocks", "edges", "loopBlocks", "waitBlocks"], + "properties": { + "blocks": { "type": "integer", "minimum": 0 }, + "edges": { "type": "integer", "minimum": 0 }, + "loopBlocks": { "type": "integer", "minimum": 0 }, + "waitBlocks": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "InspectPayload": { + "type": "object", + "required": ["program", "rules", "symbols", "references"], + "properties": { + "program": { "$ref": "#/$defs/ProgramSummary" }, + "rules": { "$ref": "#/$defs/RulesResult" }, + "symbols": { "$ref": "#/$defs/SymbolsResult" }, + "references": { "type": "array", "items": { "type": "array", "items": { "$ref": "#/$defs/Reference" } } } + }, + "additionalProperties": true + }, + "Capabilities": { + "type": "object", + "required": ["name", "version", "contract", "agent_contract", "operations", "languages", "profiles"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "contract": { "const": "wright-result/v1" }, + "agent_contract": { "const": "wright-agent/v1" }, + "operations": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string" } + }, + "languages": { "type": "array", "items": { "type": "string" } }, + "profiles": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + }, + "Sources": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "EditRange": { + "type": "object", + "required": ["start_line", "start_col", "end_line", "end_col"], + "properties": { + "start_line": { "type": "integer", "minimum": 1, "maximum": 4294967295 }, + "start_col": { "type": "integer", "minimum": 1, "maximum": 4294967295 }, + "end_line": { "type": "integer", "minimum": 1, "maximum": 4294967295 }, + "end_col": { "type": "integer", "minimum": 1, "maximum": 4294967295 } + }, + "additionalProperties": false + }, + "SourceEdit": { + "type": "object", + "required": ["kind", "source", "source_identity", "range", "new_text"], + "properties": { + "kind": { "type": "string" }, + "source": { "type": "string" }, + "source_identity": { "type": "string" }, + "range": { "$ref": "#/$defs/EditRange" }, + "new_text": { "type": "string" } + }, + "additionalProperties": false + }, + "EditTransaction": { + "type": "object", + "required": ["edits"], + "properties": { + "edits": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/SourceEdit" } } + }, + "additionalProperties": false + }, + "RenameTarget": { + "type": "object", + "required": ["source", "line", "col", "to"], + "properties": { + "source": { "type": "string" }, + "line": { "type": "integer", "minimum": 0, "maximum": 4294967295 }, + "col": { "type": "integer", "minimum": 0, "maximum": 4294967295 }, + "to": { "type": "string" } + }, + "additionalProperties": false + }, + "Document": { + "type": "object", + "required": ["uri", "languageId", "version", "text"], + "properties": { + "uri": { "type": "string" }, + "languageId": { "type": "string" }, + "version": { "type": "integer" }, + "text": { "type": "string" } + }, + "additionalProperties": false + }, + "DocumentSet": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/Document" } + }, + "Position": { + "type": "object", + "required": ["line", "character"], + "properties": { + "line": { "type": "integer", "minimum": 0, "maximum": 4294967295 }, + "character": { "type": "integer", "minimum": 0, "maximum": 4294967295 } + }, + "additionalProperties": false + }, + "CapabilitiesRequest": { "type": "object", "properties": { "op": { "const": "capabilities" } }, "required": ["op"], "additionalProperties": false }, + "CompileRequest": { "type": "object", "properties": { "op": { "const": "compile" } }, "required": ["op"], "additionalProperties": false }, + "CheckRequest": { "type": "object", "properties": { "op": { "const": "check" } }, "required": ["op"], "additionalProperties": false }, + "AnalyzeRequest": { "type": "object", "properties": { "op": { "const": "analyze" } }, "required": ["op"], "additionalProperties": false }, + "InspectRequest": { "type": "object", "properties": { "op": { "const": "inspect" } }, "required": ["op"], "additionalProperties": false }, + "ProjectRequest": { "type": "object", "properties": { "op": { "const": "project" } }, "required": ["op"], "additionalProperties": false }, + "RulesRequest": { "type": "object", "properties": { "op": { "const": "rules" } }, "required": ["op"], "additionalProperties": false }, + "SymbolsRequest": { + "type": "object", + "properties": { "op": { "const": "symbols" }, "kind": { "type": ["string", "null"] } }, + "required": ["op"], + "additionalProperties": false + }, + "ReferencesRequest": { + "type": "object", + "properties": { "op": { "const": "references" }, "symbol": { "type": "integer", "minimum": 0, "maximum": 4294967295 } }, + "required": ["op", "symbol"], + "additionalProperties": false + }, + "UsageRequest": { + "type": "object", + "properties": { "op": { "const": "usage" }, "symbol": { "type": "integer", "minimum": 0, "maximum": 4294967295 } }, + "required": ["op", "symbol"], + "additionalProperties": false + }, + "CfgRequest": { + "type": "object", + "properties": { "op": { "const": "cfg" }, "rule": { "type": "integer", "minimum": 0, "maximum": 4294967295 } }, + "required": ["op", "rule"], + "additionalProperties": false + }, + "FindingsRequest": { "type": "object", "properties": { "op": { "const": "findings" } }, "required": ["op"], "additionalProperties": false }, + "PersistentObjectsRequest": { "type": "object", "properties": { "op": { "const": "persistentObjects" } }, "required": ["op"], "additionalProperties": false }, + "LintRequest": { "type": "object", "properties": { "op": { "const": "lint" } }, "required": ["op"], "additionalProperties": false }, + "LintRulesRequest": { "type": "object", "properties": { "op": { "const": "lintRules" } }, "required": ["op"], "additionalProperties": false }, + "CallGraphRequest": { "type": "object", "properties": { "op": { "const": "callGraph" } }, "required": ["op"], "additionalProperties": false }, + "CostEstimateRequest": { "type": "object", "properties": { "op": { "const": "costEstimate" } }, "required": ["op"], "additionalProperties": false }, + "TargetMetadataRequest": { "type": "object", "properties": { "op": { "const": "targetMetadata" } }, "required": ["op"], "additionalProperties": false }, + "ValidateEditTransactionRequest": { + "type": "object", + "properties": { + "op": { "const": "validateEditTransaction" }, + "sources": { "$ref": "#/$defs/Sources" }, + "transaction": { "$ref": "#/$defs/EditTransaction" } + }, + "required": ["op", "sources", "transaction"], + "additionalProperties": false + }, + "SemanticRenameRequest": { + "type": "object", + "properties": { + "op": { "const": "semanticRename" }, + "sources": { "$ref": "#/$defs/Sources" }, + "target": { "$ref": "#/$defs/RenameTarget" } + }, + "required": ["op", "sources", "target"], + "additionalProperties": false + }, + "ProviderSemanticRenameRequest": { + "type": "object", + "properties": { + "op": { "const": "providerSemanticRename" }, + "language_id": { "type": "string" }, + "documents": { "$ref": "#/$defs/DocumentSet" }, + "position_document_uri": { "type": "string" }, + "position": { "$ref": "#/$defs/Position" }, + "new_name": { "type": "string" }, + "project_root": { "type": ["string", "null"] }, + "sources": { "$ref": "#/$defs/Sources" } + }, + "required": ["op", "language_id", "documents", "position_document_uri", "position", "new_name", "sources"], + "additionalProperties": false + }, + "ProviderValidateEditRequest": { + "type": "object", + "properties": { + "op": { "const": "providerValidateEdit" }, + "language_id": { "type": "string" }, + "documents": { "$ref": "#/$defs/DocumentSet" }, + "transaction": { "$ref": "#/$defs/EditTransaction" }, + "sources": { "$ref": "#/$defs/Sources" }, + "project_root": { "type": ["string", "null"] } + }, + "required": ["op", "language_id", "documents", "transaction", "sources"], + "additionalProperties": false + } + }, + "$ref": "#/$defs/ToolRequest" +} diff --git a/scripts/smoke-native.py b/scripts/smoke-native.py index 9ccad875..5606d5b8 100644 --- a/scripts/smoke-native.py +++ b/scripts/smoke-native.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import json import os import subprocess import sys @@ -19,7 +20,12 @@ def fail(message: str) -> NoReturn: raise SystemExit(f"native runtime smoke failed: {message}") -def run(label: str, command: list[str], env: dict[str, str] | None = None) -> str: +def run( + label: str, + command: list[str], + env: dict[str, str] | None = None, + input_text: str | None = None, +) -> str: print(f"==> native runtime: {label}") try: result = subprocess.run( @@ -29,6 +35,7 @@ def run(label: str, command: list[str], env: dict[str, str] | None = None) -> st check=True, capture_output=True, text=True, + input=input_text, ) except FileNotFoundError as error: fail(f"{label}: missing executable {error.filename}") @@ -83,6 +90,22 @@ def main() -> None: ) run("check", [str(wright), "check", str(args.check), "--profile", "compat"]) + session = run( + "agent session", + [str(wright), "serve", "--transport", "stdio", "--kind", "workshop", str(args.check)], + input_text='{"op":"capabilities"}\n{"op":"check"}\n', + ) + try: + responses = [json.loads(line) for line in session.splitlines()] + except json.JSONDecodeError as error: + fail(f"agent session returned invalid JSON: {error}") + if len(responses) != 2: + fail(f"agent session returned {len(responses)} responses, expected 2") + if responses[0].get("result", {}).get("agent_contract") != "wright-agent/v1": + fail("agent session did not negotiate wright-agent/v1") + if responses[1].get("result", {}).get("command") != "check": + fail("agent session did not return the structured check result") + if args.provider_bootstrap: with tempfile.TemporaryDirectory(prefix="wright-provider-smoke-") as store: env = os.environ.copy()