From 809c7df70236a9c5632fc045b51035e14c3f07f7 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:00:16 +0800 Subject: [PATCH 1/2] fix: include lint findings in compile and check diagnostics Run the configured lint registry for compile and check, preserving finding codes, severities, source locations, evidence classes, and exit behavior. Reuse the lint policy flags and prevent compile writes for error findings. Document the diagnostic contract and cover default warnings, JSON findings, project policy, CLI overrides, and errors before output or selection. Fixes #1032 --- crates/wright-cli/src/cli.rs | 11 +- crates/wright-cli/src/compile.rs | 137 ++++++++++++++++++ crates/wright-cli/src/main.rs | 131 ++--------------- crates/wright-cli/tests/cli.rs | 8 +- crates/wright-cli/tests/compile_check_lint.rs | 102 +++++++++++++ .../tests/compile_check_lint_policy.rs | 105 ++++++++++++++ ...enders_annotation_group_and_summary-2.snap | 5 +- ..._renders_annotation_group_and_summary.snap | 5 +- ...eck_fail_reports_diagnostics_and_next.snap | 3 +- .../tests/snapshots/terminal__compile.snap | 7 +- crates/wright-cli/tests/terminal.rs | 8 +- crates/wright-driver/src/session.rs | 7 +- crates/wright-driver/src/session/lint.rs | 31 ++++ docs/cli/commands.md | 18 ++- docs/cli/lint.md | 14 +- 15 files changed, 443 insertions(+), 149 deletions(-) create mode 100644 crates/wright-cli/src/compile.rs create mode 100644 crates/wright-cli/tests/compile_check_lint.rs create mode 100644 crates/wright-cli/tests/compile_check_lint_policy.rs 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-cli/tests/snapshots/github__check_renders_annotation_group_and_summary-2.snap b/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary-2.snap index b45ade03..39875c9d 100644 --- a/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary-2.snap +++ b/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary-2.snap @@ -1,18 +1,19 @@ --- source: crates/wright-cli/tests/github.rs -assertion_line: 105 expression: "std::fs::read_to_string(&summary).unwrap()" --- ## Wright `check .` — **ERROR** (exit 1) - `workshop.unknown-value.futurevaluething` `mixed.ws:11:26` — Workshop construct 'FutureValueThing' is unsupported (truly-unresolved-identifier) +- `while-without-wait` `mixed.ws:12:3` — loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)
Full report ```text ERROR check root=. kind=workshop locale=en-us -summary: errors=1 warnings=0 notes=0 affected=1 +summary: errors=1 warnings=1 notes=0 affected=1 diagnostic: error workshop.unknown-value.futurevaluething at=mixed.ws:11:26 stage=analysis status=unsupported message="Workshop construct 'FutureValueThing' is unsupported (truly-unresolved-identifier)" +diagnostic: warning while-without-wait at=mixed.ws:12:3 stage=analysis message="loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)" next: wright lint next: wright analyze next: wright inspect diff --git a/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary.snap b/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary.snap index 17f6c300..b8405bc8 100644 --- a/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary.snap +++ b/crates/wright-cli/tests/snapshots/github__check_renders_annotation_group_and_summary.snap @@ -1,6 +1,5 @@ --- source: crates/wright-cli/tests/github.rs -assertion_line: 104 expression: transcript(&output) --- exit 1 @@ -8,10 +7,12 @@ exit 1 == stderr ::error file=mixed.ws,title=workshop.unknown-value.futurevaluething,line=11,col=26,endLine=11,endColumn=42::Workshop construct 'FutureValueThing' is unsupported (truly-unresolved-identifier) +::warning file=mixed.ws,title=while-without-wait,line=12,col=3,endLine=14,endColumn=6::loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable) ::group::wright check — ERROR ERROR check root=. kind=workshop locale=en-us -summary: errors=1 warnings=0 notes=0 affected=1 +summary: errors=1 warnings=1 notes=0 affected=1 diagnostic: error workshop.unknown-value.futurevaluething at=mixed.ws:11:26 stage=analysis status=unsupported message="Workshop construct 'FutureValueThing' is unsupported (truly-unresolved-identifier)" +diagnostic: warning while-without-wait at=mixed.ws:12:3 stage=analysis message="loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)" next: wright lint next: wright analyze next: wright inspect diff --git a/crates/wright-cli/tests/snapshots/plain__check_fail_reports_diagnostics_and_next.snap b/crates/wright-cli/tests/snapshots/plain__check_fail_reports_diagnostics_and_next.snap index 2d37ab5b..6625e82f 100644 --- a/crates/wright-cli/tests/snapshots/plain__check_fail_reports_diagnostics_and_next.snap +++ b/crates/wright-cli/tests/snapshots/plain__check_fail_reports_diagnostics_and_next.snap @@ -5,8 +5,9 @@ expression: "transcript(&run_in(&dir, &[\"check\", \"mixed.ws\"]))" exit 1 == stdout ERROR check root=. kind=workshop locale=en-us -summary: errors=1 warnings=0 notes=0 affected=1 +summary: errors=1 warnings=1 notes=0 affected=1 diagnostic: error workshop.unknown-value.futurevaluething at=mixed.ws:11:26 stage=analysis status=unsupported message="Workshop construct 'FutureValueThing' is unsupported (truly-unresolved-identifier)" +diagnostic: warning while-without-wait at=mixed.ws:12:3 stage=analysis message="loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)" next: wright lint next: wright analyze next: wright inspect diff --git a/crates/wright-cli/tests/snapshots/terminal__compile.snap b/crates/wright-cli/tests/snapshots/terminal__compile.snap index 76dae81f..af81a48d 100644 --- a/crates/wright-cli/tests/snapshots/terminal__compile.snap +++ b/crates/wright-cli/tests/snapshots/terminal__compile.snap @@ -4,6 +4,11 @@ expression: transcript(&out) --- exit: 0 --- stdout --- - PASS compile + WARN compile wrote out.ow (sha256 3b0737bb0468d36746566bc7cd7885f62ea421c335210e67801446566254e330) --- stderr --- +warning[min-wait-loop]: loop body waits at the workshop minimum rate; the loop runs at maximum frequency + --> flow.ws:11:9 + | 11 | While(Compare(Global.index, <, 3)); + | | ^ + = analysis diff --git a/crates/wright-cli/tests/terminal.rs b/crates/wright-cli/tests/terminal.rs index ab1be862..1d252173 100644 --- a/crates/wright-cli/tests/terminal.rs +++ b/crates/wright-cli/tests/terminal.rs @@ -352,11 +352,11 @@ fn verdict_exit_and_counts_match_plain_and_json() { let cases = [ Case { args: &["check", "flow.ws"], - tag_line: " PASS check", - plain_word: "PASS check", + tag_line: " WARN check", + plain_word: "WARN check", exit: 0, - count_line: "0 diagnostics", - reported: 0, + count_line: "1 warning", + reported: 1, }, Case { args: &["lint", "flow.ws"], 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. From 50591001236556fc8a3ebf51db78e07cada433ce Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:48:59 +0800 Subject: [PATCH 2/2] fix: align compile and check regression gates with lint diagnostics --- .github/workflows/ci.yml | 17 +++++-- crates/wright-driver/tests/service.rs | 36 +-------------- crates/wright-driver/tests/service/compile.rs | 46 +++++++++++++++++++ 3 files changed, 60 insertions(+), 39 deletions(-) create mode 100644 crates/wright-driver/tests/service/compile.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b7d78c5..be471241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,11 +192,12 @@ jobs: run_workflow() { local command="$1" + shift local output local status set +e - output="$(target/debug/wright "$command" --kind workshop "$PROJECT" --format json)" + output="$(target/debug/wright "$command" "$@" --kind workshop "$PROJECT" --format json)" status=$? set -e @@ -220,16 +221,22 @@ jobs: printf '%s' "$output" } - check_output="$(run_workflow check)" - lint_output="$(run_workflow lint)" + check_output="$(run_workflow check --all)" + lint_output="$(run_workflow lint --all)" inspect_output="$(run_workflow inspect)" jq -e -n \ --argjson check "$check_output" \ --argjson lint "$lint_output" \ --argjson inspect "$inspect_output" \ --argjson expected_exit "$EXPECTED_EXIT" \ - '( - $check.diagnostics == $lint.diagnostics and + 'def diagnostic_contract: + map({code, severity, evidence, + location: (.location // (if .span then + "\(.span.path):\(.span.start.line):\(.span.start.col)" + else null end))}) | sort; + ( + ($check.diagnostics | diagnostic_contract) == + (($lint.diagnostics + ($lint.result.findings // [])) | diagnostic_contract) and $check.ok == $lint.ok and $check.exit == $lint.exit and (if $expected_exit == 0 diff --git a/crates/wright-driver/tests/service.rs b/crates/wright-driver/tests/service.rs index e8101add..4473daa8 100644 --- a/crates/wright-driver/tests/service.rs +++ b/crates/wright-driver/tests/service.rs @@ -3595,37 +3595,5 @@ fn short_circuit_folds_preserve_occurrences_for_references() { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } -#[test] -fn agent_compile_writes_a_file_and_returns_no_program_text() { - let out = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("agent-compile.ow"); - let _ = std::fs::remove_file(&out); - let mut session = CompilerSession::new(SessionConfig { - input: InputSpec::Path(workshop_path()), - kind: SourceKind::Workshop, - ..SessionConfig::default() - }) - .unwrap(); - let mut service = ToolService::new(&mut session).unwrap(); - let request = |force| ToolRequest::Compile { - output: Some(out.clone()), - force, - to: None, - }; - - let first = result_of(&mut service, &request(false)); - assert_eq!(first["ok"], true, "{first}"); - let output = &first["result"]["output"]; - assert_eq!(output["written_to"], out.to_str().unwrap()); - assert!(output.get("text").is_none(), "{output}"); - let written = std::fs::read_to_string(&out).unwrap(); - assert_eq!( - output["sha256"], - wright_driver::sha256_hex(written.as_bytes()) - ); - - let again = result_of(&mut service, &request(false)); - assert_eq!(again["exit"], 2, "{again}"); - assert_eq!(again["diagnostics"][0]["code"], "output-exists"); - assert!(result_of(&mut service, &request(true))["ok"] == true); - let _ = std::fs::remove_file(&out); -} +#[path = "service/compile.rs"] +mod compile; diff --git a/crates/wright-driver/tests/service/compile.rs b/crates/wright-driver/tests/service/compile.rs new file mode 100644 index 00000000..792584e4 --- /dev/null +++ b/crates/wright-driver/tests/service/compile.rs @@ -0,0 +1,46 @@ +use super::{ + CompilerSession, InputSpec, SessionConfig, SourceKind, ToolRequest, ToolService, result_of, + workshop_path, +}; + +#[test] +fn agent_compile_writes_a_file_and_returns_no_program_text() { + let out = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("agent-compile.ow"); + let _ = std::fs::remove_file(&out); + let mut session = CompilerSession::new(SessionConfig { + input: InputSpec::Path(workshop_path()), + kind: SourceKind::Workshop, + ..SessionConfig::default() + }) + .unwrap(); + let mut service = ToolService::new(&mut session).unwrap(); + let request = |force| ToolRequest::Compile { + output: Some(out.clone()), + force, + to: None, + }; + + let first = result_of(&mut service, &request(false)); + assert_eq!(first["ok"], true, "{first}"); + let output = &first["result"]["output"]; + assert_eq!(output["written_to"], out.to_str().unwrap()); + assert!(output.get("text").is_none(), "{output}"); + let written = std::fs::read_to_string(&out).unwrap(); + assert_eq!( + output["sha256"], + wright_driver::sha256_hex(written.as_bytes()) + ); + + let again = result_of(&mut service, &request(false)); + assert_eq!(again["exit"], 2, "{again}"); + assert!( + again["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "output-exists"), + "{again}" + ); + assert!(result_of(&mut service, &request(true))["ok"] == true); + let _ = std::fs::remove_file(&out); +}