From d96d8091a045785e0ea9e988d4d63f331588ca8e Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:55:35 +0800 Subject: [PATCH 1/3] feat(driver): add explicit source provider boundary Add a Wright-owned entry and invocation-context seam for provider-backed OPY workflows. Preserve canonical Workshop handoff, structured provider failures, and explicit artifact provenance without a native fallback. The current LPP v1 cannot carry an entry target, so protocol entry/project loading remains a follow-up before wiring the concrete LPP adapter. Refs #243 --- crates/wright-driver/src/config.rs | 10 +- crates/wright-driver/src/edit.rs | 1 + crates/wright-driver/src/input.rs | 26 ++- crates/wright-driver/src/lib.rs | 7 +- crates/wright-driver/src/result.rs | 3 + crates/wright-driver/src/session.rs | 196 ++++++++++++++++-- crates/wright-driver/src/source_provider.rs | 188 +++++++++++++++++ crates/wright-driver/tests/source_provider.rs | 160 ++++++++++++++ docs/architecture.md | 14 +- 9 files changed, 574 insertions(+), 31 deletions(-) create mode 100644 crates/wright-driver/src/source_provider.rs create mode 100644 crates/wright-driver/tests/source_provider.rs diff --git a/crates/wright-driver/src/config.rs b/crates/wright-driver/src/config.rs index f620666..816f093 100644 --- a/crates/wright-driver/src/config.rs +++ b/crates/wright-driver/src/config.rs @@ -11,13 +11,14 @@ use std::path::PathBuf; pub use wright_analyzer::registry::LintConfig; +use crate::source_provider::SourceBackend; + /// The concrete input frontend to use, or automatic detection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceKind { /// Detect from the input path extension or stdin content. Auto, - /// `.opy` source through the adapter bridge (the native frontend is the - /// default path). + /// `.opy` source through the explicitly selected backend. Opy, /// `.ostw` / `.del` source through the native OSTW frontend (#117). Ostw, @@ -102,6 +103,10 @@ pub struct SessionConfig { pub input: InputSpec, /// Frontend selection; `Auto` detects from path/stdin. pub kind: SourceKind, + /// The source implementation selected for source-language workflows. + /// Raw Workshop is always handled in-process; the provider backend is + /// currently reserved for the first-party OPY provider. + pub source_backend: SourceBackend, /// Workshop client-locale override (bypasses auto-detection). pub locale: Option, /// Include root for `.opy` inputs (defaults to the input's directory). @@ -137,6 +142,7 @@ impl Default for SessionConfig { SessionConfig { input: InputSpec::Stdin, kind: SourceKind::Auto, + source_backend: SourceBackend::Native, locale: None, root: None, output: None, diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index aa5fd22..f9286ae 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -775,6 +775,7 @@ fn resolved_input( text: main_text.to_string(), path: Some(main_path.to_path_buf()), root: root.to_path_buf(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), display: crate::input::display_path(main_path), identity: crate::input_identity(main_text), origin: origin_for(kind, locale), diff --git a/crates/wright-driver/src/input.rs b/crates/wright-driver/src/input.rs index 6f10ba1..7c1003e 100644 --- a/crates/wright-driver/src/input.rs +++ b/crates/wright-driver/src/input.rs @@ -26,6 +26,8 @@ pub struct ResolvedInput { pub path: Option, /// The include root (`.opy` include base); the input's directory by default. pub root: PathBuf, + /// The invocation working directory used to resolve a relative entry. + pub cwd: PathBuf, /// A stable display identity used in diagnostics (`` for stdin). pub display: String, /// SHA-256 hex of the input bytes (deterministic input identity). @@ -55,7 +57,19 @@ pub fn resolve(config: &SessionConfig) -> Result { } fn resolve_path(path: &Path, config: &SessionConfig) -> Result { - let bytes = std::fs::read(path).map_err(|error| { + let cwd = std::env::current_dir().map_err(|error| { + Diagnostic::error( + "cwd-io", + Stage::Discovery, + format!("cannot determine the invocation working directory: {error}"), + ) + })?; + let path = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + let bytes = std::fs::read(&path).map_err(|error| { Diagnostic::error( "input-io", Stage::Discovery, @@ -64,23 +78,25 @@ fn resolve_path(path: &Path, config: &SessionConfig) -> Result kind_from_extension(path)?, + SourceKind::Auto => kind_from_extension(&path)?, other => other, }; let root = match &config.root { - Some(root) => root.clone(), + Some(root) if root.is_absolute() => root.clone(), + Some(root) => cwd.join(root), None => path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")), }; - let display = display_path(path); + let display = display_path(&path); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { kind, text, path: Some(path.to_path_buf()), root, + cwd, display, identity: sha256_hex(&bytes), origin, @@ -111,6 +127,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result { .root .clone() .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let display = "".to_string(); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { @@ -118,6 +135,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result { text, path: None, root, + cwd, display, identity: sha256_hex(&bytes), origin, diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index e6d58cf..7cec8db 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -22,6 +22,7 @@ pub mod provider_edit; pub mod result; pub mod service; pub mod session; +pub mod source_provider; pub mod workshop_provider; pub use config::{InputSpec, LintConfig, OutputFormat, SessionConfig, SourceKind}; @@ -32,7 +33,11 @@ pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, RESULT_CONTRACT, }; -pub use session::{CompilerSession, Loaded}; +pub use session::{CompilerSession, Loaded, Provenance}; +pub use source_provider::{ + SourceBackend, SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, + SourceTarget, +}; pub use workshop_provider::WorkshopProvider; pub use wright_transform::Profile; diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index f9bb5ac..65ad152 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -79,6 +79,9 @@ pub fn exit_code_from(diagnostics: &[Diagnostic]) -> u8 { if diagnostic.code == "ostw-unsupported" { return exit::UNSUPPORTED; } + if diagnostic.code == "source-provider-unsupported" { + return exit::UNSUPPORTED; + } has_source_error = true; } if has_source_error { diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index a49434b..8ce5854 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -25,6 +25,9 @@ use crate::result::{ Envelope, InspectResult, LintResult, OstwFileSummary, OstwProjectSummary, exit_code_from, version_info, }; +use crate::source_provider::{ + SourceBackend, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, +}; use crate::{input_identity, opy}; /// A successfully loaded program with its input and origin metadata. @@ -44,6 +47,18 @@ pub struct Loaded { pub origin: Origin, /// The resolved input. pub input: ResolvedInput, + /// Whether semantic spans can be mapped to authored source files. + pub provenance: Provenance, +} + +/// Provenance of the semantic program handed to Wright's analyzer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provenance { + /// The program was parsed from the source files represented by `input`. + Source, + /// The program came from provider-returned canonical Workshop text and + /// has no source mapping for Wright-owned findings. + ProviderArtifact, } /// One reusable compiler session. @@ -54,6 +69,7 @@ pub struct CompilerSession { loaded: Option, diagnostics: Vec, progress_observer: Option>, + source_provider: Option>, } impl CompilerSession { @@ -72,9 +88,25 @@ impl CompilerSession { loaded: None, diagnostics: Vec::new(), progress_observer: None, + source_provider: None, }) } + /// Build a session whose source-language workflow must use `provider`. + /// + /// The provider is injected at the product boundary; its transport and + /// source project model are not visible to the session. A provider failure + /// is surfaced as-is and never falls back to the native OPY path. + pub fn with_source_provider( + mut config: SessionConfig, + provider: Box, + ) -> Result { + config.source_backend = SourceBackend::Provider; + let mut session = Self::new(config)?; + session.source_provider = Some(provider); + Ok(session) + } + /// Attach a transport-neutral observer for real workflow phase events. pub fn set_progress_observer(&mut self, observer: Arc) { self.progress_observer = Some(observer); @@ -102,10 +134,24 @@ impl CompilerSession { } self.progress(ProgressEvent::new(ProgressPhase::InputResolution)); let mut resolved = input::resolve(&self.config)?; + if self.config.source_backend == SourceBackend::Provider && resolved.kind != SourceKind::Opy + { + return Err(Diagnostic::error( + "source-provider-kind", + Stage::Discovery, + format!( + "the provider backend currently supports only OPY input; got '{}'", + resolved.kind.as_str() + ), + )); + } if resolved.kind == SourceKind::Ostw { self.progress(ProgressEvent::new(ProgressPhase::ProjectLoading)); return self.load_ostw(&mut resolved); } + if self.config.source_backend == SourceBackend::Provider { + return self.load_from_source_provider(&mut resolved); + } let mut program = match resolved.kind { SourceKind::Workshop => { self.progress(ProgressEvent::new(ProgressPhase::Parsing)); @@ -174,6 +220,108 @@ impl CompilerSession { ostw_semantic: None, origin: resolved.origin.clone(), input: resolved, + provenance: Provenance::Source, + }; + self.loaded = Some(loaded.clone()); + Ok(loaded) + } + + /// Load a source-language result through the explicit product provider + /// seam, then hand its canonical Workshop text to `workshop-rs`. + fn load_from_source_provider( + &mut self, + resolved: &mut ResolvedInput, + ) -> Result { + let language = match resolved.kind { + SourceKind::Opy => SourceLanguage::Opy, + other => { + return Err(Diagnostic::error( + "source-provider-kind", + Stage::Discovery, + format!( + "the provider backend currently supports only OPY input; got '{}'", + other.as_str() + ), + )); + } + }; + let target = SourceTarget::new( + language, + resolved + .path + .clone() + .unwrap_or_else(|| Path::new("").to_path_buf()), + resolved.cwd.clone(), + ); + let Some(provider) = self.source_provider.as_mut() else { + return Err(SourceProviderError::NotConfigured { language }.diagnostic()); + }; + if provider.language() != language { + return Err(Diagnostic::error( + "source-provider-language", + Stage::Discovery, + format!( + "the injected provider serves '{}' but the selected source is '{}'", + provider.language().as_str(), + language.as_str() + ), + )); + } + let compilation = provider + .compile(&target) + .map_err(|error| error.diagnostic())?; + let mut provider_diagnostics = compilation.diagnostics; + if let Some(index) = provider_diagnostics + .iter() + .position(|diagnostic| diagnostic.severity == Severity::Error) + { + let first = provider_diagnostics.remove(index); + self.diagnostics.extend(provider_diagnostics); + return Err(first); + } + self.diagnostics.extend(provider_diagnostics); + let Some(workshop_text) = compilation.workshop_text else { + return Err(Diagnostic::error( + "source-provider-no-artifact", + Stage::Frontend, + "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, + &locale, + &self.catalog, + ) + .map_err(|error| workshop_diag(error, resolved))?; + self.progress(ProgressEvent::new(ProgressPhase::Validation)); + let mut program = program; + program + .validate() + .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; + if self.config.profile != wright_transform::Profile::Off { + self.progress(ProgressEvent::new(ProgressPhase::Lowering)); + wright_transform::run(&mut program, self.config.profile).map_err(|error| { + Diagnostic::error( + "transform-error", + Stage::Internal, + format!("WIR transformation failed: {error}"), + ) + })?; + } + resolved.origin.locale = Some(locale.to_string()); + let loaded = Loaded { + program: Arc::new(program), + ostw: None, + ostw_semantic: None, + origin: resolved.origin.clone(), + input: resolved.clone(), + provenance: Provenance::ProviderArtifact, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -208,6 +356,7 @@ impl CompilerSession { ostw_semantic: Some(Arc::new(semantic)), origin: resolved.origin.clone(), input: resolved.clone(), + provenance: Provenance::Source, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -907,29 +1056,32 @@ pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loade if !span.is_object() { continue; } - let path = span - .get("file") - .and_then(serde_json::Value::as_u64) - .map(|file| { - if file == 0 { - root_relative(loaded.input.path.as_deref(), &loaded.input.root) - .unwrap_or_else(|| loaded.input.display.clone()) - } else { - loaded - .program - .files - .get(workshop_rs::source::FileId::from_index(file as usize)) - .map(|source_file| { - root_relative( - Some(&loaded.input.root.join(&source_file.path)), - &loaded.input.root, - ) + let path = if loaded.provenance == Provenance::ProviderArtifact { + "".to_string() + } else { + span.get("file") + .and_then(serde_json::Value::as_u64) + .map(|file| { + if file == 0 { + root_relative(loaded.input.path.as_deref(), &loaded.input.root) + .unwrap_or_else(|| loaded.input.display.clone()) + } else { + loaded + .program + .files + .get(workshop_rs::source::FileId::from_index(file as usize)) + .map(|source_file| { + root_relative( + Some(&loaded.input.root.join(&source_file.path)), + &loaded.input.root, + ) + .unwrap_or_else(|| format!("")) + }) .unwrap_or_else(|| format!("")) - }) - .unwrap_or_else(|| format!("")) - } - }) - .unwrap_or_else(|| loaded.input.display.clone()); + } + }) + .unwrap_or_else(|| loaded.input.display.clone()) + }; span["path"] = serde_json::Value::String(path); } } diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs new file mode 100644 index 0000000..9e6620e --- /dev/null +++ b/crates/wright-driver/src/source_provider.rs @@ -0,0 +1,188 @@ +//! Wright's product-facing source-provider boundary. +//! +//! The product layer selects a source target and receives source diagnostics +//! plus canonical Workshop text. Provider transport, document synchronization, +//! and compiler implementation types stay behind the adapter that implements +//! this trait. A provider owns project discovery from the selected entry; the +//! target deliberately carries no project graph or preloaded document set. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::diag::{Diagnostic, Stage}; + +/// A source language that can participate in the product boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceLanguage { + /// OverPy, whose provider owns `#!mainFile`, includes, and preprocessing. + Opy, +} + +impl SourceLanguage { + /// The stable product spelling. + pub const fn as_str(self) -> &'static str { + match self { + SourceLanguage::Opy => "opy", + } + } +} + +/// Whether a source language is loaded natively or through an injected +/// provider. The choice is explicit so provider failures cannot select a +/// different implementation by accident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SourceBackend { + /// Use the implementation linked into Wright for the selected source kind. + #[default] + Native, + /// Require the explicitly injected source provider. + Provider, +} + +/// The user-selected source target passed to a provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceTarget { + /// The language selected by the product layer. + pub language: SourceLanguage, + /// The one path selected by the user, resolved relative to [`cwd`]. + pub entry: PathBuf, + /// The invocation working directory used to resolve relative CLI paths. + pub cwd: PathBuf, +} + +impl SourceTarget { + /// Construct an entry target and resolve a relative path from `cwd`. + pub fn new( + language: SourceLanguage, + entry: impl Into, + cwd: impl Into, + ) -> Self { + let cwd = cwd.into(); + let entry = entry.into(); + let entry = if entry.is_absolute() { + entry + } else { + cwd.join(entry) + }; + Self { + language, + entry, + cwd, + } + } + + /// The entry path as a [`Path`]. + pub fn entry_path(&self) -> &Path { + &self.entry + } +} + +/// The result of a provider-owned source compilation. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceCompilation { + /// Canonical Workshop source returned by the provider, when compilation + /// succeeded. The driver validates and parses this text through + /// `workshop-rs`; it never consumes provider-internal IR. + pub workshop_text: Option, + /// Workshop client locale for the returned canonical source. + pub locale: Option, + /// Diagnostics already attributed to their authored source files by the + /// provider adapter. These are preserved alongside Wright diagnostics. + pub diagnostics: Vec, +} + +impl SourceCompilation { + /// A successful canonical Workshop result without diagnostics. + pub fn success(workshop_text: impl Into) -> Self { + Self { + workshop_text: Some(workshop_text.into()), + locale: None, + diagnostics: Vec::new(), + } + } +} + +/// A failure at the source-provider boundary, distinct from a source +/// diagnostic returned by the provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceProviderError { + /// The requested provider was not injected for a provider-backed session. + NotConfigured { language: SourceLanguage }, + /// The provider cannot perform the requested product operation. + Unsupported { message: String }, + /// The provider or its process failed independently of source contents. + Failed { code: String, message: String }, +} + +impl SourceProviderError { + /// Stable machine-readable error code. + pub fn code(&self) -> &str { + match self { + SourceProviderError::NotConfigured { .. } => "source-provider-not-configured", + SourceProviderError::Unsupported { .. } => "source-provider-unsupported", + SourceProviderError::Failed { code, .. } => code, + } + } + + /// Convert the boundary failure to the driver's structured diagnostic. + pub fn diagnostic(&self) -> Diagnostic { + let message = self.to_string(); + let stage = match self { + SourceProviderError::Unsupported { .. } => Stage::Frontend, + SourceProviderError::NotConfigured { .. } | SourceProviderError::Failed { .. } => { + Stage::Internal + } + }; + Diagnostic::error(self.code(), stage, message) + } +} + +impl fmt::Display for SourceProviderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SourceProviderError::NotConfigured { language } => write!( + formatter, + "no source provider is configured for '{}'", + language.as_str() + ), + SourceProviderError::Unsupported { message } + | SourceProviderError::Failed { message, .. } => formatter.write_str(message), + } + } +} + +impl std::error::Error for SourceProviderError {} + +/// The provider implementation consumed by the product layer. +pub trait SourceProvider { + /// The source language served by this provider. + fn language(&self) -> SourceLanguage; + + /// 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; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_entry_is_resolved_from_the_invocation_directory() { + let target = SourceTarget::new(SourceLanguage::Opy, "src/main.opy", "/project"); + assert_eq!(target.entry, PathBuf::from("/project/src/main.opy")); + assert_eq!(target.cwd, PathBuf::from("/project")); + } + + #[test] + fn provider_failures_have_a_distinct_structured_code() { + let diagnostic = SourceProviderError::Failed { + code: "provider-exited".to_string(), + message: "provider exited".to_string(), + } + .diagnostic(); + assert_eq!(diagnostic.code, "provider-exited"); + assert_eq!(diagnostic.stage, Stage::Internal); + assert!(diagnostic.span.is_none()); + } +} diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs new file mode 100644 index 0000000..faec269 --- /dev/null +++ b/crates/wright-driver/tests/source_provider.rs @@ -0,0 +1,160 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use wright_driver::source_provider::{ + SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, +}; +use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceBackend, SourceKind}; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn workshop_fixture(fixture: &str) -> String { + let oracle = std::fs::read_to_string( + workspace_root().join(format!("compatibility/fixtures/{fixture}/oracle.json")), + ) + .expect("fixture oracle"); + serde_json::from_str::(&oracle) + .expect("oracle JSON") + .pointer("/compile/workshop") + .and_then(serde_json::Value::as_str) + .expect("Workshop artifact") + .to_string() +} + +fn temp_entry() -> (PathBuf, PathBuf) { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-source-provider-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).expect("temp directory"); + let entry = dir.join("main.opy"); + std::fs::write(&entry, "this is intentionally not native OPY").expect("entry source"); + (dir, entry) +} + +fn cleanup(dir: PathBuf) { + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("trash") + .arg(&dir) + .status() + .expect("trash command"); + assert!(status.success(), "trash failed for {}", dir.display()); + } + #[cfg(not(target_os = "macos"))] + { + std::fs::remove_dir_all(dir).expect("remove test directory"); + } +} + +struct RecordingProvider { + target: Arc>>, + compilation: Option, + failure: Option, +} + +impl SourceProvider for RecordingProvider { + fn language(&self) -> SourceLanguage { + SourceLanguage::Opy + } + + fn compile(&mut self, target: &SourceTarget) -> Result { + *self.target.lock().expect("target lock") = Some(target.clone()); + if let Some(error) = self.failure.take() { + return Err(error); + } + Ok(self.compilation.take().expect("provider result")) + } +} + +#[test] +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 provider = RecordingProvider { + target: Arc::clone(&observed), + compilation: Some(SourceCompilation::success(workshop_fixture( + "synthetic/control-flow", + ))), + failure: None, + }; + let config = SessionConfig { + input: InputSpec::Path(entry.clone()), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + let result = session.compile(); + assert!(result.ok, "provider compile: {:?}", result.diagnostics); + assert!(result.result.output.is_some()); + let lint = session.lint(); + let findings = lint.result.findings.as_array().expect("finding array"); + assert!(!findings.is_empty(), "fixture supplies a lint finding"); + assert!(findings.iter().all(|finding| { + finding.pointer("/span/path") + == Some(&serde_json::Value::String( + "".to_string(), + )) + })); + let target = observed + .lock() + .expect("target lock") + .clone() + .expect("provider target"); + assert_eq!(target.language, SourceLanguage::Opy); + assert_eq!(target.entry, entry); + assert_eq!(target.cwd, std::env::current_dir().expect("cwd")); + assert_eq!( + session.load().expect("cached provider result").provenance, + wright_driver::Provenance::ProviderArtifact + ); + cleanup(dir); +} + +#[test] +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)), + compilation: None, + failure: Some(SourceProviderError::Failed { + code: "provider-exited".to_string(), + message: "provider exited before compiling the entry".to_string(), + }), + }; + 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); + assert_eq!(result.exit, 4); + assert_eq!(result.diagnostics[0].code, "provider-exited"); + cleanup(dir); +} + +#[test] +fn provider_backend_without_injection_is_an_explicit_failure() { + 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.check(); + assert!(!result.ok); + assert_eq!(result.exit, 4); + assert_eq!(result.diagnostics[0].code, "source-provider-not-configured"); + cleanup(dir); +} diff --git a/docs/architecture.md b/docs/architecture.md index 655a9f8..07c58fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,9 +110,11 @@ or emitter. ### OverPy ```text -OPY source +user-selected entry target ↓ -opy-rs semantic implementation +Wright source-provider seam + ↓ +opy-rs provider owns project discovery ├─ standalone check / inspect / queries ├─ OPY-specific compiler behavior └─ canonical Workshop integration through workshop-rs @@ -124,6 +126,14 @@ Wright may consume native APIs and/or an LPP provider depending on the product boundary. It must not implement missing OPY syntax/semantic/compiler behavior in the integration layer merely to keep a Wright command working. +The product seam carries only the selected source language, entry path, and +invocation working directory. A provider owns `#!mainFile`, includes, +preprocessing, macros, and the source closure. The driver accepts provider +diagnostics and canonical Workshop text, validates that text through +`workshop-rs`, and then reuses the normal lint/analyze/compile pipeline. LPP +request/session/document types remain below the adapter; a missing or failed +provider is an explicit failure and never selects the native OPY path. + ### DEL / OSTW ```text From 922c28e1cb8033428da95ce10321184c455495d7 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:59:50 +0800 Subject: [PATCH 2/3] fix(driver): address source provider review Carry explicit mapped or unmapped provenance through the provider boundary and attribute unmapped canonical-artifact diagnostics to the artifact identity. Reject provider-backed stdin before input resolution so the provider never receives a fabricated entry path. Add regression coverage for provenance, artifact diagnostics, and stdin refusal. Refs #243 --- crates/wright-driver/src/lib.rs | 6 +- crates/wright-driver/src/session.rs | 84 ++++++++++++++----- crates/wright-driver/src/source_provider.rs | 12 +++ crates/wright-driver/tests/source_provider.rs | 59 ++++++++++++- docs/architecture.md | 7 +- 5 files changed, 140 insertions(+), 28 deletions(-) diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index 7cec8db..9522378 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -33,10 +33,10 @@ pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, RESULT_CONTRACT, }; -pub use session::{CompilerSession, Loaded, Provenance}; +pub use session::{CompilerSession, Loaded}; pub use source_provider::{ - SourceBackend, SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, - SourceTarget, + SourceBackend, SourceCompilation, SourceLanguage, SourceProvenance, SourceProvider, + SourceProviderError, SourceTarget, }; pub use workshop_provider::WorkshopProvider; pub use wright_transform::Profile; diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 8ce5854..dde7f9f 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -16,7 +16,7 @@ use wright_analyzer::registry::LintConfig; use wright_analyzer::service::{Origin as ServiceOrigin, Request, SemanticService}; use crate::WorkshopProvider; -use crate::config::{SessionConfig, SourceKind}; +use crate::config::{InputSpec, SessionConfig, SourceKind}; use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; use crate::input::{self, ResolvedInput}; use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; @@ -26,7 +26,8 @@ use crate::result::{ version_info, }; use crate::source_provider::{ - SourceBackend, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, + SourceBackend, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, + SourceTarget, }; use crate::{input_identity, opy}; @@ -48,17 +49,7 @@ pub struct Loaded { /// The resolved input. pub input: ResolvedInput, /// Whether semantic spans can be mapped to authored source files. - pub provenance: Provenance, -} - -/// Provenance of the semantic program handed to Wright's analyzer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Provenance { - /// The program was parsed from the source files represented by `input`. - Source, - /// The program came from provider-returned canonical Workshop text and - /// has no source mapping for Wright-owned findings. - ProviderArtifact, + pub provenance: SourceProvenance, } /// One reusable compiler session. @@ -132,6 +123,14 @@ impl CompilerSession { if let Some(loaded) = &self.loaded { return Ok(loaded.clone()); } + if self.config.source_backend == SourceBackend::Provider + && matches!(self.config.input, InputSpec::Stdin) + { + return Err(SourceProviderError::Unsupported { + message: "provider-backed source workflows require an entry path; stdin has no entry identity".to_string(), + } + .diagnostic()); + } self.progress(ProgressEvent::new(ProgressPhase::InputResolution)); let mut resolved = input::resolve(&self.config)?; if self.config.source_backend == SourceBackend::Provider && resolved.kind != SourceKind::Opy @@ -220,7 +219,7 @@ impl CompilerSession { ostw_semantic: None, origin: resolved.origin.clone(), input: resolved, - provenance: Provenance::Source, + provenance: SourceProvenance::Mapped, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -280,6 +279,7 @@ impl CompilerSession { return Err(first); } self.diagnostics.extend(provider_diagnostics); + let provenance = compilation.provenance; let Some(workshop_text) = compilation.workshop_text else { return Err(Diagnostic::error( "source-provider-no-artifact", @@ -298,12 +298,18 @@ impl CompilerSession { &locale, &self.catalog, ) - .map_err(|error| workshop_diag(error, resolved))?; + .map_err(|error| workshop_diag_for_provenance(error, resolved, provenance))?; self.progress(ProgressEvent::new(ProgressPhase::Validation)); let mut program = program; - program - .validate() - .map_err(|error| ir_diag("validation-error", Stage::Validation, error, resolved))?; + program.validate().map_err(|error| { + ir_diag_for_provenance( + "validation-error", + Stage::Validation, + error, + resolved, + provenance, + ) + })?; if self.config.profile != wright_transform::Profile::Off { self.progress(ProgressEvent::new(ProgressPhase::Lowering)); wright_transform::run(&mut program, self.config.profile).map_err(|error| { @@ -321,7 +327,7 @@ impl CompilerSession { ostw_semantic: None, origin: resolved.origin.clone(), input: resolved.clone(), - provenance: Provenance::ProviderArtifact, + provenance, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -356,7 +362,7 @@ impl CompilerSession { ostw_semantic: Some(Arc::new(semantic)), origin: resolved.origin.clone(), input: resolved.clone(), - provenance: Provenance::Source, + provenance: SourceProvenance::Mapped, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -1056,7 +1062,7 @@ pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loade if !span.is_object() { continue; } - let path = if loaded.provenance == Provenance::ProviderArtifact { + let path = if loaded.provenance == SourceProvenance::Unmapped { "".to_string() } else { span.get("file") @@ -1197,6 +1203,28 @@ fn workshop_diag(error: workshop_rs::WorkshopError, resolved: &ResolvedInput) -> } } +fn provider_artifact_origin(resolved: &ResolvedInput) -> Origin { + Origin { + kind: "provider-artifact".to_string(), + locale: resolved.origin.locale.clone(), + } +} + +fn workshop_diag_for_provenance( + error: workshop_rs::WorkshopError, + resolved: &ResolvedInput, + provenance: SourceProvenance, +) -> Diagnostic { + let mut diagnostic = workshop_diag(error, resolved); + if provenance == SourceProvenance::Unmapped { + if let Some(span) = &mut diagnostic.span { + span.path = "".to_string(); + } + diagnostic.source = Some(provider_artifact_origin(resolved)); + } + diagnostic +} + /// Map a native frontend error to a driver diagnostic. /// /// Span paths resolve through the frontend file registry so a failure inside @@ -1408,3 +1436,17 @@ pub(crate) fn ir_diag( source: Some(resolved.origin.clone()), } } + +fn ir_diag_for_provenance( + code: &'static str, + stage: Stage, + error: wright_ir::error::IrError, + resolved: &ResolvedInput, + provenance: SourceProvenance, +) -> Diagnostic { + let mut diagnostic = ir_diag(code, stage, error, resolved); + if provenance == SourceProvenance::Unmapped { + diagnostic.source = Some(provider_artifact_origin(resolved)); + } + diagnostic +} diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index 9e6620e..e041f68 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -50,6 +50,15 @@ pub struct SourceTarget { pub cwd: PathBuf, } +/// The provenance contract for the canonical Workshop result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceProvenance { + /// The provider has preserved a truthful mapping to authored source. + Mapped, + /// The canonical artifact has no authored-source mapping. + Unmapped, +} + impl SourceTarget { /// Construct an entry target and resolve a relative path from `cwd`. pub fn new( @@ -86,6 +95,8 @@ pub struct SourceCompilation { pub workshop_text: Option, /// Workshop client locale for the returned canonical source. pub locale: Option, + /// Whether canonical Workshop spans can be mapped to authored source. + pub provenance: SourceProvenance, /// Diagnostics already attributed to their authored source files by the /// provider adapter. These are preserved alongside Wright diagnostics. pub diagnostics: Vec, @@ -97,6 +108,7 @@ impl SourceCompilation { Self { workshop_text: Some(workshop_text.into()), locale: None, + provenance: SourceProvenance::Unmapped, diagnostics: Vec::new(), } } diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index faec269..1796ee8 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use wright_driver::source_provider::{ - SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, + SourceCompilation, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, + SourceTarget, }; use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceBackend, SourceKind}; @@ -112,7 +113,61 @@ fn provider_backend_passes_only_the_selected_entry_and_uses_canonical_workshop_h assert_eq!(target.cwd, std::env::current_dir().expect("cwd")); assert_eq!( session.load().expect("cached provider result").provenance, - wright_driver::Provenance::ProviderArtifact + SourceProvenance::Unmapped + ); + cleanup(dir); +} + +#[test] +fn provider_backend_rejects_stdin_without_fabricating_an_entry() { + let config = SessionConfig { + input: InputSpec::Stdin, + kind: SourceKind::Opy, + source_backend: SourceBackend::Provider, + ..SessionConfig::default() + }; + let mut session = CompilerSession::new(config).expect("session"); + let result = session.check(); + assert!(!result.ok); + assert_eq!(result.exit, 3); + assert_eq!(result.diagnostics[0].code, "source-provider-unsupported"); +} + +#[test] +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)), + compilation: Some(SourceCompilation::success( + "rule (\"broken\") {\n actions {\n UnknownAction;\n }\n}\n", + )), + 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); + assert_eq!(result.diagnostics[0].code, "unknown-action"); + assert_eq!( + result.diagnostics[0] + .span + .as_ref() + .expect("artifact span") + .path, + "" + ); + assert_eq!( + result.diagnostics[0] + .source + .as_ref() + .expect("artifact origin") + .kind, + "provider-artifact" ); cleanup(dir); } diff --git a/docs/architecture.md b/docs/architecture.md index 07c58fd..717009a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,8 +131,11 @@ invocation working directory. A provider owns `#!mainFile`, includes, preprocessing, macros, and the source closure. The driver accepts provider diagnostics and canonical Workshop text, validates that text through `workshop-rs`, and then reuses the normal lint/analyze/compile pipeline. LPP -request/session/document types remain below the adapter; a missing or failed -provider is an explicit failure and never selects the native OPY path. +request/session/document types remain below the adapter. The result also +declares whether canonical-artifact spans are `Mapped` to authored source or +`Unmapped`; unmapped diagnostics and findings use the explicit +`` identity. A missing or failed provider is an explicit +failure and never selects the native OPY path. ### DEL / OSTW From 8a4cf821652ea74ad129ae0500e8c83cd1c68b4f Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:18:20 +0800 Subject: [PATCH 3/3] fix(driver): keep provider provenance explicitly unmapped Remove the unimplemented mapped provenance state so the provider seam cannot claim canonical Workshop spans map to authored OPY ranges without carrying real mapping data. Refs #243 --- crates/wright-driver/src/lib.rs | 2 +- crates/wright-driver/src/session.rs | 44 ++++++++++--------- crates/wright-driver/src/source_provider.rs | 6 ++- crates/wright-driver/tests/source_provider.rs | 5 +-- docs/architecture.md | 10 ++--- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index 9522378..51e7eb2 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -33,7 +33,7 @@ pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, RESULT_CONTRACT, }; -pub use session::{CompilerSession, Loaded}; +pub use session::{CompilerSession, Loaded, Provenance}; pub use source_provider::{ SourceBackend, SourceCompilation, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, SourceTarget, diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index dde7f9f..21af162 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -49,7 +49,16 @@ pub struct Loaded { /// The resolved input. pub input: ResolvedInput, /// Whether semantic spans can be mapped to authored source files. - pub provenance: SourceProvenance, + pub provenance: Provenance, +} + +/// Provenance of the semantic program handed to Wright's analyzer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provenance { + /// The program was parsed from the source files represented by `input`. + Source, + /// The program came from an unmapped provider-returned canonical artifact. + Unmapped, } /// One reusable compiler session. @@ -219,7 +228,7 @@ impl CompilerSession { ostw_semantic: None, origin: resolved.origin.clone(), input: resolved, - provenance: SourceProvenance::Mapped, + provenance: Provenance::Source, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -279,7 +288,9 @@ impl CompilerSession { return Err(first); } self.diagnostics.extend(provider_diagnostics); - let provenance = compilation.provenance; + let provenance = match compilation.provenance { + SourceProvenance::Unmapped => Provenance::Unmapped, + }; let Some(workshop_text) = compilation.workshop_text else { return Err(Diagnostic::error( "source-provider-no-artifact", @@ -298,16 +309,15 @@ impl CompilerSession { &locale, &self.catalog, ) - .map_err(|error| workshop_diag_for_provenance(error, resolved, provenance))?; + .map_err(|error| workshop_diag_for_unmapped_provider_artifact(error, resolved))?; self.progress(ProgressEvent::new(ProgressPhase::Validation)); let mut program = program; program.validate().map_err(|error| { - ir_diag_for_provenance( + ir_diag_for_unmapped_provider_artifact( "validation-error", Stage::Validation, error, resolved, - provenance, ) })?; if self.config.profile != wright_transform::Profile::Off { @@ -362,7 +372,7 @@ impl CompilerSession { ostw_semantic: Some(Arc::new(semantic)), origin: resolved.origin.clone(), input: resolved.clone(), - provenance: SourceProvenance::Mapped, + provenance: Provenance::Source, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -1062,7 +1072,7 @@ pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loade if !span.is_object() { continue; } - let path = if loaded.provenance == SourceProvenance::Unmapped { + let path = if loaded.provenance == Provenance::Unmapped { "".to_string() } else { span.get("file") @@ -1210,18 +1220,15 @@ fn provider_artifact_origin(resolved: &ResolvedInput) -> Origin { } } -fn workshop_diag_for_provenance( +fn workshop_diag_for_unmapped_provider_artifact( error: workshop_rs::WorkshopError, resolved: &ResolvedInput, - provenance: SourceProvenance, ) -> Diagnostic { let mut diagnostic = workshop_diag(error, resolved); - if provenance == SourceProvenance::Unmapped { - if let Some(span) = &mut diagnostic.span { - span.path = "".to_string(); - } - diagnostic.source = Some(provider_artifact_origin(resolved)); + if let Some(span) = &mut diagnostic.span { + span.path = "".to_string(); } + diagnostic.source = Some(provider_artifact_origin(resolved)); diagnostic } @@ -1437,16 +1444,13 @@ pub(crate) fn ir_diag( } } -fn ir_diag_for_provenance( +fn ir_diag_for_unmapped_provider_artifact( code: &'static str, stage: Stage, error: wright_ir::error::IrError, resolved: &ResolvedInput, - provenance: SourceProvenance, ) -> Diagnostic { let mut diagnostic = ir_diag(code, stage, error, resolved); - if provenance == SourceProvenance::Unmapped { - diagnostic.source = Some(provider_artifact_origin(resolved)); - } + diagnostic.source = Some(provider_artifact_origin(resolved)); diagnostic } diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index e041f68..bc591da 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -51,10 +51,12 @@ pub struct SourceTarget { } /// The provenance contract for the canonical Workshop result. +/// +/// The current provider boundary has no canonical-Workshop-to-authored-source +/// span map, so provider results remain explicitly unmapped. A mapped variant +/// must not be added without carrying and consuming the actual mapping data. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceProvenance { - /// The provider has preserved a truthful mapping to authored source. - Mapped, /// The canonical artifact has no authored-source mapping. Unmapped, } diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs index 1796ee8..be1c47b 100644 --- a/crates/wright-driver/tests/source_provider.rs +++ b/crates/wright-driver/tests/source_provider.rs @@ -2,8 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use wright_driver::source_provider::{ - SourceCompilation, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, - SourceTarget, + SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, }; use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceBackend, SourceKind}; @@ -113,7 +112,7 @@ fn provider_backend_passes_only_the_selected_entry_and_uses_canonical_workshop_h assert_eq!(target.cwd, std::env::current_dir().expect("cwd")); assert_eq!( session.load().expect("cached provider result").provenance, - SourceProvenance::Unmapped + wright_driver::Provenance::Unmapped ); cleanup(dir); } diff --git a/docs/architecture.md b/docs/architecture.md index 717009a..2748ac3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,11 +131,11 @@ invocation working directory. A provider owns `#!mainFile`, includes, preprocessing, macros, and the source closure. The driver accepts provider diagnostics and canonical Workshop text, validates that text through `workshop-rs`, and then reuses the normal lint/analyze/compile pipeline. LPP -request/session/document types remain below the adapter. The result also -declares whether canonical-artifact spans are `Mapped` to authored source or -`Unmapped`; unmapped diagnostics and findings use the explicit -`` identity. A missing or failed provider is an explicit -failure and never selects the native OPY path. +request/session/document types remain below the adapter. The current provider +result is explicitly `Unmapped`: canonical-artifact diagnostics and findings +use the `` identity until an actual canonical-to-authored +span map can be carried and consumed. A missing or failed provider is an +explicit failure and never selects the native OPY path. ### DEL / OSTW