feat(xtask): shard package mutation gates

Accept validated native SHARD/TOTAL operands, isolate shard output directories, preserve the unsharded package gate, and prove hemx-build shard 1/4 through the public xtask entry point.

req: test/022

req: test/023
This commit is contained in:
slhx agent
2026-07-17 08:14:03 +02:00
parent 216465f779
commit fe03f17667
4 changed files with 70 additions and 12 deletions
+65 -9
View File
@@ -12,7 +12,11 @@ fn main() -> ExitCode {
Some("test") | None => run_test_plan(),
Some("html-examples-smoke") => run_html_examples_smoke(),
Some("bench") => run_bench_plan(),
Some("mutation") => run_mutation_plan(args.next().as_deref()),
Some("mutation") => {
let package = args.next();
let shard = args.next();
run_mutation_plan(package.as_deref(), shard.as_deref())
}
Some("workout") => {
let subcommand = args.next();
let operand = args.next();
@@ -38,7 +42,7 @@ fn main() -> ExitCode {
fn print_help() {
println!(
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- mutation [PACKAGE]\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_MUTEST_BIN=PATH mutest executable (default: mutest)\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- html-examples-smoke\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_MUTEST_BIN=PATH mutest executable (default: mutest)\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
);
}
@@ -1280,7 +1284,30 @@ fn mutation_packages(package: Option<&str>) -> Result<Vec<&'static str>, String>
}
}
fn run_mutation_plan(package: Option<&str>) -> ExitCode {
fn mutation_shard(shard: Option<&str>) -> Result<Option<String>, String> {
let Some(shard) = shard else {
return Ok(None);
};
let Some((index, total)) = shard.split_once('/') else {
return Err(format!(
"invalid mutation shard `{shard}`; expected SHARD/TOTAL such as `1/4`"
));
};
let index = index
.parse::<usize>()
.map_err(|_| format!("invalid mutation shard `{shard}`; SHARD must be an integer"))?;
let total = total
.parse::<usize>()
.map_err(|_| format!("invalid mutation shard `{shard}`; TOTAL must be an integer"))?;
if total < 2 || index == 0 || index > total {
return Err(format!(
"invalid mutation shard `{shard}`; require TOTAL >= 2 and 1 <= SHARD <= TOTAL"
));
}
Ok(Some(format!("{index}/{total}")))
}
fn run_mutation_plan(package: Option<&str>, shard: Option<&str>) -> ExitCode {
let packages = match mutation_packages(package) {
Ok(packages) => packages,
Err(error) => {
@@ -1288,6 +1315,13 @@ fn run_mutation_plan(package: Option<&str>) -> ExitCode {
return ExitCode::from(2);
}
};
let shard = match mutation_shard(shard) {
Ok(shard) => shard,
Err(error) => {
eprintln!("{error}");
return ExitCode::from(2);
}
};
let budget = Budget::detect().with_jobs(4);
budget.report();
let mutest = env::var_os("HEMX_MUTEST_BIN").unwrap_or_else(|| "mutest".into());
@@ -1303,9 +1337,17 @@ fn run_mutation_plan(package: Option<&str>) -> ExitCode {
for package in packages {
eprintln!("\n==> mutation: {package}");
let output = output_root.join(package);
let output = shard.as_ref().map_or_else(
|| output_root.join(package),
|shard| {
output_root
.join(package)
.join(format!("shard-{}", shard.replace('/', "-of-")))
},
);
let jobs = budget.jobs.to_string();
let status = Command::new(&mutest)
let mut command = Command::new(&mutest);
command
.current_dir(workspace_root())
.args([
"-p",
@@ -1322,8 +1364,11 @@ fn run_mutation_plan(package: Option<&str>) -> ExitCode {
"--exhaustive",
"-o",
])
.arg(output)
.status();
.arg(output);
if let Some(shard) = &shard {
command.args(["--shard", shard]);
}
let status = command.status();
match status {
Ok(status) if status.success() => {}
Ok(status) => {
@@ -1565,8 +1610,8 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
mod tests {
use super::{
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
create_workout_app, mobile_external_blockers, mutation_packages, origin_host,
verify_workout_mobile_release, workout_mobile_manifest, workspace_root,
create_workout_app, mobile_external_blockers, mutation_packages, mutation_shard,
origin_host, verify_workout_mobile_release, workout_mobile_manifest, workspace_root,
write_workout_mobile_release, Budget, WorkoutMobileConfig, MUTATION_PACKAGES,
};
use std::fs;
@@ -1581,6 +1626,17 @@ mod tests {
let error = mutation_packages(Some("example-app")).unwrap_err();
assert!(error.contains("unknown mutation package `example-app`"));
assert!(error.contains("hemx-core"));
assert_eq!(mutation_shard(None).unwrap(), None);
assert_eq!(mutation_shard(Some("1/4")).unwrap().as_deref(), Some("1/4"));
assert_eq!(mutation_shard(Some("4/4")).unwrap().as_deref(), Some("4/4"));
for invalid in ["1", "a/4", "1/a", "0/4", "5/4", "1/1"] {
assert!(
mutation_shard(Some(invalid)).unwrap_err().contains(invalid),
"missing invalid shard in diagnostic"
);
}
// test req: test/022 req: test/023
}
#[test]