Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/webcodex-core/src/project_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub enum ProjectValidationAdapter {
Rust,
Go,
Python,
Node,
}

/// Test-only selection and evidence policy. Filtering changes the execution
Expand Down Expand Up @@ -167,7 +168,7 @@ impl ProjectValidationProvenance {
pub fn is_valid(&self) -> bool {
self.request.validate().is_ok()
&& validate_relative(&self.recipe_root).is_ok()
&& matches!(self.backend.as_str(), "rust" | "go" | "python")
&& matches!(self.backend.as_str(), "rust" | "go" | "python" | "node")
&& [
&self.root_digest,
&self.manifest_digest,
Expand Down
93 changes: 93 additions & 0 deletions crates/webcodex-core/src/project_validation_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,99 @@
use crate::project_validation::*;
use crate::runner_operation::{RunnerInvocationMetadata, RunnerOperation};
use crate::runner_protocol::RunnerRequest;

#[test]
fn project_node_check_step_identity_capability_and_metadata_are_closed() {
use crate::runner_protocol::{
RunnerCapabilities, ShellJobValidationMetadata, ShellJobValidationStep,
};
use crate::validation_identity::{
structured_validation_target_identity, ToolValidationIdentityKind,
};
let old: RunnerCapabilities =
serde_json::from_value(serde_json::json!({"project_validation_v1":true})).unwrap();
assert!(!old.project_validation_node_script_check_v1);
let mut ids = std::collections::HashSet::new();
for script in ["check", "typecheck", "lint"] {
let step = ShellJobValidationStep {
name: "check".into(),
program: "node".into(),
args: vec!["--run".into(), script.into()],
env: vec![],
};
assert!(step.is_canonical() && step.is_structured_node_check());
let id = structured_validation_target_identity(
ToolValidationIdentityKind::NodeScriptCheck,
&serde_json::json!({"cwd":".","script":script}),
)
.unwrap();
assert!(ids.insert(id.clone()));
let mut metadata = ShellJobValidationMetadata {
tool: "project_validate".into(),
kind: "check".into(),
steps: vec![step],
effective_timeout_secs: 60,
sync_wait_secs: 1,
adapter: "node:script:check".into(),
validation_target_id: Some(id),
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: ProjectValidationAction::Check,
adapter: ProjectValidationAdapter::Node,
scope: None,
dependency_policy: None,
test: None,
},
backend: "node".into(),
recipe_root: ".".into(),
root_digest: "a".repeat(64),
manifest_digest: "b".repeat(64),
invocation_digest: "c".repeat(64),
}),
};
assert!(metadata.is_valid());
let wire = serde_json::to_string(&metadata).unwrap();
assert!(serde_json::from_str::<ShellJobValidationMetadata>(&wire)
.unwrap()
.is_valid());
metadata.steps[0].args.push("arbitrary".into());
assert!(!metadata.is_valid());
metadata.steps[0].args.pop();
metadata.steps[0]
.env
.push(("CARGO_TARGET_DIR".into(), "/tmp".into()));
assert!(!metadata.is_valid());
metadata.steps[0].env.clear();
metadata.require_tests = Some(true);
assert!(!metadata.is_valid());
metadata.require_tests = None;
metadata.project_validation.as_mut().unwrap().request.action =
ProjectValidationAction::Test;
assert!(!metadata.is_valid());
}
assert!(structured_validation_target_identity(
ToolValidationIdentityKind::NodeScriptCheck,
&serde_json::json!({"cwd":".","script":"--eval"}),
)
.is_none());
let node = structured_validation_target_identity(
ToolValidationIdentityKind::NodeScriptCheck,
&serde_json::json!({"cwd":".","script":"check"}),
)
.unwrap();
let ruff = structured_validation_target_identity(
ToolValidationIdentityKind::PythonRuffCheck,
&serde_json::json!({"cwd":"."}),
)
.unwrap();
assert_ne!(node, ruff);
}
#[test]
fn project_validation_protocol_is_closed_declarative_and_roundtrips() {
let input = ProjectValidationRequest {
Expand Down
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 @@ -616,6 +616,13 @@ runner_capabilities! {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_python_ruff_v1: bool = false;
}
/// Bounded Node native project-script check, with Runner-owned manifest,
/// exact Node probe, selected-script identity and durable Job admission.
ProjectValidationNodeScriptCheck => RUNNER_CAPABILITY_PROJECT_VALIDATION_NODE_SCRIPT_CHECK("project_validation_node_script_check_v1"),
v2_baseline = false {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_node_script_check_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 @@ -2840,6 +2847,7 @@ mod envelope_tests {
project_validation_test_options_v1: false,
project_validation_python_pytest_v1: false,
project_validation_python_ruff_v1: false,
project_validation_node_script_check_v1: false,
structured_go_test_tool: true,
structured_go_test_packages: true,
structured_process_argv: true,
Expand Down
24 changes: 24 additions & 0 deletions crates/webcodex-core/src/runner_protocol/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ impl ShellJobValidationStep {
self.is_canonical_with_project_workspace(true)
}

/// Canonical bounded Node project script. The project manifest and Runner
/// capability remain separate required authorization facts.
pub fn is_structured_node_check(&self) -> bool {
self.name == "check"
&& self.program == "node"
&& self.env.is_empty()
&& self.args.len() == 2
&& self.args[0] == "--run"
&& matches!(self.args[1].as_str(), "check" | "typecheck" | "lint")
}

fn is_canonical_with_project_workspace(&self, allow_project_workspace: bool) -> bool {
if self
.args
Expand All @@ -245,6 +256,7 @@ impl ShellJobValidationStep {
("test", "cargo") => is_canonical_cargo_test_args(&args, allow_project_workspace),
("check", "go") => is_canonical_go_vet_args(&args),
("test", "go") => args == ["test", "./..."] || self.is_structured_go_test_json(),
("check", "node") => self.is_structured_node_check(),
("format", "python") => {
args == ["-m", "ruff", "format", "--check"]
|| args == ["-m", "black", "--check"]
Expand Down Expand Up @@ -929,7 +941,14 @@ impl ShellJobValidationMetadata {
"python",
"python:pytest:test" | "python:ruff:check" | "python:ruff:format"
)
| ("node", "node:script:check")
)
|| (provenance.backend == "node"
&& (provenance.request.action
!= crate::project_validation::ProjectValidationAction::Check
|| provenance.request.scope.is_some()
|| provenance.request.dependency_policy.is_some()
|| provenance.request.test.is_some()))
|| (self.require_tests, self.minimum_tests)
!= provenance.request.test_requirements()
|| self.no_run.is_some()
Expand Down Expand Up @@ -961,6 +980,11 @@ impl ShellJobValidationMetadata {
&& step.is_structured_ruff()
}
"go_vet" => self.kind == "check" && step.name == "check" && step.program == "go",
"node:script:check" => {
self.tool == "project_validate"
&& self.kind == "check"
&& step.is_structured_node_check()
}
_ => false,
}
}
Expand Down
9 changes: 9 additions & 0 deletions crates/webcodex-core/src/validation_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub enum ToolValidationIdentityKind {
PythonPytest,
PythonRuffCheck,
PythonRuffFormat,
NodeScriptCheck,
Project,
}

Expand All @@ -40,6 +41,7 @@ impl ToolValidationIdentityKind {
Self::PythonPytest => Some("python:pytest:test"),
Self::PythonRuffCheck => Some("python:ruff:check"),
Self::PythonRuffFormat => Some("python:ruff:format"),
Self::NodeScriptCheck => Some("node:script:check"),
}
}
}
Expand Down Expand Up @@ -214,6 +216,13 @@ pub fn structured_validation_target_identity(
| ToolValidationIdentityKind::PythonRuffFormat => {
serde_json::json!({"tool":tool_name,"kind": if kind == ToolValidationIdentityKind::PythonRuffCheck { "check" } else { "format" },"cwd":cwd})
}
ToolValidationIdentityKind::NodeScriptCheck => {
let script = obj.get("script")?.as_str()?;
if !matches!(script, "check" | "typecheck" | "lint") {
return None;
}
serde_json::json!({"tool": tool_name, "kind": "check", "cwd": cwd, "script": script, "engine": "node-native-run-v1"})
}
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 @@ -298,6 +298,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result<String,
project_validation_test_options_v1: false,
project_validation_python_pytest_v1: false,
project_validation_python_ruff_v1: false,
project_validation_node_script_check_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,
));
}
// Auto-detected Node scripts need admission recheck against the actual
// retained Job adapter and step, including on Runner replacement.
let node_script_check = validation
.as_ref()
.is_some_and(|v| v.adapter == "node:script:check")
|| validation_steps
.iter()
.any(ShellJobValidationStep::is_structured_node_check);
if node_script_check
&& !runner
.runner_features
.supports(RunnerFeature::ProjectValidationNodeScriptCheck)
{
return Err(capability_upgrade_error(
&client_id,
RunnerFeature::ProjectValidationNodeScriptCheck,
));
}
let python_ruff = validation.as_ref().is_some_and(|v| {
matches!(
v.adapter.as_str(),
Expand Down
8 changes: 8 additions & 0 deletions crates/webcodex-runner-registry/src/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2691,6 +2691,9 @@ impl RunnerRegistry {
RunnerFeature::ProjectValidationPythonRuff
},
);
let node_capability = (payload.adapter
== webcodex_core::project_validation::ProjectValidationAdapter::Node)
.then_some(RunnerFeature::ProjectValidationNodeScriptCheck);
let requires_package_scope = payload
.scope
.as_ref()
Expand Down Expand Up @@ -2727,6 +2730,11 @@ impl RunnerRegistry {
return Err(capability_upgrade_error(&client_id, capability));
}
}
if let Some(capability) = node_capability {
if !runner.runner_features.supports(capability) {
return Err(capability_upgrade_error(&client_id, capability));
}
}
if requires_all_packages
&& !runner
.runner_features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,78 @@ async fn dependency_policy_is_fenced_before_project_build_planning() {
}
}

