From ac72e04526453abb7f1c44c1ae017ef028cc5c91 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:16:53 +0800 Subject: [PATCH 1/2] test: table-drive failure classification on the check, lookup, and LSP surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one table test per public surface that reports provider or session failures. `check` and `lookup` live in a new wright-driver integration test; LSP diagnostics lives in wright-language. Every row drives its surface end to end and pins the diagnostic `code` plus the exit class (`check`/`lookup`) or the boundary code (`diagnostics`), so a wrapper that re-classifies a typed failure — a refusal mapped to `unavailable`, an `invalid-*` lookup code defaulted to `SourceError`, or a session diagnostic relabeled `provider-refusal-*` — fails its row. Removed the tests the tables subsume: the lookup classification unit test, the two process-level lookup refusal tests, the LPP provider failure-class test, and the refusal/diagnostics halves of two language-service tests (the version-negotiation and rename halves stay). Mutation checks: mapping refusals to `unavailable`, defaulting `invalid-*` lookup codes to `SourceError`, and relabeling a session diagnostic `provider-refusal-invalid-selection` each fail a table. Fixes #1022 --- crates/wright-driver/src/lookup.rs | 3 - crates/wright-driver/src/lookup/tests.rs | 110 --- crates/wright-driver/tests/check_failures.rs | 683 ++++++++++++++++++ crates/wright-driver/tests/lookup.rs | 63 -- crates/wright-driver/tests/lookup_failures.rs | 503 +++++++++++++ .../tests/source_provider_lpp.rs | 42 -- .../tests/provider_failures.rs | 383 ++++++++++ crates/wright-language/tests/service.rs | 24 +- 8 files changed, 1576 insertions(+), 235 deletions(-) delete mode 100644 crates/wright-driver/src/lookup/tests.rs create mode 100644 crates/wright-driver/tests/check_failures.rs create mode 100644 crates/wright-driver/tests/lookup_failures.rs create mode 100644 crates/wright-language/tests/provider_failures.rs diff --git a/crates/wright-driver/src/lookup.rs b/crates/wright-driver/src/lookup.rs index 3ca1be51..9875b8df 100644 --- a/crates/wright-driver/src/lookup.rs +++ b/crates/wright-driver/src/lookup.rs @@ -786,6 +786,3 @@ fn lpp_default_text(value: &Value) -> String { other => other.to_string(), } } - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod tests; diff --git a/crates/wright-driver/src/lookup/tests.rs b/crates/wright-driver/src/lookup/tests.rs deleted file mode 100644 index b2acc5b5..00000000 --- a/crates/wright-driver/src/lookup/tests.rs +++ /dev/null @@ -1,110 +0,0 @@ -use super::lookup_failure_class; -use crate::diag::ExitClass; -use wright_lpp::{LppErrorKind, ProviderError}; - -/// `lookup`'s provider-error exits keep the values the surface published -/// before classes were typed (#1003): the LPP `invalid-*` kinds stay -/// USAGE, and `lpp-error` plus the session-state failures stay -/// SOURCE_ERROR, while every other provider failure keeps the shared -/// classification. -#[test] -fn provider_failures_keep_their_lookup_exit_class() { - let lpp = |kind| ProviderError::lpp(kind, serde_json::json!({}), "m"); - // (error, expected class, expected exit) - let cases: &[(ProviderError, ExitClass, u8)] = &[ - // usage — the exits main's `invalid-` code mapping produced - (lpp(LppErrorKind::InvalidRequest), ExitClass::Usage, 2), - (lpp(LppErrorKind::InvalidLanguage), ExitClass::Usage, 2), - (lpp(LppErrorKind::InvalidDocument), ExitClass::Usage, 2), - (lpp(LppErrorKind::InvalidEntry), ExitClass::Usage, 2), - (lpp(LppErrorKind::InvalidPosition), ExitClass::Usage, 2), - (lpp(LppErrorKind::InvalidArtifact), ExitClass::Usage, 2), - // usage — a refusal whose carried `refusalCode` is `invalid-*` - // takes main's `invalid-` arm too (the selector is a - // user-supplied argument, so `invalid-selection` is USAGE on - // every path) - ( - ProviderError::lpp( - LppErrorKind::Refusal, - serde_json::json!({"refusalCode": "invalid-selection"}), - "m", - ), - ExitClass::Usage, - 2, - ), - // source/user error — non-`invalid-` codes carried at exit 1; - // every unknown LPP kind reports the `lpp-error` code - ( - lpp(LppErrorKind::Unknown("invalidFutureKind".to_string())), - ExitClass::SourceError, - 1, - ), - ( - lpp(LppErrorKind::Unknown("someFutureKind".to_string())), - ExitClass::SourceError, - 1, - ), - (lpp(LppErrorKind::Refusal), ExitClass::SourceError, 1), - ( - lpp(LppErrorKind::ProjectLoadFailed), - ExitClass::SourceError, - 1, - ), - ( - ProviderError::NotInitialized { - method: "lpp/lookup".to_string(), - }, - ExitClass::SourceError, - 1, - ), - (ProviderError::AlreadyInitialized, ExitClass::SourceError, 1), - ( - ProviderError::ShutDown { - method: "lpp/lookup".to_string(), - }, - ExitClass::SourceError, - 1, - ), - // unsupported - ( - lpp(LppErrorKind::CapabilityUnavailable), - ExitClass::Unsupported, - 3, - ), - // internal — the transport/session failures the contract already - // documents at exit 4 - ( - lpp(LppErrorKind::ProtocolVersionMismatch), - ExitClass::Internal, - 4, - ), - ( - ProviderError::Timeout { - method: "lpp/lookup".to_string(), - duration: std::time::Duration::from_secs(5), - }, - ExitClass::Internal, - 4, - ), - ( - ProviderError::Malformed { - detail: "missing id".to_string(), - }, - ExitClass::Internal, - 4, - ), - ( - ProviderError::JsonRpc { - code: -32600, - message: "invalid request".to_string(), - }, - ExitClass::Internal, - 4, - ), - ]; - for (error, class, expected) in cases { - let actual = lookup_failure_class(error); - assert_eq!(&actual, class, "{error:?}"); - assert_eq!(actual.exit_code(), *expected, "{error:?}"); - } -} diff --git a/crates/wright-driver/tests/check_failures.rs b/crates/wright-driver/tests/check_failures.rs new file mode 100644 index 00000000..c30187c6 --- /dev/null +++ b/crates/wright-driver/tests/check_failures.rs @@ -0,0 +1,683 @@ +//! The failure-classification table for the `check` surface — one of +//! the three public surfaces the classification contract covers; +//! `lookup` keeps its own table in `lookup_failures.rs` and the +//! language-service diagnostic surface keeps its own in +//! `wright-language`. Every row drives `session.check()` end to end and +//! pins the error diagnostic's `code`, its `class`, and the envelope +//! `exit` the class resolves to: a wrapper that re-classifies a failure +//! — a refusal reported as `unavailable`, a session diagnostic relabeled +//! `provider-refusal-*`, or a dropped `Diagnostic::class` — fails its +//! row. +//! +//! "Provider failure kinds" are the `wright_lpp::ProviderError` +//! taxonomy; "session failure kinds" are the session machinery's own +//! refusals — `CompilerSession::new` diagnostics, `session-config-changed`, +//! and the `source-provider-*` load refusals. Input-resolution +//! diagnostics (`input-*`, `stdin-*`, `hidden-only`, `cwd-io`) belong to +//! the input layer's own contract and stay out of this table. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use wright_driver::source_provider::{ + LppSourceProvider, SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, + SourceTarget, +}; +use wright_driver::{ + CompilerSession, ExitClass, FindingSelection, InputSpec, SessionConfig, Severity, SourceKind, +}; +use wright_lpp::{LocalProviderErrorKind, LppErrorKind, ProviderError}; + +fn temp_dir(tag: &str) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-check-failures-{}-{}-{tag}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +fn write_file(dir: &Path, name: &str, text: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, text).expect("write file"); + path +} + +/// A negotiated `LanguageProvider` whose `compile`/`check` entries fail +/// with one typed `ProviderError` — the request-side transport failure +/// an LPP provider produces after a healthy `lpp/initialize`. +struct FailingLppProvider { + negotiated: wright_lpp::NegotiatedCapabilities, + failure: ProviderError, +} + +fn negotiated() -> wright_lpp::NegotiatedCapabilities { + wright_lpp::NegotiatedCapabilities { + protocol_version: wright_lpp::LPP_PROJECT_LOADING_PROTOCOL_VERSION.to_string(), + server_info: wright_lpp::ServerInfo { + name: "fake".to_string(), + version: "0".to_string(), + }, + languages: vec![wright_lpp::LanguageInfo { + id: "opy".to_string(), + extensions: vec!["opy".to_string()], + }], + capabilities: wright_lpp::Capabilities { + check: true, + compile: true, + project_loading: true, + reconstruct: false, + symbols: false, + definition: false, + references: false, + rename: false, + edit_validation: false, + source_identity: false, + lookup: false, + resolve_ids: false, + other: Default::default(), + }, + } +} + +impl wright_lpp::LanguageProvider for FailingLppProvider { + fn initialize( + &mut self, + _client_info: Option<&wright_lpp::ClientInfo>, + ) -> Result { + unreachable!() + } + fn capabilities(&self) -> Result<&wright_lpp::NegotiatedCapabilities, ProviderError> { + Ok(&self.negotiated) + } + fn check( + &mut self, + _documents: &wright_lpp::DocumentSet, + _project_root: Option<&str>, + ) -> Result { + unreachable!() + } + fn check_entry( + &mut self, + _entry: &wright_lpp::ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, + ) -> Result { + Err(self.failure.clone()) + } + fn compile( + &mut self, + _documents: &wright_lpp::DocumentSet, + _project_root: Option<&str>, + ) -> Result { + unreachable!() + } + fn compile_entry( + &mut self, + _entry: &wright_lpp::ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, + ) -> Result { + Err(self.failure.clone()) + } + fn reconstruct( + &mut self, + _artifact: &wright_lpp::WorkshopArtifact, + ) -> Result { + unreachable!() + } + fn symbols( + &mut self, + _documents: &wright_lpp::DocumentSet, + _project_root: Option<&str>, + ) -> Result { + unreachable!() + } + fn definition( + &mut self, + _document: &wright_lpp::Document, + _position: wright_lpp::Position, + ) -> Result { + unreachable!() + } + fn references( + &mut self, + _document: &wright_lpp::Document, + _position: wright_lpp::Position, + _include_declaration: bool, + ) -> Result { + unreachable!() + } + fn rename( + &mut self, + _documents: &wright_lpp::DocumentSet, + _position_document_uri: &str, + _position: wright_lpp::Position, + _new_name: &str, + _project_root: Option<&str>, + ) -> Result { + unreachable!() + } + fn validate_edits( + &mut self, + _document: &wright_lpp::Document, + _edits: &[wright_lpp::TextEdit], + ) -> Result { + unreachable!() + } + fn shutdown(&mut self) -> Result<(), ProviderError> { + Ok(()) + } + fn exit_status(&self) -> Option { + None + } +} + +/// A `SourceProvider` whose `compile` (and the `check` fallback that +/// delegates to it) answers with one fixed result — the source-provider +/// layer's own refusal kinds that an LPP provider never emits. +struct StubSourceProvider { + answer: Result, +} + +impl SourceProvider for StubSourceProvider { + fn language(&self) -> SourceLanguage { + SourceLanguage::Opy + } + fn compile( + &mut self, + _target: &SourceTarget, + ) -> Result { + self.answer.clone() + } +} + +fn opy_entry(dir: &Path) -> PathBuf { + write_file(dir, "main.opy", "rule 'x': pass\n") +} + +/// `check`'s failure table: every provider failure kind and every +/// session failure kind that reaches the `session.check()` envelope, +/// pinned by the error diagnostic's `code`, its `class`, and the +/// envelope `exit` the class resolves to. +#[test] +fn check_surfaces_every_failure_kind_with_its_code_and_exit_class() { + let lpp = |kind, details| ProviderError::lpp(kind, details, "the provider failed"); + let refusal = |code: &str| { + ProviderError::lpp( + LppErrorKind::Refusal, + serde_json::json!({"refusalCode": code}), + "the provider refused", + ) + }; + let local = |kind| ProviderError::Local { + kind, + message: "resolution failed".to_string(), + }; + + // Every `ProviderError` variant reaches `check` as a request failure + // through the shared `provider_error` classification — the same + // function the connect path wraps its failures with. + let provider_rows: &[(ProviderError, &str, ExitClass)] = &[ + ( + ProviderError::NotConfigured { + language_id: "opy".to_string(), + }, + "provider-not-configured", + ExitClass::Internal, + ), + ( + ProviderError::Spawn { + message: "spawn failed".to_string(), + }, + "provider-spawn", + ExitClass::Internal, + ), + ( + ProviderError::Io { + message: "pipe broke".to_string(), + }, + "provider-io", + ExitClass::Internal, + ), + ( + ProviderError::Exited { + status: Some(9), + message: "the provider exited".to_string(), + }, + "provider-exited", + ExitClass::Internal, + ), + ( + ProviderError::Timeout { + method: "lpp/compile".to_string(), + duration: Duration::from_millis(1500), + }, + "provider-timeout", + ExitClass::Internal, + ), + ( + ProviderError::Malformed { + detail: "no result".to_string(), + }, + "provider-malformed", + ExitClass::Internal, + ), + ( + ProviderError::JsonRpc { + code: -32600, + message: "bad request".to_string(), + }, + "jsonrpc-error", + ExitClass::Internal, + ), + ( + lpp( + LppErrorKind::ProtocolVersionMismatch, + serde_json::json!({"supportedProtocolVersions": ["1.0"]}), + ), + "protocol-version-mismatch", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::InvalidRequest, serde_json::json!({})), + "invalid-request", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::InvalidLanguage, serde_json::json!({})), + "invalid-language", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::InvalidDocument, serde_json::json!({})), + "invalid-document", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::InvalidEntry, serde_json::json!({})), + "invalid-entry", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::ProjectLoadFailed, serde_json::json!({})), + "project-load-failed", + ExitClass::SourceError, + ), + ( + lpp(LppErrorKind::InvalidPosition, serde_json::json!({})), + "invalid-position", + ExitClass::Internal, + ), + ( + lpp(LppErrorKind::InvalidArtifact, serde_json::json!({})), + "invalid-artifact", + ExitClass::Internal, + ), + ( + lpp( + LppErrorKind::CapabilityUnavailable, + serde_json::json!({"capability": "compile", "method": "lpp/compile"}), + ), + "capability-unavailable", + ExitClass::Unsupported, + ), + ( + lpp(LppErrorKind::Refusal, serde_json::json!({})), + "provider-refusal", + ExitClass::SourceError, + ), + ( + refusal("check.tooLarge"), + "provider-refusal-check.tooLarge", + ExitClass::SourceError, + ), + ( + refusal("invalid-selection"), + "provider-refusal-invalid-selection", + ExitClass::SourceError, + ), + ( + refusal("session-config-changed"), + "session-config-changed", + ExitClass::SourceError, + ), + ( + lpp( + LppErrorKind::Unknown("futureKind".to_string()), + serde_json::json!({}), + ), + "lpp-error", + ExitClass::Internal, + ), + ( + ProviderError::NotInitialized { + method: "compile".to_string(), + }, + "provider-not-initialized", + ExitClass::Internal, + ), + ( + ProviderError::AlreadyInitialized, + "provider-already-initialized", + ExitClass::Internal, + ), + ( + ProviderError::ShutDown { + method: "compile".to_string(), + }, + "provider-shutdown", + ExitClass::Internal, + ), + ( + ProviderError::ProtocolVersionMismatch { + supported: vec!["1.0".to_string()], + message: "no mutual version".to_string(), + }, + "protocol-version-mismatch", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::Missing), + "provider-missing", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::UnsupportedPlatform), + "provider-unsupported-platform", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::Offline), + "provider-offline", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::Download), + "provider-download", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::Integrity), + "provider-integrity", + ExitClass::Internal, + ), + ( + local(LocalProviderErrorKind::Install), + "provider-install", + ExitClass::Internal, + ), + ]; + + for (failure, code, class) in provider_rows { + let dir = temp_dir("check-provider"); + let entry = opy_entry(&dir); + let provider = LppSourceProvider::new( + Box::new(FailingLppProvider { + negotiated: negotiated(), + failure: failure.clone(), + }), + None, + ); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(provider), + ) + .expect("session"); + let envelope = session.check(); + let diagnostic = envelope + .diagnostics + .iter() + .find(|d| d.severity == Severity::Error) + .unwrap_or_else(|| { + panic!( + "{failure:?} produced no error diagnostic: {:?}", + envelope.diagnostics + ) + }); + assert_eq!(diagnostic.code, *code, "{failure:?} lost its code"); + assert_eq!(diagnostic.class, *class, "{failure:?} lost its class"); + assert_eq!(envelope.exit, class.exit_code(), "{failure:?}"); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + // The session machinery's own refusal kinds: construction + // diagnostics (observed as the session boundary's `Err`), the + // fixed-config refusal, and the `source-provider-*` load refusals. + let dir = temp_dir("check-construction"); + for (configure, code, class) in [ + ( + Box::new(|config: &mut SessionConfig| { + config.selection = FindingSelection { + rule: Some("no-such-rule".to_string()), + ..FindingSelection::default() + }; + }) as Box, + "invalid-selection", + ExitClass::Usage, + ), + ( + Box::new(|config: &mut SessionConfig| { + config.lint_rule_paths = vec![dir.join("missing-rule.opy")]; + }) as Box, + "lint-rule-error", + ExitClass::SourceError, + ), + ] { + let mut config = SessionConfig::default(); + configure(&mut config); + let Err(diagnostic) = CompilerSession::new(config) else { + panic!("{code} must refuse session construction"); + }; + assert_eq!(diagnostic.code, code); + assert_eq!(diagnostic.class, class); + assert_eq!(diagnostic.class.exit_code(), class.exit_code()); + } + std::fs::remove_dir_all(&dir).expect("cleanup"); + + // `session-config-changed` — the fixed-config refusal. + { + let dir = temp_dir("check-config"); + let entry = opy_entry(&dir); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: Err(SourceProviderError::NotConfigured { + language: SourceLanguage::Opy, + }), + }), + ) + .expect("session"); + session.config.locale = Some("fr-FR".to_string()); + let envelope = session.check(); + assert!( + envelope.diagnostics.iter().any(|d| { + d.code == "session-config-changed" && d.class == ExitClass::SourceError + }) + ); + assert_eq!(envelope.exit, ExitClass::SourceError.exit_code()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + // The loader's `source-provider-*` refusal kinds. + let stub_failures: &[( + &str, + Result, + ExitClass, + )] = &[ + ( + "not-configured", + Err(SourceProviderError::NotConfigured { + language: SourceLanguage::Opy, + }), + ExitClass::Internal, + ), + ( + "unsupported", + Err(SourceProviderError::Unsupported { + message: "the provider declined".to_string(), + }), + ExitClass::Unsupported, + ), + ]; + for (tag, answer, class) in stub_failures { + let dir = temp_dir(&format!("check-stub-{tag}")); + let entry = opy_entry(&dir); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: answer.clone(), + }), + ) + .expect("session"); + let envelope = session.check(); + let code = match answer { + Err(SourceProviderError::NotConfigured { .. }) => "source-provider-not-configured", + Err(SourceProviderError::Unsupported { .. }) => "source-provider-unsupported", + _ => unreachable!(), + }; + assert!( + envelope + .diagnostics + .iter() + .any(|d| { d.code == code && d.class == *class }) + ); + assert_eq!(envelope.exit, class.exit_code()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + // `source-provider-unsupported` via the loader itself (a provider + // backend cannot serve a non-path input). + { + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Text { + text: "x".to_string(), + path: None, + }, + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: Err(SourceProviderError::NotConfigured { + language: SourceLanguage::Opy, + }), + }), + ) + .expect("session"); + let envelope = session.check(); + assert!(envelope.diagnostics.iter().any(|d| { + d.code == "source-provider-unsupported" && d.class == ExitClass::Unsupported + })); + assert_eq!(envelope.exit, 3); + } + + // `source-provider-unavailable` (an OSTW source has no provider to + // connect), `source-provider-kind` (a provider backend handed a + // non-OPY input), `source-provider-identity` (a directory compile + // answer without its source identity), and `profile-workshop-only`. + { + let dir = temp_dir("check-kinds"); + let ostw = write_file(&dir, "main.ostw", "x\n"); + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(ostw), + kind: SourceKind::Ostw, + ..SessionConfig::default() + }) + .expect("session"); + let envelope = session.check(); + assert!(envelope.diagnostics.iter().any(|d| { + d.code == "source-provider-unavailable" && d.class == ExitClass::Internal + })); + assert_eq!(envelope.exit, ExitClass::Internal.exit_code()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + { + let dir = temp_dir("check-kind-mismatch"); + let ws = write_file(&dir, "main.ws", "rule (\"x\") {}\n"); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(ws), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: Err(SourceProviderError::NotConfigured { + language: SourceLanguage::Opy, + }), + }), + ) + .expect("session"); + let envelope = session.check(); + assert!( + envelope + .diagnostics + .iter() + .any(|d| { d.code == "source-provider-kind" && d.class == ExitClass::SourceError }) + ); + assert_eq!(envelope.exit, ExitClass::SourceError.exit_code()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + { + let dir = temp_dir("check-identity"); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(dir.clone()), + kind: SourceKind::Opy, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: Ok(SourceCompilation::success("rule (\"x\") {}\n")), + }), + ) + .expect("session"); + let envelope = session.check(); + assert!(envelope.diagnostics.iter().any(|d| { + d.code == "source-provider-identity" && d.class == ExitClass::SourceError + })); + assert_eq!(envelope.exit, ExitClass::SourceError.exit_code()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + { + let dir = temp_dir("check-profile"); + let entry = opy_entry(&dir); + let mut session = CompilerSession::with_source_provider( + SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + profile: wright_driver::Profile::Compat, + ..SessionConfig::default() + }, + Box::new(StubSourceProvider { + answer: Err(SourceProviderError::NotConfigured { + language: SourceLanguage::Opy, + }), + }), + ) + .expect("session"); + let envelope = session.check(); + assert!( + envelope + .diagnostics + .iter() + .any(|d| { d.code == "profile-workshop-only" && d.class == ExitClass::Usage }) + ); + assert_eq!(envelope.exit, 2); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } +} diff --git a/crates/wright-driver/tests/lookup.rs b/crates/wright-driver/tests/lookup.rs index 17d9129f..9982e5c5 100644 --- a/crates/wright-driver/tests/lookup.rs +++ b/crates/wright-driver/tests/lookup.rs @@ -589,41 +589,6 @@ for line in sys.stdin: reply(id, {}) "#; -#[cfg(unix)] -const INIT_REFUSAL_PROVIDER: &str = r#"#!/usr/bin/env python3 -import json, sys - -for line in sys.stdin: - request = json.loads(line) - id, method = request["id"], request["method"] - if method == "lpp/initialize": - print(json.dumps({"jsonrpc": "2.0", "id": id, "error": { - "code": -32000, "message": "refusal: busy", - "data": {"lpp": {"kind": "refusal", - "details": {"refusalCode": "init.tooBusy"}}}}}), flush=True) - else: - print(json.dumps({"jsonrpc": "2.0", "id": id, "result": {}}), flush=True) -"#; - -#[cfg(unix)] -const NO_LOOKUP_PROVIDER: &str = r#"#!/usr/bin/env python3 -import json, sys - -for line in sys.stdin: - request = json.loads(line) - id, method = request["id"], request["method"] - if method == "lpp/initialize": - print(json.dumps({"jsonrpc": "2.0", "id": id, "result": { - "protocolVersion": "1.4", "serverInfo": {"name": "fake", "version": "0"}, - "languages": [{"id": "opy", "extensions": ["opy"]}], - "capabilities": {"check": True, "compile": True, "reconstruct": False, - "symbols": False, "definition": False, "references": False, - "rename": False, "editValidation": False, - "projectLoading": False}}}), flush=True) - else: - print(json.dumps({"jsonrpc": "2.0", "id": id, "result": {}}), flush=True) -"#; - #[cfg(unix)] fn write_provider(script: &str) -> (PathBuf, PathBuf) { use std::os::unix::fs::PermissionsExt; @@ -718,34 +683,6 @@ fn opy_lookup_passes_scoped_identities_through_unchanged() { let _ = std::fs::remove_dir_all(dir); } -#[cfg(unix)] -#[test] -fn opy_lookup_reports_unavailable_when_the_provider_lacks_the_capability() { - let (dir, mut service) = service_with_provider(NO_LOOKUP_PROVIDER); - let result = result_of(service.handle(&lookup_request("opy", Some("hud")))); - assert_eq!(result["owner"], "opy-rs"); - assert_eq!(result["entries"], serde_json::json!([])); - assert_eq!(result["unavailable"]["capability"], "lookup"); - - let _ = std::fs::remove_dir_all(dir); -} - -#[cfg(unix)] -#[test] -fn opy_lookup_carries_an_initialize_refusal_with_its_code() { - let (dir, mut service) = service_with_provider(INIT_REFUSAL_PROVIDER); - let error = match service.handle(&lookup_request("opy", Some("hud"))) { - ToolResponse::Error { error } => error, - ToolResponse::Ok { result } => panic!("expected a refusal, got {result:?}"), - }; - // The shared provider-failure classification (#1004): the same - // `provider-refusal-` the driver surfaces report, not an - // Ok `unavailable` that reclassifies the refusal as a capability gap. - assert_eq!(error.code, "provider-refusal-init.tooBusy"); - - let _ = std::fs::remove_dir_all(dir); -} - #[test] fn workshop_lookup_reports_parameter_content_facts() { let mut owned = session(workshop_path()); diff --git a/crates/wright-driver/tests/lookup_failures.rs b/crates/wright-driver/tests/lookup_failures.rs new file mode 100644 index 00000000..58cdc8bd --- /dev/null +++ b/crates/wright-driver/tests/lookup_failures.rs @@ -0,0 +1,503 @@ +//! The failure-classification table for the `lookup` surface — one of +//! the three public surfaces the classification contract covers; +//! `check` keeps its own table in `check_failures.rs` and the +//! language-service diagnostic surface keeps its own in +//! `wright-language`. Every row drives the tool-service `Lookup` +//! response end to end and pins the error's `code`, its `class`, and +//! the exit code the class resolves to (`unavailable` answers are the +//! surface's unsupported verdict — exit 3): a wrapper that re-classifies +//! a failure — a refusal reported as `unavailable`, an `invalid-*` code +//! defaulted to `SourceError`, or a session diagnostic relabeled +//! `provider-refusal-*` — fails its row. +//! +//! "Provider failure kinds" are the `wright_lpp::ProviderError` +//! taxonomy; "session failure kinds" are the session machinery's own +//! refusals — `CompilerSession::new` diagnostics carried verbatim and +//! the fixed-config `session-config-changed` refusal. Connect-channel +//! failures reach the surface through `connect_provider`; request-channel +//! failures through the provider's `lpp/lookup` answer. +//! `provider-not-configured` cannot reach this surface (`opy` always +//! resolves through the configured resolver), the client-side +//! session-state errors (`provider-not-initialized`, +//! `provider-already-initialized`, `provider-shutdown`) have no real +//! trigger after a successful connect, and a clean `provider-io` has no +//! deterministic process trigger. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use wright_driver::service::{ToolRequest, ToolResponse, ToolService}; +use wright_driver::{ + CompilerSession, ExitClass, FindingSelection, InputSpec, SessionConfig, SourceKind, +}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn workshop_path() -> PathBuf { + workspace_root().join("tests/fixtures/workshop/synthetic/control-flow.ws") +} + +fn temp_dir(tag: &str) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-lookup-failures-{}-{}-{tag}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// `lookup`'s failure table: every provider failure kind and every +/// session failure kind that reaches the tool-service `Lookup` response. +#[test] +fn lookup_surfaces_every_failure_kind_with_its_code_and_exit_class() { + enum Setup { + /// A provider registered under the `opy` id. + Registered { + command: PathBuf, + args: Vec, + timeout: Option, + }, + /// `opy_provider` resolves to a nonexistent executable. + MissingExecutable, + /// A registered command that does not exist. + MissingCommand, + } + + #[derive(Debug)] + enum Expect { + Error { + code: &'static str, + class: ExitClass, + }, + Unavailable, + } + + fn service(setup: Setup) -> ToolService<'static> { + let mut config = SessionConfig { + input: InputSpec::Path(workshop_path()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }; + match setup { + Setup::Registered { + command, + args, + timeout, + } => { + let mut provider = wright_lpp::ProviderConfig::new("opy", command, args); + if let Some(timeout) = timeout { + provider.request_timeout = timeout; + } + config + .providers + .register(provider) + .expect("provider registers"); + } + Setup::MissingExecutable => { + config.opy_provider = + wright_driver::OpyProviderConfig::with_executable("/nonexistent/opy-provider"); + } + Setup::MissingCommand => { + config + .providers + .register(wright_lpp::ProviderConfig::new( + "opy", + "/nonexistent/opy-provider", + Vec::new(), + )) + .expect("provider registers"); + } + } + let session = CompilerSession::new(config).expect("session"); + let session = Box::leak(Box::new(session)); + ToolService::new(session).expect("service") + } + + fn request() -> ToolRequest { + ToolRequest::Lookup { + language: "opy".to_string(), + query: Some("hud".to_string()), + kind: None, + within: None, + locale: None, + limit: None, + } + } + + let mut cases: Vec<(&'static str, Setup, Expect)> = vec![ + ( + "provider-missing", + Setup::MissingExecutable, + Expect::Unavailable, + ), + ("provider-spawn", Setup::MissingCommand, Expect::Unavailable), + ]; + + #[cfg(unix)] + { + let dir = temp_dir("lookup-provider"); + let script = { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-provider"); + std::fs::write(&path, MODE_PROVIDER).expect("provider script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + path + }; + let spec = |spec: &str| Setup::Registered { + command: script.clone(), + args: vec![spec.to_string()], + timeout: None, + }; + let timed = |spec: &str| Setup::Registered { + command: script.clone(), + args: vec![spec.to_string()], + timeout: Some(Duration::from_millis(250)), + }; + let error = |code: &'static str, class: ExitClass| Expect::Error { code, class }; + cases.extend([ + // `lpp/initialize` failures (the connect channel): the + // capability gaps degrade to `unavailable`; everything else + // keeps `provider_failure_code` + `lookup_failure_class`. + ( + "init:mismatch", + spec("init:mismatch:1.0"), + Expect::Unavailable, + ), + ( + "init:capability-unavailable", + spec("init:lpp:capabilityUnavailable"), + Expect::Unavailable, + ), + ("init:exited", spec("init:exit:3"), Expect::Unavailable), + ( + "init:refusal-coded", + spec("init:refusal:init.tooBusy"), + error("provider-refusal-init.tooBusy", ExitClass::SourceError), + ), + ( + "init:refusal-invalid", + spec("init:refusal:invalid-selection"), + error("provider-refusal-invalid-selection", ExitClass::Usage), + ), + ( + "init:refusal-plain", + spec("init:refusal"), + error("provider-refusal", ExitClass::SourceError), + ), + ( + "init:refusal-config", + spec("init:refusal:session-config-changed"), + error("session-config-changed", ExitClass::SourceError), + ), + ( + "init:invalid-request", + spec("init:lpp:invalidRequest"), + error("invalid-request", ExitClass::Usage), + ), + ( + "init:invalid-language", + spec("init:lpp:invalidLanguage"), + error("invalid-language", ExitClass::Usage), + ), + ( + "init:invalid-document", + spec("init:lpp:invalidDocument"), + error("invalid-document", ExitClass::Usage), + ), + ( + "init:invalid-entry", + spec("init:lpp:invalidEntry"), + error("invalid-entry", ExitClass::Usage), + ), + ( + "init:invalid-position", + spec("init:lpp:invalidPosition"), + error("invalid-position", ExitClass::Usage), + ), + ( + "init:invalid-artifact", + spec("init:lpp:invalidArtifact"), + error("invalid-artifact", ExitClass::Usage), + ), + ( + "init:project-load-failed", + spec("init:lpp:projectLoadFailed"), + error("project-load-failed", ExitClass::SourceError), + ), + ( + "init:unknown-kind", + spec("init:lpp:unknownFuture"), + error("lpp-error", ExitClass::SourceError), + ), + ( + "init:jsonrpc", + spec("init:raw:-32600"), + error("jsonrpc-error", ExitClass::Internal), + ), + ( + "init:malformed", + spec("init:malformed"), + error("provider-malformed", ExitClass::Internal), + ), + ( + "init:timeout", + timed("init:sleep"), + error("provider-timeout", ExitClass::Internal), + ), + // An unnegotiated `lookup` capability is `unavailable`. + ( + "init:no-lookup-capability", + spec("init:nolookup"), + Expect::Unavailable, + ), + // `lpp/lookup` failures (the request channel): the carried + // code is the refusal's own `refusalCode`, verbatim. + ( + "op:refusal-invalid", + spec("op:refusal:invalid-selection"), + error("invalid-selection", ExitClass::Usage), + ), + ( + "op:refusal-unknown-within", + spec("op:refusal:lookup.unknownWithin"), + error("lookup.unknownWithin", ExitClass::SourceError), + ), + ( + "op:refusal-coded", + spec("op:refusal:check.tooLarge"), + error("check.tooLarge", ExitClass::SourceError), + ), + ( + "op:refusal-plain", + spec("op:refusal"), + error("refusal", ExitClass::SourceError), + ), + ( + "op:refusal-config", + spec("op:refusal:session-config-changed"), + error("session-config-changed", ExitClass::SourceError), + ), + ( + "op:invalid-request", + spec("op:lpp:invalidRequest"), + error("invalid-request", ExitClass::Usage), + ), + ( + "op:invalid-language", + spec("op:lpp:invalidLanguage"), + error("invalid-language", ExitClass::Usage), + ), + ( + "op:invalid-document", + spec("op:lpp:invalidDocument"), + error("invalid-document", ExitClass::Usage), + ), + ( + "op:invalid-entry", + spec("op:lpp:invalidEntry"), + error("invalid-entry", ExitClass::Usage), + ), + ( + "op:invalid-position", + spec("op:lpp:invalidPosition"), + error("invalid-position", ExitClass::Usage), + ), + ( + "op:invalid-artifact", + spec("op:lpp:invalidArtifact"), + error("invalid-artifact", ExitClass::Usage), + ), + ( + "op:project-load-failed", + spec("op:lpp:projectLoadFailed"), + error("project-load-failed", ExitClass::SourceError), + ), + ( + "op:capability-unavailable", + spec("op:lpp:capabilityUnavailable"), + error("capability-unavailable", ExitClass::Unsupported), + ), + ( + "op:protocol-mismatch", + spec("op:lpp:protocolVersionMismatch"), + error("protocol-version-mismatch", ExitClass::Internal), + ), + ( + "op:unknown-kind", + spec("op:lpp:unknownFuture"), + error("lpp-error", ExitClass::SourceError), + ), + ( + "op:jsonrpc", + spec("op:raw:-32600"), + error("jsonrpc-error", ExitClass::Internal), + ), + ( + "op:malformed", + spec("op:malformed"), + error("provider-malformed", ExitClass::Internal), + ), + ( + "op:exited", + spec("op:exit:3"), + error("provider-exited", ExitClass::Internal), + ), + ( + "op:timeout", + timed("op:sleep"), + error("provider-timeout", ExitClass::Internal), + ), + ]); + } + + for (name, setup, expect) in cases { + let mut service = service(setup); + let response = service.handle(&request()); + match (&expect, response) { + (Expect::Unavailable, ToolResponse::Ok { result }) => { + assert_eq!( + result["unavailable"]["capability"], "lookup", + "{name}: the unavailable answer names the capability" + ); + assert_eq!(result["entries"], serde_json::json!([]), "{name}"); + } + (Expect::Error { code, class }, ToolResponse::Error { error: info }) => { + assert_eq!(&info.code, code, "{name}"); + assert_eq!(&info.class, class, "{name}"); + assert_eq!(info.class.exit_code(), class.exit_code(), "{name}"); + } + (expect, response) => panic!("{name}: wrong response for {expect:?}: {response:?}"), + } + } + + // The session machinery's own failure kinds at the service + // boundary: construction diagnostics keep their own code and class, + // and `session-config-changed` refuses `ToolService::new` itself. + let session_failures: &[(&str, ExitClass)] = &[ + ("invalid-selection", ExitClass::Usage), + ("lint-rule-error", ExitClass::SourceError), + ("session-config-changed", ExitClass::SourceError), + ]; + for (code, class) in session_failures { + let diagnostic = match *code { + "session-config-changed" => { + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(workshop_path()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .expect("session"); + session.config.locale = Some("fr-FR".to_string()); + let Err(diagnostic) = ToolService::new(&mut session) else { + panic!("a changed configuration must refuse the service"); + }; + diagnostic + } + "invalid-selection" => { + let Err(diagnostic) = CompilerSession::new(SessionConfig { + selection: FindingSelection { + rule: Some("no-such-rule".to_string()), + ..FindingSelection::default() + }, + ..SessionConfig::default() + }) else { + panic!("an unknown rule must refuse the session"); + }; + diagnostic + } + _ => { + let Err(diagnostic) = CompilerSession::new(SessionConfig { + lint_rule_paths: vec![PathBuf::from("/nonexistent/rule.opy")], + ..SessionConfig::default() + }) else { + panic!("a missing lint rule must refuse the session"); + }; + diagnostic + } + }; + assert_eq!(diagnostic.code, *code); + assert_eq!(diagnostic.class, *class); + assert_eq!(diagnostic.class.exit_code(), class.exit_code()); + } +} + +/// One scripted LPP provider for the `lookup` table: `argv[1]` is +/// `:` where `init:` fails the `lpp/initialize` answer +/// and `op:` fails `lpp/lookup`; `init:nolookup` negotiates without the +/// `lookup` capability. Behaviors: `refusal[:]`, +/// `lpp:[:
]`, `mismatch:`, +/// `raw:`, `malformed`, `sleep`, `exit:`. +#[cfg(unix)] +const MODE_PROVIDER: &str = r#"#!/usr/bin/env python3 +import json, os, sys, time + +phase, _, behavior = (sys.argv[1] if len(sys.argv) > 1 else "").partition(":") + +def reply(id, result=None, error=None): + message = {"jsonrpc": "2.0", "id": id} + message.update({"error": error} if error else {"result": result}) + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + +def lpp_error(id, kind, details, text): + reply(id, error={"code": -32000, "message": text, + "data": {"lpp": {"kind": kind, "details": details}}}) + +def negotiated(id, request, lookup): + reply(id, {"protocolVersion": request["params"]["protocolVersion"], + "serverInfo": {"name": "fake", "version": "0"}, + "languages": [{"id": "opy", "extensions": ["opy"]}], + "capabilities": {"check": True, "compile": True, + "projectLoading": True, "lookup": lookup}}) + +def fail(id, behavior): + head, _, rest = behavior.partition(":") + if head == "refusal": + details = {"refusalCode": rest} if rest else {} + lpp_error(id, "refusal", details, "the provider refused") + elif head == "lpp": + kind, _, extra = rest.partition(":") + lpp_error(id, kind, json.loads(extra) if extra else {}, "the provider failed") + elif head == "mismatch": + supported = rest.split(",") if rest else [] + lpp_error(id, "protocolVersionMismatch", + {"supportedProtocolVersions": supported}, "unsupported version") + elif head == "raw": + reply(id, error={"code": int(rest), "message": "transport-level error"}) + elif head == "malformed": + sys.stdout.write("this is not a json-rpc message\n") + sys.stdout.flush() + elif head == "sleep": + time.sleep(10) + reply(id, {}) + elif head == "exit": + os._exit(int(rest)) + else: + reply(id, {}) + +for line in sys.stdin: + try: + request = json.loads(line) + except ValueError: + continue + id, method = request.get("id"), request.get("method") + if method == "lpp/initialize": + if phase == "init": + if behavior == "nolookup": + negotiated(id, request, False) + else: + fail(id, behavior) + else: + negotiated(id, request, True) + elif method == "lpp/lookup" and phase == "op": + fail(id, behavior) + elif method == "lpp/lookup": + reply(id, {"entries": []}) + else: + reply(id, {}) +"#; diff --git a/crates/wright-driver/tests/source_provider_lpp.rs b/crates/wright-driver/tests/source_provider_lpp.rs index 78a0a84b..573d8d88 100644 --- a/crates/wright-driver/tests/source_provider_lpp.rs +++ b/crates/wright-driver/tests/source_provider_lpp.rs @@ -200,48 +200,6 @@ fn lpp_source_provider_timeout_is_internal_with_typed_detail_in_text() { cleanup(dir); } -#[test] -fn lpp_source_provider_distinguishes_failure_classes() { - for (failure, code) in [ - ( - wright_lpp::ProviderError::lpp( - wright_lpp::LppErrorKind::CapabilityUnavailable, - serde_json::json!({"capability": "projectLoading", "method": "lpp/check"}), - "capability was not negotiated", - ), - "capability-unavailable", - ), - ( - wright_lpp::ProviderError::Exited { - status: Some(9), - message: "the provider exited".to_string(), - }, - "provider-exited", - ), - ( - wright_lpp::ProviderError::Malformed { - detail: "missing result".to_string(), - }, - "provider-malformed", - ), - ] { - let (dir, entry) = temp_entry(); - let mut provider = wright_driver::source_provider::LppSourceProvider::new( - Box::new(FailingLppProvider { - failure: failure.clone(), - capabilities_failure: None, - }), - None, - ); - let error = provider - .check(&SourceTarget::new(SourceLanguage::Opy, &entry, &dir)) - .err() - .unwrap_or_else(|| panic!("{failure:?} produced no error")); - assert_eq!(error.code(), code, "{failure:?} lost its classification"); - cleanup(dir); - } -} - #[test] fn lpp_source_provider_propagates_a_capability_query_failure() { let (dir, entry) = temp_entry(); diff --git a/crates/wright-language/tests/provider_failures.rs b/crates/wright-language/tests/provider_failures.rs new file mode 100644 index 00000000..1804dbae --- /dev/null +++ b/crates/wright-language/tests/provider_failures.rs @@ -0,0 +1,383 @@ +//! The failure-classification table for the language-service diagnostic +//! surface — the third table the driver-side `failure_classification` +//! test file describes (its `check` and `lookup` tables cover the other +//! two surfaces). Every row drives `LanguageService::diagnostics` on an +//! open `.opy` buffer and pins the published diagnostic's `code` and its +//! `error` severity: this surface carries no exit class — a boundary +//! failure publishes one error diagnostic whose code is the only +//! classification that crosses it — so a wrapper that re-classifies the +//! failure (a refusal mapped to a capability gap, a session diagnostic +//! relabeled `provider-refusal-*`) fails its row. +//! +//! "Provider failure kinds" are the `wright_lpp::ProviderError` +//! taxonomy; "session failure kinds" are the session machinery's own +//! refusals — `CompilerSession::new` diagnostics carried verbatim. The +//! client-side session-state errors (`provider-not-initialized`, +//! `provider-already-initialized`, `provider-shutdown`) have no real +//! trigger after a successful connect, `provider-not-configured` cannot +//! be produced for the resolved `opy` id, `provider-io` has no +//! deterministic process trigger, and `session-config-changed` cannot be +//! reached — the service's session configuration is private and cloned +//! at construction. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use wright_language::document::Document; +use wright_language::{DiagnosticSeverity, LanguageService}; + +fn temp_dir(tag: &str) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-language-failures-{}-{}-{tag}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +fn open_opy(service: &mut LanguageService, dir: &Path) -> String { + let uri = url::Url::from_file_path(dir.join("main.opy")) + .expect("entry uri") + .to_string(); + service.store.open(Document::new( + uri.clone(), + "rule 'x': pass\n".to_string(), + dir.to_path_buf(), + )); + uri +} + +fn registered( + command: impl Into, + args: Vec, + timeout: Option, +) -> wright_language::SessionConfig { + let mut config = wright_language::SessionConfig::default(); + let mut provider = wright_lpp::ProviderConfig::new("opy", command, args); + if let Some(timeout) = timeout { + provider.request_timeout = timeout; + } + config + .providers + .register(provider) + .expect("provider registers"); + config +} + +/// `diagnostics` on an `.opy` document: every provider failure kind the +/// connect or `lpp/check` channel can produce, the negotiated-capability +/// refusal, and the session-construction failures — each pinned to the +/// `code` (and `error` severity) it publishes. +#[test] +fn opy_diagnostics_surface_every_failure_kind_with_its_code() { + let mut cases: Vec<(&'static str, wright_language::SessionConfig, &'static str)> = vec![ + // The resolution/spawn failures need no provider process. + ( + "provider-missing", + wright_language::SessionConfig { + opy_provider: wright_language::OpyProviderConfig::with_executable( + "/nonexistent/opy-provider", + ), + ..wright_language::SessionConfig::default() + }, + "provider-missing", + ), + ( + "provider-spawn", + registered("/nonexistent/opy-provider", Vec::new(), None), + "provider-spawn", + ), + // Session-construction diagnostics keep their own codes — never + // a fabricated `provider-refusal-*`. + ( + "session:invalid-selection", + wright_language::SessionConfig { + selection: wright_driver::FindingSelection { + rule: Some("no-such-rule".to_string()), + ..wright_driver::FindingSelection::default() + }, + ..wright_language::SessionConfig::default() + }, + "invalid-selection", + ), + ( + "session:lint-rule-error", + wright_language::SessionConfig { + lint_rule_paths: vec![PathBuf::from("/nonexistent/rule.opy")], + ..wright_language::SessionConfig::default() + }, + "lint-rule-error", + ), + ]; + + #[cfg(unix)] + { + let dir = temp_dir("provider-script"); + let script = { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-provider"); + std::fs::write(&path, MODE_PROVIDER).expect("provider script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + path + }; + let spec = |spec: &str| registered(script.clone(), vec![spec.to_string()], None); + let timed = |spec: &str| { + registered( + script.clone(), + vec![spec.to_string()], + Some(Duration::from_millis(250)), + ) + }; + cases.extend([ + // `lpp/initialize` failures (the connect channel) publish + // `provider_failure_code` verbatim — no `unavailable` exists + // on this surface. + ( + "init:mismatch", + spec("init:mismatch:1.0"), + "protocol-version-mismatch", + ), + ( + "init:capability-unavailable", + spec("init:lpp:capabilityUnavailable"), + "capability-unavailable", + ), + ("init:exited", spec("init:exit:3"), "provider-exited"), + ( + "init:refusal-coded", + spec("init:refusal:init.tooBusy"), + "provider-refusal-init.tooBusy", + ), + ( + "init:refusal-invalid", + spec("init:refusal:invalid-selection"), + "provider-refusal-invalid-selection", + ), + ( + "init:refusal-plain", + spec("init:refusal"), + "provider-refusal", + ), + ( + "init:refusal-config", + spec("init:refusal:session-config-changed"), + "session-config-changed", + ), + ( + "init:invalid-request", + spec("init:lpp:invalidRequest"), + "invalid-request", + ), + ( + "init:invalid-language", + spec("init:lpp:invalidLanguage"), + "invalid-language", + ), + ( + "init:invalid-document", + spec("init:lpp:invalidDocument"), + "invalid-document", + ), + ( + "init:invalid-entry", + spec("init:lpp:invalidEntry"), + "invalid-entry", + ), + ( + "init:invalid-position", + spec("init:lpp:invalidPosition"), + "invalid-position", + ), + ( + "init:invalid-artifact", + spec("init:lpp:invalidArtifact"), + "invalid-artifact", + ), + ( + "init:project-load-failed", + spec("init:lpp:projectLoadFailed"), + "project-load-failed", + ), + ( + "init:unknown-kind", + spec("init:lpp:unknownFuture"), + "lpp-error", + ), + ("init:jsonrpc", spec("init:raw:-32600"), "jsonrpc-error"), + ( + "init:malformed", + spec("init:malformed"), + "provider-malformed", + ), + ("init:timeout", timed("init:sleep"), "provider-timeout"), + // A provider that negotiated without `check` declines + // through the surface's own unavailability code. + ( + "init:no-check-capability", + spec("init:nocheck"), + "source-provider-unavailable", + ), + // `lpp/check` failures (the request channel) classify + // through the same `provider_failure_code`. + ( + "op:refusal-coded", + spec("op:refusal:check.tooLarge"), + "provider-refusal-check.tooLarge", + ), + ( + "op:refusal-invalid", + spec("op:refusal:invalid-selection"), + "provider-refusal-invalid-selection", + ), + ("op:refusal-plain", spec("op:refusal"), "provider-refusal"), + ( + "op:refusal-config", + spec("op:refusal:session-config-changed"), + "session-config-changed", + ), + ( + "op:invalid-request", + spec("op:lpp:invalidRequest"), + "invalid-request", + ), + ( + "op:invalid-language", + spec("op:lpp:invalidLanguage"), + "invalid-language", + ), + ( + "op:invalid-document", + spec("op:lpp:invalidDocument"), + "invalid-document", + ), + ( + "op:invalid-entry", + spec("op:lpp:invalidEntry"), + "invalid-entry", + ), + ( + "op:invalid-position", + spec("op:lpp:invalidPosition"), + "invalid-position", + ), + ( + "op:invalid-artifact", + spec("op:lpp:invalidArtifact"), + "invalid-artifact", + ), + ( + "op:project-load-failed", + spec("op:lpp:projectLoadFailed"), + "project-load-failed", + ), + ( + "op:capability-unavailable", + spec("op:lpp:capabilityUnavailable"), + "capability-unavailable", + ), + ( + "op:protocol-mismatch", + spec("op:lpp:protocolVersionMismatch"), + "protocol-version-mismatch", + ), + ("op:unknown-kind", spec("op:lpp:unknownFuture"), "lpp-error"), + ("op:jsonrpc", spec("op:raw:-32600"), "jsonrpc-error"), + ("op:malformed", spec("op:malformed"), "provider-malformed"), + ("op:exited", spec("op:exit:3"), "provider-exited"), + ("op:timeout", timed("op:sleep"), "provider-timeout"), + ]); + } + + for (name, config, code) in cases { + let dir = temp_dir(&format!("service-{name}")); + let mut service = LanguageService::with_config(dir.clone(), config); + let uri = open_opy(&mut service, &dir); + let diagnostics = service.diagnostics(&uri); + assert_eq!(diagnostics.len(), 1, "{name}: {diagnostics:?}"); + assert_eq!(diagnostics[0].code, code, "{name}"); + assert_eq!( + diagnostics[0].severity, + DiagnosticSeverity::Error, + "{name}: boundary failures publish as errors" + ); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } +} + +/// The scripted LPP provider for the table: `argv[1]` is +/// `:` where `init:` fails the `lpp/initialize` answer +/// and `op:` fails `lpp/check`; `init:nocheck` negotiates without the +/// `check` capability. Behaviors: `refusal[:]`, +/// `lpp:[:
]`, `mismatch:`, +/// `raw:`, `malformed`, `sleep`, `exit:`. +#[cfg(unix)] +const MODE_PROVIDER: &str = r#"#!/usr/bin/env python3 +import json, os, sys, time + +phase, _, behavior = (sys.argv[1] if len(sys.argv) > 1 else "").partition(":") + +def reply(id, result=None, error=None): + message = {"jsonrpc": "2.0", "id": id} + message.update({"error": error} if error else {"result": result}) + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + +def lpp_error(id, kind, details, text): + reply(id, error={"code": -32000, "message": text, + "data": {"lpp": {"kind": kind, "details": details}}}) + +def negotiated(id, request, check): + reply(id, {"protocolVersion": request["params"]["protocolVersion"], + "serverInfo": {"name": "fake", "version": "0"}, + "languages": [{"id": "opy", "extensions": ["opy"]}], + "capabilities": {"check": check}}) + +def fail(id, behavior): + head, _, rest = behavior.partition(":") + if head == "refusal": + details = {"refusalCode": rest} if rest else {} + lpp_error(id, "refusal", details, "the provider refused") + elif head == "lpp": + kind, _, extra = rest.partition(":") + lpp_error(id, kind, json.loads(extra) if extra else {}, "the provider failed") + elif head == "mismatch": + supported = rest.split(",") if rest else [] + lpp_error(id, "protocolVersionMismatch", + {"supportedProtocolVersions": supported}, "unsupported version") + elif head == "raw": + reply(id, error={"code": int(rest), "message": "transport-level error"}) + elif head == "malformed": + sys.stdout.write("this is not a json-rpc message\n") + sys.stdout.flush() + elif head == "sleep": + time.sleep(10) + reply(id, {}) + elif head == "exit": + os._exit(int(rest)) + else: + reply(id, {}) + +for line in sys.stdin: + try: + request = json.loads(line) + except ValueError: + continue + id, method = request.get("id"), request.get("method") + if method == "lpp/initialize": + if phase == "init": + if behavior == "nocheck": + negotiated(id, request, False) + else: + fail(id, behavior) + else: + negotiated(id, request, True) + elif method == "lpp/check" and phase == "op": + fail(id, behavior) + elif method == "lpp/check": + reply(id, {"documents": []}) + else: + reply(id, {}) +"#; diff --git a/crates/wright-language/tests/service.rs b/crates/wright-language/tests/service.rs index 6b41bb77..d1977056 100644 --- a/crates/wright-language/tests/service.rs +++ b/crates/wright-language/tests/service.rs @@ -433,13 +433,11 @@ fn non_workshop_documents_keep_their_boundaries() { /// The language service's OPY provider path runs the shared connect path /// (#1004): the provider session is spawned through `resolve()` (no -/// `installed()` fallback), `initialize` retries once at the highest -/// mutual version on `protocol-version-mismatch`, and a provider refusal -/// reaches the document surface under the driver's -/// `provider-refusal-` classification, not the raw refusal code. +/// `installed()` fallback) and `initialize` retries once at the highest +/// mutual version on `protocol-version-mismatch`. #[cfg(unix)] #[test] -fn an_opy_provider_negotiates_and_classifies_refusals_like_the_driver() { +fn an_opy_provider_retries_at_the_highest_mutual_version() { use std::os::unix::fs::PermissionsExt; const PROVIDER: &str = r#"#!/usr/bin/env python3 @@ -466,7 +464,7 @@ for line in sys.stdin: "languages": [{"id": "opy", "extensions": ["opy"]}], "capabilities": {"check": True}}) elif method == "lpp/check": - lpp_error(id, "refusal", {"refusalCode": "check.tooLarge"}, "the project is too large") + reply(id, {"documents": []}) else: reply(id, {}) "#; @@ -497,11 +495,7 @@ for line in sys.stdin: )); let diagnostics = service.diagnostics(&uri); - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!( - diagnostics[0].code, "provider-refusal-check.tooLarge", - "the language service emits the driver's refusal classification" - ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); let attempts: Vec = std::fs::read_to_string(dir.join("init-log")) .expect("init log") .lines() @@ -518,8 +512,8 @@ for line in sys.stdin: /// A `CompilerSession::new` failure is Wright's own diagnostic, not a /// provider refusal: an invalid lint selection in the service config -/// publishes `invalid-selection` on `.opy` diagnostics and rename — never -/// the fabricated `provider-refusal-invalid-selection` (#1004). +/// refuses rename with `invalid-selection` — never the fabricated +/// `provider-refusal-invalid-selection` (#1004). #[test] fn a_session_construction_failure_keeps_its_own_code() { let mut service = LanguageService::with_config( @@ -539,10 +533,6 @@ fn a_session_construction_failure_keeps_its_own_code() { PathBuf::from("."), )); - let diagnostics = service.diagnostics(uri); - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!(diagnostics[0].code, "invalid-selection"); - let outcome = service.rename( uri, Position { From 7045f4b093d92db112319f20ad4d831d4bf388ac Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:12:33 +0800 Subject: [PATCH 2/2] test: drop the unpinnable lint-rule-error rows and fix table headers Review of the failure-classification tables found two defects: - The lint-rule-error construction rows pinned `SourceError`, but every CLI surface that can emit that diagnostic (wright-cli `run_configured`, `run_lookup`, `serve::run`) discards `Diagnostic::class` and exits USAGE. Per the owner ruling the rows are removed rather than re-pinned; the class/wrapper disagreement is left for a separate owner decision. Each table now notes why the kind is deliberately unpinned. - The test-file headers named a nonexistent `failure_classification` file and claimed every row drives `session.check()`/`Lookup` end to end, though the session-boundary rows only reach `CompilerSession::new`/`ToolService::new`. The headers now name the real tables and describe the boundary each row drives. No production change. --- crates/wright-driver/tests/check_failures.rs | 59 ++++++++----------- crates/wright-driver/tests/lookup_failures.rs | 30 +++++----- .../tests/provider_failures.rs | 6 +- 3 files changed, 42 insertions(+), 53 deletions(-) diff --git a/crates/wright-driver/tests/check_failures.rs b/crates/wright-driver/tests/check_failures.rs index c30187c6..9b90923d 100644 --- a/crates/wright-driver/tests/check_failures.rs +++ b/crates/wright-driver/tests/check_failures.rs @@ -2,9 +2,11 @@ //! the three public surfaces the classification contract covers; //! `lookup` keeps its own table in `lookup_failures.rs` and the //! language-service diagnostic surface keeps its own in -//! `wright-language`. Every row drives `session.check()` end to end and -//! pins the error diagnostic's `code`, its `class`, and the envelope -//! `exit` the class resolves to: a wrapper that re-classifies a failure +//! `wright-language`. Every row drives the boundary that surfaces the +//! diagnostic — `CompilerSession::new` for the session-construction +//! rows, `session.check()` end to end for the rest — and pins the error +//! diagnostic's `code`, its `class`, and the `exit` code the class +//! resolves to: a wrapper that re-classifies a failure //! — a refusal reported as `unavailable`, a session diagnostic relabeled //! `provider-refusal-*`, or a dropped `Diagnostic::class` — fails its //! row. @@ -201,8 +203,9 @@ fn opy_entry(dir: &Path) -> PathBuf { /// `check`'s failure table: every provider failure kind and every /// session failure kind that reaches the `session.check()` envelope, -/// pinned by the error diagnostic's `code`, its `class`, and the -/// envelope `exit` the class resolves to. +/// plus the session-construction refusals observed at +/// `CompilerSession::new`, pinned by the error diagnostic's `code`, its +/// `class`, and the `exit` code the class resolves to. #[test] fn check_surfaces_every_failure_kind_with_its_code_and_exit_class() { let lpp = |kind, details| ProviderError::lpp(kind, details, "the provider failed"); @@ -451,36 +454,22 @@ fn check_surfaces_every_failure_kind_with_its_code_and_exit_class() { // The session machinery's own refusal kinds: construction // diagnostics (observed as the session boundary's `Err`), the // fixed-config refusal, and the `source-provider-*` load refusals. - let dir = temp_dir("check-construction"); - for (configure, code, class) in [ - ( - Box::new(|config: &mut SessionConfig| { - config.selection = FindingSelection { - rule: Some("no-such-rule".to_string()), - ..FindingSelection::default() - }; - }) as Box, - "invalid-selection", - ExitClass::Usage, - ), - ( - Box::new(|config: &mut SessionConfig| { - config.lint_rule_paths = vec![dir.join("missing-rule.opy")]; - }) as Box, - "lint-rule-error", - ExitClass::SourceError, - ), - ] { - let mut config = SessionConfig::default(); - configure(&mut config); - let Err(diagnostic) = CompilerSession::new(config) else { - panic!("{code} must refuse session construction"); - }; - assert_eq!(diagnostic.code, code); - assert_eq!(diagnostic.class, class); - assert_eq!(diagnostic.class.exit_code(), class.exit_code()); - } - std::fs::remove_dir_all(&dir).expect("cleanup"); + // `lint-rule-error` is deliberately unpinned: its declared + // `SourceError` class disagrees with the CLI wrappers, which exit + // `USAGE` for every session-construction failure — the discrepancy + // awaits a separate owner decision. + let Err(diagnostic) = CompilerSession::new(SessionConfig { + selection: FindingSelection { + rule: Some("no-such-rule".to_string()), + ..FindingSelection::default() + }, + ..SessionConfig::default() + }) else { + panic!("an unknown rule must refuse session construction"); + }; + assert_eq!(diagnostic.code, "invalid-selection"); + assert_eq!(diagnostic.class, ExitClass::Usage); + assert_eq!(diagnostic.class.exit_code(), ExitClass::Usage.exit_code()); // `session-config-changed` — the fixed-config refusal. { diff --git a/crates/wright-driver/tests/lookup_failures.rs b/crates/wright-driver/tests/lookup_failures.rs index 58cdc8bd..695d1b4b 100644 --- a/crates/wright-driver/tests/lookup_failures.rs +++ b/crates/wright-driver/tests/lookup_failures.rs @@ -2,10 +2,13 @@ //! the three public surfaces the classification contract covers; //! `check` keeps its own table in `check_failures.rs` and the //! language-service diagnostic surface keeps its own in -//! `wright-language`. Every row drives the tool-service `Lookup` -//! response end to end and pins the error's `code`, its `class`, and -//! the exit code the class resolves to (`unavailable` answers are the -//! surface's unsupported verdict — exit 3): a wrapper that re-classifies +//! `wright-language`. Every row drives the boundary that surfaces the +//! diagnostic — the tool-service `Lookup` response end to end for the +//! provider rows, `CompilerSession::new`/`ToolService::new` for the +//! session-boundary rows — and pins the error's `code`, its `class`, +//! and the exit code the class resolves to (`unavailable` answers are +//! the surface's unsupported verdict — exit 3): a wrapper that +//! re-classifies //! a failure — a refusal reported as `unavailable`, an `invalid-*` code //! defaulted to `SourceError`, or a session diagnostic relabeled //! `provider-refusal-*` — fails its row. @@ -52,7 +55,9 @@ fn temp_dir(tag: &str) -> PathBuf { } /// `lookup`'s failure table: every provider failure kind and every -/// session failure kind that reaches the tool-service `Lookup` response. +/// session failure kind that reaches the tool-service `Lookup` +/// response, plus the session-boundary refusals observed at +/// `CompilerSession::new`/`ToolService::new`. #[test] fn lookup_surfaces_every_failure_kind_with_its_code_and_exit_class() { enum Setup { @@ -378,9 +383,12 @@ fn lookup_surfaces_every_failure_kind_with_its_code_and_exit_class() { // The session machinery's own failure kinds at the service // boundary: construction diagnostics keep their own code and class, // and `session-config-changed` refuses `ToolService::new` itself. + // `lint-rule-error` is deliberately unpinned: its declared + // `SourceError` class disagrees with the CLI wrappers, which exit + // `USAGE` for every session-construction failure — the discrepancy + // awaits a separate owner decision. let session_failures: &[(&str, ExitClass)] = &[ ("invalid-selection", ExitClass::Usage), - ("lint-rule-error", ExitClass::SourceError), ("session-config-changed", ExitClass::SourceError), ]; for (code, class) in session_failures { @@ -410,15 +418,7 @@ fn lookup_surfaces_every_failure_kind_with_its_code_and_exit_class() { }; diagnostic } - _ => { - let Err(diagnostic) = CompilerSession::new(SessionConfig { - lint_rule_paths: vec![PathBuf::from("/nonexistent/rule.opy")], - ..SessionConfig::default() - }) else { - panic!("a missing lint rule must refuse the session"); - }; - diagnostic - } + _ => unreachable!("{code} has no session-boundary trigger"), }; assert_eq!(diagnostic.code, *code); assert_eq!(diagnostic.class, *class); diff --git a/crates/wright-language/tests/provider_failures.rs b/crates/wright-language/tests/provider_failures.rs index 1804dbae..fb7618dc 100644 --- a/crates/wright-language/tests/provider_failures.rs +++ b/crates/wright-language/tests/provider_failures.rs @@ -1,7 +1,7 @@ //! The failure-classification table for the language-service diagnostic -//! surface — the third table the driver-side `failure_classification` -//! test file describes (its `check` and `lookup` tables cover the other -//! two surfaces). Every row drives `LanguageService::diagnostics` on an +//! surface — the third table beside the driver-side `check_failures.rs` +//! and `lookup_failures.rs` tables, which cover the other two surfaces. +//! Every row drives `LanguageService::diagnostics` on an //! open `.opy` buffer and pins the published diagnostic's `code` and its //! `error` severity: this surface carries no exit class — a boundary //! failure publishes one error diagnostic whose code is the only