test(derive): close first mutation shard
Create shard output parents, exercise exact handler placement/signature/codegen contracts, preserve generated-file and form-bound diagnostics, and fail closed on malformed form symbol manifests. Public shard 1/8 passes through both 16-way partitions. req: derive_handler/001 req: client_local/001 req: form/004 req: diagnostics/003 req: test/022 req: test/023
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages and invalid shards, propagates mutest failure, and mutation-tests `hemx-axum`, `hemx-build`, `hemx-core`, `hemx-js`, and the full `hemx-test` package cleanly; full package closure remains.
|
||||
- **User value:** maintainers can run one bounded repository command and trust that meaningful Rust logic across every mutation-applicable library is either killed or explicitly justified.
|
||||
- **Build:** add a capped `hemx-xtask` mutation command that invokes `/opt/repositories/mutest`/`mutest` through package-native test targets rather than the broken workspace-wide example path; enumerate only current mutation-applicable library/proc-macro packages; finish adversarial tests or simplify code until every survivor is classified; keep equivalent, invariant-only, and infrastructure-inapplicable classifications inspectable and minimal; document the exact local release command in the existing readiness surface.
|
||||
- **Blocked by:** none; broad survivors currently remain in `hemx-derive` and `hemx-lsp` outside already-clean focused contracts. The mutation entry point accepts validated one-based `SHARD/TOTAL` operands, maps them to native zero-based shards, uses shard-specific output directories, grants repo-owned compiler probes a 120-second floor, and preserves the unsharded gate. All eight deterministic `hemx-build` shards now pass: 1,304 mutants total, 1,070 caught and 234 unviable, including final shard `8/8` with 159 mutants (139 caught, 20 unviable). The complete 470-mutant `hemx-axum` package gate passes with 262 caught and 208 unviable after public page/form/multipart/registry/response/runtime proofs and narrow classification of infallible header parsing and streamed multipart unwrap-equivalent mutants.
|
||||
- **Blocked by:** none; broad survivors currently remain in `hemx-derive` and `hemx-lsp` outside already-clean focused contracts. The xtask mutation runner now creates shard output parents before invoking mutest, fixing first-use failure for newly sharded packages. `hemx-derive` public shard `1/8` is clean through deterministic partitions `1/16` (31 mutants: 24 caught, 7 unviable) and `9/16` (31 mutants: 28 caught, 3 unviable) after exact client-handler mode/signature/codegen, generated-file diagnostics, form generic bounds, and malformed symbol-manifest coverage; shards `2/8` through `8/8` remain. The mutation entry point accepts validated one-based `SHARD/TOTAL` operands, maps them to native zero-based shards, uses shard-specific output directories, grants repo-owned compiler probes a 120-second floor, and preserves the unsharded gate. All eight deterministic `hemx-build` shards now pass: 1,304 mutants total, 1,070 caught and 234 unviable, including final shard `8/8` with 159 mutants (139 caught, 20 unviable). The complete 470-mutant `hemx-axum` package gate passes with 262 caught and 208 unviable after public page/form/multipart/registry/response/runtime proofs and narrow classification of infallible header parsing and streamed multipart unwrap-equivalent mutants.
|
||||
- **Proof:** the new xtask mutation command exits zero within its documented bound, covers each applicable package, emits no unexplained missed mutant, and a deliberate adjacent mutation makes it fail. `cargo run -p hemx-xtask -- test` remains green. req: test/020 req: test/021
|
||||
|
||||
## 3. Elect and enforce the release license policy
|
||||
|
||||
+262
-56
@@ -12,37 +12,23 @@ use syn::{
|
||||
#[proc_macro_attribute]
|
||||
pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let placement = attr.to_string();
|
||||
let is_client = match placement.as_str() {
|
||||
"" => false,
|
||||
"client" => true,
|
||||
_ => {
|
||||
return syn::Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
|
||||
)
|
||||
.into_compile_error()
|
||||
.into();
|
||||
}
|
||||
let placement = match handler_placement(&placement) {
|
||||
Ok(placement) => placement,
|
||||
Err(error) => return error.into_compile_error().into(),
|
||||
};
|
||||
let function = parse_macro_input!(item as ItemFn);
|
||||
let name = function.sig.ident.to_string();
|
||||
|
||||
let Some(syms_path) = syms_path() else {
|
||||
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
};
|
||||
if !syms_path.exists() {
|
||||
let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation";
|
||||
let syms_path = match handler_syms_path(syms_path()) {
|
||||
Ok(path) => path,
|
||||
Err(message) => {
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
}
|
||||
};
|
||||
if !syms_contains_handle(&syms_path, &name) {
|
||||
let message = format!(
|
||||
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler"
|
||||
@@ -86,33 +72,34 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
.into();
|
||||
}
|
||||
|
||||
if !is_client {
|
||||
return quote!(#function).into();
|
||||
expand_handler_function(function, placement).into()
|
||||
}
|
||||
|
||||
fn expand_handler_function(
|
||||
function: ItemFn,
|
||||
placement: HandlerPlacement,
|
||||
) -> proc_macro2::TokenStream {
|
||||
if placement == HandlerPlacement::Server {
|
||||
return quote!(#function);
|
||||
}
|
||||
let input_count = function.sig.inputs.len();
|
||||
if !matches!(input_count, 0 | 2)
|
||||
|| function.sig.asyncness.is_some()
|
||||
|| function.sig.unsafety.is_some()
|
||||
|| function.sig.constness.is_some()
|
||||
|| !function.sig.generics.params.is_empty()
|
||||
{
|
||||
let message = format!(
|
||||
"client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
|
||||
);
|
||||
let has_inputs = match client_handler_has_inputs(&function) {
|
||||
Ok(has_inputs) => has_inputs,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
return quote!(
|
||||
#function
|
||||
compile_error!(#message);
|
||||
)
|
||||
.into();
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let function_name = &function.sig.ident;
|
||||
let export_name = format_ident!("__hemx_client_{function_name}");
|
||||
let export_module = format_ident!("__hemx_client_export_{function_name}");
|
||||
let invoke_handler = if input_count == 0 {
|
||||
quote!(super::#function_name())
|
||||
} else {
|
||||
let invoke_handler = if has_inputs {
|
||||
quote!(super::#function_name(event, state))
|
||||
} else {
|
||||
quote!(super::#function_name())
|
||||
};
|
||||
quote!(
|
||||
#function
|
||||
@@ -149,7 +136,6 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
}
|
||||
}
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
@@ -162,7 +148,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let form_name = parse_macro_input!(attr as LitStr).value();
|
||||
let form_struct = parse_macro_input!(item as ItemStruct);
|
||||
let Some(syms_path) = syms_path() else {
|
||||
let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
|
||||
let message = missing_form_generated_files_message();
|
||||
return quote!(
|
||||
#form_struct
|
||||
compile_error!(#message);
|
||||
@@ -174,13 +160,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let ident = &form_struct.ident;
|
||||
let resource_id =
|
||||
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
|
||||
let mut generics = form_struct.generics.clone();
|
||||
for ty in form_parser_types(&form_struct) {
|
||||
generics
|
||||
.make_where_clause()
|
||||
.predicates
|
||||
.push(parse_quote!(#ty: ::hemx::FormValue));
|
||||
}
|
||||
let generics = form_impl_generics(&form_struct);
|
||||
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
quote!(
|
||||
@@ -201,7 +181,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
let message = errors.join("; ");
|
||||
let message = join_contract_errors(&errors);
|
||||
quote!(
|
||||
#form_struct
|
||||
compile_error!(#message);
|
||||
@@ -228,9 +208,6 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let Some(syms_path) = syms_path() else {
|
||||
return quote!(#module).into();
|
||||
};
|
||||
if !syms_path.exists() {
|
||||
return quote!(#module).into();
|
||||
}
|
||||
let component_filter = component_name.as_deref();
|
||||
let errors = component_contract_errors(&syms_path, component_filter, items);
|
||||
if !errors.is_empty() {
|
||||
@@ -336,6 +313,76 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_handler_generated_files_message() -> &'static str {
|
||||
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
}
|
||||
|
||||
fn missing_form_generated_files_message() -> &'static str {
|
||||
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
}
|
||||
|
||||
fn handler_syms_path(syms_path: Option<PathBuf>) -> Result<PathBuf, &'static str> {
|
||||
match syms_path {
|
||||
None => Err(missing_handler_generated_files_message()),
|
||||
Some(path) if !path.exists() => Err(
|
||||
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation",
|
||||
),
|
||||
Some(path) => Ok(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_contract_errors(errors: &[String]) -> String {
|
||||
errors.join("; ")
|
||||
}
|
||||
|
||||
fn form_impl_generics(form_struct: &ItemStruct) -> syn::Generics {
|
||||
let mut generics = form_struct.generics.clone();
|
||||
for ty in form_parser_types(form_struct) {
|
||||
generics
|
||||
.make_where_clause()
|
||||
.predicates
|
||||
.push(parse_quote!(#ty: ::hemx::FormValue));
|
||||
}
|
||||
generics
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HandlerPlacement {
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
fn handler_placement(placement: &str) -> syn::Result<HandlerPlacement> {
|
||||
match placement {
|
||||
"" => Ok(HandlerPlacement::Server),
|
||||
"client" => Ok(HandlerPlacement::Client),
|
||||
_ => Err(syn::Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_handler_has_inputs(function: &ItemFn) -> syn::Result<bool> {
|
||||
let input_count = function.sig.inputs.len();
|
||||
if matches!(input_count, 0 | 2)
|
||||
&& function.sig.asyncness.is_none()
|
||||
&& function.sig.unsafety.is_none()
|
||||
&& function.sig.constness.is_none()
|
||||
&& function.sig.generics.params.is_empty()
|
||||
{
|
||||
Ok(input_count == 2)
|
||||
} else {
|
||||
Err(syn::Error::new_spanned(
|
||||
&function.sig,
|
||||
format!(
|
||||
"client-local hemx handler `{}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`",
|
||||
function.sig.ident
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
|
||||
function.sig.inputs.iter().rev().find_map(|arg| match arg {
|
||||
FnArg::Typed(arg) => form_model_type(&arg.ty),
|
||||
@@ -984,14 +1031,173 @@ fn compile_error(message: &str) -> TokenStream {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_app_registry_helper, add_component_register_helper, component_handler_names,
|
||||
form_model_type, handle_params, handle_requires_form, handler_form_model_type,
|
||||
has_form_param, has_non_unit_return, missing_component_handlers, missing_handle_params,
|
||||
parser_type, returns_result, syms_contains_handle,
|
||||
add_app_registry_helper, add_component_register_helper, client_handler_has_inputs,
|
||||
component_handler_names, expand_handler_function, form_fields, form_impl_generics,
|
||||
form_model_type, form_resource_id, handle_params, handle_requires_form,
|
||||
handler_form_model_type, handler_placement, handler_syms_path, has_form_param,
|
||||
has_non_unit_return, is_type_named, join_contract_errors, missing_component_handlers,
|
||||
missing_form_generated_files_message, missing_handle_params,
|
||||
missing_handler_generated_files_message, parser_type, returns_result, syms_contains_handle,
|
||||
HandlerPlacement,
|
||||
};
|
||||
use quote::quote;
|
||||
use quote::{quote, ToTokens};
|
||||
use syn::{parse_quote, ItemFn, Type};
|
||||
|
||||
#[test]
|
||||
fn handler_attribute_parses_server_and_client_modes_exactly() {
|
||||
assert_eq!(handler_placement("").unwrap(), HandlerPlacement::Server);
|
||||
assert_eq!(
|
||||
handler_placement("client").unwrap(),
|
||||
HandlerPlacement::Client
|
||||
);
|
||||
for invalid in ["server", " client", "client ", "CLIENT"] {
|
||||
assert_eq!(
|
||||
handler_placement(invalid).unwrap_err().to_string(),
|
||||
"unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]"
|
||||
);
|
||||
}
|
||||
|
||||
let no_inputs: ItemFn = parse_quote!(
|
||||
fn save() {}
|
||||
);
|
||||
let two_inputs: ItemFn = parse_quote!(
|
||||
fn save(event: Event, state: State) {}
|
||||
);
|
||||
assert!(!client_handler_has_inputs(&no_inputs).unwrap());
|
||||
assert!(client_handler_has_inputs(&two_inputs).unwrap());
|
||||
|
||||
let server =
|
||||
expand_handler_function(no_inputs.clone(), HandlerPlacement::Server).to_string();
|
||||
assert_eq!(
|
||||
server,
|
||||
quote!(
|
||||
fn save() {}
|
||||
)
|
||||
.to_string()
|
||||
);
|
||||
let no_input_client =
|
||||
expand_handler_function(no_inputs.clone(), HandlerPlacement::Client).to_string();
|
||||
assert!(no_input_client.contains("super :: save ()"));
|
||||
assert!(!no_input_client.contains("super :: save (event , state)"));
|
||||
let input_client =
|
||||
expand_handler_function(two_inputs.clone(), HandlerPlacement::Client).to_string();
|
||||
assert!(input_client.contains("super :: save (event , state)"));
|
||||
assert!(!input_client.contains("super :: save ()"));
|
||||
|
||||
for invalid in [
|
||||
parse_quote!(
|
||||
fn save(event: Event) {}
|
||||
),
|
||||
parse_quote!(
|
||||
fn save(a: A, b: B, c: C) {}
|
||||
),
|
||||
parse_quote!(
|
||||
async fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
unsafe fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
const fn save() {}
|
||||
),
|
||||
parse_quote!(
|
||||
fn save<T>() {}
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
client_handler_has_inputs(&invalid).unwrap_err().to_string(),
|
||||
"client-local hemx handler `save` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
|
||||
);
|
||||
let expanded = expand_handler_function(invalid, HandlerPlacement::Client).to_string();
|
||||
assert!(expanded.contains("compile_error !"));
|
||||
assert!(expanded.contains("client-local hemx handler"));
|
||||
}
|
||||
// test req: derive_handler/001 req: client_local/001
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_file_and_form_helpers_preserve_exact_contracts() {
|
||||
assert_eq!(
|
||||
missing_handler_generated_files_message(),
|
||||
"#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
);
|
||||
assert_eq!(
|
||||
missing_form_generated_files_message(),
|
||||
"#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
|
||||
);
|
||||
assert_eq!(
|
||||
handler_syms_path(None).unwrap_err(),
|
||||
missing_handler_generated_files_message()
|
||||
);
|
||||
let missing = std::env::temp_dir().join("hemx-derive-missing-symbols");
|
||||
assert_eq!(
|
||||
handler_syms_path(Some(missing)).unwrap_err(),
|
||||
"#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"
|
||||
);
|
||||
let existing = std::env::current_exe().unwrap();
|
||||
assert_eq!(handler_syms_path(Some(existing.clone())).unwrap(), existing);
|
||||
assert_eq!(
|
||||
join_contract_errors(&["first".into(), "second".into()]),
|
||||
"first; second"
|
||||
);
|
||||
|
||||
let form: syn::ItemStruct = parse_quote!(
|
||||
struct Profile<T> {
|
||||
name: String,
|
||||
tags: Vec<T>,
|
||||
}
|
||||
);
|
||||
let generics = form_impl_generics(&form);
|
||||
let where_clause = generics
|
||||
.where_clause
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_token_stream()
|
||||
.to_string();
|
||||
assert!(where_clause.contains("String : :: hemx :: FormValue"));
|
||||
assert!(where_clause.contains("T : :: hemx :: FormValue"));
|
||||
// test req: derive_handler/001 req: form/004
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_form_symbol_lookup_is_exact_and_fail_closed() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("hemx-derive-form-symbols-{}", std::process::id()));
|
||||
std::fs::write(
|
||||
&path,
|
||||
"hemx-syms-v1\nform\tprofile.heml::profile\tprofile\t42\nform\nform\tbroken\nform\tmissing-id\tmissing-id\nform\tother.heml::other\tother\tbad\nform_field\tprofile\tname\ttrue\tfalse\nform_field\nform_field\tprofile\nform_field\tprofile\tbad name\tfalse\tfalse\nform_field\tprofile\ttags\tfalse\ttrue\nform_field\tprofile\tbad-name\tfalse\tfalse\nform_field\tother\tignored\tfalse\tfalse\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(form_resource_id(&path, "profile"), Some(42));
|
||||
assert_eq!(form_resource_id(&path, "missing"), None);
|
||||
assert_eq!(form_resource_id(&path, "broken"), None);
|
||||
assert_eq!(form_resource_id(&path, "missing-id"), None);
|
||||
assert_eq!(form_resource_id(&path, "other"), None);
|
||||
|
||||
let fields = form_fields(&path, "profile");
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(fields[0].ident, "name");
|
||||
assert!(fields[0].required);
|
||||
assert!(!fields[0].multiple);
|
||||
assert_eq!(fields[1].ident, "tags");
|
||||
assert!(!fields[1].required);
|
||||
assert!(fields[1].multiple);
|
||||
assert_eq!(fields[2].ident, "bad_name");
|
||||
assert!(form_fields(&path, "missing").is_empty());
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(form_resource_id(&path, "profile"), None);
|
||||
assert!(form_fields(&path, "profile").is_empty());
|
||||
|
||||
let string: Type = parse_quote!(String);
|
||||
let qualified: Type = parse_quote!(std::string::String);
|
||||
let reference: Type = parse_quote!(&String);
|
||||
assert!(is_type_named(&string, "String"));
|
||||
assert!(is_type_named(&qualified, "String"));
|
||||
assert!(!is_type_named(&string, "Vec"));
|
||||
assert!(!is_type_named(&reference, "String"));
|
||||
// test req: form/004 req: diagnostics/003
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_type_helpers_recognize_only_the_public_form_and_result_shapes() {
|
||||
let bare: Type = parse_quote!(Form<String>);
|
||||
|
||||
@@ -1347,6 +1347,15 @@ fn run_mutation_plan(package: Option<&str>, shard: Option<&str>) -> ExitCode {
|
||||
.join(format!("shard-{}", shard.replace('/', "-of-")))
|
||||
},
|
||||
);
|
||||
if let Some(parent) = output.parent() {
|
||||
if let Err(error) = fs::create_dir_all(parent) {
|
||||
eprintln!(
|
||||
"failed to create mutation output parent {}: {error}",
|
||||
parent.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
let jobs = budget.jobs.to_string();
|
||||
let mut command = Command::new(&mutest);
|
||||
command
|
||||
|
||||
Reference in New Issue
Block a user