Skip to content
95 changes: 95 additions & 0 deletions crates/webcodex-core/src/project_validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,98 @@ fn project_validation_python_argv_identity_and_metadata_are_closed() {
metadata.steps[0].args.push("--collect-only".into());
assert!(!metadata.is_valid());
}

#[test]
fn project_ruff_step_identity_metadata_and_capability_are_independent() {
use crate::runner_protocol::{
RunnerCapabilities, ShellJobValidationMetadata, ShellJobValidationStep,
};
use crate::validation_identity::{
structured_validation_target_identity, ToolValidationIdentityKind,
};
let capabilities: RunnerCapabilities =
serde_json::from_value(serde_json::json!({"project_validation_python_pytest_v1":true}))
.unwrap();
assert!(!capabilities.project_validation_python_ruff_v1);
let mut identities = std::collections::HashSet::new();
for (action, kind, identity_kind) in [
(
ProjectValidationAction::Check,
"check",
ToolValidationIdentityKind::PythonRuffCheck,
),
(
ProjectValidationAction::FormatCheck,
"format",
ToolValidationIdentityKind::PythonRuffFormat,
),
] {
let step = ShellJobValidationStep::python_ruff(kind).unwrap();
assert!(step.is_canonical() && step.is_structured_ruff());
assert_eq!(&step.args[..4], ["-I", "-B", "-m", "ruff"]);
for removed in 0..step.args.len() {
let mut bad = step.clone();
bad.args.remove(removed);
assert!(!bad.is_structured_ruff());
}
for extra in [
"--fix",
"--fix-only",
"--output-file=report.json",
"--config=../ruff.toml",
"--isolated",
"src",
] {
let mut bad = step.clone();
bad.args.push(extra.into());
assert!(!bad.is_canonical());
}
let identity =
structured_validation_target_identity(identity_kind, &serde_json::json!({"cwd":"."}))
.unwrap();
assert!(identities.insert(identity.clone()));
let mut metadata = ShellJobValidationMetadata {
tool: "project_validate".into(),
kind: kind.into(),
steps: vec![step],
effective_timeout_secs: 60,
sync_wait_secs: 1,
adapter: identity_kind.tool_name().unwrap().into(),
validation_target_id: Some(identity),
source_fence: None,
minimum_tests: None,
require_tests: None,
no_run: None,
project_validation: Some(ProjectValidationProvenance {
request: ProjectValidationRequest {
project_id: "demo".into(),
cwd: None,
action,
adapter: ProjectValidationAdapter::Python,
scope: None,
dependency_policy: None,
test: None,
},
backend: "python".into(),
recipe_root: ".".into(),
root_digest: "a".repeat(64),
manifest_digest: "b".repeat(64),
invocation_digest: "c".repeat(64),
}),
};
assert!(metadata.is_valid());
metadata.minimum_tests = Some(1);
assert!(!metadata.is_valid());
metadata.minimum_tests = None;
metadata.steps[0]
.env
.push(("CARGO_TARGET_DIR".into(), "/tmp/cache".into()));
assert!(!metadata.is_valid());
metadata.steps[0].env.clear();
metadata.adapter = "python:pytest:test".into();
assert!(!metadata.is_valid());
metadata.adapter = identity_kind.tool_name().unwrap().into();
metadata.project_validation = None;
assert!(!metadata.is_valid());
}
}
8 changes: 8 additions & 0 deletions crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ runner_capabilities! {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_python_pytest_v1: bool = false;
}
/// Read-only project Ruff check/format with pinned local configuration,
/// exact Python preflight and independent evidence identities.
ProjectValidationPythonRuff => RUNNER_CAPABILITY_PROJECT_VALIDATION_PYTHON_RUFF("project_validation_python_ruff_v1"),
v2_baseline = false {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_python_ruff_v1: bool = false;
}
/// The Runner understands the first-class model-facing `go_test` tool identity
/// and its durable `ShellJobValidationMetadata` contract. This is deliberately
/// separate from Go JSON parsing support: older Runners may advertise
Expand Down Expand Up @@ -2832,6 +2839,7 @@ mod envelope_tests {
project_all_packages_v1: false,
project_validation_test_options_v1: false,
project_validation_python_pytest_v1: false,
project_validation_python_ruff_v1: false,
structured_go_test_tool: true,
structured_go_test_packages: true,
structured_process_argv: true,
Expand Down
68 changes: 65 additions & 3 deletions crates/webcodex-core/src/runner_protocol/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,15 @@ impl ShellJobValidationStep {
("check", "go") => is_canonical_go_vet_args(&args),
("test", "go") => args == ["test", "./..."] || self.is_structured_go_test_json(),
("format", "python") => {
args == ["-m", "ruff", "format", "--check"] || args == ["-m", "black", "--check"]
args == ["-m", "ruff", "format", "--check"]
|| args == ["-m", "black", "--check"]
|| self.is_structured_ruff()
}
("check", "python") => {
args == ["-m", "ruff", "check"]
|| args == ["-m", "mypy"]
|| self.is_structured_ruff()
}
("check", "python") => args == ["-m", "ruff", "check"] || args == ["-m", "mypy"],
("test", "python") => {
args == ["-m", "pytest"]
|| args == ["-B", "-m", "unittest", "discover", "-v"]
Expand All @@ -264,6 +270,53 @@ impl ShellJobValidationStep {
}
}

/// Exact Runner-owned Ruff argv. Callers must additionally prove the local
/// manifest and project provenance; this constructor grants no authority.
pub fn python_ruff(kind: &str) -> Option<Self> {
let args: &[&str] = match kind {
"check" => &[
"-I",
"-B",
"-m",
"ruff",
"check",
"--no-fix",
"--no-fix-only",
"--no-cache",
"--no-respect-gitignore",
"--output-format",
"json-lines",
"--config",
"pyproject.toml",
".",
],
"format" => &[
"-I",
"-B",
"-m",
"ruff",
"format",
"--check",
"--no-cache",
"--no-respect-gitignore",
"--config",
"pyproject.toml",
".",
],
_ => return None,
};
Some(Self {
name: kind.into(),
program: "python".into(),
args: args.iter().map(|arg| (*arg).into()).collect(),
env: Vec::new(),
})
}

pub fn is_structured_ruff(&self) -> bool {
Self::python_ruff(&self.name).as_ref() == Some(self)
}

/// Exact project pytest argv; no executable, free flags or environment input.
pub fn is_structured_pytest(&self) -> bool {
if self.name != "test" || self.program != "python" || !self.env.is_empty() {
Expand Down Expand Up @@ -872,7 +925,10 @@ impl ShellJobValidationMetadata {
(provenance.backend.as_str(), self.adapter.as_str()),
("rust", "cargo_fmt" | "cargo_check" | "cargo_test")
| ("go", "go_vet" | "go_test")
| ("python", "python:pytest:test")
| (
"python",
"python:pytest:test" | "python:ruff:check" | "python:ruff:format"
)
)
|| (self.require_tests, self.minimum_tests)
!= provenance.request.test_requirements()
Expand All @@ -898,6 +954,12 @@ impl ShellJobValidationMetadata {
&& self.kind == "test"
&& step.is_structured_pytest()
}
"python:ruff:check" | "python:ruff:format" => {
self.tool == "project_validate"
&& self.adapter == format!("python:ruff:{}", self.kind)
&& self.kind == step.name
&& step.is_structured_ruff()
}
"go_vet" => self.kind == "check" && step.name == "check" && step.program == "go",
_ => false,
}
Expand Down
8 changes: 8 additions & 0 deletions crates/webcodex-core/src/validation_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub enum ToolValidationIdentityKind {
GoTest,
GoVet,
PythonPytest,
PythonRuffCheck,
PythonRuffFormat,
Project,
}

Expand All @@ -36,6 +38,8 @@ impl ToolValidationIdentityKind {
Self::GoTest => Some("go_test"),
Self::GoVet => Some("go_vet"),
Self::PythonPytest => Some("python:pytest:test"),
Self::PythonRuffCheck => Some("python:ruff:check"),
Self::PythonRuffFormat => Some("python:ruff:format"),
}
}
}
Expand Down Expand Up @@ -206,6 +210,10 @@ pub fn structured_validation_target_identity(
}
semantic
}
ToolValidationIdentityKind::PythonRuffCheck
| ToolValidationIdentityKind::PythonRuffFormat => {
serde_json::json!({"tool":tool_name,"kind": if kind == ToolValidationIdentityKind::PythonRuffCheck { "check" } else { "format" },"cwd":cwd})
}
ToolValidationIdentityKind::PythonPytest => {
let filter = match obj.get("filter") {
None | Some(Value::Null) => None,
Expand Down
1 change: 1 addition & 0 deletions crates/webcodex-runner-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result<String,
project_all_packages_v1: false,
project_validation_test_options_v1: false,
project_validation_python_pytest_v1: false,
project_validation_python_ruff_v1: false,
// Like JSON parsing, first-class durable go_test support is
// advertised by the running binary, never by generated static config.
structured_go_test_tool: false,
Expand Down
18 changes: 18 additions & 0 deletions crates/webcodex-runner-registry/src/job_updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,24 @@ impl RunnerRegistry {
RunnerFeature::ProjectValidation,
));
}
let python_ruff = validation.as_ref().is_some_and(|v| {
matches!(
v.adapter.as_str(),
"python:ruff:check" | "python:ruff:format"
)
}) || validation_steps
.iter()
.any(ShellJobValidationStep::is_structured_ruff);
if python_ruff
&& !runner
.runner_features
.supports(RunnerFeature::ProjectValidationPythonRuff)
{
return Err(capability_upgrade_error(
&client_id,
RunnerFeature::ProjectValidationPythonRuff,
));
}
let python_pytest = validation
.as_ref()
.is_some_and(|v| v.adapter == "python:pytest:test")
Expand Down
26 changes: 15 additions & 11 deletions crates/webcodex-runner-registry/src/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2680,8 +2680,17 @@ impl RunnerRegistry {
access: Option<&crate::RunnerAccess>,
) -> Result<(String, oneshot::Receiver<ShellRunResponse>), String> {
payload.validate()?;
let requires_python =
payload.adapter == webcodex_core::project_validation::ProjectValidationAdapter::Python;
let python_capability = (payload.adapter
== webcodex_core::project_validation::ProjectValidationAdapter::Python)
.then_some(
if payload.action
== webcodex_core::project_validation::ProjectValidationAction::Test
{
RunnerFeature::ProjectValidationPythonPytest
} else {
RunnerFeature::ProjectValidationPythonRuff
},
);
let requires_package_scope = payload
.scope
.as_ref()
Expand Down Expand Up @@ -2713,15 +2722,10 @@ impl RunnerRegistry {
RunnerFeature::ProjectValidation,
));
}
if requires_python
&& !runner
.runner_features
.supports(RunnerFeature::ProjectValidationPythonPytest)
{
return Err(capability_upgrade_error(
&client_id,
RunnerFeature::ProjectValidationPythonPytest,
));
if let Some(capability) = python_capability {
if !runner.runner_features.supports(capability) {
return Err(capability_upgrade_error(&client_id, capability));
}
}
if requires_all_packages
&& !runner
Expand Down
Loading
Loading