From d6487e390b24618cd49e400b4dfc3d4cc15c3b96 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:39:53 +0800 Subject: [PATCH 1/4] feat(validation): support bounded Rust Clippy lint lifecycle --- .../webcodex-core/src/project_validation.rs | 20 +- .../src/project_validation_tests.rs | 10 +- .../webcodex-core/src/runner_job_receipt.rs | 2 +- crates/webcodex-core/src/runner_protocol.rs | 7 + .../webcodex-core/src/runner_protocol/job.rs | 42 +++- .../webcodex-core/src/validation_identity.rs | 17 ++ crates/webcodex-runner-config/src/lib.rs | 1 + .../src/job_updates.rs | 4 + .../src/reconciliation.rs | 2 +- .../webcodex-runner-registry/src/requests.rs | 6 + .../src/tests/project_dependency_policy.rs | 66 ++++++ .../webcodex_runner/job_manager/admission.rs | 2 +- .../webcodex_runner/job_manager/detached.rs | 2 +- .../webcodex_runner/job_manager/lifecycle.rs | 2 +- .../job_manager/local_shell.rs | 8 + .../job_manager_tests/project_validation.rs | 47 ++++- .../src/webcodex_runner/shell.rs | 8 +- .../src/webcodex_runner/shell/commands.rs | 44 ++++ .../src/webcodex_runner/shell_tests.rs | 91 ++++++++ .../webcodex_runner/transport/registration.rs | 1 + .../src/webcodex_runner/validation/project.rs | 8 +- .../validation/project_tests.rs | 196 ++++++++++++++++-- .../src/registry/output_schemas/common.rs | 2 +- .../src/registry/output_schemas/sessions.rs | 2 +- .../src/registry/output_schemas/testing.rs | 4 +- .../src/tests/input_schemas.rs | 24 +++ .../src/tool_call/canonical_enum.rs | 3 +- .../src/tool_definition/testing.rs | 2 +- .../webcodex-validation/src/adapters/mod.rs | 33 ++- .../webcodex-validation/src/adapters/rust.rs | 32 ++- crates/webcodex-validation/src/evidence.rs | 2 +- .../webcodex-validation/src/profile_tests.rs | 133 ++++++++++++ crates/webcodex-validation/src/recipe.rs | 9 +- .../webcodex-workflow-session/src/events.rs | 3 +- docs/MCP.md | 8 +- docs/MCP.zh-CN.md | 6 +- src/runner_quic.rs | 1 + src/runner_ws.rs | 1 + src/tool_runtime/job_audit.rs | 2 +- src/tool_runtime/jobs.rs | 8 +- src/tool_runtime/tests/jobs.rs | 29 +++ src/tool_runtime/tests/metadata.rs | 1 + src/tool_runtime/tests/validation_handoff.rs | 3 + .../validation_handoff/project_validation.rs | 82 ++++++++ src/tool_runtime/validation.rs | 7 +- src/tool_runtime/validation_profile/mod.rs | 2 +- 46 files changed, 916 insertions(+), 69 deletions(-) diff --git a/crates/webcodex-core/src/project_validation.rs b/crates/webcodex-core/src/project_validation.rs index caeb68009..63bff5909 100644 --- a/crates/webcodex-core/src/project_validation.rs +++ b/crates/webcodex-core/src/project_validation.rs @@ -12,11 +12,18 @@ pub use crate::project_operation::{ pub enum ProjectValidationAction { FormatCheck, Check, - /// Run only a Runner-selected Node `lint` script; never fall back to check/typecheck. + /// Run Runner-owned Rust Clippy or a Node `lint` script. Lint, Test, } impl ProjectValidationAction { + pub fn kind_for_backend(self, backend: &str) -> &'static str { + if self == Self::Lint && backend == "rust" { + "lint" + } else { + self.kind() + } + } pub fn kind(self) -> &'static str { match self { Self::FormatCheck => "format", @@ -128,6 +135,9 @@ impl ProjectValidationRequest { })?; } if self.dependency_resolution_policy.is_some() { + if self.action == ProjectValidationAction::Lint { + return Err("dependency_resolution_policy_unsupported: lint offline policy requires maintainer approval".into()); + } if !matches!( self.action, ProjectValidationAction::Check | ProjectValidationAction::Test @@ -139,7 +149,9 @@ impl ProjectValidationRequest { } } if self.dependency_policy.is_some() && self.action == ProjectValidationAction::FormatCheck { - return Err("dependency_policy requires project_validate action=check or test".into()); + return Err( + "dependency_policy requires project_validate action=check, lint or test".into(), + ); } if let Some(test) = &self.test { if self.action != ProjectValidationAction::Test { @@ -192,8 +204,8 @@ impl ProjectValidationProvenance { self.request.validate().is_ok() && validate_relative(&self.recipe_root).is_ok() && matches!(self.backend.as_str(), "rust" | "go" | "python" | "node") - // Explicit lint is Node-only, including retained provenance. - && (self.request.action != ProjectValidationAction::Lint || self.backend == "node") + // Lint has a closed backend set, including retained provenance. + && (self.request.action != ProjectValidationAction::Lint || matches!(self.backend.as_str(), "node" | "rust")) && (self.request.dependency_resolution_policy.is_none() || self.backend == "rust") && [ &self.root_digest, diff --git a/crates/webcodex-core/src/project_validation_tests.rs b/crates/webcodex-core/src/project_validation_tests.rs index 08110590a..231a541aa 100644 --- a/crates/webcodex-core/src/project_validation_tests.rs +++ b/crates/webcodex-core/src/project_validation_tests.rs @@ -97,7 +97,7 @@ fn project_node_check_step_identity_capability_and_metadata_are_closed() { } #[test] -fn explicit_lint_provenance_is_node_only() { +fn explicit_lint_provenance_has_closed_backend_set() { let mut provenance = ProjectValidationProvenance { request: ProjectValidationRequest { project_id: "demo".into(), @@ -116,7 +116,13 @@ fn explicit_lint_provenance_is_node_only() { invocation_digest: "c".repeat(64), }; assert!(provenance.is_valid()); - for backend in ["rust", "go", "python"] { + provenance.backend = "rust".into(); + assert!(provenance.is_valid()); + provenance.request.dependency_resolution_policy = + Some(ProjectDependencyResolutionPolicy::Offline); + assert!(!provenance.is_valid()); + provenance.request.dependency_resolution_policy = None; + for backend in ["go", "python"] { provenance.backend = backend.into(); assert!(!provenance.is_valid(), "unsupported lint backend {backend}"); } diff --git a/crates/webcodex-core/src/runner_job_receipt.rs b/crates/webcodex-core/src/runner_job_receipt.rs index 332fc2ca1..4b6bf8ebb 100644 --- a/crates/webcodex-core/src/runner_job_receipt.rs +++ b/crates/webcodex-core/src/runner_job_receipt.rs @@ -151,7 +151,7 @@ impl RetainedJobReceipt { || context .validation_steps .iter() - .any(|step| !matches!(step.as_str(), "format" | "check" | "test")) + .any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test")) || snapshot .validation_progress .as_ref() diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 0bf19e9e1..ea42c6b98 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -552,6 +552,12 @@ runner_capabilities! { #[serde(default)] pub project_validation_v1: bool = false; } + /// Runner-owned Cargo Clippy lint, including scope and locked policy. + ProjectValidationRustClippy => RUNNER_CAPABILITY_PROJECT_VALIDATION_RUST_CLIPPY("project_validation_rust_clippy_v1"), + v2_baseline = false { + #[serde(default, skip_serializing_if = "is_false")] + pub project_validation_rust_clippy_v1: bool = false; + } /// Runner-owned project build planning plus typed StartBuild admission. /// Missing on older Runners is false and must fail closed. ProjectBuild => RUNNER_CAPABILITY_PROJECT_BUILD("project_build_v1"), @@ -2917,6 +2923,7 @@ mod envelope_tests { project_validation_python_ruff_v1: false, project_validation_node_script_check_v1: false, project_validation_node_tap_v1: false, + project_validation_rust_clippy_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/crates/webcodex-core/src/runner_protocol/job.rs b/crates/webcodex-core/src/runner_protocol/job.rs index e80d714fb..28e48b436 100644 --- a/crates/webcodex-core/src/runner_protocol/job.rs +++ b/crates/webcodex-core/src/runner_protocol/job.rs @@ -298,6 +298,25 @@ impl ShellJobValidationStep { let args = self.args.iter().map(String::as_str).collect::>(); match (self.name.as_str(), self.program.as_str()) { ("format", "cargo") => args == ["fmt", "--", "--check"], + ("lint", "cargo") => { + args.starts_with(&["clippy"]) + && args.ends_with(&["--", "-D", "clippy::correctness"]) + && args.len() >= 5 + && args.iter().filter(|arg| **arg == "-p").count() <= 8 + && args[1..args.len() - 3].contains(&"--all-targets") + && args[1..args.len() - 3].iter().all(|a| { + !matches!( + *a, + "--offline" | "--all-features" | "--no-default-features" | "--features" + ) + }) + && is_canonical_cargo_flags( + &args[1..args.len() - 3], + false, + true, + allow_project_workspace, + ) + } ("check", "cargo") => is_canonical_cargo_check_args(&args, allow_project_workspace), ("test", "cargo") => is_canonical_cargo_test_args(&args, allow_project_workspace), ("check", "go") => is_canonical_go_vet_args(&args), @@ -406,7 +425,7 @@ impl ShellJobValidationStep { pub fn is_project_workspace_cargo(&self) -> bool { self.program == "cargo" - && matches!(self.name.as_str(), "check" | "test") + && matches!(self.name.as_str(), "check" | "lint" | "test") && self.args.iter().any(|arg| arg == "--workspace") && self.is_canonical_project_step() } @@ -1003,14 +1022,20 @@ impl ShellJobValidationMetadata { return false; }; if !provenance.is_valid() - || provenance.request.action.kind() != self.kind + || provenance + .request + .action + .kind_for_backend(&provenance.backend) + != self.kind || (provenance.backend == "rust" && project_all_packages && !self.steps[0].is_project_workspace_cargo()) || !matches!( (provenance.backend.as_str(), self.adapter.as_str()), - ("rust", "cargo_fmt" | "cargo_check" | "cargo_test") - | ("go", "go_vet" | "go_test") + ( + "rust", + "cargo_fmt" | "cargo_check" | "cargo_clippy" | "cargo_test" + ) | ("go", "go_vet" | "go_test") | ( "python", "python:pytest:test" | "python:ruff:check" | "python:ruff:format" @@ -1055,6 +1080,15 @@ impl ShellJobValidationMetadata { "cargo_fmt" => { self.kind == "format" && step.name == "format" && step.program == "cargo" } + "cargo_clippy" => { + self.tool == "project_validate" + && self.kind == "lint" + && step.name == "lint" + && step.program == "cargo" + && self.project_validation.as_ref().is_some_and(|p| { + p.request.action == crate::project_validation::ProjectValidationAction::Lint + }) + } "cargo_check" => { self.kind == "check" && step.name == "check" && step.program == "cargo" } diff --git a/crates/webcodex-core/src/validation_identity.rs b/crates/webcodex-core/src/validation_identity.rs index 460effb0c..b5466a426 100644 --- a/crates/webcodex-core/src/validation_identity.rs +++ b/crates/webcodex-core/src/validation_identity.rs @@ -19,6 +19,7 @@ pub enum ToolValidationIdentityKind { None, CargoFmt, CargoCheck, + CargoClippy, CargoTest, GoTest, GoVet, @@ -38,6 +39,7 @@ impl ToolValidationIdentityKind { Self::None | Self::Project => None, Self::CargoFmt => Some("cargo_fmt"), Self::CargoCheck => Some("cargo_check"), + Self::CargoClippy => Some("cargo_clippy"), Self::CargoTest => Some("cargo_test"), Self::GoTest => Some("go_test"), Self::GoVet => Some("go_vet"), @@ -150,6 +152,21 @@ pub fn structured_validation_target_identity( } semantic } + ToolValidationIdentityKind::CargoClippy => { + let packages = + normalized_cargo_target_packages(obj.get("package"), obj.get("packages"))?; + let mut semantic = serde_json::json!({ + "tool": tool_name, "kind": "lint", "cwd": cwd, + "all_targets": true, "deny": "clippy::correctness", + }); + if packages.as_ref().is_some_and(|packages| packages.len() > 1) { + semantic["packages"] = serde_json::json!(packages); + } else { + semantic["package"] = + serde_json::json!(packages.and_then(|mut values| values.pop())); + } + semantic + } ToolValidationIdentityKind::CargoTest => { if obj.get("filter_present").and_then(Value::as_bool) == Some(true) && obj.get("filter").is_none() diff --git a/crates/webcodex-runner-config/src/lib.rs b/crates/webcodex-runner-config/src/lib.rs index 9892c1943..fdccb994d 100644 --- a/crates/webcodex-runner-config/src/lib.rs +++ b/crates/webcodex-runner-config/src/lib.rs @@ -306,6 +306,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result ShellJobActivity { pub(super) fn validation_step_activity(step: &ShellJobValidationStep) -> ShellJobActivity { let phase = match step.name.as_str() { "format" => ShellJobActivityPhase::ValidationFormat, - "check" => ShellJobActivityPhase::ValidationCheck, + "check" | "lint" => ShellJobActivityPhase::ValidationCheck, "test" => ShellJobActivityPhase::ValidationTest, _ => unreachable!("canonical validation step name"), }; diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs index 15d45c5bd..8ddcb9229 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs @@ -189,6 +189,14 @@ impl JobManager { &cwd_path, Some(self.shutting_down.as_ref()), ) + } else if validation && steps[index].program == "cargo" && steps[index].name == "lint" { + crate::webcodex_runner::shell::configured_clippy_job_command( + &shell, + prepared_profile.as_deref(), + &steps[index], + &cwd_path, + Some(self.shutting_down.as_ref()), + ) } else if validation { configured_validation_job_command( &shell, diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs index 21c2a756f..77e8a3e6d 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs @@ -20,6 +20,27 @@ fn project_validation_rechecks_manifest_and_dependency_state_after_queueing() { ("pyproject.toml", "pytest.ini", true, false, None), ("Cargo.toml", "Cargo.lock", false, true, None), ("Cargo.toml", "Cargo.lock", true, true, None), + ( + "Cargo.toml", + "Cargo.lock", + false, + false, + Some(ProjectValidationAction::Lint), + ), + ( + "Cargo.toml", + "Cargo.lock", + true, + false, + Some(ProjectValidationAction::Lint), + ), + ( + "Cargo.toml", + "Cargo.lock", + true, + true, + Some(ProjectValidationAction::Lint), + ), ( "pyproject.toml", "unused", @@ -64,7 +85,7 @@ edition = "2021" root.join(marker), if marker == "package.json" { r#"{"scripts":{"test":"vitest run"}}"# - } else if ruff_action.is_some() { + } else if marker == "pyproject.toml" && ruff_action.is_some() { "[tool.ruff]\ntarget-version='py311'\n" } else { "" @@ -110,7 +131,10 @@ edition = "2021" project_validation: Some(plan.provenance.clone()), source_fence: None, tool: "project_validate".into(), - kind: request.action.kind().into(), + kind: request + .action + .kind_for_backend(&plan.provenance.backend) + .into(), steps: vec![plan.step.clone()], effective_timeout_secs: 60, sync_wait_secs: 10, @@ -635,3 +659,22 @@ fn project_validation_ruff_missing_interpreter_is_definitely_not_started() { ); } } + +#[test] +fn rust_clippy_activity_keeps_lint_step_identity() { + let step = ShellJobValidationStep { + name: "lint".into(), + program: "cargo".into(), + args: ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + .map(str::to_string) + .to_vec(), + env: vec![], + }; + let activity = validation_step_activity(&step); + assert_eq!(activity.phase, ShellJobActivityPhase::ValidationCheck); + assert_eq!( + cargo_activity_from_stderr(&step, "Finished dev profile\n"), + Some(activity) + ); + assert_eq!(step.name, "lint"); +} diff --git a/crates/webcodex-runner/src/webcodex_runner/shell.rs b/crates/webcodex-runner/src/webcodex_runner/shell.rs index 9601bd1ee..df0a7a9d5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell.rs @@ -127,10 +127,10 @@ mod search; #[cfg(windows)] pub(crate) use commands::shell_quote_powershell; pub(crate) use commands::{ - configured_explicit_shell_command, configured_node_project_job_command, - configured_prepared_shell_job_command, configured_pytest_job_command, - configured_ruff_job_command, configured_shell_job_command, configured_validation_job_command, - explicit_shell_available, probe_ruff_formatter, shell_quote, + configured_clippy_job_command, configured_explicit_shell_command, + configured_node_project_job_command, configured_prepared_shell_job_command, + configured_pytest_job_command, configured_ruff_job_command, configured_shell_job_command, + configured_validation_job_command, explicit_shell_available, probe_ruff_formatter, shell_quote, }; use commands::{ configured_prepared_shell_command, configured_process_command, configured_process_path, diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs index 2f2761461..ebbf2dfb2 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs @@ -464,6 +464,50 @@ fn configured_python_module_job_command( Ok(command) } +/// Probe and launch the same native Cargo in the prepared environment. No shell, +/// installation, dependency fetch, or validation execution happens in preflight. +pub(crate) fn configured_clippy_job_command( + shell: &ShellConfig, + profile: Option<&PreparedShellProfile>, + step: &ShellJobValidationStep, + cwd: &Path, + stop_requested: Option<&AtomicBool>, +) -> Result { + let unavailable = + || webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE.to_string(); + if step.name != "lint" || step.program != "cargo" || !step.is_canonical_project_step() { + return Err(unavailable()); + } + let path = configured_process_path(shell, profile).map_err(|_| unavailable())?; + let Some(crate::webcodex_runner::util::ResolvedProgram::Native(program)) = + crate::webcodex_runner::util::resolve_program_in_path("cargo", &path) + else { + return Err(unavailable()); + }; + let mut probe = Command::new(&program); + probe + .args(["clippy", "--version"]) + .current_dir(cwd) + .stdin(Stdio::null()); + super::scripts::apply_script_environment(&mut probe, shell, profile) + .map_err(|_| unavailable())?; + let (status, stdout, _) = run_prepare_command(probe, Duration::from_secs(5), stop_requested) + .map_err(|_| unavailable())?; + if !status.success() + || !std::str::from_utf8(&stdout).is_ok_and(|text| text.trim_start().starts_with("clippy ")) + { + return Err(unavailable()); + } + let mut command = Command::new(program); + command + .args(&step.args) + .current_dir(cwd) + .stdin(Stdio::null()); + super::scripts::apply_script_environment(&mut command, shell, profile) + .map_err(|_| unavailable())?; + Ok(command) +} + pub(crate) fn configured_validation_job_command( shell: &ShellConfig, profile: Option<&PreparedShellProfile>, diff --git a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs index da8868a0b..7130007dc 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs @@ -3192,3 +3192,94 @@ fn runner_real_process_project_validation_ruff_module_isolation() { } } } + +#[cfg(unix)] +#[test] +fn rust_clippy_probe_missing_component_and_pinned_command() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let cargo = temp.path().join("cargo"); + let mut shell = ShellConfig::default(); + shell + .env + .insert("PATH".into(), temp.path().to_string_lossy().into_owned()); + let step = webcodex_core::runner_protocol::ShellJobValidationStep { + name: "lint".into(), + program: "cargo".into(), + args: ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + .map(str::to_string) + .to_vec(), + env: vec![], + }; + assert!(configured_clippy_job_command(&shell, None, &step, temp.path(), None).is_err()); + for available in [false, true] { + std::fs::write(&cargo, if available { + "#!/bin/sh\n[ \"$1\" = clippy ] && [ \"$2\" = --version ] || exit 9\nprintf 'clippy 0.1\\n'\n" + } else { "#!/bin/sh\nexit 1\n" }).unwrap(); + std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = configured_clippy_job_command(&shell, None, &step, temp.path(), None); + if available { + let command = result.unwrap(); + assert_eq!(PathBuf::from(command.get_program()), cargo); + assert_eq!( + command + .get_args() + .map(|a| a.to_str().unwrap()) + .collect::>(), + step.args + ); + } else { + assert_eq!( + result.unwrap_err(), + webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE + ); + } + } +} + +#[cfg(feature = "runner-real-process-tests")] +#[test] +#[ignore = "requires installed Cargo Clippy; run explicitly with --ignored"] +fn runner_real_process_rust_clippy_warnings_pass_and_correctness_fails() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("src")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='clippy_fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n", + ) + .unwrap(); + let step = webcodex_core::runner_protocol::ShellJobValidationStep { + name: "lint".into(), + program: "cargo".into(), + args: ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + .map(str::to_string) + .to_vec(), + env: vec![], + }; + for (source, success, diagnostic) in [ + ( + "pub fn warning() { let unused_variable = 1; }\n", + true, + "warning:", + ), + ( + "pub fn broken(x: i32) -> bool { x > 1 && x < 0 }\n", + false, + "clippy::impossible_comparisons", + ), + ] { + std::fs::write(temp.path().join("src/lib.rs"), source).unwrap(); + let command = + configured_clippy_job_command(&ShellConfig::default(), None, &step, temp.path(), None) + .expect("Clippy must be installed for explicit real-process evidence"); + let (status, stdout, stderr) = + run_prepare_command(command, Duration::from_secs(30), None).unwrap(); + let output = format!( + "{}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + assert_eq!(status.success(), success, "{output}"); + assert!(output.contains(diagnostic), "{output}"); + } +} diff --git a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs index 37524b231..9799e89b6 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport/registration.rs @@ -273,6 +273,7 @@ pub(crate) fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabili capabilities.set(RunnerCapabilityId::ProjectValidationPythonRuff, true); capabilities.set(RunnerCapabilityId::ProjectValidationNodeScriptCheck, true); capabilities.set(RunnerCapabilityId::ProjectValidationNodeTap, true); + capabilities.set(RunnerCapabilityId::ProjectValidationRustClippy, true); // This binary also understands the first-class go_test durable metadata // identity. Keep this independent from JSON parsing so an old Runner that // supported Connector Go evidence cannot be mistaken for a first-class diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs index 8bac4c1d4..67c58b8e5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs @@ -51,7 +51,9 @@ pub(crate) fn plan( Some(backend.as_str()), )); } - if request.action == ProjectValidationAction::Lint && backend != RecipeId::Node { + if request.action == ProjectValidationAction::Lint + && !matches!(backend, RecipeId::Node | RecipeId::Rust) + { return Err(unavailable( "validation_action_unsupported", Some(backend.as_str()), @@ -59,7 +61,9 @@ pub(crate) fn plan( } let action = match request.action { ProjectValidationAction::FormatCheck => SemanticCheck::Format, - ProjectValidationAction::Check | ProjectValidationAction::Lint => SemanticCheck::Check, + ProjectValidationAction::Check => SemanticCheck::Check, + ProjectValidationAction::Lint if backend == RecipeId::Rust => SemanticCheck::Lint, + ProjectValidationAction::Lint => SemanticCheck::Check, ProjectValidationAction::Test => SemanticCheck::Test, }; let packages = request diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs index 71aadd305..a85b47cb6 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs @@ -826,11 +826,11 @@ fn start_project_validation_operation( use webcodex_core::runner_operation::{RunnerJobOperation, RunnerJobValidationOperation}; use webcodex_core::runner_protocol::{ShellJobContext, ShellJobValidationMetadata}; - let kind = match plan.provenance.request.action { - ProjectValidationAction::FormatCheck => "format", - ProjectValidationAction::Check | ProjectValidationAction::Lint => "check", - ProjectValidationAction::Test => "test", - }; + let kind = plan + .provenance + .request + .action + .kind_for_backend(&plan.provenance.backend); let metadata = ShellJobValidationMetadata { project_validation: Some(plan.provenance.clone()), tool: "project_validate".into(), @@ -1224,18 +1224,18 @@ fn explicit_node_lint_plan_preserves_target_identity_and_stale_fence() { } #[test] -fn explicit_lint_rejects_non_node_backend() { - let (_tmp, root, registry, policy) = fixture("Cargo.toml"); - fs::write( - root.join("Cargo.toml"), - "[package]\nname='demo'\nversion='0.1.0'\n", - ) - .unwrap(); - let mut req = request(ProjectValidationAction::Lint); - req.adapter = ProjectValidationAdapter::Rust; - assert!( - matches!(project::plan(&policy, ®istry, &req), Err(ProjectValidationPlanningResult::Unavailable { code, .. }) if code == "validation_action_unsupported") - ); +fn explicit_lint_rejects_unsupported_backends() { + for (marker, adapter) in [ + ("go.mod", ProjectValidationAdapter::Go), + ("pyproject.toml", ProjectValidationAdapter::Python), + ] { + let (_tmp, _root, registry, policy) = fixture(marker); + let mut req = request(ProjectValidationAction::Lint); + req.adapter = adapter; + assert!( + matches!(project::plan(&policy, ®istry, &req), Err(ProjectValidationPlanningResult::Unavailable { code, .. }) if code == "validation_action_unsupported") + ); + } } #[test] @@ -1259,3 +1259,165 @@ fn rust_dependency_offline_plan_is_runner_owned_and_fenced() { unsupported.action = ProjectValidationAction::FormatCheck; assert!(project::plan(&policy, ®istry, &unsupported).is_err()); } + +#[test] +fn rust_clippy_plan_identity_and_queue_fence() { + use webcodex_core::runner_operation::{RunnerJobOperation, RunnerJobValidationOperation}; + use webcodex_core::runner_protocol::{ShellJobContext, ShellJobValidationMetadata}; + let (_tmp, root, registry, policy) = fixture("Cargo.toml"); + let req = request(ProjectValidationAction::Lint); + let (plan, cwd) = project::plan(&policy, ®istry, &req).unwrap(); + assert_eq!(plan.adapter, "cargo_clippy"); + assert_eq!( + plan.step.args, + ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + ); + let mut explicit = req.clone(); + explicit.adapter = ProjectValidationAdapter::Rust; + assert_eq!( + plan.step, + project::plan(&policy, ®istry, &explicit).unwrap().0.step + ); + for action in [ + ProjectValidationAction::Check, + ProjectValidationAction::Test, + ] { + assert_ne!( + plan.validation_target_id, + project::plan(&policy, ®istry, &request(action)) + .unwrap() + .0 + .validation_target_id + ); + } + let metadata = ShellJobValidationMetadata { + project_validation: Some(plan.provenance), + tool: "project_validate".into(), + kind: "lint".into(), + adapter: plan.adapter, + steps: vec![plan.step.clone()], + effective_timeout_secs: 60, + sync_wait_secs: 1, + validation_target_id: Some(plan.validation_target_id), + source_fence: None, + minimum_tests: None, + require_tests: None, + no_run: None, + }; + assert!(metadata.is_valid()); + let mut substituted = metadata.clone(); + substituted.adapter = "cargo_check".into(); + assert!(!substituted.is_valid()); + let operation = RunnerJobOperation::StartValidation(RunnerJobValidationOperation { + job_id: "clippy-job".into(), + cwd: Some(cwd.to_string_lossy().into_owned()), + steps: metadata.steps.clone(), + timeout_secs: 60, + context: ShellJobContext { + runtime_project_id: Some("agent:runner:demo".into()), + validation: Some(metadata), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some(cwd.to_string_lossy().into_owned()), + purpose: Some("validation".into()), + shell: None, + command_preview: "cargo clippy".into(), + validation_steps: vec!["lint".into()], + structured_execution: None, + }, + }); + project::fence(&policy, ®istry, &operation).unwrap(); + fs::write(root.join("Cargo.lock"), "# changed after planning\n").unwrap(); + assert!(project::fence(&policy, ®istry, &operation) + .unwrap_err() + .contains("validation_plan_stale")); + let mut offline = req; + offline.dependency_resolution_policy = Some(ProjectDependencyResolutionPolicy::Offline); + assert!(project::plan(&policy, ®istry, &offline).is_err()); +} + +#[test] +fn rust_clippy_workspace_scope_and_locked_policy_are_fenced() { + let (_tmp, root, registry, policy) = fixture("Cargo.toml"); + fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers=['a','b']\nresolver='2'\n", + ) + .unwrap(); + for name in ["a", "b"] { + fs::create_dir_all(root.join(name).join("src")).unwrap(); + fs::write( + root.join(name).join("Cargo.toml"), + format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2021'\n"), + ) + .unwrap(); + fs::write(root.join(name).join("src/lib.rs"), "").unwrap(); + } + let req = request(ProjectValidationAction::Lint); + let plain = project::plan(&policy, ®istry, &req).unwrap().0; + let mut scoped = req.clone(); + scoped.scope = Some(ProjectValidationScope { + packages: vec!["b".into(), "a".into(), "a".into()], + all_packages: false, + }); + let selected = project::plan(&policy, ®istry, &scoped).unwrap().0; + assert_eq!( + selected.step.args, + [ + "clippy", + "--all-targets", + "-p", + "a", + "-p", + "b", + "--", + "-D", + "clippy::correctness" + ] + ); + assert_ne!(selected.validation_target_id, plain.validation_target_id); + scoped.scope = Some(ProjectValidationScope { + packages: vec![], + all_packages: true, + }); + scoped.dependency_policy = Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }); + let all = project::plan(&policy, ®istry, &scoped).unwrap().0; + assert_eq!( + all.step.args, + [ + "clippy", + "--locked", + "--all-targets", + "--workspace", + "--", + "-D", + "clippy::correctness" + ] + ); + assert!(all.step.is_project_workspace_cargo()); + assert_ne!(all.validation_target_id, selected.validation_target_id); + let operation = start_project_validation_operation(&all, &root); + project::fence(&policy, ®istry, &operation).unwrap(); + fs::write( + root.join("a/Cargo.toml"), + "[package]\nname='a'\nversion='0.2.0'\nedition='2021'\n", + ) + .unwrap(); + assert!(project::fence(&policy, ®istry, &operation) + .unwrap_err() + .contains("validation_plan_stale")); + let changed = project::plan(&policy, ®istry, &scoped).unwrap().0; + assert_ne!( + all.provenance.manifest_digest, + changed.provenance.manifest_digest + ); + scoped.cwd = Some("a".into()); + assert!(project::plan(&policy, ®istry, &scoped).is_ok()); + fs::create_dir(root.join("nested")).unwrap(); + fs::write(root.join("nested/Cargo.toml"), "[workspace]\nmembers=[]\n").unwrap(); + scoped.cwd = Some("nested".into()); + assert!(project::plan(&policy, ®istry, &scoped).is_err()); +} diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/common.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/common.rs index c3de873b3..d55853b88 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/common.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/common.rs @@ -633,7 +633,7 @@ pub(super) fn passive_job_attention_schema() -> Value { "description": "Historical validation proof; source_state independently describes the mutation fence.", "properties": { "tool": {"type": "string", "maxLength": 64}, - "kind": {"type": "string", "enum": ["format", "check", "test", "validation", "build", "release"]}, + "kind": {"type": "string", "enum": ["format", "check", "lint", "test", "validation", "build", "release"]}, "state": {"type": "string", "enum": ["pending", "running", "completed", "timed_out", "cancelled", "lost"]}, "passed": nullable_schema("boolean", "Authoritative validation verdict; null is unproven."), "tests_detected": nullable_schema("boolean", "Tests detected by authoritative evidence."), diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs index 12c537b3e..16da4312c 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/sessions.rs @@ -1076,7 +1076,7 @@ fn validation_event_schema() -> Value { "identity": { "type": "string", "maxLength": 256 }, "assertion_name": { "type": "string", "minLength": 1, "maxLength": MAX_MODEL_VALIDATION_ASSERTION_NAME_CHARS }, "purpose": { "type": "string", "enum": validation_like_purposes }, - "validation_kind": { "type": "string", "enum": ["format", "check", "test", "build", "release", "validation"] }, + "validation_kind": { "type": "string", "enum": ["format", "check", "lint", "test", "build", "release", "validation"] }, "success": { "type": "boolean", "description": "Immutable raw ToolResult success recorded by the Workflow Session. A request-scoped evidence assertion can make this false even when validator execution and correctness passed." }, "validation_passed": { "type": "boolean", "description": "True when the validator/correctness execution itself passed. This can remain true while the invocation's evidence assertion is insufficient." }, "failure_class": { "type": "string", "enum": ["none", "execution_or_correctness", "outcome_unknown", "evidence_assertion", "evidence_insufficient", "expected_result"] }, diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs index a2065da26..ce0ec5719 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs @@ -191,8 +191,8 @@ fn cargo_output_schema(tool_name: &str) -> Value { fields.extend([ ("execution_source", json!({"const":"project_validate"})), ("backend", json!({"type":"string", "enum":["rust","go","python","node"]})), - ("action", json!({"type":"string", "enum":["format_check","check","test"]})), - ("adapter", json!({"type":"string", "enum":["cargo_fmt","cargo_check","cargo_test","go_vet","go_test","python:pytest:test","python:ruff:check","python:ruff:format","node:script:check","node:tap:test","node:vitest:test","node:jest:test"]})), + ("action", json!({"type":"string", "enum":["format_check","check","lint","test"]})), + ("adapter", json!({"type":"string", "enum":["cargo_fmt","cargo_check","cargo_clippy","cargo_test","go_vet","go_test","python:pytest:test","python:ruff:check","python:ruff:format","node:script:check","node:tap:test","node:vitest:test","node:jest:test"]})), ("validation_target_id", schema_type("string", "Canonical resolved validation target, independent of source freshness.")), ("detected_backend", json!({"type":["string","null"], "enum":["rust","go","node","python",null]})), ]); diff --git a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs index a34257ea8..863c5e64e 100644 --- a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs @@ -1566,3 +1566,27 @@ fn project_validation_node_contract_advertises_bounded_test_and_execution_effect ); } } + +#[test] +fn rust_clippy_uses_project_validate_without_new_tool_or_argv() { + let specs = registered_tool_specs(); + let spec = spec_named(&specs, "project_validate"); + for phrase in [ + "cargo clippy --all-targets -- -D clippy::correctness", + "project_validation_rust_clippy_v1", + "Rust lint explicitly rejects offline", + ] { + assert!(spec.description.contains(phrase), "{phrase}"); + } + for hint in ["rust", "auto"] { + let value = json!({"project":"demo", "action":"lint", "adapter":hint, "scope":{"packages":["demo"]}, "dependency_policy":{"mode":"locked"}}); + test_support::validate_schema_instance(&value, &spec.input_schema).unwrap(); + assert!(ToolCall::from_tool_name("project_validate", value).is_ok()); + } + assert!(!specs.iter().any(|s| s.name == "cargo_clippy")); + for field in ["argv", "command", "executable"] { + let value = json!({"project":"demo", "action":"lint", (field): "cargo clippy"}); + assert!(test_support::validate_schema_instance(&value, &spec.input_schema).is_err()); + assert!(ToolCall::from_tool_name("project_validate", value).is_err()); + } +} diff --git a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs index 227823f2c..03ebc80c0 100644 --- a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs +++ b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs @@ -1572,7 +1572,8 @@ pub enum ToolCall { #[serde(default)] cwd: Option, /// check uses Runner priority (check/typecheck/lint); lint selects only the - /// Node lint script, retaining check validation identity and effects. + /// Node lint script with check identity, or Rust Clippy with distinct lint evidence. + /// Rust lint fixes --all-targets -- -D clippy::correctness; offline policy is unsupported. action: webcodex_core::project_validation::ProjectValidationAction, /// Omission means auto. Rust and Go are supported; Python supports test through pytest and /// check/format_check through project-local Ruff with explicit target-version. diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index a8155a3bc..4a14df660 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ ) .with_composition_policy(super::ToolCompositionPolicy::Sequential) .with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()), - "Runner-owned project validation. Project scripts may modify files or access network. Rust/Go: format_check/check/test; Python: pytest/Ruff; Node >=22.3: check picks check > typecheck > lint, lint selects only scripts.lint with check identity; node --run without pre/post. Node TAP test only for exact scripts.test=node --test or node --test --test-reporter=tap. Vitest/Jest: fixed installed CLI and feature-gated (project_validation_node_json_v1) per-Job JSON counts; other scripts can't prove test counts. scope packages 1..8 or all_packages=true only for Rust/Go check/test; Rust requires Runner-proven Cargo workspace root, Go single module. test.filter: Rust substring, Go -run, pytest -k; not for Node. require_tests/min_tests require proven counts. Python tools preinstalled; Ruff requires local pyproject.toml [tool.ruff] target-version, no extend/fixes/cache/install. Missing tools are unavailable. locked dependencies: Cargo --locked/Go -mod=readonly, not offline; not Python/Node/format. Optional dependency_resolution_policy=offline is only for Rust check/test: Runner-owned Cargo --offline prevents Cargo dependency fetching but build scripts/tests may use network; not an OS sandbox. Omission keeps defaults; unsupported actions/backends fail before dispatch. Go enforces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Same Runner Job; observe pending, never retry.", ).with_discovery_keywords(&["project", "validation", "build", "tests", "lint", "verify", "项目", "验证", "构建", "测试", "检查"]) + "Project validation. Project scripts may modify files or access network. Rust: format_check/check/lint/test; Go: check/test. Rust lint: cargo clippy --all-targets -- -D clippy::correctness; cargo_clippy lint evidence; requires project_validation_rust_clippy_v1. Missing Clippy: unavailable, no install/fallback. Node >=22.3: check picks check > typecheck > lint; lint selects only scripts.lint with check identity; node --run without pre/post. Node TAP test only for exact scripts.test=node --test or node --test --test-reporter=tap. Vitest/Jest: fixed installed CLI, project_validation_node_json_v1 per-Job JSON counts. Other scripts can't prove counts. scope packages 1..8 or all_packages=true: Rust check/lint/test, Go check/test; Rust requires proven Cargo workspace root, Go single module. test.filter: Rust substring, Go -run, pytest -k; not for Node. require_tests/min_tests need proven counts. Python tools preinstalled; Ruff requires local pyproject.toml [tool.ruff] target-version, no extend/fixes/cache/install. locked: Cargo --locked/Go -mod=readonly; independent from offline; not Python/Node/format. dependency_resolution_policy=offline: Rust check/test Cargo --offline only; build scripts/tests may use network, not an OS sandbox. Rust lint explicitly rejects offline pending maintainer approval. Omission keeps defaults; unsupported backends/actions fail closed. Go fixes GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Same Runner Job; revalidate provenance at queue; observe pending, never retry.", ).with_discovery_keywords(&["project", "validation", "build", "tests", "lint", "verify", "项目", "验证", "构建", "测试", "检查"]) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, diff --git a/crates/webcodex-validation/src/adapters/mod.rs b/crates/webcodex-validation/src/adapters/mod.rs index d45b2f23c..498400181 100644 --- a/crates/webcodex-validation/src/adapters/mod.rs +++ b/crates/webcodex-validation/src/adapters/mod.rs @@ -93,6 +93,7 @@ pub struct GoTestOptions { pub enum CargoReadOnlyValidationOperation { FormatCheck, Check(CargoCheckOptions), + Clippy(CargoCheckOptions), Test(CargoTestOptions), } @@ -165,7 +166,8 @@ impl ReadOnlyValidationOperation { | Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => { return Err("dependency_policy_unsupported") } - Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) + | Self::Cargo(CargoReadOnlyValidationOperation::Clippy(options)) => { options.dependency_mode = Some(policy.mode); } Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => { @@ -220,7 +222,8 @@ impl ReadOnlyValidationOperation { | Self::NodeScriptCheck | Self::NodeNativeTest | Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => None, - Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) + | Self::Cargo(CargoReadOnlyValidationOperation::Clippy(options)) => { options.dependency_mode } Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => options.dependency_mode, @@ -231,7 +234,10 @@ impl ReadOnlyValidationOperation { fn selects_all_packages(&self) -> bool { match self { - Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => options.all_packages, + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) + | Self::Cargo(CargoReadOnlyValidationOperation::Clippy(options)) => { + options.all_packages + } Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => options.all_packages, Self::Go(GoReadOnlyValidationOperation::Check(options)) => options.all_packages, Self::Go(GoReadOnlyValidationOperation::Test(options)) => options.all_packages, @@ -278,6 +284,12 @@ impl ReadOnlyValidationOperation { validation_identity: ToolValidationIdentityKind::CargoCheck, } } + Self::Cargo(CargoReadOnlyValidationOperation::Clippy(_)) => { + ValidationCompatibilityProfile { + tool_identity: "cargo_clippy", + validation_identity: ToolValidationIdentityKind::CargoClippy, + } + } Self::Cargo(CargoReadOnlyValidationOperation::Test(_)) => { ValidationCompatibilityProfile { tool_identity: "cargo_test", @@ -324,7 +336,8 @@ impl ReadOnlyValidationOperation { check: true, ..ValidationCommandOptions::default() }), - Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) + | Self::Cargo(CargoReadOnlyValidationOperation::Clippy(options)) => { adapter.build_readonly_plan(ValidationCommandOptions::from(options.clone())) } Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => { @@ -355,7 +368,8 @@ impl ReadOnlyValidationOperation { "cwd": cwd, "check": true, }), - Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => serde_json::json!({ + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) + | Self::Cargo(CargoReadOnlyValidationOperation::Clippy(options)) => serde_json::json!({ "cwd": cwd, "all_targets": options.all_targets, "all_features": options.all_features, @@ -439,6 +453,14 @@ pub fn project_validation_operation( ..Default::default() }), )), + ("rust", Lint) => Ok(ReadOnlyValidationOperation::Cargo( + CargoReadOnlyValidationOperation::Clippy(CargoCheckOptions { + packages, + all_packages, + ..Default::default() + }), + )), + (_, Lint) => Err("validation_action_unsupported"), ("rust", Test) => Ok(ReadOnlyValidationOperation::Cargo( CargoReadOnlyValidationOperation::Test(CargoTestOptions { packages, @@ -662,6 +684,7 @@ pub fn validation_adapter_for_recipe( validation_adapter_for_tool(match (backend, action) { ("rust", Format) => "cargo_fmt", ("rust", Check) => "cargo_check", + ("rust", Lint) => "cargo_clippy", ("rust", Test) => "cargo_test", ("go", Check) => "go_vet", ("go", Test) => "go_test", diff --git a/crates/webcodex-validation/src/adapters/rust.rs b/crates/webcodex-validation/src/adapters/rust.rs index 1a1b47d55..cb0f601d4 100644 --- a/crates/webcodex-validation/src/adapters/rust.rs +++ b/crates/webcodex-validation/src/adapters/rust.rs @@ -14,6 +14,7 @@ use webcodex_core::validation_evidence::{ enum RustAdapterKind { Format, Check, + Clippy, Test, } @@ -34,9 +35,14 @@ static CARGO_TEST_ADAPTER: RustValidationAdapter = RustValidationAdapter { kind: RustAdapterKind::Test, tool_identity: "cargo_test", }; -static RUST_ADAPTERS: [&dyn ValidationAdapter; 3] = [ +static CARGO_CLIPPY_ADAPTER: RustValidationAdapter = RustValidationAdapter { + kind: RustAdapterKind::Clippy, + tool_identity: "cargo_clippy", +}; +static RUST_ADAPTERS: [&dyn ValidationAdapter; 4] = [ &CARGO_FMT_ADAPTER, &CARGO_CHECK_ADAPTER, + &CARGO_CLIPPY_ADAPTER, &CARGO_TEST_ADAPTER, ]; @@ -49,6 +55,7 @@ impl ValidationEvidenceProfile for RustValidationAdapter { match self.kind { RustAdapterKind::Format => "format", RustAdapterKind::Check => "check", + RustAdapterKind::Clippy => "lint", RustAdapterKind::Test => "test", } } @@ -64,7 +71,7 @@ impl ValidationEvidenceProfile for RustValidationAdapter { truncated: bool, ) -> ValidationDiagnostics { match self.kind { - RustAdapterKind::Format | RustAdapterKind::Check => { + RustAdapterKind::Format | RustAdapterKind::Check | RustAdapterKind::Clippy => { parse_cargo_check_diagnostics(stdout_excerpt, stderr_excerpt, truncated) } RustAdapterKind::Test => { @@ -163,6 +170,27 @@ impl ValidationAdapter for RustValidationAdapter { ], ) } + RustAdapterKind::Clippy => { + if options.dependency_resolution_policy.is_some() { + return Err("dependency_resolution_policy_unsupported".into()); + } + let mut options = options; + options.all_targets = Some(true); + let plan = cargo_check_plan(options)?; + let mut step = plan.structured_step; + step.name = "lint".into(); + step.args[0] = "clippy".into(); + step.args + .extend(["--", "-D", "clippy::correctness"].map(str::to_string)); + read_only_validation_plan( + "lint", + "cargo", + step.args + .into_iter() + .map(ValidationPlanArg::Value) + .collect(), + ) + } RustAdapterKind::Check => cargo_check_plan(options), RustAdapterKind::Test => cargo_test_plan(options), } diff --git a/crates/webcodex-validation/src/evidence.rs b/crates/webcodex-validation/src/evidence.rs index 384e477c8..53790fa2d 100644 --- a/crates/webcodex-validation/src/evidence.rs +++ b/crates/webcodex-validation/src/evidence.rs @@ -1027,7 +1027,7 @@ fn execution_purpose(event: &SessionEvent) -> Option { return Some( match action { "format_check" => "format", - "check" => "validation", + "check" | "lint" => "validation", "test" => "test", _ => return None, } diff --git a/crates/webcodex-validation/src/profile_tests.rs b/crates/webcodex-validation/src/profile_tests.rs index e0a641c9e..1d0d5e9a1 100644 --- a/crates/webcodex-validation/src/profile_tests.rs +++ b/crates/webcodex-validation/src/profile_tests.rs @@ -1122,3 +1122,136 @@ fn node_native_tap_parser_matches_real_node_process() { .test_summary.is_none()); } } + +#[test] +fn rust_clippy_scope_policy_identity_and_evidence_are_distinct() { + use webcodex_core::project_validation::{ + ProjectDependencyMode, ProjectDependencyPolicy, ProjectDependencyResolutionPolicy, + }; + let lint = project_validation_operation("rust", SemanticCheck::Lint, None, false).unwrap(); + let plain = lint.validation_target_id(Some(".")); + assert_eq!(lint.compatibility_profile().tool_identity, "cargo_clippy"); + let step = lint.build_readonly_plan().unwrap().structured_step; + assert_eq!(step.name, "lint"); + assert_eq!( + step.args, + ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + ); + assert!(step.is_canonical()); + assert!(!is_known_tool_name("cargo_clippy")); + for action in [SemanticCheck::Check, SemanticCheck::Test] { + assert_ne!( + plain, + project_validation_operation("rust", action, None, false) + .unwrap() + .validation_target_id(Some(".")) + ); + } + let scoped = project_validation_operation( + "rust", + SemanticCheck::Lint, + Some(vec!["b".into(), "a".into(), "a".into()]), + false, + ) + .unwrap(); + let reordered = project_validation_operation( + "rust", + SemanticCheck::Lint, + Some(vec!["a".into(), "b".into()]), + false, + ) + .unwrap(); + assert_eq!( + scoped.validation_target_id(Some(".")), + reordered.validation_target_id(Some(".")) + ); + assert_ne!(plain, scoped.validation_target_id(Some("."))); + assert_eq!( + scoped.build_readonly_plan().unwrap().structured_step.args, + [ + "clippy", + "--all-targets", + "-p", + "a", + "-p", + "b", + "--", + "-D", + "clippy::correctness" + ] + ); + let all = project_validation_operation("rust", SemanticCheck::Lint, None, true).unwrap(); + assert_ne!(plain, all.validation_target_id(Some("."))); + let all_step = all.build_readonly_plan().unwrap().structured_step; + assert!(all_step.is_project_workspace_cargo()); + assert!(!all_step.is_canonical()); + let locked = lint + .clone() + .with_dependency_policy(Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + })) + .unwrap(); + assert_ne!(plain, locked.validation_target_id(Some("."))); + assert_eq!( + locked.build_readonly_plan().unwrap().structured_step.args, + [ + "clippy", + "--locked", + "--all-targets", + "--", + "-D", + "clippy::correctness" + ] + ); + assert!(lint + .clone() + .with_dependency_resolution_policy(Some(ProjectDependencyResolutionPolicy::Offline)) + .is_err()); + assert!(lint.clone().with_test_filter(Some("a")).is_err()); + for backend in ["go", "python", "node"] { + assert!(project_validation_operation(backend, SemanticCheck::Lint, None, false).is_err()); + } + let adapter = lint.evidence_profile(); + assert_eq!(adapter.validation_kind(), "lint"); + let warnings = adapter.parse( + "", + "warning: unused variable: `x`\n --> src/lib.rs:1:5\n", + false, + ); + let failure = adapter.map_failure_kind(ValidationFailureEvidence { + success: false, + exit_code: Some(101), + reported_failure_kind: None, + stdout_excerpt: "", + stderr_excerpt: "", + diagnostics: Some(&warnings), + }); + assert_eq!(failure, "process_exit"); + assert_eq!( + adapter.map_failure_kind(ValidationFailureEvidence { + success: true, + exit_code: Some(0), + reported_failure_kind: None, + stdout_excerpt: "", + stderr_excerpt: "", + diagnostics: Some(&warnings), + }), + "unknown" + ); + for args in [ + vec!["clippy", "--all-targets"], + vec!["clippy", "--all-targets", "--", "-D", "warnings"], + vec![ + "clippy", + "--offline", + "--all-targets", + "--", + "-D", + "clippy::correctness", + ], + ] { + let mut bad = step.clone(); + bad.args = args.into_iter().map(str::to_string).collect(); + assert!(!bad.is_canonical_project_step()); + } +} diff --git a/crates/webcodex-validation/src/recipe.rs b/crates/webcodex-validation/src/recipe.rs index 8be9195c7..d4ccbd733 100644 --- a/crates/webcodex-validation/src/recipe.rs +++ b/crates/webcodex-validation/src/recipe.rs @@ -44,11 +44,12 @@ pub enum SemanticCheck { Format, Check, Test, + Lint, } impl SemanticCheck { pub fn as_str(self) -> &'static str { - ["format", "check", "test"][self as usize] + ["format", "check", "test", "lint"][self as usize] } } @@ -764,6 +765,7 @@ fn select_node_script( let names: &[&str] = match check { SemanticCheck::Format => &["format:check", "format-check", "check:format"], SemanticCheck::Check => &["check", "typecheck", "lint"], + SemanticCheck::Lint => return Err(check_unavailable()), SemanticCheck::Test => &["test"], }; let name = names @@ -855,7 +857,9 @@ fn python_manifestless_steps( .map(|a| (*a).to_string()) .collect(), )), - SemanticCheck::Format | SemanticCheck::Check => Err(check_unavailable()), + SemanticCheck::Format | SemanticCheck::Check | SemanticCheck::Lint => { + Err(check_unavailable()) + } }) .collect() } @@ -871,6 +875,7 @@ fn step(check: SemanticCheck, program: &str, args: Vec) -> ShellJobValid fn tool_identity(step: &ShellJobValidationStep) -> String { match step.program.as_str() { + "cargo" if step.name == "lint" => "cargo_clippy".to_string(), "cargo" if step.name == "format" => "cargo_fmt".to_string(), "cargo" => format!("cargo_{}", step.name), "python" => format!( diff --git a/crates/webcodex-workflow-session/src/events.rs b/crates/webcodex-workflow-session/src/events.rs index 329a44c97..aa34f431c 100644 --- a/crates/webcodex-workflow-session/src/events.rs +++ b/crates/webcodex-workflow-session/src/events.rs @@ -1654,6 +1654,7 @@ fn copy_project_validation_evidence(summary: &mut Value, output: &Value) { &[ "cargo_fmt", "cargo_check", + "cargo_clippy", "cargo_test", "go_vet", "go_test", @@ -1664,7 +1665,7 @@ fn copy_project_validation_evidence(summary: &mut Value, output: &Value) { "node:tap:test", ][..], ), - ("action", &["format_check", "check", "test"][..]), + ("action", &["format_check", "check", "lint", "test"][..]), ("backend", &["rust", "go", "python", "node"][..]), ] { if let Some(value) = output diff --git a/docs/MCP.md b/docs/MCP.md index 9e0ed2c27..1ab39454d 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -430,15 +430,17 @@ work_on_project For intentional Go source formatting, `project_format` accepts `adapter="go"` or `auto` with 1–8 explicit `.go` paths under one nearest unambiguous `go.mod` recipe. It requires `project:write`, `job:run`, `project_format_v1` and the additive `project_format_go_v1` Runner capability. Installed `gofmt` reads private source bytes on stdin with no arguments and returns candidates on stdout; the Runner owns guarded writes and durable `state_changed` evidence. No workspace traversal, `-w`/rewrite/simplification flags, goimports, module loading, installs or automatic retries are introduced. `go.mod` is a bounded UTF-8 scope/identity witness, not parsed module semantics; native stdin fragments are accepted and formatting is not compilation. Missing tooling fails closed. Pending or uncertain work must be observed by its original Job, not redispatched. See [bounded project formatting](implementation/project-format-lifecycle.md). -For ordinary bounded project validation, prefer `project_validate`; project-authored scripts and tests may modify files or use network. It accepts only a closed `format_check` / `check` / `lint` / `test` intent plus an optional `auto` / `rust` / `go` / `python` / `node` adapter hint; the Runner resolves the nearest unambiguous recipe on its own registered filesystem and then starts the existing structured validation Job. Rust maps to `cargo fmt -- --check`, `cargo check --all-targets`, or `cargo test`; Go maps to `go vet ./...` or `go test -json ./...`. Go project validation runs in Runner-owned single-module mode with `GO111MODULE=on` and `GOWORK=off`, so ambient module mode or parent `go.work` selection cannot silently change the gateway's workspace semantics. Its validation target identity is domain-separated from ambient Go specialist evidence, so success under different workspace semantics cannot reconcile a gateway failure. The standalone `go_test` specialist keeps its existing environment behavior. Optional `scope` selects exactly one portable package intent: bounded `packages` (1..8 entries) narrows Rust check/test through repeated Cargo `-p` selectors and Go check/test through project-relative package patterns, while `all_packages=true` selects the complete project unit. Rust all-packages maps to Cargo `--workspace` only when the Runner proves the effective Cargo workspace root is exactly the registered Project root and binds the in-Project Cargo manifest graph into the existing re-plan fence; Go all-packages keeps the canonical `./...` single-module scope. Scoped formatting fails closed. Python uses existing configured/profile/PATH Python 3 for pytest test (`python -m pytest --color=no -rA`) and project-local Ruff check/format_check (explicit target-version, no fixes, cache or bytecode). Python scope and dependency policy fail closed. Node supports Runner-owned native script-based `check` through `node --run ` (see #995), plus a separate, opt-in native TAP `test` profile when `package.json` declares exactly `node --test` (or `node --test --test-reporter=tap`). The native test executes fixed Runner argv, parses only complete TAP v13 summaries, and requires the distinct `project_validation_node_tap_v1` capability. Direct `scripts.test` values `vitest`, `vitest run`, and `jest` also support native tests through the installed local framework CLI. The Runner adds fixed official JSON reporter flags and a fresh private per-Job output file, bounded to 1 MiB. Only a complete consistent file supplies executed counts; stdout/stderr never do. Reports are removed after aggregate evidence is retained. Missing/oversized/invalid reports, timeout and cancellation cannot prove counts. JSON admission requires `project_validation_node_json_v1`; compound/custom scripts return `structured_test_report_unavailable` and remain usable through ordinary recipes. Node scope and filters remain unsupported. See [Node native TAP validation](implementation/node-native-tap-project-validation.md). Python test planning and Job admission require `project_validation_python_pytest_v1`, while Ruff check/format_check require the separate `project_validation_python_ruff_v1` capability. Missing selected tooling is a definite not-started failure with no automatic installation or fallback. See [Python/pytest validation](implementation/python-pytest-project-validation.md) for environment selection, evidence and same-Job behavior. The request never carries arbitrary executable, argv, shell grammar, installation or an explicit source-mutation intent; project-authored scripts and tests can still cause effects. Existing `cargo_*` / `go_test` tools remain available for ecosystem-specific advanced options. `project_validate` requires the additive `project_validation_v1` Runner capability; explicit `packages` additionally require `project_validation_package_scope_v1`, while `all_packages=true` requires `project_all_packages_v1`. Go project-validation Job admission additionally requires `project_go_single_module_v1`, so a Server cannot hand a Go gateway plan to an older Runner that may inherit ambient workspace state. +For ordinary bounded project validation, prefer `project_validate`; project-authored scripts and tests may modify files or use network. It accepts only a closed `format_check` / `check` / `lint` / `test` intent plus an optional `auto` / `rust` / `go` / `python` / `node` adapter hint; the Runner resolves the nearest unambiguous recipe on its own registered filesystem and then starts the existing structured validation Job. Rust maps to `cargo fmt -- --check`, `cargo check --all-targets`, `cargo clippy --all-targets -- -D clippy::correctness`, or `cargo test`; Go maps to `go vet ./...` or `go test -json ./...`. Go project validation runs in Runner-owned single-module mode with `GO111MODULE=on` and `GOWORK=off`, so ambient module mode or parent `go.work` selection cannot silently change the gateway's workspace semantics. Its validation target identity is domain-separated from ambient Go specialist evidence, so success under different workspace semantics cannot reconcile a gateway failure. The standalone `go_test` specialist keeps its existing environment behavior. Optional `scope` selects exactly one portable package intent: bounded `packages` (1..8 entries) narrows Rust check/lint/test through repeated Cargo `-p` selectors and Go check/test through project-relative package patterns, while `all_packages=true` selects the complete project unit. Rust all-packages maps to Cargo `--workspace` only when the Runner proves the effective Cargo workspace root is exactly the registered Project root and binds the in-Project Cargo manifest graph into the existing re-plan fence; Go all-packages keeps the canonical `./...` single-module scope. Scoped formatting fails closed. Python uses existing configured/profile/PATH Python 3 for pytest test (`python -m pytest --color=no -rA`) and project-local Ruff check/format_check (explicit target-version, no fixes, cache or bytecode). Python scope and dependency policy fail closed. Node supports Runner-owned native script-based `check` through `node --run ` (see #995), plus a separate, opt-in native TAP `test` profile when `package.json` declares exactly `node --test` (or `node --test --test-reporter=tap`). The native test executes fixed Runner argv, parses only complete TAP v13 summaries, and requires the distinct `project_validation_node_tap_v1` capability. Direct `scripts.test` values `vitest`, `vitest run`, and `jest` also support native tests through the installed local framework CLI. The Runner adds fixed official JSON reporter flags and a fresh private per-Job output file, bounded to 1 MiB. Only a complete consistent file supplies executed counts; stdout/stderr never do. Reports are removed after aggregate evidence is retained. Missing/oversized/invalid reports, timeout and cancellation cannot prove counts. JSON admission requires `project_validation_node_json_v1`; compound/custom scripts return `structured_test_report_unavailable` and remain usable through ordinary recipes. Node scope and filters remain unsupported. See [Node native TAP validation](implementation/node-native-tap-project-validation.md). Python test planning and Job admission require `project_validation_python_pytest_v1`, while Ruff check/format_check require the separate `project_validation_python_ruff_v1` capability. Missing selected tooling is a definite not-started failure with no automatic installation or fallback. See [Python/pytest validation](implementation/python-pytest-project-validation.md) for environment selection, evidence and same-Job behavior. The request never carries arbitrary executable, argv, shell grammar, installation or an explicit source-mutation intent; project-authored scripts and tests can still cause effects. Existing `cargo_*` / `go_test` tools remain available for ecosystem-specific advanced options. `project_validate` requires the additive `project_validation_v1` Runner capability; explicit `packages` additionally require `project_validation_package_scope_v1`, while `all_packages=true` requires `project_all_packages_v1`. Go project-validation Job admission additionally requires `project_go_single_module_v1`, so a Server cannot hand a Go gateway plan to an older Runner that may inherit ambient workspace state. Cargo all-packages provenance is a bounded package-selection witness, not a complete build-input snapshot. It requires a contained workspace, or a standalone package with no ancestor `Cargo.toml` marker; parent markers are only probed, never read outside the registered Project. External path dependencies are unavailable in this scope because their `package.workspace` metadata can add out-of-Project members. Relevant manifest/member/dependency aliases remain fenced, while unrelated non-manifest links are ignored. Unproven topology or exhausted bounds returns `validation_scope_unavailable` / `build_scope_unavailable`; explicit package scope and the existing specialist tools retain their contracts. For ordinary portable Rust/Go/Python/Node builds, prefer `project_build`. It accepts only an exact registered `project`, optional project-relative `cwd`, an optional `auto` / `rust` / `go` / `python` / `node` adapter hint, optional portable `scope` selecting either bounded `packages` (1..8 entries) or `all_packages=true`, and total `timeout_secs`. The Runner resolves the nearest unambiguous recipe and owns canonical argv: Rust maps explicit packages to repeated `-p` selectors and all-packages to `cargo build --workspace` only after proving the effective Cargo workspace root is the registered Project root; Go maps explicit package patterns directly and all-packages to `go build ./...`. Go project builds execute with Runner-owned `GO111MODULE=on` and `GOWORK=off`; full `go.work` workspace semantics are outside the v1 gateway rather than inherited implicitly from the Runner host. The request cannot provide an executable, argv, shell, script, release/profile/target/features, native workspace/exclude flags, offline/network policy, or artifact-discovery contract. Portable all-packages requests require the additive `project_all_packages_v1` Runner capability. Node executes the existing manifest-declared `build` script through detected npm/pnpm/yarn/bun, reusing packageManager/lockfile detection; missing/conflicting manager evidence and missing build scripts fail closed. Node rejects scope and dependency policy and requires `project_build_node_v1`. No dependencies or managers are installed; Runner overrides Corepack network/download/auto-pin settings to prevent implicit manager bootstrap. Build scripts and lifecycle hooks may write files or use network. Python requires `pyproject.toml`, rejects package/all-packages scope, and runs `python -m build --no-isolation` using the existing configured/profile/PATH Python interpreter. Default packaging creates an sdist then builds its wheel from the sdist; frontend and backend dependency checks remain enabled. Dependencies must already exist: the adapter never installs packages or creates an environment. `dependency_policy.mode=locked` retains these same no-install semantics. -Both gateways optionally accept `dependency_policy: {"mode":"locked"}`. This is a portable dependency-resolution guarantee, not a literal cross-ecosystem flag contract: Rust build/check/test use Cargo `--locked`, while Go build/vet/test use `-mod=readonly`. The policy tells the adapter not to repair project dependency selection state in order to make the operation succeed; it does **not** disable registry/module/toolchain network access. Offline dependency resolution is available as an opt-in Rust-only validation extension in #962. `project_validate(action="format_check")` rejects the dependency policy instead of silently ignoring it. Policy-bearing planning and typed Job admission both require the additive `project_dependency_policy_v1` Runner capability. Locked validation derives a distinct durable validation target identity, while requests that omit the policy preserve the historical argv and identity. +Both gateways optionally accept `dependency_policy: {"mode":"locked"}`. This is a portable dependency-resolution guarantee, not a literal cross-ecosystem flag contract: Rust build/check/lint/test use Cargo `--locked`, while Go build/vet/test use `-mod=readonly`. The policy tells the adapter not to repair project dependency selection state in order to make the operation succeed; it does **not** disable registry/module/toolchain network access. Offline dependency resolution is available as an opt-in Rust-only validation extension in #962. `project_validate(action="format_check")` rejects the dependency policy instead of silently ignoring it. Policy-bearing planning and typed Job admission both require the additive `project_dependency_policy_v1` Runner capability. Locked validation derives a distinct durable validation target identity, while requests that omit the policy preserve the historical argv and identity. -For Rust `project_validate(action="check"|"test")`, the optional `dependency_resolution_policy: "offline"` requests Cargo dependency resolution/fetching with Runner-owned `--offline`. It is independent of `dependency_policy: {"mode":"locked"}` and can be combined with it; omission preserves existing argv and validation identity. Runner-resolved non-Rust recipes and unsupported actions fail before execution instead of silently running online. A missing local dependency cache is an ordinary failed validation, never a reason to retry online. Offline and default executions have distinct canonical invocation and validation-target identities; durable Jobs and test evidence remain unchanged. **This does not sandbox the process**: build scripts and tests can still access the network, and `project_build` does not accept this policy. +For Rust `project_validate(action="lint", adapter="rust"|"auto")` selects Runner-owned Cargo Clippy with fixed `--all-targets -- -D clippy::correctness`. It has distinct `cargo_clippy` tool/evidence identity and `lint` kind; Rust check/test and Node lint identities stay unchanged (Node lint still selects only `scripts.lint` with check evidence). Scope supports 1..8 sorted/deduplicated packages or the proven Cargo workspace through `all_packages=true`. Independent `dependency_policy: {"mode":"locked"}` adds `--locked` and changes target identity. Rust lint **rejects** `dependency_resolution_policy=offline` pending maintainer approval. Explicit Rust lint planning and every resolved Clippy Job admission require `project_validation_rust_clippy_v1`; auto planning keeps existing Node support and gates the resolved backend before execution. Missing Clippy returns `validation_tool_unavailable` before the validation command starts, without install, arbitrary argv, shell fallback or redispatch. Go/Python lint are unsupported. Manifest/lock/workspace provenance is revalidated at queue admission and execution; drift fails closed. One durable Job owns execution and observation. Exit zero may include warnings; nonzero, missing exit status, timeout or cancellation cannot become successful lint evidence. + +Rust `project_validate(action="check"|"test")`, the optional `dependency_resolution_policy: "offline"` requests Cargo dependency resolution/fetching with Runner-owned `--offline`. It is independent of `dependency_policy: {"mode":"locked"}` and can be combined with it; omission preserves existing argv and validation identity. Runner-resolved non-Rust recipes and unsupported actions fail before execution instead of silently running online. A missing local dependency cache is an ordinary failed validation, never a reason to retry online. Offline and default executions have distinct canonical invocation and validation-target identities; durable Jobs and test evidence remain unchanged. **This does not sandbox the process**: build scripts and tests can still access the network, and `project_build` does not accept this policy. A successful Node build proves the declared script completed. It does not prove a particular output exists or works. Use `finish_coding_task(outputs=...)` for explicit output metadata inspection; artifact presence is separate from functionality. diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index 9f021bc2e..54b34ac7c 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -326,7 +326,7 @@ work_on_project 需要有意修改 Go 源码格式时,`project_format` 接受 `adapter="go"`,或配合显式 `.go` 路径使用 `auto`;一次选择同一最近且无歧义 `go.mod` recipe 下的 1–8 个文件。需要 `project:write`、`job:run`、`project_format_v1` 及新增的 `project_format_go_v1` Runner capability。已安装的 `gofmt` 使用固定空参数,从 stdin 读取私有源内容、向 stdout 输出候选;受保护写入和持久 `state_changed` 证据仍由 Runner 管理。不增加工作区遍历、`-w`/重写/简化参数、goimports、模块加载、安装或自动重试。`go.mod` 仅作有界 UTF-8 范围与文件身份凭据,不解析模块语义;保留 gofmt stdin 接受代码片段的原生行为,格式化不等于编译验证。工具缺失时明确失败。pending 或不确定结果须观察原 Job,不得重新派发。详见[有界项目格式化](implementation/project-format-lifecycle.md)。 -普通的有界 Project validation 优先使用 `project_validate`;项目自定义脚本与测试仍可能修改文件或访问网络。它只接受封闭的 `format_check` / `check` / `test` intent,以及可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint;Runner 在自己注册的真实文件系统上解析最近且无歧义的 recipe,然后进入现有 structured validation Job。Rust 分别映射到 `cargo fmt -- --check`、`cargo check --all-targets`、`cargo test`;Go 映射到 `go vet ./...` 或 `go test -json ./...`。Go project validation 由 Runner 固定为 single-module 模式(`GO111MODULE=on`、`GOWORK=off`),因此 ambient module mode 或父目录 `go.work` 选择不会静默改变 gateway 的 workspace 语义;其 validation target identity 与 ambient Go specialist evidence 做 domain separation,因此不同 workspace 语义下产生的成功不会消解 gateway failure。独立的 `go_test` specialist 保持现有环境语义。可选 `scope` 只允许二选一的 portable package intent:有界 `packages`(1..8 项)把 Rust check/test 映射为重复 Cargo `-p` selector,把 Go check/test 映射为 project-relative package pattern;`all_packages=true` 则选择完整 project unit。Rust all-packages 只有在 Runner 证明 effective Cargo workspace root 与 registered Project root 完全一致后才映射为 Cargo `--workspace`,并把 Project 内 Cargo manifest graph 绑定到既有 re-plan fence;Go all-packages 保持 canonical `./...` single-module scope。带 scope 的格式检查会 fail closed。Python 使用 configured/profile/PATH 中已有的 Python 3 执行 pytest test(`python -m pytest --color=no -rA`),也支持项目本地 Ruff check/format_check(显式 target-version、禁用自动修复、缓存与字节码);Python 的 scope 与 dependency policy 均 fail closed。Node 已支持 Runner 管理的脚本式 `check`(#995),并在 `package.json` 的 `scripts.test` 精确声明 `node --test`(或 `node --test --test-reporter=tap`)时支持独立的原生 TAP `test`。Node 测试由 Runner 使用固定 argv 直接启动,仅完整 TAP v13 汇总可证明测试数量,且需要独立能力 `project_validation_node_tap_v1`。复合/自定义 test 脚本、scope 和 filter 仍不支持;下面说明独立的原生 Vitest/Jest JSON profile。详见 [Node 原生 TAP 验证](implementation/node-native-tap-project-validation.md)。Python test 的 planning 与 Job 准入要求 `project_validation_python_pytest_v1`,Ruff check/format_check 则独立要求 `project_validation_python_ruff_v1`;所选工具缺失时明确 not-started,不自动安装或 fallback。环境、证据与同 Job 行为见 [Python/pytest validation](implementation/python-pytest-project-validation.md)。请求不会携带 arbitrary executable、argv、shell grammar、安装动作或显式修改源文件的意图;项目脚本和测试仍可能产生副作用;需要 ecosystem-specific 高级参数时继续使用现有 `cargo_*` / `go_test`。`project_validate` 依赖 additive `project_validation_v1` Runner capability;显式 `packages` 额外要求 `project_validation_package_scope_v1`,而 `all_packages=true` 要求 `project_all_packages_v1`;Go project-validation Job 准入还额外要求 `project_go_single_module_v1`,因此 Server 不会把 Go gateway plan 交给仍可能继承 ambient workspace 状态的旧 Runner。 +普通的有界 Project validation 优先使用 `project_validate`;项目自定义脚本与测试仍可能修改文件或访问网络。它只接受封闭的 `format_check` / `check` / `lint` / `test` intent,以及可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint;Runner 在自己注册的真实文件系统上解析最近且无歧义的 recipe,然后进入现有 structured validation Job。Rust 分别映射到 `cargo fmt -- --check`、`cargo check --all-targets`、`cargo clippy --all-targets -- -D clippy::correctness`、`cargo test`;Go 映射到 `go vet ./...` 或 `go test -json ./...`。Go project validation 由 Runner 固定为 single-module 模式(`GO111MODULE=on`、`GOWORK=off`),因此 ambient module mode 或父目录 `go.work` 选择不会静默改变 gateway 的 workspace 语义;其 validation target identity 与 ambient Go specialist evidence 做 domain separation,因此不同 workspace 语义下产生的成功不会消解 gateway failure。独立的 `go_test` specialist 保持现有环境语义。可选 `scope` 只允许二选一的 portable package intent:有界 `packages`(1..8 项)把 Rust check/lint/test 映射为重复 Cargo `-p` selector,把 Go check/test 映射为 project-relative package pattern;`all_packages=true` 则选择完整 project unit。Rust all-packages 只有在 Runner 证明 effective Cargo workspace root 与 registered Project root 完全一致后才映射为 Cargo `--workspace`,并把 Project 内 Cargo manifest graph 绑定到既有 re-plan fence;Go all-packages 保持 canonical `./...` single-module scope。带 scope 的格式检查会 fail closed。Python 使用 configured/profile/PATH 中已有的 Python 3 执行 pytest test(`python -m pytest --color=no -rA`),也支持项目本地 Ruff check/format_check(显式 target-version、禁用自动修复、缓存与字节码);Python 的 scope 与 dependency policy 均 fail closed。Node 已支持 Runner 管理的脚本式 `check`(#995),并在 `package.json` 的 `scripts.test` 精确声明 `node --test`(或 `node --test --test-reporter=tap`)时支持独立的原生 TAP `test`。Node 测试由 Runner 使用固定 argv 直接启动,仅完整 TAP v13 汇总可证明测试数量,且需要独立能力 `project_validation_node_tap_v1`。复合/自定义 test 脚本、scope 和 filter 仍不支持;下面说明独立的原生 Vitest/Jest JSON profile。详见 [Node 原生 TAP 验证](implementation/node-native-tap-project-validation.md)。Python test 的 planning 与 Job 准入要求 `project_validation_python_pytest_v1`,Ruff check/format_check 则独立要求 `project_validation_python_ruff_v1`;所选工具缺失时明确 not-started,不自动安装或 fallback。环境、证据与同 Job 行为见 [Python/pytest validation](implementation/python-pytest-project-validation.md)。请求不会携带 arbitrary executable、argv、shell grammar、安装动作或显式修改源文件的意图;项目脚本和测试仍可能产生副作用;需要 ecosystem-specific 高级参数时继续使用现有 `cargo_*` / `go_test`。`project_validate` 依赖 additive `project_validation_v1` Runner capability;显式 `packages` 额外要求 `project_validation_package_scope_v1`,而 `all_packages=true` 要求 `project_all_packages_v1`;Go project-validation Job 准入还额外要求 `project_go_single_module_v1`,因此 Server 不会把 Go gateway plan 交给仍可能继承 ambient workspace 状态的旧 Runner。 Cargo all-packages provenance 是有界的 package-selection witness,并非完整构建输入快照。它要求 workspace 完全位于 registered Project 内,或独立 package 的祖先目录不存在 `Cargo.toml` marker;对 Project 外的祖先只探测 marker,不读取 manifest 内容。此 scope 不接受外部 path dependency,因为外部 manifest 的 `package.workspace` 可以把 Project 外 package 加入 workspace。相关 manifest/member/dependency alias 仍会被 fence,无关的非 manifest 链接会被忽略。无法证明的 topology 或超出边界上限时返回 `validation_scope_unavailable` / `build_scope_unavailable`;显式 package scope 和现有 specialist tools 保持各自契约。 @@ -337,7 +337,9 @@ Node 构建通过检测到的 npm/pnpm/yarn/bun 执行 `package.json` 中已有 Node `project_validate(action=test)` 还支持 `scripts.test` 精确为 `vitest`、`vitest run` 或 `jest` 的已安装本地框架。Runner 使用固定官方 JSON reporter 参数与每个 Job 独占的新建私有报告文件(上限 1 MiB)。只有完整且一致的报告才能证明已执行测试数;不会从 stdout/stderr 推测数量。聚合 evidence 保留后删除报告。缺失、超限、无效报告、超时与取消均不能证明数量。JSON Job admission 要求 `project_validation_node_json_v1`。复合或自定义脚本返回 `structured_test_report_unavailable`,仍可用普通 recipe 执行;Node scope/filter 仍不支持。 -两个 gateway 都可选接受 `dependency_policy: {"mode":"locked"}`。这是 portable 的依赖解析保证,而不是宣称不同生态的原生 flag 完全等价:Rust build/check/test 映射为 Cargo `--locked`,Go build/vet/test 映射为 `-mod=readonly`。它要求 adapter 不得为了让本次操作成功而修复或改写项目级依赖选择状态,但**不**表示关闭 registry/module/toolchain 网络访问;#962 中的 Rust 验证已新增可选的离线依赖解析策略。`project_validate(action="format_check")` 会拒绝该 policy,而不是静默忽略。携带 policy 的 planning 与 typed Job admission 都要求 additive `project_dependency_policy_v1` Runner capability。locked validation 使用独立的 durable validation target identity;省略 policy 的请求保持原有 argv 与 identity。 +两个 gateway 都可选接受 `dependency_policy: {"mode":"locked"}`。这是 portable 的依赖解析保证,而不是宣称不同生态的原生 flag 完全等价:Rust build/check/lint/test 映射为 Cargo `--locked`,Go build/vet/test 映射为 `-mod=readonly`。它要求 adapter 不得为了让本次操作成功而修复或改写项目级依赖选择状态,但**不**表示关闭 registry/module/toolchain 网络访问;#962 中的 Rust 验证已新增可选的离线依赖解析策略。`project_validate(action="format_check")` 会拒绝该 policy,而不是静默忽略。携带 policy 的 planning 与 typed Job admission 都要求 additive `project_dependency_policy_v1` Runner capability。locked validation 使用独立的 durable validation target identity;省略 policy 的请求保持原有 argv 与 identity。 + +Rust `project_validate(action="lint", adapter="rust"|"auto")` 使用 Runner 固定的 Cargo Clippy 命令 `--all-targets -- -D clippy::correctness`,采用独立的 `cargo_clippy` 工具/证据身份与 `lint` kind。Rust check/test 与 Node lint 身份保持不变;Node lint 仍只选择 `scripts.lint`,保留 check 证据。scope 支持 1..8 个排序去重 package,或经证明的 Cargo workspace(`all_packages=true`)。独立的 `dependency_policy: {"mode":"locked"}` 添加 `--locked` 并改变目标身份。Rust lint **拒绝** `dependency_resolution_policy=offline`,需维护者批准后才可支持。显式 Rust lint planning 和所有已解析 Clippy Job 准入均要求 `project_validation_rust_clippy_v1`;auto planning 保留现有 Node 支持,并在执行前检查实际 backend 能力。Clippy 缺失时在验证命令启动前返回 `validation_tool_unavailable`,不会安装、接受任意 argv、回退 shell 或重新派发。Go/Python lint 明确不支持。队列准入与执行前重新验证 manifest/lock/workspace provenance,发生漂移则 fail closed。同一个持久 Job 负责执行和观察。退出码为零时可保留警告;非零、缺失退出码、超时或取消均不能产生成功 lint 证据。 Rust `project_validate(action="check"|"test")` 可选传入 `dependency_resolution_policy: "offline"`,由 Runner 为 Cargo 生成固定的 `--offline`,仅禁止 Cargo 自身在线解析/获取依赖。它与 `dependency_policy: {"mode":"locked"}` 独立,可同时使用;省略时保持原有命令与验证身份。对非 Rust recipe 或其他 action,必须在执行前明确拒绝,不会悄悄回退在线运行;本地缓存不足时验证直接失败,不会自动在线重试。在线/离线请求有不同的规范命令与验证目标身份,原有持久 Job 和测试证据语义不变。**这不是操作系统级网络沙盒**:构建脚本和测试依然可能访问网络;`project_build` 不支持此策略。 diff --git a/src/runner_quic.rs b/src/runner_quic.rs index f9598b70d..2864d428a 100644 --- a/src/runner_quic.rs +++ b/src/runner_quic.rs @@ -628,6 +628,7 @@ mod tests { project_validation_python_ruff_v1: false, project_validation_node_script_check_v1: false, project_validation_node_tap_v1: false, + project_validation_rust_clippy_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/runner_ws.rs b/src/runner_ws.rs index c20c55b5b..1c9d07614 100644 --- a/src/runner_ws.rs +++ b/src/runner_ws.rs @@ -439,6 +439,7 @@ mod tests { project_validation_python_ruff_v1: false, project_validation_node_script_check_v1: false, project_validation_node_tap_v1: false, + project_validation_rust_clippy_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/tool_runtime/job_audit.rs b/src/tool_runtime/job_audit.rs index 63428db23..8037c016e 100644 --- a/src/tool_runtime/job_audit.rs +++ b/src/tool_runtime/job_audit.rs @@ -97,7 +97,7 @@ fn safe_kind(job: &ShellJobInfo, execution_source: Option<&str>) -> Option Some(validation.kind.clone()), + "format" | "check" | "lint" | "test" => Some(validation.kind.clone()), _ => None, }; } diff --git a/src/tool_runtime/jobs.rs b/src/tool_runtime/jobs.rs index 03d4f642a..1e6727411 100644 --- a/src/tool_runtime/jobs.rs +++ b/src/tool_runtime/jobs.rs @@ -156,7 +156,7 @@ pub(crate) fn detected_job_summary_with_activity( "summary": "Waiting for Cargo build lock", })) } else if cargo_command - && matches!(kind, "test" | "check" | "build" | "format") + && matches!(kind, "test" | "check" | "lint" | "build" | "format") && lower .lines() .any(|line| line.trim_start().starts_with("compiling ")) @@ -167,7 +167,7 @@ pub(crate) fn detected_job_summary_with_activity( "summary": "Cargo compilation in progress", })) } else if cargo_command - && matches!(kind, "test" | "check" | "build" | "format") + && matches!(kind, "test" | "check" | "lint" | "build" | "format") && lower .lines() .any(|line| line.trim_start().starts_with("checking ")) @@ -427,7 +427,7 @@ pub(crate) fn structured_validation_evidence( truncated: bool, ) -> StructuredValidationEvidence { let combined = format!("{stdout}\n{stderr}"); - let profile = (matches!(kind, "check" | "test") + let profile = (matches!(kind, "check" | "lint" | "test") || (kind == "format" && tool == "python:ruff:format")) .then(|| super::validation_profile::validation_evidence_profile_for_tool(tool)) .flatten() @@ -505,7 +505,7 @@ pub(crate) fn structured_validation_evidence( } // Preserve the existing check counter projection for known Cargo/Go // adapters; unknown check identities must not inherit Rustc parsing. - Some("cargo_check" | "go_vet") if !truncated => { + Some("cargo_check" | "cargo_clippy" | "go_vet") if !truncated => { evidence.warnings_count = Some(super::cargo::count_rustc_diagnostics(&combined, "warning:") as u64); evidence.errors_count = diff --git a/src/tool_runtime/tests/jobs.rs b/src/tool_runtime/tests/jobs.rs index 284ae6c24..911dd3b89 100644 --- a/src/tool_runtime/tests/jobs.rs +++ b/src/tool_runtime/tests/jobs.rs @@ -3588,3 +3588,32 @@ fn node_json_validation_uses_only_owned_report_evidence_and_same_job_policy() { } } } + +#[test] +fn rust_clippy_projection_requires_completed_zero_exit_and_keeps_warnings() { + use crate::tool_runtime::jobs::validation_job_projection; + for (status, exit, expected) in [ + ("completed", Some(0), json!(true)), + ("completed", None, json!(false)), + ("failed", Some(101), json!(false)), + ("timed_out", None, serde_json::Value::Null), + ("cancelled", None, serde_json::Value::Null), + ("lost", None, serde_json::Value::Null), + ] { + let value = validation_job_projection( + Some("cargo_clippy"), + Some("lint"), + status, + exit, + "", + "warning: unused variable: `x`\n --> src/lib.rs:1:5\n", + false, + None, + ) + .unwrap(); + assert_eq!(value["passed"], expected, "{status}: {value}"); + assert_eq!(value["kind"], "lint"); + assert!(value.get("diagnostics").is_some()); + assert!(value.get("tests_run_count").is_none()); + } +} diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index 17dd2501e..07c9c2748 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -529,6 +529,7 @@ async fn register_agent_projects_for_auth( project_validation_python_ruff_v1: false, project_validation_node_script_check_v1: false, project_validation_node_tap_v1: false, + project_validation_rust_clippy_v1: false, structured_go_test_tool: true, structured_go_test_packages: true, structured_process_argv: true, diff --git a/src/tool_runtime/tests/validation_handoff.rs b/src/tool_runtime/tests/validation_handoff.rs index ecdcef845..5e02e923e 100644 --- a/src/tool_runtime/tests/validation_handoff.rs +++ b/src/tool_runtime/tests/validation_handoff.rs @@ -69,6 +69,9 @@ pub(super) async fn complete_project_validation_plan_request( assert_eq!(semantic.action, expected_action); let check = match semantic.action { ProjectValidationAction::FormatCheck => webcodex_validation::SemanticCheck::Format, + ProjectValidationAction::Lint if backend == "rust" => { + webcodex_validation::SemanticCheck::Lint + } ProjectValidationAction::Check | ProjectValidationAction::Lint => { webcodex_validation::SemanticCheck::Check } diff --git a/src/tool_runtime/tests/validation_handoff/project_validation.rs b/src/tool_runtime/tests/validation_handoff/project_validation.rs index 74fcdb0d0..ee7426a72 100644 --- a/src/tool_runtime/tests/validation_handoff/project_validation.rs +++ b/src/tool_runtime/tests/validation_handoff/project_validation.rs @@ -14,6 +14,7 @@ async fn setup(grace_ms: u64) -> ToolRuntime { structured_validation_argv: true, structured_cargo_check_packages: true, project_validation_v1: true, + project_validation_rust_clippy_v1: true, project_go_single_module_v1: true, project_validation_package_scope_v1: true, project_all_packages_v1: true, @@ -428,6 +429,7 @@ async fn project_validation_package_scope_requires_additive_runner_capability() async_shell_jobs: true, structured_validation_argv: true, project_validation_v1: true, + project_validation_rust_clippy_v1: true, project_validation_package_scope_v1: false, project_all_packages_v1: false, ..Default::default() @@ -466,6 +468,7 @@ async fn project_validation_all_packages_requires_additive_runner_capability() { async_shell_jobs: true, structured_validation_argv: true, project_validation_v1: true, + project_validation_rust_clippy_v1: true, project_validation_package_scope_v1: true, project_all_packages_v1: false, ..Default::default() @@ -512,6 +515,7 @@ async fn project_validation_test_options_reject_before_any_plan_on_old_runner() async_shell_jobs: true, structured_validation_argv: true, project_validation_v1: true, + project_validation_rust_clippy_v1: true, project_validation_package_scope_v1: true, project_all_packages_v1: true, ..Default::default() @@ -826,6 +830,7 @@ async fn project_validation_python_pytest_old_runner_rejects_before_planning() { None, RunnerCapabilities { project_validation_v1: true, + project_validation_rust_clippy_v1: true, ..Default::default() }, ) @@ -1150,3 +1155,80 @@ async fn node_json_project_validation_handoff_owns_counts_and_keeps_freshness_un } } } + +#[tokio::test] +async fn rust_clippy_pending_observes_same_job_and_exit_status_controls_outcome() { + for (exit, status, stderr) in [ + ( + 0, + "completed", + "warning: unused variable: `x`\n --> src/lib.rs:1:5\n", + ), + ( + 101, + "failed", + "error: impossible comparison\n --> src/lib.rs:1:5\n", + ), + ] { + let runtime = setup(1).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + runtime + .dispatch_with_auth( + call(ProjectValidationAction::Lint, None), + Some(&auth_context(None, true)), + ) + .await + } + }); + let (request, job_id) = reply_plan(&runtime, "rust", ProjectValidationAction::Lint).await; + let metadata = request + .job_context + .as_ref() + .unwrap() + .validation + .as_ref() + .unwrap(); + assert_eq!(metadata.adapter, "cargo_clippy"); + assert_eq!(metadata.kind, "lint"); + assert!(metadata.is_valid()); + let result = task.await.unwrap(); + assert!(result.success, "{result:?}"); + assert_eq!( + result.output["continuation"]["arguments"]["items"][0]["job_id"], + job_id + ); + assert_model_cargo_result_matches_schema("project_validate", &result); + assert!(probe_patch_agent_request(&runtime, "project-validation") + .await + .is_none()); + runtime + .runner_registry + .update_job(cargo_test_update( + "project-validation", + &request.request_id, + &job_id, + status, + "", + stderr, + Some(exit), + completed_progress(), + true, + )) + .await + .unwrap(); + let observed = runtime + .job_status_for_auth(job_id.clone(), false, None) + .await; + assert_eq!( + observed.output["validation"]["passed"], + exit == 0, + "{observed:?}" + ); + assert_eq!(observed.output["validation"]["adapter"], "cargo_clippy"); + assert!(probe_patch_agent_request(&runtime, "project-validation") + .await + .is_none()); + } +} diff --git a/src/tool_runtime/validation.rs b/src/tool_runtime/validation.rs index 77b787d17..0cd059fee 100644 --- a/src/tool_runtime/validation.rs +++ b/src/tool_runtime/validation.rs @@ -539,6 +539,9 @@ impl ToolRuntime { } let semantic_action = match action { ProjectValidationAction::FormatCheck => webcodex_validation::SemanticCheck::Format, + ProjectValidationAction::Lint if plan.provenance.backend == "rust" => { + webcodex_validation::SemanticCheck::Lint + } ProjectValidationAction::Check | ProjectValidationAction::Lint => { webcodex_validation::SemanticCheck::Check } @@ -553,7 +556,9 @@ impl ToolRuntime { "validation adapter unavailable; upgrade Server and Runner together", ); }; - if adapter.tool_identity() != plan.adapter || adapter.validation_kind() != action.kind() { + if adapter.tool_identity() != plan.adapter + || adapter.validation_kind() != action.kind_for_backend(&plan.provenance.backend) + { return ToolResult::err("invalid Runner project validation evidence profile"); } let source_fence = self.validation_sources.capture(&resolved.resolved_id); diff --git a/src/tool_runtime/validation_profile/mod.rs b/src/tool_runtime/validation_profile/mod.rs index 879aab4b2..f3a1a6c74 100644 --- a/src/tool_runtime/validation_profile/mod.rs +++ b/src/tool_runtime/validation_profile/mod.rs @@ -45,7 +45,7 @@ pub(crate) fn runtime_profile(operation: &ReadOnlyValidationOperation) -> Valida invalid_argument_guidance: "fix the cargo argument format, then retry.", } } - ReadOnlyValidationOperation::Cargo(CargoReadOnlyValidationOperation::Check(_)) => { + ReadOnlyValidationOperation::Cargo(CargoReadOnlyValidationOperation::Check(_) | CargoReadOnlyValidationOperation::Clippy(_)) => { ValidationRuntimeProfile { default_timeout_secs: DEFAULT_CARGO_CHECK_TIMEOUT_SECS, force_agent_handoff: false, From 323c2ff6e6643857afee1085f49b459c309d57b6 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:12:44 +0800 Subject: [PATCH 2/4] fix(validation): preserve policy compatibility and bounded tool surface --- .../webcodex-core/src/project_validation.rs | 4 +--- .../src/project_validation_tests.rs | 22 +++++++++++++++++++ .../src/tool_definition/edits.rs | 2 +- docs/MCP.zh-CN.md | 6 ++--- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/webcodex-core/src/project_validation.rs b/crates/webcodex-core/src/project_validation.rs index 63bff5909..e4cb51645 100644 --- a/crates/webcodex-core/src/project_validation.rs +++ b/crates/webcodex-core/src/project_validation.rs @@ -149,9 +149,7 @@ impl ProjectValidationRequest { } } if self.dependency_policy.is_some() && self.action == ProjectValidationAction::FormatCheck { - return Err( - "dependency_policy requires project_validate action=check, lint or test".into(), - ); + return Err("dependency_policy requires project_validate action=check or test".into()); } if let Some(test) = &self.test { if self.action != ProjectValidationAction::Test { diff --git a/crates/webcodex-core/src/project_validation_tests.rs b/crates/webcodex-core/src/project_validation_tests.rs index 231a541aa..7ceab95f8 100644 --- a/crates/webcodex-core/src/project_validation_tests.rs +++ b/crates/webcodex-core/src/project_validation_tests.rs @@ -355,6 +355,28 @@ fn project_validation_package_scope_roundtrips_and_is_bounded() { } } +#[test] +fn project_validation_locked_policy_accepts_rust_lint() { + for adapter in [ + ProjectValidationAdapter::Rust, + ProjectValidationAdapter::Auto, + ] { + let request = ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Lint, + adapter, + scope: None, + dependency_policy: Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }), + dependency_resolution_policy: None, + test: None, + }; + assert!(request.validate().is_ok()); + } +} + #[test] fn project_validation_locked_policy_rejects_format_check_without_silent_ignore() { let request = ProjectValidationRequest { diff --git a/crates/webcodex-tool-contracts/src/tool_definition/edits.rs b/crates/webcodex-tool-contracts/src/tool_definition/edits.rs index 8ea03944c..f08060292 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/edits.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/edits.rs @@ -65,7 +65,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ super::ToolHostOrchestrationHint::sequential() .with_native_batch_field("changes"), ), - "Project editor: read_files → edit_project_files. Use review_changes when Git review is useful, then task-appropriate validation. One change per file. For existing-file edits/deletes/renames, set expected_read_revision from read_files.read_revision; create requires content. Exact edits fail closed on ambiguity; replace_range edits 1-based inclusive lines from that snapshot. Batches preflight transactionally; Runner rechecks source before mutation. dry_run plans only. Stale state returns read_files recovery; outcome_unknown requires workspace observation before writing.", + "Project editor: read_files first. Use review_changes when Git review is useful, then task-appropriate validation. One change per file. For existing-file edits/deletes/renames, set expected_read_revision from read_files.read_revision; create requires content. Exact edits fail closed on ambiguity; replace_range edits 1-based inclusive lines from that snapshot. Batches preflight transactionally; Runner rechecks source before mutation. dry_run plans only. Stale state returns read_files recovery; outcome_unknown requires workspace observation before writing.", ).with_discovery_keywords(&["replace", "exact", "snippets", "edit", "existing", "files", "text", "insert", "delete", "rename", "move", "替换", "编辑", "修改", "文字", "文本", "插入", "删除", "重命名", "移动", "文件"]), PERMISSION_RISK_WRITE, ), diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index aca324c81..62b066e8d 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -330,14 +330,14 @@ work_on_project Cargo all-packages provenance 是有界的 package-selection witness,并非完整构建输入快照。它要求 workspace 完全位于 registered Project 内,或独立 package 的祖先目录不存在 `Cargo.toml` marker;对 Project 外的祖先只探测 marker,不读取 manifest 内容。此 scope 不接受外部 path dependency,因为外部 manifest 的 `package.workspace` 可以把 Project 外 package 加入 workspace。相关 manifest/member/dependency alias 仍会被 fence,无关的非 manifest 链接会被忽略。无法证明的 topology 或超出边界上限时返回 `validation_scope_unavailable` / `build_scope_unavailable`;显式 package scope 和现有 specialist tools 保持各自契约。 -普通的 portable Rust/Go/Python/Node 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint、portable `scope`(有界 `packages` 1..8 项,或 `all_packages=true`,二者不可同时出现)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 的显式 packages 使用重复 `-p` selector,all-packages 只有在 effective Cargo workspace root 与 registered Project root 完全一致时才映射为 `cargo build --workspace`;Go 的显式 package pattern 直接传入,all-packages 映射为 `go build ./...`。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、原生 workspace/exclude flag、offline/network 策略或 artifact discovery contract;portable all-packages request 额外要求 additive `project_all_packages_v1` Runner capability。Python 要求 `pyproject.toml`,拒绝 package/all-packages scope,使用已有 configured/profile/PATH Python 解释器运行 `python -m build --no-isolation`。默认先构建 sdist,再从 sdist 构建 wheel;保留 frontend/backend dependency checks。依赖必须预先存在:adapter 不安装包、不创建环境。`dependency_policy.mode=locked` 保持同样的 no-install 语义。 -Node 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` / `node` adapter hint、portable `scope`(有界 `packages` 1..8 项,或 `all_packages=true`,二者不可同时出现)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 的显式 packages 使用重复 `-p` selector,all-packages 只有在 effective Cargo workspace root 与 registered Project root 完全一致时才映射为 `cargo build --workspace`;Go 的显式 package pattern 直接传入,all-packages 映射为 `go build ./...`。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、原生 workspace/exclude flag、offline/network 策略或 artifact discovery contract;portable all-packages request 额外要求 additive `project_all_packages_v1` Runner capability;v1 检测到 Python recipe 时 fail closed;Node 支持如下。 +普通的 portable Rust/Go/Python/Node 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint、portable `scope`(有界 `packages` 1..8 项,或 `all_packages=true`,二者不可同时出现)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 的显式 packages 使用重复 `-p` selector,all-packages 只有在 effective Cargo workspace root 与 registered Project root 完全一致时才映射为 `cargo build --workspace`;Go 的显式 package pattern 直接传入,all-packages 映射为 `go build ./...`。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、原生 workspace/exclude flag、任意 network 策略或 artifact discovery contract;portable all-packages request 额外要求 additive `project_all_packages_v1` Runner capability。Python 要求 `pyproject.toml`,拒绝 package/all-packages scope,使用已有 configured/profile/PATH Python 解释器运行 `python -m build --no-isolation`。默认先构建 sdist,再从 sdist 构建 wheel;保留 frontend/backend dependency checks。依赖必须预先存在:adapter 不安装包、不创建环境。`dependency_policy.mode=locked` 保持同样的 no-install 语义。 +Node 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` / `node` adapter hint、portable `scope`(有界 `packages` 1..8 项,或 `all_packages=true`,二者不可同时出现)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 的显式 packages 使用重复 `-p` selector,all-packages 只有在 effective Cargo workspace root 与 registered Project root 完全一致时才映射为 `cargo build --workspace`;Go 的显式 package pattern 直接传入,all-packages 映射为 `go build ./...`。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、原生 workspace/exclude flag、任意 network 策略或 artifact discovery contract;portable all-packages request 额外要求 additive `project_all_packages_v1` Runner capability;v1 检测到 Python recipe 时 fail closed;Node 支持如下。 Node 构建通过检测到的 npm/pnpm/yarn/bun 执行 `package.json` 中已有的 `build` script,复用 packageManager/lockfile 检测;缺失/冲突的 manager evidence 或缺失 script 均 fail closed。Node 拒绝 scope 与 dependency policy,并要求 `project_build_node_v1`;不会安装依赖或包管理器;Runner 覆盖 Corepack 网络/下载/auto-pin 配置,防止隐式引导包管理器。脚本及 lifecycle hooks 可能写文件或访问网络。成功只证明脚本完成;通过 `finish_coding_task(outputs=...)` 显式检查产物元数据,产物存在并不证明功能正确。 Node `project_validate(action=test)` 还支持 `scripts.test` 精确为 `vitest`、`vitest run` 或 `jest` 的已安装本地框架。Runner 使用固定官方 JSON reporter 参数与每个 Job 独占的新建私有报告文件(上限 1 MiB)。只有完整且一致的报告才能证明已执行测试数;不会从 stdout/stderr 推测数量。聚合 evidence 保留后删除报告。缺失、超限、无效报告、超时与取消均不能证明数量。JSON Job admission 要求 `project_validation_node_json_v1`。复合或自定义脚本返回 `structured_test_report_unavailable`,仍可用普通 recipe 执行;Node scope/filter 仍不支持。 -两个 gateway 都可选接受 `dependency_policy: {"mode":"locked"}`。这是 portable 的依赖解析保证,而不是宣称不同生态的原生 flag 完全等价:Rust build/check/lint/test 映射为 Cargo `--locked`,Go build/vet/test 映射为 `-mod=readonly`。它要求 adapter 不得为了让本次操作成功而修复或改写项目级依赖选择状态,但**不**表示关闭 registry/module/toolchain 网络访问;#962 中的 Rust 验证已新增可选的离线依赖解析策略。`project_validate(action="format_check")` 会拒绝该 policy,而不是静默忽略。携带 policy 的 planning 与 typed Job admission 都要求 additive `project_dependency_policy_v1` Runner capability。locked validation 使用独立的 durable validation target identity;省略 policy 的请求保持原有 argv 与 identity。 +两个 gateway 都可选接受 `dependency_policy: {"mode":"locked"}`。这是 portable 的依赖解析保证,而不是宣称不同生态的原生 flag 完全等价:Rust build/check/lint/test 映射为 Cargo `--locked`,Go build/vet/test 映射为 `-mod=readonly`。它要求 adapter 不得为了让本次操作成功而修复或改写项目级依赖选择状态,但**不**表示关闭 registry/module/toolchain 网络访问;#962 中的 Rust build/check/test 已新增可选的离线依赖解析策略。`project_validate(action="format_check")` 会拒绝该 policy,而不是静默忽略。携带 policy 的 planning 与 typed Job admission 都要求 additive `project_dependency_policy_v1` Runner capability。locked validation 使用独立的 durable validation target identity;省略 policy 的请求保持原有 argv 与 identity。 Rust `project_validate(action="lint", adapter="rust"|"auto")` 使用 Runner 固定的 Cargo Clippy 命令 `--all-targets -- -D clippy::correctness`,采用独立的 `cargo_clippy` 工具/证据身份与 `lint` kind。Rust check/test 与 Node lint 身份保持不变;Node lint 仍只选择 `scripts.lint`,保留 check 证据。scope 支持 1..8 个排序去重 package,或经证明的 Cargo workspace(`all_packages=true`)。独立的 `dependency_policy: {"mode":"locked"}` 添加 `--locked` 并改变目标身份。Rust lint **拒绝** `dependency_resolution_policy=offline`,需维护者批准后才可支持。显式 Rust lint planning 和所有已解析 Clippy Job 准入均要求 `project_validation_rust_clippy_v1`;auto planning 保留现有 Node 支持,并在执行前检查实际 backend 能力。Clippy 缺失时在验证命令启动前返回 `validation_tool_unavailable`,不会安装、接受任意 argv、回退 shell 或重新派发。Go/Python lint 明确不支持。队列准入与执行前重新验证 manifest/lock/workspace provenance,发生漂移则 fail closed。同一个持久 Job 负责执行和观察。退出码为零时可保留警告;非零、缺失退出码、超时或取消均不能产生成功 lint 证据。 From 24090c271ff60f30791e08d0ed4e210a21c002c8 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:34:04 +0800 Subject: [PATCH 3/4] fix(validation): complete Clippy receipt projection and smoke coverage --- .github/workflows/ci.yml | 3 + .../src/registry/output_schemas/testing.rs | 5 +- src/tool_runtime/jobs.rs | 85 ++++++++++++++++++- .../validation_handoff/project_validation.rs | 11 +++ .../tests/validation_success_projection.rs | 82 +++++++++++++++++- .../validation_success_projection.rs | 1 + 6 files changed, 181 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32bcdd0da..00dec2fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,6 +312,9 @@ jobs: env: CI_RUST_SHARD: ${{ matrix.shard }} run: python3 scripts/ci_rust_test.py --report target/ci-test-reports/${{ matrix.shard }}.json -- cargo test --locked ${{ matrix.packages }} + - name: Run real Clippy smoke regression + if: matrix.shard == 'runner' + run: cargo test --locked -p webcodex-runner --features runner-real-process-tests --bin webcodex-runner runner_real_process_rust_clippy_warnings_pass_and_correctness_fails -- --ignored - name: Run experimental Code Mode and receipt tests if: matrix.shard == 'server' run: | diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs index 39fe4bf1e..37d964bca 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs @@ -612,7 +612,8 @@ fn validation_success_evidence_schema(tool: &str, rich_required: &[&str]) -> Val rich["allOf"] = json!([rich_assertion_schema(), {"oneOf":[ {"properties":{"action":{"const":"format_check"}}}, {"properties":{"action":{"const":"check"},"adapter":{"not":{"const":"node:script:check"}}},"required":["warnings_count","errors_count","diagnostics"]}, - {"properties":{"action":{"const":"check"},"adapter":{"const":"node:script:check"}, + {"properties":{"action":{"const":"lint"},"adapter":{"const":"cargo_clippy"}},"required":["warnings_count","errors_count","diagnostics"]}, + {"properties":{"action":{"enum":["check","lint"]},"adapter":{"const":"node:script:check"}, "warnings_count":{"type":"null"},"errors_count":{"type":"null"}, "tests_detected":{"enum":[]},"tests_run_count":{"enum":[]}, "tests_passed":{"enum":[]},"tests_failed":{"enum":[]}, @@ -654,7 +655,7 @@ fn validation_success_evidence_schema(tool: &str, rich_required: &[&str]) -> Val "project_validate" => { compact["required"] = json!(["source_state", "adapter", "validation_target_id"]); compact["oneOf"] = json!([ - {"properties":{"adapter":{"const":"cargo_check"},"tests_run_count":{"enum":[]},"test_count_assertion":{"enum":[]},"no_run":{"enum":[]},"require_tests":{"enum":[]}}}, + {"properties":{"adapter":{"enum":["cargo_check","cargo_clippy"]},"tests_run_count":{"enum":[]},"test_count_assertion":{"enum":[]},"no_run":{"enum":[]},"require_tests":{"enum":[]}}}, {"properties":{"adapter":{"const":"go_vet"},"tests_run_count":{"enum":[]},"test_count_assertion":{"enum":[]},"no_run":{"enum":[]},"require_tests":{"enum":[]}}}, {"allOf":[test["oneOf"][0].clone()],"properties":{"adapter":{"const":"cargo_test"},"test_count_assertion":{"enum":[]}}}, {"allOf":[test["oneOf"][0].clone()],"properties":{"adapter":{"const":"go_test"},"test_count_assertion":{"enum":[]}}}, diff --git a/src/tool_runtime/jobs.rs b/src/tool_runtime/jobs.rs index 1e6727411..962330847 100644 --- a/src/tool_runtime/jobs.rs +++ b/src/tool_runtime/jobs.rs @@ -604,7 +604,7 @@ pub(crate) fn validation_job_projection_with_policy( value["tests_failed"] = Value::Null; value["zero_tests_run"] = Value::Null; } - "check" => { + "check" | "lint" if kind == "check" || tool == "cargo_clippy" => { value["warnings_count"] = Value::Null; value["errors_count"] = Value::Null; } @@ -700,7 +700,7 @@ pub(crate) fn validation_job_projection_with_policy( value["tests_failed"] = json!(evidence.tests_failed); value["zero_tests_run"] = json!(evidence.zero_tests_run); } - "check" => { + "check" | "lint" if kind == "check" || tool == "cargo_clippy" => { value["warnings_count"] = json!(evidence.warnings_count); value["errors_count"] = json!(evidence.errors_count); } @@ -2336,7 +2336,7 @@ mod recovery_projection_tests { validation_job_projection, validation_job_projection_with_policy, }; use crate::runner_protocol::{ShellJobInfo, ShellJobTestCountEvidence}; - use serde_json::json; + use serde_json::{json, Value}; use webcodex_core::validation_evidence::CargoTestCountEvidenceStatus; #[test] @@ -2893,6 +2893,85 @@ mod recovery_projection_tests { assert_eq!(truncated["diagnostics"]["truncated"], true); } + #[test] + fn clippy_projection_delivers_counts_without_changing_check_or_node_defaults() { + for (tool, kind) in [("cargo_clippy", "lint"), ("cargo_check", "check")] { + for (status, exit, stderr, warnings, errors) in [ + ("completed", Some(0), "warning: unused import\n", 1, 0), + ("failed", Some(101), "error: impossible comparison\n", 0, 1), + ] { + for truncated in [false, true] { + let value = validation_job_projection( + Some(tool), + Some(kind), + status, + exit, + "", + stderr, + truncated, + None, + ) + .unwrap(); + assert_eq!( + value.get("warnings_count"), + Some(&if truncated { + Value::Null + } else { + json!(warnings) + }), + "{value}" + ); + assert_eq!( + value.get("errors_count"), + Some(&if truncated { + Value::Null + } else { + json!(errors) + }), + "{value}" + ); + assert_eq!(value["passed"], exit == Some(0)); + } + } + for status in ["timed_out", "cancelled", "lost"] { + let value = validation_job_projection( + Some(tool), + Some(kind), + status, + None, + "", + "warning: partial\n", + false, + None, + ) + .unwrap(); + assert_eq!(value.get("warnings_count"), Some(&Value::Null), "{value}"); + assert_eq!(value.get("errors_count"), Some(&Value::Null), "{value}"); + assert_eq!(value["passed"], Value::Null); + } + } + for kind in ["check", "lint"] { + let value = validation_job_projection( + Some("node:script:check"), + Some(kind), + "completed", + Some(0), + "", + "warning: opaque script output\n", + false, + None, + ) + .unwrap(); + if kind == "check" { + assert_eq!(value.get("warnings_count"), Some(&Value::Null)); + assert_eq!(value.get("errors_count"), Some(&Value::Null)); + } else { + assert!(value.get("warnings_count").is_none()); + assert!(value.get("errors_count").is_none()); + } + } + } + #[test] fn validation_projection_keeps_lifecycle_terminals_distinct_from_validation_failure() { let fmt = validation_job_projection( diff --git a/src/tool_runtime/tests/validation_handoff/project_validation.rs b/src/tool_runtime/tests/validation_handoff/project_validation.rs index ee7426a72..fb5cdf2a5 100644 --- a/src/tool_runtime/tests/validation_handoff/project_validation.rs +++ b/src/tool_runtime/tests/validation_handoff/project_validation.rs @@ -1227,6 +1227,17 @@ async fn rust_clippy_pending_observes_same_job_and_exit_status_controls_outcome( "{observed:?}" ); assert_eq!(observed.output["validation"]["adapter"], "cargo_clippy"); + assert_eq!( + observed.output["validation"].get("warnings_count"), + Some(&json!(u64::from(exit == 0))) + ); + assert_eq!( + observed.output["validation"].get("errors_count"), + Some(&json!(u64::from(exit != 0))) + ); + // Re-observation retains the same Job and its structured evidence. + let retained = runtime.job_status_for_auth(job_id, false, None).await; + assert_eq!(retained.output["validation"], observed.output["validation"]); assert!(probe_patch_agent_request(&runtime, "project-validation") .await .is_none()); diff --git a/src/tool_runtime/tests/validation_success_projection.rs b/src/tool_runtime/tests/validation_success_projection.rs index d68f47a36..a76317a35 100644 --- a/src/tool_runtime/tests/validation_success_projection.rs +++ b/src/tool_runtime/tests/validation_success_projection.rs @@ -45,11 +45,19 @@ fn fixture( if tool == "project_validate" { result.output["backend"] = json!(if adapter.starts_with("go_") { "go" + } else if adapter.starts_with("node:") { + "node" } else { "rust" }); result.output["adapter"] = json!(adapter); - result.output["action"] = json!(if test { "test" } else { "check" }); + result.output["action"] = json!(if adapter == "cargo_clippy" { + "lint" + } else if test { + "test" + } else { + "check" + }); result.output["validation_target_id"] = json!("target:0123456789abcdef01234567"); } result @@ -490,3 +498,75 @@ fn validation_success_registry_capture_preserves_cargo_postconditions() { } } } + +#[test] +fn clippy_rich_terminal_projection_matches_schema() { + let policy = ValidationSuccessPolicy::default(); + for stderr in ["", "warning: unused variable\n --> src/lib.rs:1:1\n"] { + let mut result = fixture("project_validate", "cargo_clippy", policy, "", stderr); + // Uncertain source coverage must keep the rich evidence receipt. + result.output["source_state"]["observed_mutation_fence"] = json!("unknown"); + project("project_validate", policy, &mut result); + assert_eq!(result.output["action"], "lint"); + validate("project_validate", &result).unwrap_or_else(|e| panic!("{e}: {}", result.output)); + for field in ["warnings_count", "errors_count", "diagnostics"] { + let mut invalid = ToolResult::ok(result.output.clone()); + invalid.output.as_object_mut().unwrap().remove(field); + assert!( + validate("project_validate", &invalid).is_err(), + "missing {field}" + ); + } + } +} + +#[test] +fn clippy_compact_terminal_projection_matches_schema() { + let policy = ValidationSuccessPolicy::default(); + for stderr in ["", "warning: unused variable\n --> src/lib.rs:1:1\n"] { + let mut result = fixture("project_validate", "cargo_clippy", policy, "", stderr); + project("project_validate", policy, &mut result); + assert!(result.output.get("action").is_none(), "{}", result.output); + assert_eq!( + result.output.get("warnings_count"), + (!stderr.is_empty()).then_some(&json!(1)) + ); + assert!(result.output.get("errors_count").is_none()); + validate("project_validate", &result).unwrap_or_else(|e| panic!("{e}: {}", result.output)); + for (field, value) in [ + ("errors_count", json!(0)), + ("warnings_count", json!(0)), + ("action", json!("lint")), + ("tests_run_count", json!(1)), + ] { + let mut invalid = ToolResult::ok(result.output.clone()); + invalid.output[field] = value; + assert!( + validate("project_validate", &invalid).is_err(), + "unexpected {field}" + ); + } + } +} + +#[test] +fn node_lint_terminal_projection_preserves_script_defaults_and_schema() { + let policy = ValidationSuccessPolicy::default(); + let mut result = fixture( + "project_validate", + "node:script:check", + policy, + "", + "warning: opaque script output\n", + ); + result.output["action"] = json!("lint"); + project("project_validate", policy, &mut result); + validate("project_validate", &result).unwrap_or_else(|e| panic!("{e}: {}", result.output)); + assert_eq!(result.output["action"], "lint"); + // Node check counters remain unknown, even for a lint script. + assert_eq!(result.output.get("warnings_count"), Some(&Value::Null)); + assert_eq!(result.output.get("errors_count"), Some(&Value::Null)); + let mut invalid = ToolResult::ok(result.output.clone()); + invalid.output["warnings_count"] = json!(1); + assert!(validate("project_validate", &invalid).is_err()); +} diff --git a/src/tool_runtime/validation_success_projection.rs b/src/tool_runtime/validation_success_projection.rs index 49fd4ca8a..82dd696a3 100644 --- a/src/tool_runtime/validation_success_projection.rs +++ b/src/tool_runtime/validation_success_projection.rs @@ -57,6 +57,7 @@ pub(super) fn sparsify_structured_validation_success_evidence( }; let (backend, action) = match adapter { "cargo_check" => ("rust", "check"), + "cargo_clippy" => ("rust", "lint"), "cargo_test" => ("rust", "test"), "go_vet" => ("go", "check"), "go_test" => ("go", "test"), From dcd46816223a5995540f9f3b4a86ee405c8c4f2a Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:44:48 +0800 Subject: [PATCH 4/4] fix(validation): fence Clippy toolchain setup and verify native execution --- .../src/webcodex_runner/shell/commands.rs | 10 +- .../src/webcodex_runner/shell_tests.rs | 197 +++++++++++++++--- 2 files changed, 177 insertions(+), 30 deletions(-) diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs index ebbf2dfb2..4d034fa45 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/commands.rs @@ -464,7 +464,7 @@ fn configured_python_module_job_command( Ok(command) } -/// Probe and launch the same native Cargo in the prepared environment. No shell, +/// Probe and launch the same native cargo-clippy in the prepared environment. No shell, /// installation, dependency fetch, or validation execution happens in preflight. pub(crate) fn configured_clippy_job_command( shell: &ShellConfig, @@ -480,7 +480,7 @@ pub(crate) fn configured_clippy_job_command( } let path = configured_process_path(shell, profile).map_err(|_| unavailable())?; let Some(crate::webcodex_runner::util::ResolvedProgram::Native(program)) = - crate::webcodex_runner::util::resolve_program_in_path("cargo", &path) + crate::webcodex_runner::util::resolve_program_in_path("cargo-clippy", &path) else { return Err(unavailable()); }; @@ -491,6 +491,9 @@ pub(crate) fn configured_clippy_job_command( .stdin(Stdio::null()); super::scripts::apply_script_environment(&mut probe, shell, profile) .map_err(|_| unavailable())?; + // Rustup proxies must not install a project-selected toolchain or missing + // components. Apply after configured environment values so they cannot opt in. + probe.env("RUSTUP_AUTO_INSTALL", "0"); let (status, stdout, _) = run_prepare_command(probe, Duration::from_secs(5), stop_requested) .map_err(|_| unavailable())?; if !status.success() @@ -499,12 +502,15 @@ pub(crate) fn configured_clippy_job_command( return Err(unavailable()); } let mut command = Command::new(program); + // cargo-clippy expects Cargo's subcommand prefix too. Keep the canonical + // cargo/clippy step unchanged for provenance while bypassing Cargo aliases. command .args(&step.args) .current_dir(cwd) .stdin(Stdio::null()); super::scripts::apply_script_environment(&mut command, shell, profile) .map_err(|_| unavailable())?; + command.env("RUSTUP_AUTO_INSTALL", "0"); Ok(command) } diff --git a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs index 7130007dc..25195bc94 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell_tests.rs @@ -3198,11 +3198,27 @@ fn runner_real_process_project_validation_ruff_module_isolation() { fn rust_clippy_probe_missing_component_and_pinned_command() { use std::os::unix::fs::PermissionsExt; let temp = crate::tests::executable_tempdir(); + let clippy = temp.path().join("cargo-clippy"); let cargo = temp.path().join("cargo"); + // Dispatching through Cargo would run an alias (or this sentinel). + std::fs::write( + &cargo, + "#!/bin/sh\nprintf dispatched > side-effect\nexit 0\n", + ) + .unwrap(); + std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o700)).unwrap(); let mut shell = ShellConfig::default(); shell .env .insert("PATH".into(), temp.path().to_string_lossy().into_owned()); + shell.env.insert("RUSTUP_AUTO_INSTALL".into(), "1".into()); + let profile = PreparedShellProfile { + profile_name: "clippy".into(), + program: "/bin/sh".into(), + args: vec![], + dialect: ShellDialect::Posix, + env_snapshot: shell.env.clone(), + }; let step = webcodex_core::runner_protocol::ShellJobValidationStep { name: "lint".into(), program: "cargo".into(), @@ -3211,28 +3227,126 @@ fn rust_clippy_probe_missing_component_and_pinned_command() { .to_vec(), env: vec![], }; - assert!(configured_clippy_job_command(&shell, None, &step, temp.path(), None).is_err()); + for profile in [None, Some(&profile)] { + assert!(configured_clippy_job_command(&shell, profile, &step, temp.path(), None).is_err()); + } for available in [false, true] { - std::fs::write(&cargo, if available { - "#!/bin/sh\n[ \"$1\" = clippy ] && [ \"$2\" = --version ] || exit 9\nprintf 'clippy 0.1\\n'\n" + std::fs::write(&clippy, if available { + "#!/bin/sh\n[ \"$RUSTUP_AUTO_INSTALL\" = 0 ] || exit 8\n[ \"$1\" = clippy ] || exit 9\nshift\nif [ \"$1\" = --version ]; then printf 'clippy 0.1\\n'; else [ \"$*\" = '--all-targets -- -D clippy::correctness' ] || exit 10; printf 'stable diagnostic\\n' >&2; fi\n" } else { "#!/bin/sh\nexit 1\n" }).unwrap(); - std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o700)).unwrap(); - let result = configured_clippy_job_command(&shell, None, &step, temp.path(), None); - if available { - let command = result.unwrap(); - assert_eq!(PathBuf::from(command.get_program()), cargo); + std::fs::set_permissions(&clippy, std::fs::Permissions::from_mode(0o700)).unwrap(); + for profile in [None, Some(&profile)] { + let result = configured_clippy_job_command(&shell, profile, &step, temp.path(), None); + if available { + let mut command = result.unwrap(); + assert_eq!(PathBuf::from(command.get_program()), clippy); + assert_eq!( + command + .get_args() + .map(|a| a.to_str().unwrap()) + .collect::>(), + step.args + ); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"stable diagnostic\n"); + } else { + assert_eq!( + result.unwrap_err(), + webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE + ); + } + assert!(!temp.path().join("side-effect").exists()); + } + } +} + +#[cfg(unix)] +#[test] +fn rust_clippy_disables_auto_install_for_probe_and_execution() { + use std::os::unix::fs::PermissionsExt; + let temp = crate::tests::executable_tempdir(); + let cargo = temp.path().join("cargo"); + // Model a rustup proxy without touching installed toolchains or the network. + // Any opt-in records an installation attempt, even if tooling is available. + std::fs::write( + &cargo, + r#"#!/bin/sh +[ "$1" = clippy ] || exit 9 +printf '%s:%s\n' "$2" "$RUSTUP_AUTO_INSTALL" >> calls +if [ "$RUSTUP_AUTO_INSTALL" != 0 ]; then + : > install-attempt + exit 98 +fi +if [ -f missing-toolchain ] || [ -f missing-component ]; then + exit 1 +fi +if [ "$2" = --version ]; then + printf 'clippy 0.1\n' +else + [ "$2" = --all-targets ] || exit 9 +fi +"#, + ) + .unwrap(); + std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::os::unix::fs::symlink("cargo", temp.path().join("cargo-clippy")).unwrap(); + std::fs::write( + temp.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = 'nightly-2099-01-01'\ncomponents = ['clippy']\n", + ) + .unwrap(); + let mut shell = ShellConfig::default(); + shell + .env + .insert("PATH".into(), temp.path().to_string_lossy().into_owned()); + shell.env.insert("RUSTUP_AUTO_INSTALL".into(), "1".into()); + let profile = PreparedShellProfile { + profile_name: "auto-install-enabled".into(), + program: "/bin/sh".into(), + args: vec![], + dialect: ShellDialect::Posix, + env_snapshot: shell.env.clone(), + }; + let step = webcodex_core::runner_protocol::ShellJobValidationStep { + name: "lint".into(), + program: "cargo".into(), + args: ["clippy", "--all-targets", "--", "-D", "clippy::correctness"] + .map(str::to_string) + .to_vec(), + env: vec![], + }; + for profile in [None, Some(&profile)] { + for missing in ["missing-toolchain", "missing-component"] { + let marker = temp.path().join(missing); + std::fs::write(&marker, "").unwrap(); assert_eq!( - command - .get_args() - .map(|a| a.to_str().unwrap()) - .collect::>(), - step.args + configured_clippy_job_command(&shell, profile, &step, temp.path(), None) + .unwrap_err(), + webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE ); - } else { + std::fs::remove_file(&marker).unwrap(); assert_eq!( - result.unwrap_err(), - webcodex_core::runner_protocol::VALIDATION_TOOL_UNAVAILABLE_CODE + std::fs::read_to_string(temp.path().join("calls")).unwrap(), + "--version:0\n" ); + std::fs::remove_file(temp.path().join("calls")).unwrap(); + + // Loss after preflight must fail at execution without installing. + let command = + configured_clippy_job_command(&shell, profile, &step, temp.path(), None).unwrap(); + std::fs::write(&marker, "").unwrap(); + let (status, _, _) = + run_prepare_command(command, Duration::from_secs(5), None).unwrap(); + assert_eq!(status.code(), Some(1)); + std::fs::remove_file(&marker).unwrap(); + assert_eq!( + std::fs::read_to_string(temp.path().join("calls")).unwrap(), + "--version:0\n--all-targets:0\n" + ); + std::fs::remove_file(temp.path().join("calls")).unwrap(); + assert!(!temp.path().join("install-attempt").exists()); } } } @@ -3241,13 +3355,6 @@ fn rust_clippy_probe_missing_component_and_pinned_command() { #[test] #[ignore = "requires installed Cargo Clippy; run explicitly with --ignored"] fn runner_real_process_rust_clippy_warnings_pass_and_correctness_fails() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir(temp.path().join("src")).unwrap(); - std::fs::write( - temp.path().join("Cargo.toml"), - "[package]\nname='clippy_fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n", - ) - .unwrap(); let step = webcodex_core::runner_protocol::ShellJobValidationStep { name: "lint".into(), program: "cargo".into(), @@ -3258,9 +3365,9 @@ fn runner_real_process_rust_clippy_warnings_pass_and_correctness_fails() { }; for (source, success, diagnostic) in [ ( - "pub fn warning() { let unused_variable = 1; }\n", + "pub fn warning() -> i32 { return 1; }\n", true, - "warning:", + "clippy::needless_return", ), ( "pub fn broken(x: i32) -> bool { x > 1 && x < 0 }\n", @@ -3268,10 +3375,42 @@ fn runner_real_process_rust_clippy_warnings_pass_and_correctness_fails() { "clippy::impossible_comparisons", ), ] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("src")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='clippy_fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n", + ) + .unwrap(); std::fs::write(temp.path().join("src/lib.rs"), source).unwrap(); - let command = - configured_clippy_job_command(&ShellConfig::default(), None, &step, temp.path(), None) - .expect("Clippy must be installed for explicit real-process evidence"); + let cargo_home = temp.path().join("cargo-home"); + std::fs::create_dir(&cargo_home).unwrap(); + // Cover global and project aliases, including a command with an effect. + let (config_dir, alias) = if success { + (cargo_home.clone(), "version") + } else { + let config_dir = temp.path().join(".cargo"); + std::fs::create_dir(&config_dir).unwrap(); + (config_dir, "!echo alias-executed > alias-side-effect") + }; + std::fs::write( + config_dir.join("config.toml"), + format!("[alias]\nclippy = '{alias}'\n"), + ) + .unwrap(); + let mut shell = ShellConfig::default(); + shell.env.insert( + "CARGO_HOME".into(), + cargo_home.to_string_lossy().into_owned(), + ); + shell.env.insert("CARGO_NET_OFFLINE".into(), "true".into()); + shell.env.insert( + "CARGO_TARGET_DIR".into(), + temp.path().join("target").to_string_lossy().into_owned(), + ); + let mut command = configured_clippy_job_command(&shell, None, &step, temp.path(), None) + .expect("Clippy must be installed for explicit real-process evidence"); + command.env("CARGO_TERM_COLOR", "never"); let (status, stdout, stderr) = run_prepare_command(command, Duration::from_secs(30), None).unwrap(); let output = format!( @@ -3281,5 +3420,7 @@ fn runner_real_process_rust_clippy_warnings_pass_and_correctness_fails() { ); assert_eq!(status.success(), success, "{output}"); assert!(output.contains(diagnostic), "{output}"); + assert!(stdout.is_empty(), "{output}"); + assert!(!temp.path().join("alias-side-effect").exists()); } }