From 54e5dd27ebeb3bc4d556ba77ad34cf3fe16ea0b3 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:30:57 +0800 Subject: [PATCH 1/5] feat(driver): route OPY workflows through first-party provider Refs #245 --- Cargo.lock | 1 + crates/wright-cli/src/main.rs | 6 + crates/wright-driver/Cargo.toml | 2 + crates/wright-driver/src/session.rs | 92 ++++++- crates/wright-driver/src/source_provider.rs | 189 +++++++++++++- crates/wright-driver/tests/source_provider.rs | 102 +++++++- crates/wright-lpp/src/error.rs | 6 + crates/wright-lpp/src/lib.rs | 11 +- crates/wright-lpp/src/provider.rs | 236 +++++++++++++++--- crates/wright-lpp/src/types.rs | 24 +- crates/wright-lpp/tests/mock_provider.rs | 3 + crates/wright-lpp/tests/unit.rs | 7 + 12 files changed, 613 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae8cf50..de7e2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2152,6 +2152,7 @@ dependencies = [ "sha2", "tar", "ureq", + "url", "workshop-rs", "wright-analyzer", "wright-core", diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 9bc2dfd..b199590 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use clap::{CommandFactory, Parser}; use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind}; use wright_driver::result::exit; +use wright_driver::source_provider::SourceBackend; use crate::cli::{ Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg, ProviderCommand, ProviderNameArg, @@ -246,6 +247,11 @@ fn config_from_common(common: &CommonArgs) -> SessionConfig { }; SessionConfig { input, + source_backend: if common.opy_provider.is_some() { + SourceBackend::Provider + } else { + SourceBackend::Native + }, kind: match common.kind { cli::SourceKindArg::Auto => SourceKind::Auto, cli::SourceKindArg::Opy => SourceKind::Opy, diff --git a/crates/wright-driver/Cargo.toml b/crates/wright-driver/Cargo.toml index f83e599..839d15c 100644 --- a/crates/wright-driver/Cargo.toml +++ b/crates/wright-driver/Cargo.toml @@ -17,6 +17,8 @@ sha2.workspace = true flate2.workspace = true tar.workspace = true ureq.workspace = true +url = "2" +zip.workspace = true wright-analyzer.workspace = true wright-core.workspace = true wright-ir.workspace = true diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 6ba2898..37a64b0 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -77,12 +77,19 @@ pub enum Provenance { Unmapped, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderOperation { + Check, + Compile, +} + /// One reusable compiler session. pub struct CompilerSession { /// The session configuration (input, frontend, overrides, format). pub config: SessionConfig, catalog: workshop_rs::catalog::Catalog, loaded: Option, + loaded_operation: Option, diagnostics: Vec, progress_observer: Option>, source_provider: Option>, @@ -102,6 +109,7 @@ impl CompilerSession { config, catalog, loaded: None, + loaded_operation: None, diagnostics: Vec::new(), progress_observer: None, source_provider: None, @@ -145,8 +153,21 @@ impl CompilerSession { /// re-reading the input. Returns an owned snapshot so callers can hold it /// while mutating the session. pub fn load(&mut self) -> Result { + self.load_with_operation(ProviderOperation::Check) + } + + fn load_with_operation( + &mut self, + provider_operation: ProviderOperation, + ) -> Result { if let Some(loaded) = &self.loaded { - return Ok(loaded.clone()); + if self.config.source_backend != SourceBackend::Provider + || self.loaded_operation == Some(provider_operation) + || (self.loaded_operation == Some(ProviderOperation::Compile) + && provider_operation == ProviderOperation::Check) + { + return Ok(loaded.clone()); + } } if self.config.source_backend == SourceBackend::Provider && matches!(self.config.input, InputSpec::Stdin) @@ -174,7 +195,7 @@ impl CompilerSession { return self.load_ostw(&mut resolved); } if self.config.source_backend == SourceBackend::Provider { - return self.load_from_source_provider(&mut resolved); + return self.load_from_source_provider(&mut resolved, provider_operation); } let mut program = match resolved.kind { SourceKind::Workshop => { @@ -255,6 +276,7 @@ impl CompilerSession { fn load_from_source_provider( &mut self, resolved: &mut ResolvedInput, + operation: ProviderOperation, ) -> Result { let language = match resolved.kind { SourceKind::Opy => SourceLanguage::Opy, @@ -276,7 +298,35 @@ impl CompilerSession { .clone() .unwrap_or_else(|| Path::new("").to_path_buf()), resolved.cwd.clone(), - ); + ) + .with_project_root(resolved.root.clone()); + if self.source_provider.is_none() { + let mut provider = self + .language_provider(opy_provider::OPY_LANGUAGE_ID) + .map_err(|error| { + SourceProviderError::Failed { + code: error.code().to_string(), + message: error.to_string(), + } + .diagnostic() + })?; + provider + .initialize_project_loading(Some(&wright_lpp::ClientInfo { + name: wright_lpp::LPP_CLIENT_NAME.to_string(), + version: crate::result::DRIVER_VERSION.to_string(), + })) + .map_err(|error| { + SourceProviderError::Failed { + code: error.code().to_string(), + message: error.to_string(), + } + .diagnostic() + })?; + self.source_provider = Some(Box::new(crate::source_provider::LppSourceProvider::new( + provider, + self.config.locale.clone(), + ))); + } let Some(provider) = self.source_provider.as_mut() else { return Err(SourceProviderError::NotConfigured { language }.diagnostic()); }; @@ -291,9 +341,11 @@ impl CompilerSession { ), )); } - let compilation = provider - .compile(&target) - .map_err(|error| error.diagnostic())?; + let compilation = match operation { + ProviderOperation::Check => provider.check(&target), + ProviderOperation::Compile => provider.compile(&target), + } + .map_err(|error| error.diagnostic())?; let mut provider_diagnostics = compilation.diagnostics; if let Some(index) = provider_diagnostics .iter() @@ -307,6 +359,24 @@ impl CompilerSession { let provenance = match compilation.provenance { SourceProvenance::Unmapped => Provenance::Unmapped, }; + let locale_name = compilation + .locale + .or_else(|| resolved.origin.locale.clone()) + .unwrap_or_else(|| "en-US".to_string()); + let locale = workshop_rs::catalog::Locale::new(&locale_name); + if operation == ProviderOperation::Check { + let loaded = Loaded { + program: Arc::new(wir::Program::default()), + ostw: None, + ostw_semantic: None, + origin: resolved.origin.clone(), + input: resolved.clone(), + provenance, + }; + self.loaded = Some(loaded.clone()); + self.loaded_operation = Some(operation); + return Ok(loaded); + } let Some(workshop_text) = compilation.workshop_text else { return Err(Diagnostic::error( "source-provider-no-artifact", @@ -314,11 +384,6 @@ impl CompilerSession { "the source provider returned no canonical Workshop output", )); }; - let locale_name = compilation - .locale - .or_else(|| resolved.origin.locale.clone()) - .unwrap_or_else(|| "en-US".to_string()); - let locale = workshop_rs::catalog::Locale::new(&locale_name); let program = workshop_rs::parser::parse_with_context( &workshop_text, &self.catalog, @@ -356,6 +421,7 @@ impl CompilerSession { provenance, }; self.loaded = Some(loaded.clone()); + self.loaded_operation = Some(operation); Ok(loaded) } @@ -548,7 +614,7 @@ impl CompilerSession { } fn compile_output(&mut self) -> Result { - let loaded = self.load()?; + let loaded = self.load_with_operation(ProviderOperation::Compile)?; if let Some(outcome) = &loaded.ostw { // OSTW (#119): the load lowered the semantic HIR into the // session program. Project-boundary diagnostics (missing @@ -594,7 +660,7 @@ impl CompilerSession { /// correctness gate. pub fn check(&mut self) -> Envelope { let command = "check"; - let loaded = match self.load() { + let loaded = match self.load_with_operation(ProviderOperation::Check) { Ok(loaded) => loaded, Err(diagnostic) => { self.diagnostics.push(diagnostic); diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index bc591da..6121218 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -9,7 +9,7 @@ use std::fmt; use std::path::{Path, PathBuf}; -use crate::diag::{Diagnostic, Stage}; +use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; /// A source language that can participate in the product boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -48,6 +48,8 @@ pub struct SourceTarget { pub entry: PathBuf, /// The invocation working directory used to resolve relative CLI paths. pub cwd: PathBuf, + /// The CLI-supplied project root, when one was provided. + pub project_root: Option, } /// The provenance contract for the canonical Workshop result. @@ -79,9 +81,17 @@ impl SourceTarget { language, entry, cwd, + project_root: None, } } + /// Attach the CLI-supplied project root without changing owner-side + /// project discovery. + pub fn with_project_root(mut self, project_root: PathBuf) -> Self { + self.project_root = Some(project_root); + self + } + /// The entry path as a [`Path`]. pub fn entry_path(&self) -> &Path { &self.entry @@ -172,11 +182,188 @@ pub trait SourceProvider { /// The source language served by this provider. fn language(&self) -> SourceLanguage; + /// Check the user-selected entry and return owner diagnostics. + fn check(&mut self, target: &SourceTarget) -> Result { + self.compile(target) + } + /// Compile the user-selected entry. Project discovery and source closure /// are owned by the implementation behind this boundary. fn compile(&mut self, target: &SourceTarget) -> Result; } +/// The Wright adapter for a first-party LPP source provider. +pub struct LppSourceProvider { + provider: Box, + locale: Option, +} + +impl LppSourceProvider { + /// Construct an already initialized LPP provider adapter. + pub fn new(provider: Box, locale: Option) -> Self { + Self { provider, locale } + } + + fn entry( + &self, + target: &SourceTarget, + ) -> Result { + let uri = url::Url::from_file_path(target.entry_path()) + .map_err(|()| SourceProviderError::Failed { + code: "provider-invalid-entry".to_string(), + message: format!( + "cannot convert OPY entry '{}' to an absolute file URI", + target.entry_path().display() + ), + })? + .to_string(); + Ok(wright_lpp::ProjectEntry { + uri, + language_id: SourceLanguage::Opy.as_str().to_string(), + version: 1, + }) + } + + fn project_root_uri(target: &SourceTarget) -> Option { + target + .project_root + .as_deref() + .and_then(|root| url::Url::from_directory_path(root).ok()) + .map(|uri| uri.to_string()) + } + + fn check_result( + &mut self, + target: &SourceTarget, + ) -> Result { + let entry = self.entry(target)?; + let result = self + .provider + .check_entry( + &entry, + Self::project_root_uri(target).as_deref(), + self.locale.as_deref(), + ) + .map_err(provider_error)?; + Ok(SourceCompilation { + workshop_text: None, + locale: self.locale.clone(), + provenance: SourceProvenance::Unmapped, + diagnostics: provider_diagnostics(result.documents, self.locale.as_deref()), + }) + } +} + +impl Drop for LppSourceProvider { + fn drop(&mut self) { + let _ = self.provider.shutdown(); + } +} + +impl SourceProvider for LppSourceProvider { + fn language(&self) -> SourceLanguage { + SourceLanguage::Opy + } + + fn check(&mut self, target: &SourceTarget) -> Result { + self.check_result(target) + } + + fn compile(&mut self, target: &SourceTarget) -> Result { + let entry = self.entry(target)?; + let result = self + .provider + .compile_entry( + &entry, + Self::project_root_uri(target).as_deref(), + self.locale.as_deref(), + ) + .map_err(provider_error)?; + let workshop_text = match result.artifact { + Some(artifact) if artifact.format == "workshop-rs/text-v1" => Some(artifact.content), + Some(artifact) => { + return Err(SourceProviderError::Failed { + code: "provider-artifact-format".to_string(), + message: format!( + "the source provider returned unsupported artifact format '{}', expected 'workshop-rs/text-v1'", + artifact.format + ), + }); + } + None => None, + }; + Ok(SourceCompilation { + workshop_text, + locale: self.locale.clone(), + provenance: SourceProvenance::Unmapped, + diagnostics: provider_diagnostics(result.diagnostics, self.locale.as_deref()), + }) + } +} + +fn provider_error(error: wright_lpp::ProviderError) -> SourceProviderError { + SourceProviderError::Failed { + code: error.code().to_string(), + message: error.to_string(), + } +} + +fn provider_diagnostics( + documents: Vec, + locale: Option<&str>, +) -> Vec { + documents + .into_iter() + .enumerate() + .flat_map(|(file, document)| { + let path = provider_uri_path(&document.uri); + document.diagnostics.into_iter().map(move |diagnostic| { + let severity = match diagnostic.severity { + wright_lpp::DiagnosticSeverity::Error => Severity::Error, + wright_lpp::DiagnosticSeverity::Warning => Severity::Warning, + wright_lpp::DiagnosticSeverity::Info | wright_lpp::DiagnosticSeverity::Hint => { + Severity::Info + } + }; + Diagnostic { + code: diagnostic + .code + .unwrap_or_else(|| "provider-diagnostic".to_string()), + stage: Stage::Frontend, + severity, + message: diagnostic.message, + status: None, + span: Some(SourceSpan { + file, + path: path.clone(), + start: provider_position(diagnostic.range.start), + end: provider_position(diagnostic.range.end), + }), + source: Some(Origin { + kind: SourceLanguage::Opy.as_str().to_string(), + locale: locale.map(str::to_owned), + }), + } + }) + }) + .collect() +} + +fn provider_uri_path(uri: &str) -> String { + url::Url::parse(uri) + .ok() + .and_then(|url| url.to_file_path().ok()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| uri.to_string()) +} + +fn provider_position(position: wright_lpp::Position) -> Position { + Position { + line: position.line.saturating_add(1), + col: position.character.saturating_add(1), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index be1c47b..952e8b2 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -54,6 +54,8 @@ fn cleanup(dir: PathBuf) { struct RecordingProvider { target: Arc>>, + operations: Arc>>, + check_compilation: Option, compilation: Option, failure: Option, } @@ -63,7 +65,26 @@ impl SourceProvider for RecordingProvider { SourceLanguage::Opy } + fn check(&mut self, target: &SourceTarget) -> Result { + if self.check_compilation.is_none() { + return self.compile(target); + } + self.operations + .lock() + .expect("operation lock") + .push("check"); + *self.target.lock().expect("target lock") = Some(target.clone()); + if let Some(error) = self.failure.take() { + return Err(error); + } + Ok(self.check_compilation.take().expect("check result")) + } + fn compile(&mut self, target: &SourceTarget) -> Result { + self.operations + .lock() + .expect("operation lock") + .push("compile"); *self.target.lock().expect("target lock") = Some(target.clone()); if let Some(error) = self.failure.take() { return Err(error); @@ -76,8 +97,11 @@ impl SourceProvider for RecordingProvider { fn provider_backend_passes_only_the_selected_entry_and_uses_canonical_workshop_handoff() { let (dir, entry) = temp_entry(); let observed = Arc::new(Mutex::new(None)); + let operations = Arc::new(Mutex::new(Vec::new())); let provider = RecordingProvider { target: Arc::clone(&observed), + operations: Arc::clone(&operations), + check_compilation: None, compilation: Some(SourceCompilation::success(workshop_fixture( "synthetic/control-flow", ))), @@ -110,10 +134,75 @@ fn provider_backend_passes_only_the_selected_entry_and_uses_canonical_workshop_h assert_eq!(target.language, SourceLanguage::Opy); assert_eq!(target.entry, entry); assert_eq!(target.cwd, std::env::current_dir().expect("cwd")); + assert_eq!(target.project_root, Some(dir.clone())); assert_eq!( session.load().expect("cached provider result").provenance, wright_driver::Provenance::Unmapped ); + assert_eq!(*operations.lock().expect("operation lock"), vec!["compile"]); + cleanup(dir); +} + +#[test] +fn provider_backend_check_uses_the_provider_check_operation() { + let (dir, entry) = temp_entry(); + let operations = Arc::new(Mutex::new(Vec::new())); + let provider = RecordingProvider { + target: Arc::new(Mutex::new(None)), + operations: Arc::clone(&operations), + check_compilation: Some(SourceCompilation { + workshop_text: None, + locale: Some("zh-CN".to_string()), + provenance: wright_driver::source_provider::SourceProvenance::Unmapped, + diagnostics: Vec::new(), + }), + compilation: Some(SourceCompilation::success("unused")), + failure: None, + }; + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + let result = session.check(); + assert!(result.ok, "provider check: {:?}", result.diagnostics); + assert_eq!(*operations.lock().expect("operation lock"), vec!["check"]); + cleanup(dir); +} + +#[test] +fn provider_backend_does_not_reuse_a_check_load_for_compile() { + let (dir, entry) = temp_entry(); + let operations = Arc::new(Mutex::new(Vec::new())); + let provider = RecordingProvider { + target: Arc::new(Mutex::new(None)), + operations: Arc::clone(&operations), + check_compilation: Some(SourceCompilation { + workshop_text: None, + locale: None, + provenance: wright_driver::source_provider::SourceProvenance::Unmapped, + diagnostics: Vec::new(), + }), + compilation: Some(SourceCompilation::success(workshop_fixture( + "synthetic/basic-rule", + ))), + failure: None, + }; + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + assert!(session.check().ok); + assert!(session.compile().ok); + assert_eq!( + *operations.lock().expect("operation lock"), + vec!["check", "compile"] + ); cleanup(dir); } @@ -137,6 +226,8 @@ fn unmapped_provider_artifact_errors_do_not_claim_the_opy_entry() { let (dir, entry) = temp_entry(); let provider = RecordingProvider { target: Arc::new(Mutex::new(None)), + operations: Arc::new(Mutex::new(Vec::new())), + check_compilation: None, compilation: Some(SourceCompilation::success( "rule (\"broken\") {\n actions {\n UnknownAction;\n }\n}\n", )), @@ -149,7 +240,7 @@ fn unmapped_provider_artifact_errors_do_not_claim_the_opy_entry() { }; let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) .expect("provider session"); - let result = session.check(); + let result = session.compile(); assert!(!result.ok); assert_eq!(result.diagnostics[0].code, "unknown-action"); assert_eq!( @@ -176,6 +267,8 @@ fn provider_backend_does_not_fall_back_when_the_provider_fails() { let (dir, entry) = temp_entry(); let provider = RecordingProvider { target: Arc::new(Mutex::new(None)), + operations: Arc::new(Mutex::new(Vec::new())), + check_compilation: None, compilation: None, failure: Some(SourceProviderError::Failed { code: "provider-exited".to_string(), @@ -197,18 +290,21 @@ fn provider_backend_does_not_fall_back_when_the_provider_fails() { } #[test] -fn provider_backend_without_injection_is_an_explicit_failure() { +fn provider_backend_provider_resolution_failure_is_explicit() { let (dir, entry) = temp_entry(); let config = SessionConfig { input: InputSpec::Path(entry), kind: SourceKind::Opy, source_backend: SourceBackend::Provider, + opy_provider: wright_driver::OpyProviderConfig::with_executable( + dir.join("missing-provider"), + ), ..SessionConfig::default() }; let mut session = CompilerSession::new(config).expect("session"); let result = session.check(); assert!(!result.ok); assert_eq!(result.exit, 4); - assert_eq!(result.diagnostics[0].code, "source-provider-not-configured"); + assert_eq!(result.diagnostics[0].code, "provider-missing"); cleanup(dir); } diff --git a/crates/wright-lpp/src/error.rs b/crates/wright-lpp/src/error.rs index bf218e3..1c7b356 100644 --- a/crates/wright-lpp/src/error.rs +++ b/crates/wright-lpp/src/error.rs @@ -85,6 +85,8 @@ pub enum LppErrorKind { InvalidRequest, InvalidLanguage, InvalidDocument, + InvalidEntry, + ProjectLoadFailed, InvalidPosition, InvalidArtifact, CapabilityUnavailable, @@ -103,6 +105,8 @@ impl LppErrorKind { "invalidRequest" => LppErrorKind::InvalidRequest, "invalidLanguage" => LppErrorKind::InvalidLanguage, "invalidDocument" => LppErrorKind::InvalidDocument, + "invalidEntry" => LppErrorKind::InvalidEntry, + "projectLoadFailed" => LppErrorKind::ProjectLoadFailed, "invalidPosition" => LppErrorKind::InvalidPosition, "invalidArtifact" => LppErrorKind::InvalidArtifact, "capabilityUnavailable" => LppErrorKind::CapabilityUnavailable, @@ -118,6 +122,8 @@ impl LppErrorKind { LppErrorKind::InvalidRequest => "invalid-request", LppErrorKind::InvalidLanguage => "invalid-language", LppErrorKind::InvalidDocument => "invalid-document", + LppErrorKind::InvalidEntry => "invalid-entry", + LppErrorKind::ProjectLoadFailed => "project-load-failed", LppErrorKind::InvalidPosition => "invalid-position", LppErrorKind::InvalidArtifact => "invalid-artifact", LppErrorKind::CapabilityUnavailable => "capability-unavailable", diff --git a/crates/wright-lpp/src/lib.rs b/crates/wright-lpp/src/lib.rs index 5229219..f855bf8 100644 --- a/crates/wright-lpp/src/lib.rs +++ b/crates/wright-lpp/src/lib.rs @@ -61,9 +61,12 @@ pub mod provider; pub mod registry; pub mod types; -/// The only LPP protocol version this client speaks. +/// The LPP 1.0 protocol version used by document-supplied requests. pub const LPP_PROTOCOL_VERSION: &str = "1.0"; +/// The additive LPP 1.1 version that enables provider-owned project loading. +pub const LPP_PROJECT_LOADING_PROTOCOL_VERSION: &str = "1.1"; + /// The client name reported in `lpp/initialize` `clientInfo`. pub const LPP_CLIENT_NAME: &str = "wright"; @@ -75,7 +78,7 @@ pub use registry::{ProviderConfig, ProviderRegistry, RegistryError}; pub use types::{ Capabilities, Capability, CheckResult, ClientInfo, CompileResult, Diagnostic, DiagnosticSeverity, Document, DocumentDiagnostics, DocumentEdits, DocumentSet, DocumentSymbols, - InitializeResult, LanguageInfo, Location, LocationsResult, Position, Range, ReconstructResult, - RenameResult, ServerInfo, Symbol, SymbolsResult, TextEdit, ValidateEditsResult, - WorkshopArtifact, + InitializeResult, LanguageInfo, Location, LocationsResult, Position, ProjectEntry, Range, + ReconstructResult, RenameResult, ServerInfo, Symbol, SymbolsResult, TextEdit, + ValidateEditsResult, WorkshopArtifact, }; diff --git a/crates/wright-lpp/src/provider.rs b/crates/wright-lpp/src/provider.rs index 03b9300..e949fb2 100644 --- a/crates/wright-lpp/src/provider.rs +++ b/crates/wright-lpp/src/provider.rs @@ -23,8 +23,8 @@ use crate::error::ProviderError; use crate::process::ChildProcess; use crate::types::{ Capabilities, Capability, CheckResult, ClientInfo, CompileResult, Document, DocumentSet, - InitializeResult, LocationsResult, Position, ReconstructResult, RenameResult, SymbolsResult, - TextEdit, ValidateEditsResult, WorkshopArtifact, + InitializeResult, LocationsResult, Position, ProjectEntry, ReconstructResult, RenameResult, + SymbolsResult, TextEdit, ValidateEditsResult, WorkshopArtifact, }; /// The negotiated result of a successful `lpp/initialize`. @@ -76,6 +76,18 @@ pub trait LanguageProvider { client_info: Option<&ClientInfo>, ) -> Result; + /// Initialize with LPP 1.1 for provider-owned project loading. + fn initialize_project_loading( + &mut self, + client_info: Option<&ClientInfo>, + ) -> Result { + let result = self.initialize(client_info)?; + Err(ProviderError::ProtocolVersionMismatch { + supported: vec![result.protocol_version], + message: "the provider client does not support LPP 1.1 project loading".to_string(), + }) + } + /// The negotiated capabilities, after a successful initialize. fn capabilities(&self) -> Result<&NegotiatedCapabilities, ProviderError>; @@ -86,6 +98,21 @@ pub trait LanguageProvider { project_root: Option<&str>, ) -> Result; + /// `lpp/check` over a provider-owned filesystem project entry. + fn check_entry( + &mut self, + entry: &ProjectEntry, + project_root: Option<&str>, + locale: Option<&str>, + ) -> Result { + let _ = (entry, project_root, locale); + Err(ProviderError::lpp( + crate::error::LppErrorKind::CapabilityUnavailable, + json!({ "capability": "projectLoading", "method": "lpp/check" }), + "capability 'projectLoading' is not available in this provider client", + )) + } + /// `lpp/compile`: compile a document set into one opaque Workshop /// artifact. fn compile( @@ -94,6 +121,21 @@ pub trait LanguageProvider { project_root: Option<&str>, ) -> Result; + /// `lpp/compile` over a provider-owned filesystem project entry. + fn compile_entry( + &mut self, + entry: &ProjectEntry, + project_root: Option<&str>, + locale: Option<&str>, + ) -> Result { + let _ = (entry, project_root, locale); + Err(ProviderError::lpp( + crate::error::LppErrorKind::CapabilityUnavailable, + json!({ "capability": "projectLoading", "method": "lpp/compile" }), + "capability 'projectLoading' is not available in this provider client", + )) + } + /// `lpp/reconstruct`: reconstruct source from a provider-owned artifact. fn reconstruct( &mut self, @@ -179,6 +221,52 @@ impl StdioLanguageProvider { }) } + fn initialize_with_version( + &mut self, + protocol_version: &str, + client_info: Option<&ClientInfo>, + ) -> Result { + let mut params = json!({ "protocolVersion": protocol_version }); + if let Some(info) = client_info { + params["clientInfo"] = serde_json::to_value(info).expect("client info serializes"); + } + let value = match self.client.initialize(params) { + Ok(value) => value, + Err(mut error) => { + self.enrich_transport_error(&mut error, "lpp/initialize"); + return Err(error); + } + }; + let result: InitializeResult = match serde_json::from_value(value) { + Ok(result) => result, + Err(error) => { + self.client.reset_initialize(); + return Err(ProviderError::Malformed { + detail: format!( + "lpp/initialize result is not a valid LPP v1 response: {error}" + ), + }); + } + }; + if result.protocol_version != protocol_version { + self.client.reset_initialize(); + return Err(ProviderError::ProtocolVersionMismatch { + supported: vec![result.protocol_version.clone()], + message: format!( + "provider negotiated protocol version '{}' but this client requested '{protocol_version}'", + result.protocol_version + ), + }); + } + self.negotiated = Some(NegotiatedCapabilities { + protocol_version: result.protocol_version.clone(), + server_info: result.server_info.clone(), + languages: result.languages.clone(), + capabilities: result.capabilities.clone(), + }); + Ok(result) + } + /// Send a request through the client, enriching transport failures with /// the observed process status. fn request(&mut self, method: &str, params: Value) -> Result { @@ -242,45 +330,14 @@ impl LanguageProvider for StdioLanguageProvider { &mut self, client_info: Option<&ClientInfo>, ) -> Result { - let mut params = json!({ "protocolVersion": LPP_PROTOCOL_VERSION }); - if let Some(info) = client_info { - params["clientInfo"] = serde_json::to_value(info).expect("client info serializes"); - } - let value = match self.client.initialize(params) { - Ok(value) => value, - Err(mut error) => { - self.enrich_transport_error(&mut error, "lpp/initialize"); - return Err(error); - } - }; - let result: InitializeResult = match serde_json::from_value(value) { - Ok(result) => result, - Err(error) => { - self.client.reset_initialize(); - return Err(ProviderError::Malformed { - detail: format!( - "lpp/initialize result is not a valid LPP v1 response: {error}" - ), - }); - } - }; - if result.protocol_version != LPP_PROTOCOL_VERSION { - self.client.reset_initialize(); - return Err(ProviderError::ProtocolVersionMismatch { - supported: vec![result.protocol_version.clone()], - message: format!( - "provider negotiated protocol version '{}' but this client only speaks '{LPP_PROTOCOL_VERSION}'", - result.protocol_version - ), - }); - } - self.negotiated = Some(NegotiatedCapabilities { - protocol_version: result.protocol_version.clone(), - server_info: result.server_info.clone(), - languages: result.languages.clone(), - capabilities: result.capabilities.clone(), - }); - Ok(result) + self.initialize_with_version(LPP_PROTOCOL_VERSION, client_info) + } + + fn initialize_project_loading( + &mut self, + client_info: Option<&ClientInfo>, + ) -> Result { + self.initialize_with_version(crate::LPP_PROJECT_LOADING_PROTOCOL_VERSION, client_info) } fn capabilities(&self) -> Result<&NegotiatedCapabilities, ProviderError> { @@ -301,6 +358,18 @@ impl LanguageProvider for StdioLanguageProvider { parse_result(value, "lpp/check") } + fn check_entry( + &mut self, + entry: &ProjectEntry, + project_root: Option<&str>, + locale: Option<&str>, + ) -> Result { + self.require_capability(Capability::Check)?; + self.require_capability(Capability::ProjectLoading)?; + let value = self.request("lpp/check", entry_params(entry, project_root, locale))?; + parse_result(value, "lpp/check") + } + fn compile( &mut self, documents: &DocumentSet, @@ -311,6 +380,18 @@ impl LanguageProvider for StdioLanguageProvider { parse_result(value, "lpp/compile") } + fn compile_entry( + &mut self, + entry: &ProjectEntry, + project_root: Option<&str>, + locale: Option<&str>, + ) -> Result { + self.require_capability(Capability::Compile)?; + self.require_capability(Capability::ProjectLoading)?; + let value = self.request("lpp/compile", entry_params(entry, project_root, locale))?; + parse_result(value, "lpp/compile") + } + fn reconstruct( &mut self, artifact: &WorkshopArtifact, @@ -423,6 +504,18 @@ fn documents_params(documents: &DocumentSet, project_root: Option<&str>) -> Valu params } +fn entry_params(entry: &ProjectEntry, project_root: Option<&str>, locale: Option<&str>) -> Value { + let mut params = + json!({ "entry": serde_json::to_value(entry).expect("project entry serializes") }); + if let Some(root) = project_root { + params["projectRoot"] = json!(root); + } + if let Some(locale) = locale { + params["locale"] = json!(locale); + } + params +} + /// Parse a typed result, converting shape failures into a deterministic /// malformed-response error. fn parse_result(value: Value, method: &str) -> Result { @@ -561,6 +654,13 @@ mod tests { }) } + fn init_result_project_loading_json() -> Value { + let mut result = init_result_json(); + result["protocolVersion"] = json!("1.1"); + result["capabilities"]["projectLoading"] = json!(true); + result + } + fn ok_response(result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": 0, "result": result }) } @@ -642,4 +742,58 @@ mod tests { assert!(check.documents.is_empty()); fake.assert_only_requests(2); } + + #[test] + fn project_loading_initialization_and_entry_requests_use_lpp_11() { + let (mut provider, fake) = Fake::spawn(vec![ + FakeStep::Respond(ok_response(init_result_project_loading_json())), + FakeStep::Respond(ok_response(json!({ "documents": [] }))), + FakeStep::Respond(ok_response(json!({ "diagnostics": [], "artifact": null }))), + ]); + provider + .initialize_project_loading(None) + .expect("LPP 1.1 initialize"); + let entry = ProjectEntry { + uri: "file:///project/main.opy".to_string(), + language_id: "opy".to_string(), + version: 7, + }; + provider + .check_entry(&entry, Some("file:///project"), Some("zh-CN")) + .expect("entry check"); + provider + .compile_entry(&entry, Some("file:///project"), Some("zh-CN")) + .expect("entry compile"); + let initialize: Value = serde_json::from_str( + &fake + .requests + .recv_timeout(Duration::from_millis(250)) + .expect("initialize request"), + ) + .expect("initialize JSON"); + assert_eq!(initialize["params"]["protocolVersion"], "1.1"); + let check: Value = serde_json::from_str( + &fake + .requests + .recv_timeout(Duration::from_millis(250)) + .expect("check request"), + ) + .expect("check JSON"); + assert_eq!(check["method"], "lpp/check"); + assert_eq!( + check["params"]["entry"], + serde_json::to_value(&entry).unwrap() + ); + assert_eq!(check["params"]["projectRoot"], "file:///project"); + assert_eq!(check["params"]["locale"], "zh-CN"); + let compile: Value = serde_json::from_str( + &fake + .requests + .recv_timeout(Duration::from_millis(250)) + .expect("compile request"), + ) + .expect("compile JSON"); + assert_eq!(compile["method"], "lpp/compile"); + fake.assert_only_requests(0); + } } diff --git a/crates/wright-lpp/src/types.rs b/crates/wright-lpp/src/types.rs index 4b76efc..cb10580 100644 --- a/crates/wright-lpp/src/types.rs +++ b/crates/wright-lpp/src/types.rs @@ -75,6 +75,15 @@ pub struct Document { /// A set of documents keyed by URI, supplied with a request. pub type DocumentSet = BTreeMap; +/// A client-selected entry for provider-owned filesystem project loading. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectEntry { + pub uri: String, + #[serde(rename = "languageId")] + pub language_id: String, + pub version: i64, +} + /// The severity of a provider diagnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -230,6 +239,7 @@ pub struct ValidateEditsResult { pub enum Capability { Check, Compile, + ProjectLoading, Reconstruct, Symbols, Definition, @@ -240,9 +250,10 @@ pub enum Capability { impl Capability { /// Every LPP v1 capability. - pub const ALL: [Capability; 8] = [ + pub const ALL: [Capability; 9] = [ Capability::Check, Capability::Compile, + Capability::ProjectLoading, Capability::Reconstruct, Capability::Symbols, Capability::Definition, @@ -256,6 +267,7 @@ impl Capability { match self { Capability::Check => "check", Capability::Compile => "compile", + Capability::ProjectLoading => "projectLoading", Capability::Reconstruct => "reconstruct", Capability::Symbols => "symbols", Capability::Definition => "definition", @@ -270,6 +282,7 @@ impl Capability { match self { Capability::Check => "lpp/check", Capability::Compile => "lpp/compile", + Capability::ProjectLoading => "lpp/check", Capability::Reconstruct => "lpp/reconstruct", Capability::Symbols => "lpp/symbols", Capability::Definition => "lpp/definition", @@ -286,6 +299,7 @@ impl Capability { Some(match name { "check" => Capability::Check, "compile" => Capability::Compile, + "projectLoading" => Capability::ProjectLoading, "reconstruct" => Capability::Reconstruct, "symbols" => Capability::Symbols, "definition" => Capability::Definition, @@ -298,13 +312,14 @@ impl Capability { } /// The negotiated capability set advertised by a provider during -/// initialization. All eight LPP v1 fields are REQUIRED on the wire, so a -/// response missing any of them fails deserialization and surfaces as a -/// malformed response. +/// initialization. The LPP 1.0 fields are required; `projectLoading` is the +/// additive LPP 1.1 capability and defaults to false for 1.0 providers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capabilities { pub check: bool, pub compile: bool, + #[serde(rename = "projectLoading", default)] + pub project_loading: bool, pub reconstruct: bool, pub symbols: bool, pub definition: bool, @@ -320,6 +335,7 @@ impl Capabilities { match capability { Capability::Check => self.check, Capability::Compile => self.compile, + Capability::ProjectLoading => self.project_loading, Capability::Reconstruct => self.reconstruct, Capability::Symbols => self.symbols, Capability::Definition => self.definition, diff --git a/crates/wright-lpp/tests/mock_provider.rs b/crates/wright-lpp/tests/mock_provider.rs index a271c94..98d7ed8 100644 --- a/crates/wright-lpp/tests/mock_provider.rs +++ b/crates/wright-lpp/tests/mock_provider.rs @@ -123,6 +123,9 @@ fn initializes_and_negotiates_capabilities_with_x_demo_lang() { let negotiated = provider.capabilities().expect("negotiated"); assert_eq!(negotiated.language_ids(), vec![DEMO_LANGUAGE_ID]); for capability in Capability::ALL { + if capability == Capability::ProjectLoading { + continue; + } assert!( negotiated.supports(capability), "capability {} negotiated", diff --git a/crates/wright-lpp/tests/unit.rs b/crates/wright-lpp/tests/unit.rs index 3fbe3fc..278b97f 100644 --- a/crates/wright-lpp/tests/unit.rs +++ b/crates/wright-lpp/tests/unit.rs @@ -731,6 +731,7 @@ fn capability_require_refuses_unnegotiated_capabilities() { let capabilities = Capabilities { check: true, compile: false, + project_loading: false, reconstruct: true, symbols: true, definition: true, @@ -766,6 +767,7 @@ fn capability_require_refuses_unnegotiated_capabilities() { fn capability_ids_and_methods_match_the_spec_table() { assert_eq!(Capability::Check.as_str(), "check"); assert_eq!(Capability::Compile.as_str(), "compile"); + assert_eq!(Capability::ProjectLoading.as_str(), "projectLoading"); assert_eq!(Capability::Reconstruct.as_str(), "reconstruct"); assert_eq!(Capability::Symbols.as_str(), "symbols"); assert_eq!(Capability::Definition.as_str(), "definition"); @@ -774,6 +776,7 @@ fn capability_ids_and_methods_match_the_spec_table() { assert_eq!(Capability::EditValidation.as_str(), "editValidation"); assert_eq!(Capability::Check.method(), "lpp/check"); assert_eq!(Capability::Compile.method(), "lpp/compile"); + assert_eq!(Capability::ProjectLoading.method(), "lpp/check"); assert_eq!(Capability::Reconstruct.method(), "lpp/reconstruct"); assert_eq!(Capability::Symbols.method(), "lpp/symbols"); assert_eq!(Capability::Definition.method(), "lpp/definition"); @@ -784,6 +787,10 @@ fn capability_ids_and_methods_match_the_spec_table() { Capability::parse("editValidation"), Some(Capability::EditValidation) ); + assert_eq!( + Capability::parse("projectLoading"), + Some(Capability::ProjectLoading) + ); assert_eq!(Capability::parse("bogus"), None); } From c5c587a3010df137917124083ce5367edfc50c27 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:29:46 +0800 Subject: [PATCH 2/5] fix: complete OPY provider workflow cutover Select the first-party provider for ordinary OPY check and compile workflows, and refuse check-only provider loads for WIR consumers. Refs #245 --- crates/wright-cli/src/cli.rs | 2 +- crates/wright-cli/src/main.rs | 82 ++++++++++- crates/wright-cli/tests/cli.rs | 139 +++++------------- crates/wright-driver/src/session.rs | 15 +- crates/wright-driver/tests/source_provider.rs | 19 +++ docs/cli.md | 15 +- 6 files changed, 148 insertions(+), 124 deletions(-) diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index b1e8b73..7d018f9 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -114,7 +114,7 @@ pub(crate) struct CommonArgs { /// Include/project root for source inputs. #[arg(long, value_name = "DIR")] pub(crate) root: Option, - /// Explicit local first-party OPY provider executable. + /// Override the first-party OPY provider executable. #[arg(long, value_name = "PATH")] pub(crate) opy_provider: Option, /// WIR transformation policy. diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index b199590..44679dc 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -118,7 +118,7 @@ fn main() -> ExitCode { fn run_workflow(command: Command) -> ExitCode { let (name, config, presentation, convert_target) = match command { Command::Compile(args) => { - let mut config = config_from_common(&args.common); + let mut config = config_from_common(&args.common, true); config.output = args.output; ( "compile", @@ -129,24 +129,24 @@ fn run_workflow(command: Command) -> ExitCode { } Command::Convert(args) => ( "convert", - config_from_common(&args.common), + config_from_common(&args.common, false), present::Presentation::from_common(&args.common), Some(args.target), ), Command::Check(args) => ( "check", - config_from_common(&args), + config_from_common(&args, true), present::Presentation::from_common(&args), None, ), Command::Analyze(args) => ( "analyze", - config_from_common(&args), + config_from_common(&args, false), present::Presentation::from_common(&args), None, ), Command::Lint(args) => { - let mut config = config_from_common(&args.common); + let mut config = config_from_common(&args.common, false); for rule in &args.disable_rule { config.lint.disable(rule); } @@ -174,7 +174,7 @@ fn run_workflow(command: Command) -> ExitCode { } Command::Inspect(args) => ( "inspect", - config_from_common(&args), + config_from_common(&args, false), present::Presentation::from_common(&args), None, ), @@ -240,14 +240,16 @@ fn run_workflow(command: Command) -> ExitCode { ExitCode::from(code) } -fn config_from_common(common: &CommonArgs) -> SessionConfig { +fn config_from_common(common: &CommonArgs, provider_workflow: bool) -> SessionConfig { let input = match &common.input { Some(path) if path.as_os_str() != "-" => InputSpec::Path(path.clone()), _ => InputSpec::Stdin, }; SessionConfig { input, - source_backend: if common.opy_provider.is_some() { + source_backend: if is_opy_input(common) + && (provider_workflow || common.opy_provider.is_some()) + { SourceBackend::Provider } else { SourceBackend::Native @@ -278,6 +280,19 @@ fn config_from_common(common: &CommonArgs) -> SessionConfig { } } +fn is_opy_input(common: &CommonArgs) -> bool { + match common.kind { + cli::SourceKindArg::Opy => true, + cli::SourceKindArg::Auto => common + .input + .as_deref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("opy")), + _ => false, + } +} + /// Run one driver workflow and render its envelope in the CLI presentation. fn run_command( session: &mut wright_driver::CompilerSession, @@ -293,3 +308,54 @@ fn run_command( present::render(&envelope, presentation); code } + +#[cfg(test)] +mod tests { + use super::*; + + fn common(input: Option<&str>, kind: cli::SourceKindArg) -> CommonArgs { + CommonArgs { + input: input.map(Into::into), + kind, + locale: None, + root: None, + opy_provider: None, + profile: cli::ProfileArg::Off, + format: cli::OutputFormatArg::Json, + renderer: cli::RendererArg::Plain, + color: cli::ColorArg::Never, + } + } + + #[test] + fn ordinary_opy_check_and_compile_select_the_provider_backend() { + let opy = common(Some("main.opy"), cli::SourceKindArg::Auto); + assert_eq!( + config_from_common(&opy, true).source_backend, + SourceBackend::Provider + ); + + let workshop = common(Some("main.txt"), cli::SourceKindArg::Auto); + assert_eq!( + config_from_common(&workshop, true).source_backend, + SourceBackend::Native + ); + + let explicit_opy = common(None, cli::SourceKindArg::Opy); + assert_eq!( + config_from_common(&explicit_opy, true).source_backend, + SourceBackend::Provider + ); + assert_eq!( + config_from_common(&opy, false).source_backend, + SourceBackend::Native + ); + + let mut explicit_provider = common(Some("main.opy"), cli::SourceKindArg::Auto); + explicit_provider.opy_provider = Some("opy-provider".into()); + assert_eq!( + config_from_common(&explicit_provider, false).source_backend, + SourceBackend::Provider + ); + } +} diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index b8466fd..89ea03a 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -537,25 +537,18 @@ fn stdin_workshop_and_protocol_piping_work() { } #[test] -fn stdin_opy_compiles_natively() { +fn stdin_opy_requires_an_entry_for_provider_workflows() { let source = std::fs::read_to_string( workspace_root().join("compatibility/fixtures/synthetic/basic-rule/source.opy"), ) .unwrap(); let output = run_with_stdin(&["compile", "-", "--kind", "opy", "-f", "json"], &source); - assert_eq!( - output.status.code(), - Some(0), - "native .opy stdin: {}", - String::from_utf8_lossy(&output.stderr) - ); + assert_eq!(output.status.code(), Some(3)); let envelope = parse_json(&output.stdout); - assert_eq!(envelope["ok"], true); - assert!( - envelope["result"]["output"]["text"] - .as_str() - .unwrap() - .contains("Disable Inspector Recording") + assert_eq!(envelope["ok"], false); + assert_eq!( + envelope["diagnostics"][0]["code"], + "source-provider-unsupported" ); } @@ -1094,110 +1087,48 @@ fn github_summary_uses_step_summary_file_when_available() { } #[test] -fn opy_file_compiles_through_the_native_frontend() { +fn opy_file_does_not_fall_back_to_native_frontend() { let source = std::fs::read_to_string( workspace_root().join("compatibility/fixtures/synthetic/basic-rule/source.opy"), ) .unwrap(); let path = temp_file("basic-rule.opy", &source); - let output = run(&["compile", path.to_str().unwrap(), "-f", "json"]); - assert_eq!( - output.status.code(), - Some(0), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let envelope = parse_json(&output.stdout); - assert_eq!(envelope["ok"], true); - let oracle = std::fs::read_to_string( - workspace_root().join("compatibility/fixtures/synthetic/basic-rule/oracle.json"), - ) - .unwrap(); - let oracle_value: serde_json::Value = serde_json::from_str(&oracle).unwrap(); - let expected = oracle_value["compile"]["workshop"].as_str().unwrap(); - assert_eq!( - envelope["result"]["output"]["text"] - .as_str() - .unwrap() - .trim(), - expected.trim(), - "native .opy output matches the oracle Workshop text" - ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); -} - -#[test] -fn opy_corpus_frontend_errors_are_structured() { - // Malformed `.opy` fails with a structured frontend diagnostic, not a - // panic, and does not fall back to the adapter silently. - let path = temp_file("broken.opy", "rule \"missing colon\"\n @Event global\n"); - let output = run(&["check", path.to_str().unwrap(), "-f", "json"]); - assert_eq!(output.status.code(), Some(1)); + let missing_provider = path.parent().unwrap().join("missing-opy-provider"); + let output = run(&[ + "compile", + path.to_str().unwrap(), + "--opy-provider", + missing_provider.to_str().unwrap(), + "-f", + "json", + ]); + assert_eq!(output.status.code(), Some(4)); let envelope = parse_json(&output.stdout); - assert_eq!(envelope["diagnostics"][0]["code"], "parse-error"); - assert_eq!(envelope["diagnostics"][0]["stage"], "frontend"); + assert_eq!(envelope["ok"], false); + assert_eq!(envelope["diagnostics"][0]["code"], "provider-missing"); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } #[test] -fn opy_chase_reevaluation_enums_compile_with_reference_semantics() { - // #105: ChaseTimeReeval.NONE (and the other reference-validated members - // of the ChaseTimeReeval/ChaseRateReeval domains) lower through the enum - // catalog data path and emit with the same Workshop value as the pinned - // oracle — `None`/`Destination and Duration`/`Destination and Rate`, - // distinct from the `Null` literal. - let fixture = workspace_root().join("compatibility/fixtures/synthetic/chase-enums"); - let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap(); - let path = temp_file("chase-enums.opy", &source); - let output = run(&["compile", path.to_str().unwrap(), "-f", "json"]); - assert_eq!( - output.status.code(), - Some(0), - "{}", - String::from_utf8_lossy(&output.stderr) - ); +fn non_compile_opy_workflows_do_not_analyze_a_check_only_provider_result() { + let path = temp_file("main.opy", "rule \"r\":\n @Event global\n"); + let missing_provider = path.parent().unwrap().join("missing-opy-provider"); + let output = run(&[ + "analyze", + path.to_str().unwrap(), + "--opy-provider", + missing_provider.to_str().unwrap(), + "-f", + "json", + ]); + assert_eq!(output.status.code(), Some(3)); let envelope = parse_json(&output.stdout); - assert_eq!(envelope["ok"], true); - let oracle = std::fs::read_to_string(fixture.join("oracle.json")).unwrap(); - let oracle_value: serde_json::Value = serde_json::from_str(&oracle).unwrap(); - let expected = oracle_value["compile"]["workshop"].as_str().unwrap(); - let emitted = envelope["result"]["output"]["text"].as_str().unwrap(); + assert_eq!(envelope["ok"], false); assert_eq!( - emitted.trim(), - expected.trim(), - "native .opy output matches the oracle Workshop text" - ); - assert!( - emitted.contains("None"), - "the NONE member emits its spelling: {emitted}" - ); - assert!(emitted.contains("Destination and Duration"), "{emitted}"); - assert!(emitted.contains("Destination and Rate"), "{emitted}"); - assert!( - emitted.contains("Set Global Variable(time_reeval, None)"), - "the enum member emits as a bare spelling, not the Null literal: {emitted}" - ); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); -} - -#[test] -fn opy_unknown_enum_member_is_a_deterministic_frontend_diagnostic() { - // #105: enum members outside the evidenced catalog surface keep failing - // with a deterministic, source-located structured diagnostic. - let path = temp_file( - "unknown-member.opy", - "globalvar g\nrule \"r\":\n @Event global\n g = ChaseTimeReeval.NOPE\n", - ); - let output = run(&["check", path.to_str().unwrap(), "-f", "json"]); - assert_eq!(output.status.code(), Some(1)); - let envelope = parse_json(&output.stdout); - let diagnostic = &envelope["diagnostics"][0]; - assert_eq!(diagnostic["code"], "unknown-enum-member"); - assert_eq!(diagnostic["stage"], "frontend"); - assert!( - diagnostic["span"].is_object(), - "the diagnostic is source-located" + envelope["diagnostics"][0]["code"], + "source-provider-unsupported" ); + assert!(envelope["result"]["program"].is_null()); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 37a64b0..0e4149a 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -153,6 +153,14 @@ impl CompilerSession { /// re-reading the input. Returns an owned snapshot so callers can hold it /// while mutating the session. pub fn load(&mut self) -> Result { + if self.config.source_backend == SourceBackend::Provider + && self.loaded_operation != Some(ProviderOperation::Compile) + { + return Err(SourceProviderError::Unsupported { + message: "provider-backed load requires a canonical compile result; lint, analyze, inspect, and convert are not available on the check-only provider path".to_string(), + } + .diagnostic()); + } self.load_with_operation(ProviderOperation::Check) } @@ -365,17 +373,14 @@ impl CompilerSession { .unwrap_or_else(|| "en-US".to_string()); let locale = workshop_rs::catalog::Locale::new(&locale_name); if operation == ProviderOperation::Check { - let loaded = Loaded { + return Ok(Loaded { program: Arc::new(wir::Program::default()), ostw: None, ostw_semantic: None, origin: resolved.origin.clone(), input: resolved.clone(), provenance, - }; - self.loaded = Some(loaded.clone()); - self.loaded_operation = Some(operation); - return Ok(loaded); + }); } let Some(workshop_text) = compilation.workshop_text else { return Err(Diagnostic::error( diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index 952e8b2..fd31647 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -308,3 +308,22 @@ fn provider_backend_provider_resolution_failure_is_explicit() { assert_eq!(result.diagnostics[0].code, "provider-missing"); cleanup(dir); } + +#[test] +fn provider_backend_non_compile_workflows_refuse_without_empty_wir() { + let (dir, entry) = temp_entry(); + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + source_backend: SourceBackend::Provider, + ..SessionConfig::default() + }; + let mut session = CompilerSession::new(config).expect("session"); + + let result = session.analyze(); + assert!(!result.ok); + assert_eq!(result.exit, 3); + assert_eq!(result.diagnostics[0].code, "source-provider-unsupported"); + assert!(result.result.program.is_null()); + cleanup(dir); +} diff --git a/docs/cli.md b/docs/cli.md index 7287cc7..2722564 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -466,12 +466,15 @@ carry their own SHA-256 (`result.output.sha256`). ## The `.opy` source implementation -`.opy` inputs are compiled through the owner-backed `opy-rs` implementation -through Wright's narrow adapter: no Node, no OverPy, and stdin `.opy` is -supported (the include root defaults to the working directory for stdin, -`--root` for files). The pinned OverPy adapter remains available only as an -explicit compatibility fallback by setting `WRIGHT_ADAPTER_PATH`; it is never -selected silently. The source surface is declared in +`.opy` `check` and `compile` inputs use the owner-backed `opy-rs` implementation +through Wright's narrow LPP 1.1 adapter: project loading is entry-based, no +Node or OverPy is involved, and provider failures never fall back to the native +frontend. An entry path is required for provider-backed workflows, so stdin +`.opy` is rejected; `--root` supplies the project root for file inputs. The +provider executable is resolved by the #244 bootstrap path and can be +overridden with `--opy-provider`. `lint`, `analyze`, and `inspect` remain on the +native frontend until a canonical provider WIR handoff is available. The +source surface is declared in [`opy/support-matrix.md`](opy/support-matrix.md). ## Library reuse From e2c761a6bcd908978d26235876e57a29ac04ed2a Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:04:25 +0800 Subject: [PATCH 3/5] fix(ci): accept provider canonical up-vector spelling Normalize the provider's explicit unit-up vector against the pinned oracle alias while keeping the compatibility snapshot unchanged. Refs #245 --- docs/v1-matrix.md | 3 +++ scripts/v1-gates.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/v1-matrix.md b/docs/v1-matrix.md index 0afd57b..2fc6ba1 100644 --- a/docs/v1-matrix.md +++ b/docs/v1-matrix.md @@ -52,6 +52,9 @@ The compatibility contract does **not** claim: declaration order, matching the reference for the corpus. 3. **Float formatting.** Floats emit with at most 16 significant digits, matching the reference snapshots. +4. **Unit-up vector spelling.** The provider's canonical Workshop emitter uses + `Vector(0, 1, 0)` where the pinned reference uses `Up`; the N-level + normalizer treats these equivalent Workshop values identically. ## Unsupported / deferred diff --git a/scripts/v1-gates.py b/scripts/v1-gates.py index 995f311..426773b 100755 --- a/scripts/v1-gates.py +++ b/scripts/v1-gates.py @@ -63,6 +63,9 @@ def normalize(text: str) -> str: # The canonical emitter's `All Players` spelling is equivalent to # OverPy's explicit `All Players(All Teams)` selector. text = text.replace("All Players(All Teams)", "All Players") + # The provider's canonical Workshop emitter spells the unit-up vector + # explicitly; the pinned oracle uses the equivalent `Up` constant. + text = re.sub(r"\bUp\b", "Vector(0, 1, 0)", text) return re.sub(r"\s+", "", text) @@ -84,7 +87,7 @@ def main() -> int: "wright": {"commit": subprocess.check_output( ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip()}, - "normalizer": "debug-hud-collapse + all-players-selector + whitespace-collapse", + "normalizer": "debug-hud-collapse + all-players-selector + up-vector-alias + whitespace-collapse", "fixtures": {}, } failures = [] From f9cd0802666528c9b003ec68d687e8466c4d383a Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:32:05 +0800 Subject: [PATCH 4/5] fix(provider): use native TLS roots for clean acquisition Load platform certificate stores for first-party OPY provider downloads so packaged clean installs bootstrap on macOS and Windows without weakening HTTPS verification or falling back to native OPY semantics. Refs #245 --- Cargo.lock | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index de7e2d7..93bc018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1092,6 +1108,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "opy-compiler" version = "0.1.5" @@ -1346,6 +1368,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1387,12 +1431,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1800,6 +1876,7 @@ dependencies = [ "log", "once_cell", "rustls", + "rustls-native-certs", "rustls-pki-types", "url", "webpki-roots 0.26.11", diff --git a/Cargo.toml b/Cargo.toml index 6bf73b0..8e24cd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ serde_json = "1" sha2 = "0.10" flate2 = "1" tar = "0.4" -ureq = "2" +ureq = { version = "2", features = ["native-certs"] } wright-analyzer = { path = "crates/wright-analyzer" } wright-core = { path = "crates/wright-core" } wright-driver = { path = "crates/wright-driver" } From 4932e866bbd0b6cd5f191d41aea51e141373504f Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:51:46 +0800 Subject: [PATCH 5/5] fix: remove orphaned zip workspace dependency Restore cargo metadata and cargo fmt checks after rebasing the provider workflow branch onto main. Refs #245 --- crates/wright-driver/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/wright-driver/Cargo.toml b/crates/wright-driver/Cargo.toml index 839d15c..fe2338a 100644 --- a/crates/wright-driver/Cargo.toml +++ b/crates/wright-driver/Cargo.toml @@ -18,7 +18,6 @@ flate2.workspace = true tar.workspace = true ureq.workspace = true url = "2" -zip.workspace = true wright-analyzer.workspace = true wright-core.workspace = true wright-ir.workspace = true