#[tokio::test]
async fn node_project_check_requires_additive_capability_before_planning() {
for supported in [false, true] {
let registry = RunnerRegistry::default();
let mut runner = registration(true);
runner.capabilities.project_validation_node_script_check_v1 = supported;
registry.register(runner).await.unwrap();
let access = auth_context(None, true);
let mut request = locked_validation_request();
request.adapter = ProjectValidationAdapter::Node;
request.dependency_policy = None;
let result = registry
.enqueue_project_validation_plan("policy-runner".into(), request, Some(&access))
.await;
if supported {
assert!(result.is_ok(), "{result:?}");
} else {
let error = result.unwrap_err();
assert!(
error.contains("project_validation_node_script_check_v1"),
"{error}"
);
assert!(registry
.poll(RunnerPollRequest {
client_id: "policy-runner".into(),
runner_instance_id: "inst".into(),
})
.await
.unwrap()
.is_none());
}
}
}

#[tokio::test]
async fn node_auto_job_admission_rechecks_actual_step_capability() {
for hint in [
ProjectValidationAdapter::Auto,
ProjectValidationAdapter::Node,
] {
let registry = RunnerRegistry::default();
let mut runner = registration(true);
runner.capabilities.project_validation_node_script_check_v1 = false;
registry.register(runner).await.unwrap();
let mut metadata = locked_validation_metadata();
let step = ShellJobValidationStep {
name: "check".into(),
program: "node".into(),
args: vec!["--run".into(), "lint".into()],
env: vec![],
};
let validation = metadata.validation.as_mut().unwrap();
let provenance = validation.project_validation.as_mut().unwrap();
provenance.backend = "node".into();
provenance.request.adapter = hint;
provenance.request.dependency_policy = None;
validation.adapter = "node:script:check".into();
validation.steps = vec![step.clone()];
metadata.validation_steps = vec![step];
assert!(validation.is_valid());
let error = registry
.start_job_with_metadata(start_request("node --run lint"), "test".into(), metadata)
.await
.unwrap_err();
assert!(
error.contains("project_validation_node_script_check_v1"),
"{error}"
);
assert!(registry.list_jobs(Some(10)).await.is_empty());
}
}

#[tokio::test]
async fn project_validation_base_capability_error_identifies_runner_without_dispatch() {
let registry = RunnerRegistry::default();
Expand Down
Loading
Loading