diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index dcbe12d1..88180cc0 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -190,6 +190,9 @@ pub(crate) struct ReportArgs { pub(crate) common: CommonArgs, #[command(flatten)] pub(crate) select: SelectArgs, + /// Load a local YAML rule file or directory (repeatable). + #[arg(long = "rule-file", value_name = "PATH")] + pub(crate) rule_file: Vec, } /// Arguments of `analyze`: shared workflow options, finding selection, and @@ -294,6 +297,9 @@ pub(crate) struct LookupArgs { /// Override the first-party OPY provider executable. #[arg(long, value_name = "PATH")] pub(crate) opy_provider: Option, + /// Load a local YAML rule file or directory (repeatable). + #[arg(long = "rule-file", value_name = "PATH")] + pub(crate) rule_file: Vec, /// Output format. #[arg(short = 'f', long, value_enum, default_value_t = OutputFormatArg::Text)] pub(crate) format: OutputFormatArg, diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 8709c132..47e40e17 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -8,6 +8,7 @@ mod mcp; mod plain; mod present; mod serve; +mod session; mod tooldefs; mod update; mod usage_hint; @@ -176,6 +177,7 @@ fn run_workflow(command: Command) -> ExitCode { Command::Check(args) => { let mut config = config_from_common(&args.common); config.selection = selection_from_args(&args.select); + config.lint_rule_paths = args.rule_file; run_configured( "check", config, @@ -360,10 +362,7 @@ pub(crate) fn run_configured< ) -> ExitCode { let mut session = match wright_driver::CompilerSession::new(config) { Ok(session) => session, - Err(diagnostic) => { - present::terminal::report_error(presentation, "USAGE", name, &diagnostic.message); - return ExitCode::from(exit::USAGE); - } + Err(diagnostic) => return session::refuse(presentation, name, &diagnostic), }; ExitCode::from(run_command(&mut session, run, presentation, subject)) @@ -467,6 +466,7 @@ fn run_lookup(args: &cli::LookupArgs) -> ExitCode { text: String::new(), path: None, }, + lint_rule_paths: args.rule_file.clone(), opy_provider: wright_driver::OpyProviderConfig { executable: args.opy_provider.clone(), ..wright_driver::OpyProviderConfig::default() @@ -475,17 +475,11 @@ fn run_lookup(args: &cli::LookupArgs) -> ExitCode { }; let mut session = match wright_driver::CompilerSession::new(config) { Ok(session) => session, - Err(diagnostic) => { - present::terminal::report_error(pres, "USAGE", "lookup", &diagnostic.message); - return ExitCode::from(exit::USAGE); - } + Err(diagnostic) => return session::refuse(pres, "lookup", &diagnostic), }; let mut service = match wright_driver::service::ToolService::new(&mut session) { Ok(service) => service, - Err(diagnostic) => { - present::terminal::report_error(pres, "USAGE", "lookup", &diagnostic.message); - return ExitCode::from(exit::USAGE); - } + Err(diagnostic) => return session::refuse(pres, "lookup", &diagnostic), }; let request = wright_driver::service::ToolRequest::Lookup { language: args.language.as_str().to_string(), diff --git a/crates/wright-cli/src/serve.rs b/crates/wright-cli/src/serve.rs index 27f82e98..f22cd30d 100644 --- a/crates/wright-cli/src/serve.rs +++ b/crates/wright-cli/src/serve.rs @@ -130,14 +130,14 @@ pub(crate) fn run(args: ServeArgs) -> ExitCode { Ok(session) => session, Err(diagnostic) => { eprintln!("wright: {}", diagnostic.message); - return ExitCode::from(2); + return ExitCode::from(diagnostic.class.exit_code()); } }; let mut service = match ToolService::new(&mut session) { Ok(service) => service, Err(diagnostic) => { eprintln!("wright: {}", diagnostic.message); - return ExitCode::from(2); + return ExitCode::from(diagnostic.class.exit_code()); } }; diff --git a/crates/wright-cli/src/session.rs b/crates/wright-cli/src/session.rs new file mode 100644 index 00000000..b6bb8c3b --- /dev/null +++ b/crates/wright-cli/src/session.rs @@ -0,0 +1,16 @@ +use std::process::ExitCode; + +use wright_driver::Diagnostic; + +use crate::present::{self, Presentation}; + +pub(crate) fn refuse(pres: Presentation, name: &str, diagnostic: &Diagnostic) -> ExitCode { + let code = diagnostic.class.exit_code(); + present::terminal::report_error( + pres, + present::terminal::exit_word(code), + name, + &diagnostic.message, + ); + ExitCode::from(code) +} diff --git a/crates/wright-cli/tests/session_exits.rs b/crates/wright-cli/tests/session_exits.rs new file mode 100644 index 00000000..f8fdd7a2 --- /dev/null +++ b/crates/wright-cli/tests/session_exits.rs @@ -0,0 +1,54 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn wright() -> &'static str { + env!("CARGO_BIN_EXE_wright") +} + +fn run(args: &[&str]) -> std::process::Output { + Command::new(wright()) + .args(args) + .output() + .expect("wright runs") +} + +fn temp_dir() -> PathBuf { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-session-exits-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn session_construction_exits_follow_the_diagnostic_class() { + let dir = temp_dir(); + let missing = dir.join("missing.yaml"); + let invalid = dir.join("invalid.yaml"); + std::fs::write(&invalid, "id: [unclosed\n").unwrap(); + for command in [&["check"][..], &["lookup", "wait"][..], &["serve"][..]] { + for (rule_file, code) in [(&missing, 2), (&invalid, 1)] { + let args: Vec = command + .iter() + .map(|arg| arg.to_string()) + .chain([ + "--rule-file".to_string(), + rule_file.to_string_lossy().into_owned(), + ]) + .collect(); + let output = run(&args.iter().map(String::as_str).collect::>()); + assert_eq!( + output.status.code(), + Some(code), + "{args:?}: status={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + } + let _ = std::fs::remove_dir_all(Path::new(&dir)); +} diff --git a/crates/wright-driver/src/lint_package.rs b/crates/wright-driver/src/lint_package.rs index 327f2042..0b2542f6 100644 --- a/crates/wright-driver/src/lint_package.rs +++ b/crates/wright-driver/src/lint_package.rs @@ -496,6 +496,7 @@ pub(crate) fn activate_pinned( let pin_error = |message: String| { let error = LintPackageError::source(message); Diagnostic::error(error.code(), Stage::Discovery, error.to_string()) + .with_class(error.exit_class()) }; let store = packages.store(); for pin in &lint.packages { @@ -505,6 +506,7 @@ pub(crate) fn activate_pinned( Stage::Discovery, format!("lint package pin '{}': {error}", pin.repo), ) + .with_class(error.exit_class()) })?; if let Some(reference) = reference { return Err(pin_error(format!( @@ -529,6 +531,7 @@ pub(crate) fn activate_pinned( stage, format!("lint package '{source}' at {}: {error}", pin.sha), ) + .with_class(error.exit_class()) })?; let rules_dir = installed.rules_dir(); let loaded = if source.is_first_party() { diff --git a/crates/wright-driver/src/session/registry.rs b/crates/wright-driver/src/session/registry.rs index 13f866ef..a8c58796 100644 --- a/crates/wright-driver/src/session/registry.rs +++ b/crates/wright-driver/src/session/registry.rs @@ -1,7 +1,7 @@ -use wright_analyzer::registry::LintRegistry; +use wright_analyzer::registry::{LintRegistry, RuleRegistryError}; use crate::config::SessionConfig; -use crate::diag::{Diagnostic, Stage}; +use crate::diag::{Diagnostic, ExitClass, Stage}; pub(super) fn compose(config: &SessionConfig) -> Result { let mut registry = LintRegistry::default(); @@ -12,7 +12,11 @@ pub(super) fn compose(config: &SessionConfig) -> Result ExitClass::Usage, + _ => ExitClass::SourceError, + }; + Diagnostic::classed_error("lint-rule-error", Stage::Analysis, class, error.to_string()) })?; } if let Err(message) = config.selection.validate(®istry) { diff --git a/crates/wright-driver/tests/check_failures.rs b/crates/wright-driver/tests/check_failures.rs index 37f395ab..b1ff7643 100644 --- a/crates/wright-driver/tests/check_failures.rs +++ b/crates/wright-driver/tests/check_failures.rs @@ -455,10 +455,6 @@ 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. - // `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()), @@ -472,6 +468,34 @@ fn check_surfaces_every_failure_kind_with_its_code_and_exit_class() { assert_eq!(diagnostic.class, ExitClass::Usage); assert_eq!(diagnostic.class.exit_code(), ExitClass::Usage.exit_code()); + { + let dir = temp_dir("check-rule-file"); + let Err(diagnostic) = CompilerSession::new(SessionConfig { + lint_rule_paths: vec![dir.join("missing.yaml")], + ..SessionConfig::default() + }) else { + panic!("a missing --rule-file must refuse session construction"); + }; + assert_eq!(diagnostic.code, "lint-rule-error"); + assert_eq!(diagnostic.class, ExitClass::Usage); + assert_eq!(diagnostic.class.exit_code(), ExitClass::Usage.exit_code()); + + let invalid = write_file(&dir, "invalid.yaml", "id: [unclosed\n"); + let Err(diagnostic) = CompilerSession::new(SessionConfig { + lint_rule_paths: vec![invalid], + ..SessionConfig::default() + }) else { + panic!("an invalid --rule-file must refuse session construction"); + }; + assert_eq!(diagnostic.code, "lint-rule-error"); + assert_eq!(diagnostic.class, ExitClass::SourceError); + assert_eq!( + diagnostic.class.exit_code(), + ExitClass::SourceError.exit_code() + ); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + // `session-config-changed` — the fixed-config refusal. { let dir = temp_dir("check-config"); diff --git a/crates/wright-driver/tests/lookup_failures.rs b/crates/wright-driver/tests/lookup_failures.rs index 695d1b4b..d5e37922 100644 --- a/crates/wright-driver/tests/lookup_failures.rs +++ b/crates/wright-driver/tests/lookup_failures.rs @@ -383,16 +383,26 @@ 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), - ("session-config-changed", ExitClass::SourceError), + let session_failures: &[(&str, &str, ExitClass)] = &[ + ("invalid-selection", "invalid-selection", ExitClass::Usage), + ( + "session-config-changed", + "session-config-changed", + ExitClass::SourceError, + ), + ( + "lint-rule-error:missing", + "lint-rule-error", + ExitClass::Usage, + ), + ( + "lint-rule-error:invalid", + "lint-rule-error", + ExitClass::SourceError, + ), ]; - for (code, class) in session_failures { - let diagnostic = match *code { + for (name, code, class) in session_failures { + let diagnostic = match *name { "session-config-changed" => { let mut session = CompilerSession::new(SessionConfig { input: InputSpec::Path(workshop_path()), @@ -418,11 +428,35 @@ fn lookup_surfaces_every_failure_kind_with_its_code_and_exit_class() { }; diagnostic } - _ => unreachable!("{code} has no session-boundary trigger"), + "lint-rule-error:missing" => { + let dir = temp_dir("lookup-rule-file"); + let Err(diagnostic) = CompilerSession::new(SessionConfig { + lint_rule_paths: vec![dir.join("missing.yaml")], + ..SessionConfig::default() + }) else { + panic!("a missing --rule-file must refuse the session"); + }; + std::fs::remove_dir_all(&dir).expect("cleanup"); + diagnostic + } + "lint-rule-error:invalid" => { + let dir = temp_dir("lookup-rule-file"); + let path = dir.join("invalid.yaml"); + std::fs::write(&path, "id: [unclosed\n").expect("write file"); + let Err(diagnostic) = CompilerSession::new(SessionConfig { + lint_rule_paths: vec![path], + ..SessionConfig::default() + }) else { + panic!("an invalid --rule-file must refuse the session"); + }; + std::fs::remove_dir_all(&dir).expect("cleanup"); + diagnostic + } + _ => unreachable!("{name} has no session-boundary trigger"), }; - assert_eq!(diagnostic.code, *code); - assert_eq!(diagnostic.class, *class); - assert_eq!(diagnostic.class.exit_code(), class.exit_code()); + assert_eq!(diagnostic.code, *code, "{name}"); + assert_eq!(diagnostic.class, *class, "{name}"); + assert_eq!(diagnostic.class.exit_code(), class.exit_code(), "{name}"); } }