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
18 changes: 17 additions & 1 deletion crates/webcodex-core/src/project_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use crate::runner_protocol::{normalize_cargo_packages, normalize_go_packages, Sh
use serde::{Deserialize, Serialize};

/// Portable project-operation scope retained under the project_build API name.
pub use crate::project_operation::ProjectOperationScope as ProjectBuildScope;
pub use crate::project_operation::{
ProjectDependencyMode, ProjectDependencyPolicy, ProjectOperationScope as ProjectBuildScope,
};
use sha2::{Digest, Sha256};

pub const PROJECT_BUILD_PROVENANCE_MAX_BYTES: usize = 8 * 1024;
Expand All @@ -30,6 +32,8 @@ pub struct ProjectBuildRequest {
pub adapter: ProjectBuildAdapter,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<ProjectBuildScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dependency_policy: Option<ProjectDependencyPolicy>,
}

impl ProjectBuildRequest {
Expand Down Expand Up @@ -137,6 +141,12 @@ pub fn canonical_project_build_process(
"rust" => {
let packages = normalize_cargo_packages(None, packages)?;
let mut args = vec!["build".to_string()];
if request
.dependency_policy
.is_some_and(|policy| policy.mode == ProjectDependencyMode::Locked)
{
args.push("--locked".to_string());
}
if let Some(packages) = packages {
for package in packages {
args.push("-p".to_string());
Expand All @@ -151,6 +161,12 @@ pub fn canonical_project_build_process(
"go" => {
let packages = normalize_go_packages(packages)?;
let mut args = vec!["build".to_string()];
if request
.dependency_policy
.is_some_and(|policy| policy.mode == ProjectDependencyMode::Locked)
{
args.push("-mod=readonly".to_string());
}
args.extend(packages);
Ok(ShellProcessArgv {
executable: "go".to_string(),
Expand Down
46 changes: 46 additions & 0 deletions crates/webcodex-core/src/project_build_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ fn request() -> ProjectBuildRequest {
cwd: Some("src".into()),
adapter: ProjectBuildAdapter::Auto,
scope: None,
dependency_policy: None,
}
}

Expand Down Expand Up @@ -94,6 +95,7 @@ fn start_build_wire_roundtrip_preserves_typed_process_and_provenance() {
cwd: None,
adapter: ProjectBuildAdapter::Rust,
scope: None,
dependency_policy: None,
};
let process = canonical_project_build_process("rust", &request).unwrap();
let provenance = ProjectBuildProvenance {
Expand Down Expand Up @@ -185,3 +187,47 @@ fn start_build_wire_roundtrip_preserves_typed_process_and_provenance() {
.unwrap_err()
.contains("recovery metadata does not match typed operation"));
}

#[test]
fn project_build_locked_dependency_policy_maps_to_native_readonly_flags() {
let locked = ProjectDependencyPolicy {
mode: ProjectDependencyMode::Locked,
};
for (backend, expected) in [
("rust", vec!["build", "--locked"]),
("go", vec!["build", "-mod=readonly", "./..."]),
] {
let request = ProjectBuildRequest {
project_id: "demo".into(),
cwd: None,
adapter: ProjectBuildAdapter::Auto,
scope: None,
dependency_policy: Some(locked),
};
let process = canonical_project_build_process(backend, &request).unwrap();
assert_eq!(process.args, expected);
}
}

#[test]
fn project_build_default_dependency_policy_preserves_historical_argv() {
let request = ProjectBuildRequest {
project_id: "demo".into(),
cwd: None,
adapter: ProjectBuildAdapter::Auto,
scope: None,
dependency_policy: None,
};
assert_eq!(
canonical_project_build_process("rust", &request)
.unwrap()
.args,
vec!["build"]
);
assert_eq!(
canonical_project_build_process("go", &request)
.unwrap()
.args,
vec!["build", "./..."]
);
}
17 changes: 17 additions & 0 deletions crates/webcodex-core/src/project_operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ use serde::{Deserialize, Serialize};
pub(crate) const PROJECT_OPERATION_SCOPE_MAX_PACKAGES: usize = 8;
pub(crate) const PROJECT_OPERATION_SCOPE_MAX_PACKAGE_BYTES: usize = 256;

/// Portable dependency-resolution policy for bounded project operations.
///
/// `Locked` means the adapter must not repair dependency selection by updating
/// the ecosystem's project-level resolution state. Network access is a separate
/// policy dimension and is intentionally not controlled here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProjectDependencyMode {
Locked,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProjectDependencyPolicy {
pub mode: ProjectDependencyMode,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProjectOperationScope {
Expand Down
2 changes: 2 additions & 0 deletions crates/webcodex-core/src/project_operation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn build_request(scope: ProjectBuildScope) -> ProjectBuildRequest {
cwd: None,
adapter: ProjectBuildAdapter::Auto,
scope: Some(scope),
dependency_policy: None,
}
}

Expand All @@ -21,6 +22,7 @@ fn validation_request(scope: ProjectValidationScope) -> ProjectValidationRequest
action: ProjectValidationAction::Check,
adapter: ProjectValidationAdapter::Auto,
scope: Some(scope),
dependency_policy: None,
test: None,
}
}
Expand Down
9 changes: 8 additions & 1 deletion crates/webcodex-core/src/project_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use crate::project_operation::ProjectOperationScopeError;
use serde::{Deserialize, Serialize};

/// Portable project-operation scope retained under the project_validate API name.
pub use crate::project_operation::ProjectOperationScope as ProjectValidationScope;
pub use crate::project_operation::{
ProjectDependencyMode, ProjectDependencyPolicy, ProjectOperationScope as ProjectValidationScope,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -65,6 +67,8 @@ pub struct ProjectValidationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<ProjectValidationScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dependency_policy: Option<ProjectDependencyPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test: Option<ProjectValidationTestOptions>,
}
impl ProjectValidationRequest {
Expand Down Expand Up @@ -105,6 +109,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());
}
if let Some(test) = &self.test {
if self.action != ProjectValidationAction::Test {
return Err("test options require project_validate action=test".into());
Expand Down
21 changes: 21 additions & 0 deletions crates/webcodex-core/src/project_validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ fn project_validation_protocol_is_closed_declarative_and_roundtrips() {
action: ProjectValidationAction::Check,
adapter: ProjectValidationAdapter::Auto,
scope: None,
dependency_policy: None,
test: None,
};
let wire = RunnerRequest::from_operation(
Expand Down Expand Up @@ -201,6 +202,7 @@ fn project_validation_package_scope_roundtrips_and_is_bounded() {
scope: Some(ProjectValidationScope {
packages: vec!["package-a".into(), "package-b".into()],
}),
dependency_policy: None,
test: None,
};
assert!(input.validate().is_ok());
Expand All @@ -219,3 +221,22 @@ fn project_validation_package_scope_roundtrips_and_is_bounded() {
assert!(invalid.validate().is_err());
}
}

#[test]
fn project_validation_locked_policy_rejects_format_check_without_silent_ignore() {
let request = ProjectValidationRequest {
project_id: "demo".into(),
cwd: None,
action: ProjectValidationAction::FormatCheck,
adapter: ProjectValidationAdapter::Rust,
scope: None,
dependency_policy: Some(ProjectDependencyPolicy {
mode: ProjectDependencyMode::Locked,
}),
test: None,
};
assert_eq!(
request.validate().unwrap_err(),
"dependency_policy requires project_validate action=check or test"
);
}
1 change: 1 addition & 0 deletions crates/webcodex-core/src/runner_operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,7 @@ mod tests {
cwd: None,
adapter: crate::project_build::ProjectBuildAdapter::Rust,
scope: None,
dependency_policy: None,
};
let build_process =
crate::project_build::canonical_project_build_process("rust", &build_request).unwrap();
Expand Down
35 changes: 34 additions & 1 deletion crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,13 @@ runner_capabilities! {
#[serde(default, skip_serializing_if = "is_false")]
pub project_build_v1: bool = false;
}
/// Additive portable dependency policy for project build/validation gateways.
/// Missing on older Runners is false; policy-bearing plans must fail closed.
ProjectDependencyPolicy => RUNNER_CAPABILITY_PROJECT_DEPENDENCY_POLICY("project_dependency_policy_v1"),
v2_baseline = false {
#[serde(default, skip_serializing_if = "is_false")]
pub project_dependency_policy_v1: bool = false;
}
/// Go project lifecycle gateways execute in Runner-owned single-module mode.
/// The Runner forces GO111MODULE=on and GOWORK=off after shell/profile environment
/// preparation so ambient module/workspace mode cannot change the planned graph.
Expand Down Expand Up @@ -2748,6 +2755,7 @@ mod envelope_tests {
structured_go_test_json: true,
project_validation_v1: false,
project_build_v1: false,
project_dependency_policy_v1: false,
project_go_single_module_v1: false,
project_validation_package_scope_v1: false,
project_validation_test_options_v1: false,
Expand Down Expand Up @@ -4422,6 +4430,14 @@ mod filter_canonical_tests {
assert!(step(&["test", "-json", "./pkg"]).is_canonical());
assert!(step(&["test", "-json", ".", "./pkg", "./internal/..."]).is_canonical());
assert!(step(&["test", "-json", "-run", "TestOne", "./..."]).is_canonical());
assert!(step(&["test", "-json", "-mod=readonly", "./..."]).is_canonical());
assert!(
step(&["test", "-json", "-mod=readonly", "-run", "TestOne", "./...",]).is_canonical()
);
assert!(!step(&["test", "-json", "-mod=mod", "./..."]).is_canonical());
assert!(
!step(&["test", "-json", "-mod=readonly", "-mod=readonly", "./...",]).is_canonical()
);
assert!(!step(&["test", "-v", "./..."]).is_canonical());
assert!(!step(&["run", "./..."]).is_canonical());
}
Expand Down Expand Up @@ -4560,10 +4576,20 @@ mod filter_canonical_tests {

let go_vet = validation_step("check", "go", &["vet", "./cmd/...", "./internal"]);
assert!(go_vet.is_canonical());
let locked_cargo_test = validation_step(
"test",
"cargo",
&["test", "--locked", "-p", "package-a", "-p", "package-b"],
);
assert!(locked_cargo_test.is_canonical());
let readonly_go_vet =
validation_step("check", "go", &["vet", "-mod=readonly", "./internal"]);
assert!(readonly_go_vet.is_canonical());

for rejected in [
validation_step("check", "go", &["vet", "not-relative"]),
validation_step("check", "go", &["vet", "./internal", "--flag"]),
validation_step("check", "go", &["vet", "-mod=mod", "./internal"]),
validation_step(
"test",
"cargo",
Expand All @@ -4585,6 +4611,8 @@ mod filter_canonical_tests {
let accepted = [
vec!["check"],
vec!["check", "--all-targets"],
vec!["check", "--locked"],
vec!["check", "--locked", "-p", "my-crate"],
vec!["check", "--all-features"],
vec!["check", "--no-default-features"],
vec!["check", "--all-targets", "--all-features"],
Expand Down Expand Up @@ -4622,7 +4650,8 @@ mod filter_canonical_tests {
vec!["check", "-p", "tab\tvalue"],
vec!["check", "-p", "same-crate", "-p", "same-crate"],
vec!["check", "--manifest-path", "/tmp/Cargo.toml"],
vec!["check", "--locked"],
vec!["check", "--locked", "--locked"],
vec!["check", "--locked=true"],
vec!["check", "--", "--all-targets"],
];
let over_long_value = "a".repeat(CARGO_VALUE_MAX_BYTES + 1);
Expand Down Expand Up @@ -4657,6 +4686,8 @@ mod filter_canonical_tests {
vec!["test"],
vec!["test", "focused"],
vec!["test", "--all-targets"],
vec!["test", "--locked"],
vec!["test", "focused", "--locked"],
vec!["test", "--lib"],
vec!["test", "--lib", "--all-targets", "--no-run"],
vec!["test", "--no-run"],
Expand Down Expand Up @@ -4684,6 +4715,8 @@ mod filter_canonical_tests {
vec!["test", "--features", "nul\0byte"],
vec!["test", "--features", "col\tumn"],
vec!["test", "--manifest-path", "/tmp/Cargo.toml"],
vec!["test", "--locked", "--locked"],
vec!["test", "--locked=true"],
vec!["test", "--", "--all-targets"],
];
for args in rejected {
Expand Down
33 changes: 23 additions & 10 deletions crates/webcodex-core/src/runner_protocol/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,14 +273,16 @@ impl ShellJobValidationStep {

/// Canonical `cargo check` argv: `check` followed by zero or more distinct
/// read-only flags (`--all-targets`, `--all-features`,
/// `--no-default-features`) and `--features <value>` / `-p <value>` pairs.
/// `--no-default-features`, `--locked`) and `--features <value>` /
/// `-p <value>` pairs.
fn is_canonical_cargo_check_args(args: &[&str]) -> bool {
args.first() == Some(&"check") && is_canonical_cargo_flags(&args[1..], false, true)
}

/// Canonical `cargo test` argv: the `test` subcommand, an optional libtest
/// filter (never a Cargo option), then zero or more distinct read-only flags
/// and `--features <value>` / repeated `-p <value>` pairs, optionally including
/// (including `--locked`) and `--features <value>` / repeated `-p <value>`
/// pairs, optionally including
/// the Cargo test-only `--lib` and `--no-run` selectors.
///
/// The flat argv boundary has inherent information loss: `["test",
Expand Down Expand Up @@ -457,7 +459,15 @@ fn is_canonical_go_vet_args(args: &[&str]) -> bool {
if args.len() < 2 || args[0] != "vet" {
return false;
}
let packages = args[1..]
let package_start = if args.get(1) == Some(&"-mod=readonly") {
2
} else {
1
};
if args.len() <= package_start {
return false;
}
let packages = args[package_start..]
.iter()
.map(|value| (*value).to_string())
.collect::<Vec<_>>();
Expand All @@ -471,17 +481,20 @@ fn is_canonical_go_test_json_args(args: &[&str]) -> bool {
if args.len() < 3 || args[0] != "test" || args[1] != "-json" {
return false;
}
let package_start = if args.get(2) == Some(&"-run") {
let Some(filter) = args.get(3) else {
let mut index = 2;
if args.get(index) == Some(&"-mod=readonly") {
index += 1;
}
if args.get(index) == Some(&"-run") {
let Some(filter) = args.get(index + 1) else {
return false;
};
if !matches!(normalize_go_test_filter(filter), Ok(Some(value)) if value == *filter) {
return false;
}
4
} else {
2
};
index += 2;
}
let package_start = index;
let packages = args[package_start..]
.iter()
.map(|value| (*value).to_string())
Expand Down Expand Up @@ -510,7 +523,7 @@ fn is_canonical_cargo_flags(
let mut iter = args.iter();
while let Some(arg) = iter.next() {
let key = match *arg {
"--all-targets" | "--all-features" | "--no-default-features" => *arg,
"--all-targets" | "--all-features" | "--no-default-features" | "--locked" => *arg,
"--lib" | "--no-run" if cargo_test => *arg,
"--features" => {
if !seen.insert(*arg) {
Expand Down
Loading
Loading