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-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/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);
+}
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.