From b2718533f9558a45315eaa806389909673491f2d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:34:33 +0800 Subject: [PATCH 1/3] fix(lint): diagnose nested current array element scope Refine the existing nested-binder-lambda warning to require an element or index read in the nested iterator's source or per-element arguments. Keep reads confined to deeper iterator bodies from creating redundant warnings, and explain the unconfirmed client import risk in diagnostics and docs. Cover all iterator pairs, source-only reads, deeper scopes, CLI locations, severity overrides, and rule disablement without changing OPY lowering. Fixes #1032 --- .../wright-analyzer/src/canonical/analysis.rs | 44 ++++++------ crates/wright-analyzer/src/registry.rs | 15 ++-- .../tests/nested_array_scopes.rs | 69 ++++++++++++++++++ .../wright-cli/tests/nested_array_scopes.rs | 72 +++++++++++++++++++ docs/cli/lint.md | 13 +++- 5 files changed, 183 insertions(+), 30 deletions(-) create mode 100644 crates/wright-analyzer/tests/nested_array_scopes.rs create mode 100644 crates/wright-cli/tests/nested_array_scopes.rs diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index cda80c08..7b911e32 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -380,17 +380,7 @@ const ELEMENT_BINDER_VALUES: &[&str] = &[ "sortedArray", ]; -/// `nested-binder-lambda` (#713): report each binder call that occurs -/// inside an enclosing binder's per-element argument — any argument after -/// the array source at index 0. The inner call rebinds `Current Array -/// Element`/`Current Array Index` inside its own per-element argument, -/// shadowing the enclosing element, and the shape is the one structural -/// signature measured so far that separates client-rejected Workshop -/// builds from accepted ones: the `OWBastion/Bastion#391`-era build and the -/// import-limits verbatim reproducer carry it (1 and 16 occurrences), the -/// measured accepted builds carry none (`workshop-rs` -/// `docs/import-limits.md`). Correlation, not a confirmed client limit — -/// the finding's evidence class says so. +// Client-rejected nested-binding evidence: wrightkit/wright#713 and #1032. fn nested_binder_findings( program: &Program, rule_id: RuleId, @@ -432,10 +422,10 @@ fn nested_binder_findings( code: NESTED_BINDER_LAMBDA.id.into(), severity: NESTED_BINDER_LAMBDA.default_severity, message: format!( - "`{inner}` nested in `{outer}`'s per-element argument binds \ - `Current Array Element` to the inner array, shadowing the \ - outer element; nested-binder shapes appear in Workshop \ - builds the game client rejected on import" + "`{inner}` nested in `{outer}`'s per-element argument reads \ + `Current Array Element`/`Current Array Index` across nested \ + binding scopes; this shape may be rejected by the Workshop \ + client on import (the exact client limit is unconfirmed)" ), span, rule: rule_id, @@ -456,8 +446,10 @@ fn collect_nested_binders<'a>( out: &mut Vec<(Vec, &'a str, &'a Value, &'a str)>, ) { let binder = match value { - Value::Call { name, .. } if ELEMENT_BINDER_VALUES.contains(&name.as_str()) => { - if let Some(outer) = enclosing { + Value::Call { name, args } if ELEMENT_BINDER_VALUES.contains(&name.as_str()) => { + if let Some(outer) = enclosing + && args.iter().any(reads_array_binding) + { out.push((path.clone(), name.as_str(), value, outer)); } Some(name.as_str()) @@ -468,11 +460,7 @@ fn collect_nested_binders<'a>( path.push(index); collect_nested_binders( child, - if index >= 1 { - binder.or(enclosing) - } else { - enclosing - }, + binder.filter(|_| index >= 1).or(enclosing), path, out, ); @@ -480,6 +468,18 @@ fn collect_nested_binders<'a>( } } +fn reads_array_binding(value: &Value) -> bool { + if let Value::Call { name, args } = value { + if matches!(name.as_str(), "currentArrayElement" | "currentArrayIndex") { + return true; + } + if ELEMENT_BINDER_VALUES.contains(&name.as_str()) { + return args.first().is_some_and(reads_array_binding); + } + } + value_children(value).into_iter().any(reads_array_binding) +} + fn duplicate_condition_findings( program: &Program, rule_id: RuleId, diff --git a/crates/wright-analyzer/src/registry.rs b/crates/wright-analyzer/src/registry.rs index dbc119cc..e3cf5a80 100644 --- a/crates/wright-analyzer/src/registry.rs +++ b/crates/wright-analyzer/src/registry.rs @@ -258,17 +258,18 @@ pub(crate) const NESTED_BINDER_LAMBDA: RuleMeta = RuleMeta { id: "nested-binder-lambda", default_severity: Severity::Warning, evidence: EvidenceClass::StaticIndicator, - summary: "element-binding array call nested inside another binder's per-element argument", + summary: "nested element-binding array call reads the current array element or index", rationale: "Surface a Workshop shape observed only in client-rejected builds.", documentation: concat!( "The element-binding values — `filteredArray`, `sortedArray`, ", "`mappedArray`, `isTrueForAll`, `isTrueForAny` — evaluate every ", "argument after the first once per array element, binding ", "`Current Array Element`/`Current Array Index` to that element. ", - "A binder inside that per-element argument creates a nested ", - "binding scope: inside the inner call's own per-element ", - "argument the element values denote the inner array and the ", - "outer element is shadowed. The shape compiles and is ", + "A binder inside that per-element argument that reads either binding ", + "in its array source or its own per-element arguments creates a nested ", + "binding risk. The array source can still read the outer element, ", + "while the inner per-element arguments bind the inner element. ", + "The shape compiles and is ", "semantically defined, but it is the one structural signature ", "measured so far that separates client-rejected Workshop builds ", "from accepted ones: the `OWBastion/Bastion#391`-era build and ", @@ -286,7 +287,9 @@ pub(crate) const NESTED_BINDER_LAMBDA: RuleMeta = RuleMeta { "`Filtered Array(Sorted Array(x, r), c)`, creates no nested ", "scope and is not reported. The rule names a suspect shape, not ", "a measured client boundary: no confirmed import depth or ", - "nesting limit exists yet in workshop-rs.", + "nesting limit exists yet in workshop-rs. Calls that read neither ", + "binding outside a deeper binder's per-element arguments are not ", + "reported. This does not prove that other nested calls import safely.", ), tags: &["correctness", "stability"], }; diff --git a/crates/wright-analyzer/tests/nested_array_scopes.rs b/crates/wright-analyzer/tests/nested_array_scopes.rs new file mode 100644 index 00000000..4dbd500a --- /dev/null +++ b/crates/wright-analyzer/tests/nested_array_scopes.rs @@ -0,0 +1,69 @@ +use workshop_rs::{Event, Program, Rule, Value}; +use wright_analyzer::canonical::analyze; +use wright_analyzer::registry::LintConfig; + +#[test] +fn nested_binders_require_a_read_of_their_array_binding() { + let binders = [ + "filteredArray", + "sortedArray", + "mappedArray", + "isTrueForAny", + "isTrueForAll", + ]; + for outer in binders { + for inner in binders { + for (source, body, expected) in [ + (Value::Array(vec![]), Value::Bool(true), None), + ( + Value::Array(vec![]), + Value::call("currentArrayElement", []), + Some(inner), + ), + ( + Value::Array(vec![]), + Value::call("currentArrayIndex", []), + Some(inner), + ), + ( + Value::Array(vec![Value::call("currentArrayElement", [])]), + Value::Bool(true), + Some(inner), + ), + ( + Value::Array(vec![]), + Value::call( + "isTrueForAny", + [Value::Array(vec![]), Value::call("currentArrayElement", [])], + ), + Some("isTrueForAny"), + ), + ] { + let mut program = Program::new(); + program.rule(Rule::new("nested binding reads", Event::Global).condition( + Value::call( + outer, + [Value::Array(vec![]), Value::call(inner, [source, body])], + ), + )); + let findings = analyze(&program, &LintConfig::default()); + let nested: Vec<_> = findings + .iter() + .filter(|finding| finding.code == "nested-binder-lambda") + .collect(); + assert_eq!( + nested.len(), + usize::from(expected.is_some()), + "{outer}/{inner}: {nested:?}" + ); + if let Some(reported_inner) = expected { + assert!( + nested[0] + .message + .starts_with(&format!("`{reported_inner}` nested")) + ); + } + } + } + } +} diff --git a/crates/wright-cli/tests/nested_array_scopes.rs b/crates/wright-cli/tests/nested_array_scopes.rs new file mode 100644 index 00000000..c0d7bebb --- /dev/null +++ b/crates/wright-cli/tests/nested_array_scopes.rs @@ -0,0 +1,72 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +#[test] +fn lint_reports_nested_current_array_element_import_risk() { + let source = r#" +variables { + global: + 0: pool +} +rule ("nested any in filter") { + event { + Ongoing - Global; + } + actions { + Set Global Variable(pool, Filtered Array(All Players(Team 1), And(Is Alive(Current Array Element), Is True For Any(Array(Compare(Hero Of(Current Array Element), ==, Hero(Ana)), Is Alive(Current Array Element)), Current Array Element)))); + Set Global Variable(pool, Is True For Any(Array(Is Alive(Event Player)), Current Array Element)); + Set Global Variable(pool, Filtered Array(Sorted Array(All Players(Team 1), Current Array Element), Is Alive(Current Array Element))); + Set Global Variable(pool, Filtered Array(All Players(Team 1), Is True For Any(Array(True), True))); + } +} +"#; + for (flags, exit, severity) in [ + (vec![], 0, Some("warning")), + ( + vec!["--rule-severity", "nested-binder-lambda:error"], + 1, + Some("error"), + ), + (vec!["--disable-rule", "nested-binder-lambda"], 0, None), + ] { + let mut child = Command::new(env!("CARGO_BIN_EXE_wright")) + .args(["lint", "-", "-f", "json"]) + .args(flags) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(source.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(exit), "{output:?}"); + let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let findings: Vec<_> = envelope["result"]["findings"] + .as_array() + .unwrap() + .iter() + .filter(|finding| finding["code"] == "nested-binder-lambda") + .collect(); + assert_eq!(findings.len(), usize::from(severity.is_some())); + if let Some(severity) = severity { + assert_eq!(findings[0]["severity"], severity); + assert_eq!(findings[0]["evidence"], "static-indicator"); + let message = findings[0]["message"].as_str().unwrap(); + assert!(message.contains("isTrueForAny")); + assert!(message.contains("filteredArray")); + assert!(message.contains("Current Array Element")); + assert!(message.contains("unconfirmed")); + assert!( + findings[0]["location"] + .as_str() + .unwrap() + .starts_with(":11:") + ); + } + } +} diff --git a/docs/cli/lint.md b/docs/cli/lint.md index ce3c7f4f..e4765ff1 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -93,8 +93,17 @@ scopes. The binder values (`filteredArray`, `sortedArray`, `mappedArray`, `isTrueForAll`, `isTrueForAny`) evaluate every argument after the first per array element, binding `Current Array Element`/`Current Array Index`; a binder nested inside that per-element argument shadows the outer element -inside the inner binder's own argument. The shape is semantically valid and -may be intentional, but it is the structural signature measured on +inside the inner binder's own argument. The warning requires a read of +`Current Array Element` or `Current Array Index` in the inner call's array +source or its own per-element arguments. In particular, an `any([...])` or +`all([...])` inside an OPY `filter(lambda p: ...)` can build its input array +from the outer element, then rebind the element in its predicate. Calls +without either read, reads confined to a deeper binder's per-element +arguments, and binders chained through array-source arguments do not trigger +this rule. Silence does not establish client importability. + +The shape is semantically valid and may be intentional, but it is the +structural signature measured on client-rejected Workshop builds and absent from measured accepted ones (`workshop-rs` `docs/import-limits.md`; #713). It is a warning, not a confirmed client import boundary. From cbb50f392b5dc266a8d12c048e8c20b0267c3372 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:43:07 +0800 Subject: [PATCH 2/3] revert: withdraw nested iterator rule changes Restore the existing lint rule and OPY behavior before implementing the owner-approved compile/check diagnostic integration. --- .../wright-analyzer/src/canonical/analysis.rs | 54 +++++++------- crates/wright-analyzer/src/registry.rs | 15 ++-- .../tests/nested_array_scopes.rs | 69 ------------------ .../wright-cli/tests/nested_array_scopes.rs | 72 ------------------- docs/cli/lint.md | 13 +--- 5 files changed, 32 insertions(+), 191 deletions(-) delete mode 100644 crates/wright-analyzer/tests/nested_array_scopes.rs delete mode 100644 crates/wright-cli/tests/nested_array_scopes.rs diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index 7b911e32..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, } @@ -380,7 +374,17 @@ const ELEMENT_BINDER_VALUES: &[&str] = &[ "sortedArray", ]; -// Client-rejected nested-binding evidence: wrightkit/wright#713 and #1032. +/// `nested-binder-lambda` (#713): report each binder call that occurs +/// inside an enclosing binder's per-element argument — any argument after +/// the array source at index 0. The inner call rebinds `Current Array +/// Element`/`Current Array Index` inside its own per-element argument, +/// shadowing the enclosing element, and the shape is the one structural +/// signature measured so far that separates client-rejected Workshop +/// builds from accepted ones: the `OWBastion/Bastion#391`-era build and the +/// import-limits verbatim reproducer carry it (1 and 16 occurrences), the +/// measured accepted builds carry none (`workshop-rs` +/// `docs/import-limits.md`). Correlation, not a confirmed client limit — +/// the finding's evidence class says so. fn nested_binder_findings( program: &Program, rule_id: RuleId, @@ -422,10 +426,10 @@ fn nested_binder_findings( code: NESTED_BINDER_LAMBDA.id.into(), severity: NESTED_BINDER_LAMBDA.default_severity, message: format!( - "`{inner}` nested in `{outer}`'s per-element argument reads \ - `Current Array Element`/`Current Array Index` across nested \ - binding scopes; this shape may be rejected by the Workshop \ - client on import (the exact client limit is unconfirmed)" + "`{inner}` nested in `{outer}`'s per-element argument binds \ + `Current Array Element` to the inner array, shadowing the \ + outer element; nested-binder shapes appear in Workshop \ + builds the game client rejected on import" ), span, rule: rule_id, @@ -446,10 +450,8 @@ fn collect_nested_binders<'a>( out: &mut Vec<(Vec, &'a str, &'a Value, &'a str)>, ) { let binder = match value { - Value::Call { name, args } if ELEMENT_BINDER_VALUES.contains(&name.as_str()) => { - if let Some(outer) = enclosing - && args.iter().any(reads_array_binding) - { + Value::Call { name, .. } if ELEMENT_BINDER_VALUES.contains(&name.as_str()) => { + if let Some(outer) = enclosing { out.push((path.clone(), name.as_str(), value, outer)); } Some(name.as_str()) @@ -460,7 +462,11 @@ fn collect_nested_binders<'a>( path.push(index); collect_nested_binders( child, - binder.filter(|_| index >= 1).or(enclosing), + if index >= 1 { + binder.or(enclosing) + } else { + enclosing + }, path, out, ); @@ -468,18 +474,6 @@ fn collect_nested_binders<'a>( } } -fn reads_array_binding(value: &Value) -> bool { - if let Value::Call { name, args } = value { - if matches!(name.as_str(), "currentArrayElement" | "currentArrayIndex") { - return true; - } - if ELEMENT_BINDER_VALUES.contains(&name.as_str()) { - return args.first().is_some_and(reads_array_binding); - } - } - value_children(value).into_iter().any(reads_array_binding) -} - fn duplicate_condition_findings( program: &Program, rule_id: RuleId, diff --git a/crates/wright-analyzer/src/registry.rs b/crates/wright-analyzer/src/registry.rs index e3cf5a80..dbc119cc 100644 --- a/crates/wright-analyzer/src/registry.rs +++ b/crates/wright-analyzer/src/registry.rs @@ -258,18 +258,17 @@ pub(crate) const NESTED_BINDER_LAMBDA: RuleMeta = RuleMeta { id: "nested-binder-lambda", default_severity: Severity::Warning, evidence: EvidenceClass::StaticIndicator, - summary: "nested element-binding array call reads the current array element or index", + summary: "element-binding array call nested inside another binder's per-element argument", rationale: "Surface a Workshop shape observed only in client-rejected builds.", documentation: concat!( "The element-binding values — `filteredArray`, `sortedArray`, ", "`mappedArray`, `isTrueForAll`, `isTrueForAny` — evaluate every ", "argument after the first once per array element, binding ", "`Current Array Element`/`Current Array Index` to that element. ", - "A binder inside that per-element argument that reads either binding ", - "in its array source or its own per-element arguments creates a nested ", - "binding risk. The array source can still read the outer element, ", - "while the inner per-element arguments bind the inner element. ", - "The shape compiles and is ", + "A binder inside that per-element argument creates a nested ", + "binding scope: inside the inner call's own per-element ", + "argument the element values denote the inner array and the ", + "outer element is shadowed. The shape compiles and is ", "semantically defined, but it is the one structural signature ", "measured so far that separates client-rejected Workshop builds ", "from accepted ones: the `OWBastion/Bastion#391`-era build and ", @@ -287,9 +286,7 @@ pub(crate) const NESTED_BINDER_LAMBDA: RuleMeta = RuleMeta { "`Filtered Array(Sorted Array(x, r), c)`, creates no nested ", "scope and is not reported. The rule names a suspect shape, not ", "a measured client boundary: no confirmed import depth or ", - "nesting limit exists yet in workshop-rs. Calls that read neither ", - "binding outside a deeper binder's per-element arguments are not ", - "reported. This does not prove that other nested calls import safely.", + "nesting limit exists yet in workshop-rs.", ), tags: &["correctness", "stability"], }; diff --git a/crates/wright-analyzer/tests/nested_array_scopes.rs b/crates/wright-analyzer/tests/nested_array_scopes.rs deleted file mode 100644 index 4dbd500a..00000000 --- a/crates/wright-analyzer/tests/nested_array_scopes.rs +++ /dev/null @@ -1,69 +0,0 @@ -use workshop_rs::{Event, Program, Rule, Value}; -use wright_analyzer::canonical::analyze; -use wright_analyzer::registry::LintConfig; - -#[test] -fn nested_binders_require_a_read_of_their_array_binding() { - let binders = [ - "filteredArray", - "sortedArray", - "mappedArray", - "isTrueForAny", - "isTrueForAll", - ]; - for outer in binders { - for inner in binders { - for (source, body, expected) in [ - (Value::Array(vec![]), Value::Bool(true), None), - ( - Value::Array(vec![]), - Value::call("currentArrayElement", []), - Some(inner), - ), - ( - Value::Array(vec![]), - Value::call("currentArrayIndex", []), - Some(inner), - ), - ( - Value::Array(vec![Value::call("currentArrayElement", [])]), - Value::Bool(true), - Some(inner), - ), - ( - Value::Array(vec![]), - Value::call( - "isTrueForAny", - [Value::Array(vec![]), Value::call("currentArrayElement", [])], - ), - Some("isTrueForAny"), - ), - ] { - let mut program = Program::new(); - program.rule(Rule::new("nested binding reads", Event::Global).condition( - Value::call( - outer, - [Value::Array(vec![]), Value::call(inner, [source, body])], - ), - )); - let findings = analyze(&program, &LintConfig::default()); - let nested: Vec<_> = findings - .iter() - .filter(|finding| finding.code == "nested-binder-lambda") - .collect(); - assert_eq!( - nested.len(), - usize::from(expected.is_some()), - "{outer}/{inner}: {nested:?}" - ); - if let Some(reported_inner) = expected { - assert!( - nested[0] - .message - .starts_with(&format!("`{reported_inner}` nested")) - ); - } - } - } - } -} diff --git a/crates/wright-cli/tests/nested_array_scopes.rs b/crates/wright-cli/tests/nested_array_scopes.rs deleted file mode 100644 index c0d7bebb..00000000 --- a/crates/wright-cli/tests/nested_array_scopes.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::io::Write; -use std::process::{Command, Stdio}; - -#[test] -fn lint_reports_nested_current_array_element_import_risk() { - let source = r#" -variables { - global: - 0: pool -} -rule ("nested any in filter") { - event { - Ongoing - Global; - } - actions { - Set Global Variable(pool, Filtered Array(All Players(Team 1), And(Is Alive(Current Array Element), Is True For Any(Array(Compare(Hero Of(Current Array Element), ==, Hero(Ana)), Is Alive(Current Array Element)), Current Array Element)))); - Set Global Variable(pool, Is True For Any(Array(Is Alive(Event Player)), Current Array Element)); - Set Global Variable(pool, Filtered Array(Sorted Array(All Players(Team 1), Current Array Element), Is Alive(Current Array Element))); - Set Global Variable(pool, Filtered Array(All Players(Team 1), Is True For Any(Array(True), True))); - } -} -"#; - for (flags, exit, severity) in [ - (vec![], 0, Some("warning")), - ( - vec!["--rule-severity", "nested-binder-lambda:error"], - 1, - Some("error"), - ), - (vec!["--disable-rule", "nested-binder-lambda"], 0, None), - ] { - let mut child = Command::new(env!("CARGO_BIN_EXE_wright")) - .args(["lint", "-", "-f", "json"]) - .args(flags) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(source.as_bytes()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert_eq!(output.status.code(), Some(exit), "{output:?}"); - let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let findings: Vec<_> = envelope["result"]["findings"] - .as_array() - .unwrap() - .iter() - .filter(|finding| finding["code"] == "nested-binder-lambda") - .collect(); - assert_eq!(findings.len(), usize::from(severity.is_some())); - if let Some(severity) = severity { - assert_eq!(findings[0]["severity"], severity); - assert_eq!(findings[0]["evidence"], "static-indicator"); - let message = findings[0]["message"].as_str().unwrap(); - assert!(message.contains("isTrueForAny")); - assert!(message.contains("filteredArray")); - assert!(message.contains("Current Array Element")); - assert!(message.contains("unconfirmed")); - assert!( - findings[0]["location"] - .as_str() - .unwrap() - .starts_with(":11:") - ); - } - } -} diff --git a/docs/cli/lint.md b/docs/cli/lint.md index e4765ff1..ce3c7f4f 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -93,17 +93,8 @@ scopes. The binder values (`filteredArray`, `sortedArray`, `mappedArray`, `isTrueForAll`, `isTrueForAny`) evaluate every argument after the first per array element, binding `Current Array Element`/`Current Array Index`; a binder nested inside that per-element argument shadows the outer element -inside the inner binder's own argument. The warning requires a read of -`Current Array Element` or `Current Array Index` in the inner call's array -source or its own per-element arguments. In particular, an `any([...])` or -`all([...])` inside an OPY `filter(lambda p: ...)` can build its input array -from the outer element, then rebind the element in its predicate. Calls -without either read, reads confined to a deeper binder's per-element -arguments, and binders chained through array-source arguments do not trigger -this rule. Silence does not establish client importability. - -The shape is semantically valid and may be intentional, but it is the -structural signature measured on +inside the inner binder's own argument. The shape is semantically valid and +may be intentional, but it is the structural signature measured on client-rejected Workshop builds and absent from measured accepted ones (`workshop-rs` `docs/import-limits.md`; #713). It is a warning, not a confirmed client import boundary. From e6886648924bab2950cf5e4dd283cc985dad1195 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:52:29 +0800 Subject: [PATCH 3/3] 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. Apply the same lint policy flags on both commands and refuse compile output when a lint finding has error severity. Document the diagnostic contract and cover warning counts, JSON findings, project policy, CLI overrides, and failure 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 ++++++++++++++ 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 +- 10 files changed, 425 insertions(+), 139 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-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.