diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index cda80c08..e4833c69 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -30,15 +30,9 @@ pub struct Finding { pub value: Option, pub evidence: EvidenceClass, pub boundedness: Option, - /// The mechanically determined correction for this finding, when one - /// exists and its safety preconditions hold (#556). The plan names the - /// spans to rewrite; the driver materializes it into a validated - /// source-edit transaction. + /// The correction plan for a finding whose safety preconditions hold. pub fix: Option, - /// The repeated-expression facts behind a `repeated-value` finding - /// (#791): the expression, how often the loop scope evaluates it, and - /// which location `span` reports. Renderers consume these fields - /// instead of parsing `message`; `None` on every other finding. + /// The expression, evaluation count, and reported span of a repeated value. pub repeated: Option, } diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index 88180cc0..1273fea3 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -88,13 +88,13 @@ UPDATE OPTIONS: #[derive(Debug, Subcommand)] pub(crate) enum Command { - /// Parse, lower, validate, and emit Workshop text. + /// Parse, lower, validate, lint, and emit Workshop text. Compile(CompileArgs), /// Reconstruct validated Workshop input as canonical OPY source; OSTW is /// recognized but unavailable because DEL/OSTW provider support is not /// currently shipped. Convert(ConvertArgs), - /// Check frontend, project, semantic, and validation correctness. + /// Check frontend, project, semantic, validation, and lint diagnostics. Check(ReportArgs), /// Summarize Workshop cost, complexity hotspots, risk indicators, and /// cross-cutting state. @@ -136,6 +136,8 @@ pub(crate) enum Command { pub(crate) struct CompileArgs { #[command(flatten)] pub(crate) common: CommonArgs, + #[command(flatten)] + pub(crate) policy: crate::serve::LintPolicyArgs, /// Write the program to PATH instead of `.ow`; `-` prints the /// program to stdout and nothing else. #[arg(short = 'o', long, value_name = "PATH")] @@ -190,9 +192,8 @@ 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, + #[command(flatten)] + pub(crate) policy: crate::serve::LintPolicyArgs, } /// Arguments of `analyze`: shared workflow options, finding selection, and diff --git a/crates/wright-cli/src/compile.rs b/crates/wright-cli/src/compile.rs new file mode 100644 index 00000000..c0bc3050 --- /dev/null +++ b/crates/wright-cli/src/compile.rs @@ -0,0 +1,137 @@ +use std::io::{IsTerminal, Write}; +use std::process::ExitCode; + +use wright_driver::result::exit; +use wright_driver::{CompileWrite, OutputDest, Overwrite}; + +use crate::cli::{self, OutputFormatArg}; +use crate::{config_from_common, present, run_configured}; + +pub(crate) fn run(args: &cli::CompileArgs) -> ExitCode { + let to_stdout = args.output.as_deref().is_some_and(|p| p.as_os_str() == "-"); + if to_stdout && args.common.format == OutputFormatArg::Json { + present::terminal::report_error( + present::Presentation::from_common(&args.common), + "USAGE", + "compile", + "-o - prints the program to stdout and cannot be combined with --format json", + ); + return ExitCode::from(exit::USAGE); + } + let dest = match &args.output { + _ if to_stdout => OutputDest::Stdout, + Some(path) => OutputDest::Path(path.clone()), + None => OutputDest::Default, + }; + let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + let copy = args.copy; + let force = args.force; + let mut compile_config = config_from_common(&args.common); + if let Err(message) = args.policy.apply_to(&mut compile_config) { + present::terminal::report_error( + present::Presentation::from_common(&args.common), + "USAGE", + "compile", + &message, + ); + return ExitCode::from(exit::USAGE); + } + compile_config.run = wright_driver::config::RunOptions { + no_cache: args.no_cache, + baseline: args.baseline.clone(), + save_baseline: args.save_baseline.clone(), + max_total: None, + }; + run_configured( + "compile", + compile_config, + present::Presentation::from_common(&args.common), + None, + move |session: &mut wright_driver::CompilerSession| { + let mut confirm = |path: &std::path::Path| { + confirm_overwrite( + path, + &mut std::io::stdin().lock(), + &mut std::io::stderr().lock(), + ) + }; + let overwrite = if force { + Overwrite::Force + } else if interactive { + Overwrite::Ask(&mut confirm) + } else { + Overwrite::Refuse + }; + let to = args.to.clone(); + let mut envelope = session.compile_to(CompileWrite { + dest, + overwrite, + to, + }); + let copied = envelope + .result + .output + .as_ref() + .filter(|_| copy && envelope.ok) + .map(|output| { + arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.set_text(&output.text)) + }); + if let Some(Err(error)) = copied { + envelope + .diagnostics + .push(wright_driver::Diagnostic::warning( + "clipboard-failed", + wright_driver::Stage::Emission, + format!("cannot copy to the clipboard: {error}"), + )); + } + envelope + }, + ) +} + +fn confirm_overwrite( + path: &std::path::Path, + input: &mut impl std::io::BufRead, + prompt: &mut impl Write, +) -> bool { + let _ = write!( + prompt, + "Overwrite {}? [y/N] ", + wright_driver::input::display_path(path) + ); + let _ = prompt.flush(); + let mut answer = String::new(); + input.read_line(&mut answer).is_ok() + && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overwrite_prompt_defaults_to_no_and_accepts_only_yes() { + for (answer, expected) in [ + ("y\n", true), + ("Yes\n", true), + ("\n", false), + ("n\n", false), + ("", false), + ("maybe\n", false), + ] { + let mut prompt = Vec::new(); + let confirmed = confirm_overwrite( + std::path::Path::new("mode.ow"), + &mut answer.as_bytes(), + &mut prompt, + ); + assert_eq!(confirmed, expected, "{answer:?}"); + assert_eq!( + String::from_utf8(prompt).unwrap(), + "Overwrite mode.ow? [y/N] " + ); + } + } +} diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 47e40e17..1d4708f0 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -1,6 +1,7 @@ mod agent; mod agenttools; mod cli; +mod compile; mod completion; mod essential; mod lint; @@ -13,8 +14,8 @@ mod tooldefs; mod update; mod usage_hint; +use std::io::Read; use std::io::Write; -use std::io::{IsTerminal, Read}; use std::process::ExitCode; use std::sync::Arc; @@ -23,7 +24,6 @@ use clap::{CommandFactory, Parser, error::ErrorKind}; use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind}; use wright_driver::result::exit; use wright_driver::source_provider::SourceBackend; -use wright_driver::{CompileWrite, OutputDest, Overwrite}; use crate::cli::{Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg}; @@ -160,7 +160,7 @@ fn main() -> ExitCode { fn run_workflow(command: Command) -> ExitCode { match command { - Command::Compile(args) => run_compile(&args), + Command::Compile(args) => compile::run(&args), Command::Convert(args) => { let target = match args.target { ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, @@ -177,7 +177,15 @@ 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; + if let Err(message) = args.policy.apply_to(&mut config) { + present::terminal::report_error( + present::Presentation::from_common(&args.common), + "USAGE", + "check", + &message, + ); + return ExitCode::from(exit::USAGE); + } run_configured( "check", config, @@ -368,97 +376,6 @@ pub(crate) fn run_configured< ExitCode::from(run_command(&mut session, run, presentation, subject)) } -fn run_compile(args: &cli::CompileArgs) -> ExitCode { - let to_stdout = args.output.as_deref().is_some_and(|p| p.as_os_str() == "-"); - if to_stdout && args.common.format == OutputFormatArg::Json { - present::terminal::report_error( - present::Presentation::from_common(&args.common), - "USAGE", - "compile", - "-o - prints the program to stdout and cannot be combined with --format json", - ); - return ExitCode::from(exit::USAGE); - } - let dest = match &args.output { - _ if to_stdout => OutputDest::Stdout, - Some(path) => OutputDest::Path(path.clone()), - None => OutputDest::Default, - }; - let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); - let copy = args.copy; - let force = args.force; - let mut compile_config = config_from_common(&args.common); - compile_config.run = wright_driver::config::RunOptions { - no_cache: args.no_cache, - baseline: args.baseline.clone(), - save_baseline: args.save_baseline.clone(), - max_total: None, - }; - run_configured( - "compile", - compile_config, - present::Presentation::from_common(&args.common), - None, - move |session: &mut wright_driver::CompilerSession| { - let mut confirm = |path: &std::path::Path| { - confirm_overwrite( - path, - &mut std::io::stdin().lock(), - &mut std::io::stderr().lock(), - ) - }; - let overwrite = if force { - Overwrite::Force - } else if interactive { - Overwrite::Ask(&mut confirm) - } else { - Overwrite::Refuse - }; - let to = args.to.clone(); - let mut envelope = session.compile_to(CompileWrite { - dest, - overwrite, - to, - }); - let copied = envelope - .result - .output - .as_ref() - .filter(|_| copy && envelope.ok) - .map(|output| { - arboard::Clipboard::new() - .and_then(|mut clipboard| clipboard.set_text(&output.text)) - }); - if let Some(Err(error)) = copied { - envelope - .diagnostics - .push(wright_driver::Diagnostic::warning( - "clipboard-failed", - wright_driver::Stage::Emission, - format!("cannot copy to the clipboard: {error}"), - )); - } - envelope - }, - ) -} - -fn confirm_overwrite( - path: &std::path::Path, - input: &mut impl std::io::BufRead, - prompt: &mut impl Write, -) -> bool { - let _ = write!( - prompt, - "Overwrite {}? [y/N] ", - wright_driver::input::display_path(path) - ); - let _ = prompt.flush(); - let mut answer = String::new(); - input.read_line(&mut answer).is_ok() - && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") -} - fn run_lookup(args: &cli::LookupArgs) -> ExitCode { let pres = present::Presentation::from_output(args.format, args.renderer, args.color); let config = SessionConfig { @@ -760,30 +677,6 @@ mod tests { } } - #[test] - fn overwrite_prompt_defaults_to_no_and_accepts_only_yes() { - for (answer, expected) in [ - ("y\n", true), - ("Yes\n", true), - ("\n", false), - ("n\n", false), - ("", false), - ("maybe\n", false), - ] { - let mut prompt = Vec::new(); - let confirmed = confirm_overwrite( - std::path::Path::new("mode.ow"), - &mut answer.as_bytes(), - &mut prompt, - ); - assert_eq!(confirmed, expected, "{answer:?}"); - assert_eq!( - String::from_utf8(prompt).unwrap(), - "Overwrite mode.ow? [y/N] " - ); - } - } - #[test] fn every_opy_workflow_selects_the_provider_backend() { let opy = common(Some("main.opy"), cli::SourceKindArg::Auto); diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 619785a3..ae6be5ab 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -474,7 +474,7 @@ fn check_over_malformed_input_exits_one_with_structured_diagnostics() { } #[test] -fn check_excludes_configurable_lint_findings() { +fn check_reports_configurable_lint_findings() { let path = temp_file("flow.txt", &corpus_workshop("synthetic/control-flow")); let output = run(&["check", path.to_str().unwrap(), "-f", "json"]); assert_eq!(output.status.code(), Some(0), "warnings do not fail check"); @@ -484,7 +484,7 @@ fn check_excludes_configurable_lint_findings() { .as_array() .unwrap() .iter() - .all(|diagnostic| diagnostic["code"] != "min-wait-loop") + .any(|diagnostic| diagnostic["code"] == "min-wait-loop") ); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -781,7 +781,7 @@ fn lint_rule_flags_control_findings() { #[test] fn lint_flags_are_usage_errors_for_other_commands() { for flags in [ - &["check", "--disable-rule", "min-wait-loop"][..], + &["convert", "--disable-rule", "min-wait-loop"][..], &["analyze", "--rule-severity", "min-wait-loop:info"][..], ] { let output = run(flags); @@ -1561,7 +1561,7 @@ fn check_tty_clears_progress_then_renders_verdict_and_elapsed() { transcript.extend_from_slice(&output.stderr); let transcript = String::from_utf8_lossy(&transcript); let verdict = transcript - .find(" PASS check") + .find(" WARN check") .expect("the final verdict renders"); assert!( transcript[..verdict].contains("\x1b[2K"), diff --git a/crates/wright-cli/tests/compile_check_lint.rs b/crates/wright-cli/tests/compile_check_lint.rs new file mode 100644 index 00000000..b864495c --- /dev/null +++ b/crates/wright-cli/tests/compile_check_lint.rs @@ -0,0 +1,102 @@ +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +const SOURCE: &str = r#"rule ("nested") { + event { Ongoing - Global; } + actions { + Set Global Variable(A, Filtered Array(All Players(All Teams), Is True For Any(Array(Current Array Element, True), Current Array Element))); + } +}"#; + +fn run(command: &str, args: &[&str], json: bool) -> Output { + use std::io::Write; + let mut child = Command::new(env!("CARGO_BIN_EXE_wright")) + .args([command, "-", "--kind", "workshop"]) + .args(args) + .args(if json { + vec!["-f", "json"] + } else { + vec!["--renderer", "plain", "--color", "never"] + }) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(SOURCE.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn envelope(command: &str, args: &[&str]) -> Value { + let output = run(command, args, true); + let value: Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "{command}: {error}: {}", + String::from_utf8_lossy(&output.stderr) + ) + }); + assert_eq!( + output.status.code().unwrap(), + value["exit"].as_i64().unwrap() as i32 + ); + value +} + +#[test] +fn check_and_compile_carry_the_same_lint_diagnostics_and_verdict() { + for args in [ + vec![], + vec!["--rule-severity", "nested-binder-lambda:error"], + vec!["--rule-severity", "nested-binder-lambda:off"], + vec!["--disable-rule", "nested-binder-lambda"], + vec!["--fail-on", "warning"], + ] { + let lint = envelope("lint", &args); + let findings = lint["result"]["findings"].as_array().unwrap(); + for command in ["check", "compile"] { + let dir = + std::env::temp_dir().join(format!("wright-compile-lint-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let output_path = dir.join("output.ow"); + let mut flags = args.clone(); + if command == "compile" { + flags.extend(["-o", output_path.to_str().unwrap(), "--force", "--no-cache"]); + } + let previous_output = std::fs::read(&output_path).ok(); + let result = envelope(command, &flags); + assert_eq!(result["ok"], lint["ok"], "{command}: {args:?}: {result}"); + assert_eq!(result["exit"], lint["exit"], "{command}: {args:?}"); + let diagnostics = result["diagnostics"].as_array().unwrap(); + assert_eq!(diagnostics.len(), findings.len(), "{command}: {result}"); + for (diagnostic, finding) in diagnostics.iter().zip(findings) { + for field in ["code", "severity", "message", "evidence", "boundedness"] { + assert_eq!(diagnostic[field], finding[field], "{command}: {field}"); + } + assert_eq!(diagnostic["stage"], "analysis"); + } + if command == "compile" && args.contains(&"nested-binder-lambda:error") { + assert!(result["result"]["output"].is_null(), "{result}"); + assert_eq!(std::fs::read(&output_path).ok(), previous_output); + } + } + } +} + +#[test] +fn check_summary_counts_the_default_lint_warning() { + let output = run("check", &[], false); + assert!(output.status.success()); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(text.contains("nested-binder-lambda"), "{text}"); + assert!(text.contains("warnings=1"), "{text}"); +} diff --git a/crates/wright-cli/tests/compile_check_lint_policy.rs b/crates/wright-cli/tests/compile_check_lint_policy.rs new file mode 100644 index 00000000..3795a4a1 --- /dev/null +++ b/crates/wright-cli/tests/compile_check_lint_policy.rs @@ -0,0 +1,105 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; + +const SOURCE: &str = r#"rule("nested") { + event { Ongoing - Global; } + actions { + Set Global Variable(A, Filtered Array(All Players(All Teams), Is True For Any(Array(Current Array Element, True), Current Array Element))); + } +}"#; +const CODE: &str = "nested-binder-lambda"; + +fn input(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("wright-lint-policy-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("nested.ws"); + std::fs::write(&input, SOURCE).unwrap(); + input +} + +fn run(command: &str, input: &Path, flags: &[&str]) -> Output { + let mut process = Command::new(env!("CARGO_BIN_EXE_wright")); + process + .arg(command) + .arg(input) + .args(flags) + .args(["-f", "json"]); + if command == "compile" { + process.args(["--no-cache", "--force", "-o"]); + process.arg(input.with_extension("ow")); + } + process.output().unwrap() +} + +fn json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| panic!("{error}: {output:?}")) +} + +#[test] +fn project_lint_policy_and_cli_overrides_match_lint() { + let input = input("project"); + let config = input.with_extension("yaml"); + std::fs::write( + &config, + format!("rules:\n '{CODE}':\n severity: error\n"), + ) + .unwrap(); + for overrides in [vec![], vec!["--disable-rule", CODE]] { + let mut flags = vec!["--lint-config", config.to_str().unwrap()]; + flags.extend(overrides); + let lint = run("lint", &input, &flags); + let reference = json(&lint); + for command in ["compile", "check"] { + let output = run(command, &input, &flags); + assert_eq!(output.status.code(), lint.status.code(), "{output:?}"); + let result = json(&output); + assert_eq!(result["ok"], reference["ok"]); + let findings = reference["result"]["findings"].as_array().unwrap(); + let diagnostics = result["diagnostics"].as_array().unwrap(); + assert_eq!(findings.len(), diagnostics.len(), "{result}"); + for (finding, diagnostic) in findings.iter().zip(diagnostics) { + for field in ["code", "severity", "evidence"] { + assert_eq!(diagnostic[field], finding[field], "{command}: {field}"); + } + let span = &diagnostic["span"]; + assert_eq!( + finding["location"], + format!( + "{}:{}:{}", + span["path"].as_str().unwrap(), + span["start"]["line"], + span["start"]["col"] + ) + ); + } + } + } + for command in ["compile", "check"] { + let output = run(command, &input, &["--disable-rule", "unknown-lint-rule"]); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unknown rule id 'unknown-lint-rule'") + ); + } +} + +#[test] +fn lint_errors_preserve_compile_destination_and_check_verdict_before_selection() { + let input = input("errors"); + let dest = input.with_extension("ow"); + std::fs::write(&dest, "existing output").unwrap(); + let policy = format!("{CODE}:error"); + let output = run("compile", &input, &["--rule-severity", &policy]); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(json(&output)["result"]["output"].is_null()); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "existing output"); + let output = run("check", &input, &["--rule-severity", &policy, "--max", "0"]); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let result = json(&output); + assert_eq!(result["diagnostics"], serde_json::json!([])); + assert_eq!(result["selection"]["withheld"], 1); +} diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index b55e7669..1a7d0ae3 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -557,10 +557,7 @@ impl CompilerSession { }) } - /// Compile and deliver the program as `write` directs (ADR-0025). A - /// failed compile or a refused destination writes nothing. `write.to` - /// selects the output locale (#759); `None` emits the loaded input's - /// locale. + /// Compile to the destination and locale in `write`, refusing output on failure. pub fn compile_to(&mut self, write: CompileWrite<'_>) -> Envelope { let mut result = CompileResult::default(); let (output, entry) = match self.compile_output(write.to.as_deref()) { @@ -671,6 +668,7 @@ impl CompilerSession { None => None, }; let loaded = self.load_with_operation(ProviderOperation::Compile)?; + self.attach_lint_diagnostics(&loaded); let locale = named.unwrap_or_else(|| Self::locale_for(&loaded)); self.progress(ProgressEvent::new(ProgressPhase::Emission)); let text = hotpath::measure_block!("compile::emit", { @@ -697,6 +695,7 @@ impl CompilerSession { |session, loaded| { session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); session.attach_workshop_completeness(&loaded); + session.attach_lint_diagnostics(&loaded); CheckResult { program: Some(semantic::check_program_summary(&loaded)), next: crate::result::next::list(crate::result::next::CHECK), diff --git a/crates/wright-driver/src/session/lint.rs b/crates/wright-driver/src/session/lint.rs index b11bd51f..776f04a3 100644 --- a/crates/wright-driver/src/session/lint.rs +++ b/crates/wright-driver/src/session/lint.rs @@ -14,6 +14,37 @@ use crate::progress::{ProgressEvent, ProgressPhase, ProgressUnit}; use crate::result::{Envelope, LintResult}; impl CompilerSession { + pub(super) fn attach_lint_diagnostics(&mut self, loaded: &Loaded) { + self.progress(ProgressEvent::new(ProgressPhase::Linting)); + let findings = self.lint_registry.run(&loaded.program, &self.config.lint); + for finding in findings { + let mut diagnostic = crate::Diagnostic::carried_error( + &finding.code, + crate::Stage::Analysis, + &finding.message, + ); + diagnostic.severity = finding.severity; + diagnostic.span = finding.span.map(|span| { + crate::diag::source_span( + span, + super::semantic::span_path(Some(span.file.index() as u64), loaded), + ) + }); + diagnostic.source = Some(super::loaded_origin(loaded)); + diagnostic.fields.insert( + "evidence".into(), + serde_json::json!(finding.evidence.as_str()), + ); + if let Some(boundedness) = finding.boundedness { + diagnostic.fields.insert( + "boundedness".into(), + serde_json::json!(boundedness.as_str()), + ); + } + self.diagnostics.push(diagnostic); + } + } + /// `lint` over an already-served snapshot (#1002): the same /// pipeline [`Self::lint`] runs — the service's snapshot is the /// loaded input, so the `lint` operation and `wright lint` share one diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 390c94b1..c17fb385 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -31,9 +31,9 @@ result. | Command | Purpose | Text-mode stdout | | --- | --- | --- | -| `wright compile [INPUT] [--to LOCALE]` | Parse, lower, validate, emit Workshop text; warn on known client import limits | verdict, diagnostics, the written path, and the output SHA-256; the program goes to `.ow` (or `-o PATH`; `-o -` prints only the program); `--to` emits in another Workshop client locale | +| `wright compile [INPUT] [--to LOCALE]` | Parse, lower, validate, lint, emit Workshop text; warn on known client import limits | verdict, diagnostics, the written path, and the output SHA-256; the program goes to `.ow` (or `-o PATH`; `-o -` prints only the program); `--to` emits in another Workshop client locale | | `wright convert [INPUT] --target opy\|ostw` | Reconstruct validated Workshop input as canonical OPY or OSTW source through a provider that negotiates `reconstruct` (the shipped OPY provider does since 0.3.19; no OSTW provider is shipped) | the reconstructed source | -| `wright check [INPUT]` | Parse, lower, validate, and report correctness diagnostics | verdict and validation diagnostics | +| `wright check [INPUT]` | Parse, lower, validate, and run enabled lint rules | verdict and validation/lint diagnostics | | `wright analyze [INPUT] [--all]` | Summarize Workshop cost, ranked complexity hotspots, performance/stability risk indicators, and cross-cutting state | overview, cost, top hotspots, complexity, risks, and top variables with exact/static/heuristic evidence labels; `--all` lifts list caps | | `wright lint [INPUT] [--all] [--fix [--write]]` | Parse, lower, lint; report findings | finding counts by severity and actionable findings; `--all` lifts list caps; `--fix` previews each offered fix's validated diff, `--write` applies them | | `wright lint package ` | Discover, install, and manage GitHub lint rule packages in the Wright-owned store keyed by commit SHA (#844); takes no `INPUT` and never activates a package | per-package rows: source, status, SHA, entry directory, rule ids, and error detail ([lint configuration and findings](lint.md#lint-rule-packages-wright-lint-package-844)) | @@ -83,6 +83,20 @@ an upstream/static fallback. DEL/OSTW provider support is not currently shipped with Wright and is outside this contract. OPY, Workshop, and protocol inputs continue through their existing owner-backed paths. +### `wright check` and `wright compile` lint diagnostics + +Both commands run the default-enabled lint rules and include their findings in +`diagnostics` and the normal severity counts. Codes, severities, source spans, +and evidence classes match `wright lint`; JSON uses the existing diagnostic +shape, with `stage: "analysis"` and an `evidence` field. Static indicators +retain their evidence class and do not guarantee client importability. + +Both commands accept the same `--lint-config PATH`, `--rule-file PATH`, +`--disable-rule ID`, and `--rule-severity ID:SEVERITY` policy options as +`lint`. The lint configuration file applies first, then flag overrides. +Warnings keep exit 0 unless `--fail-on` gates them. A finding raised to `error` +exits 1; `compile` writes no artifact for an error-level finding. + ### `wright compile` output (ADR-0025) `wright compile` writes the program to a file and reports only status and diff --git a/docs/cli/lint.md b/docs/cli/lint.md index ce3c7f4f..09122be3 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -33,7 +33,9 @@ The following lint-policy flags configure the registry and are repeatable: `nested-binder-lambda`). * `--rule-severity :`: override a rule's project policy. -These flags are usage errors on every other workflow command (exit 2). +`compile` and `check` accept these four flags too; their enabled lint findings +appear in normal diagnostics. These flags are usage errors on the other +workflow commands (exit 2). `wright serve` accepts the same four flags at startup (#594): they configure the served session's lint policy identically, so `lint`/`lintRules` over stdio, JSON-RPC, or MCP report the same effective enablement, severity, @@ -152,10 +154,12 @@ documentation, known limits, evidence class, tags) is served once by the The core workflows have separate contracts: -* `check` is the correctness gate. It reports discovery, source implementation, project, - semantic, lowering, and validation diagnostics. Ordinary configurable lint - findings such as `duplicate-condition` and `min-wait-loop` are not emitted - by default. +* `check` reports discovery, source implementation, project, semantic, lowering, + and validation diagnostics together with the enabled lint findings. + `compile` also reports enabled lint findings before writing its artifact. + Both preserve the lint codes, severities, source spans, and evidence classes + in their diagnostics and accept the same policy flags; see + [command behavior](commands.md#wright-check-and-wright-compile-lint-diagnostics). * `lint` executes the configurable `LintRegistry` and returns stable rule IDs, severity, evidence class, boundedness where applicable, source spans, a per-rule id/effective-severity list, and effective configuration.