diff --git a/PLAN.md b/PLAN.md index d02a669..db9931e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/hemx-derive/src/lib.rs b/hemx-derive/src/lib.rs index bd77b7e..80338fe 100644 --- a/hemx-derive/src/lib.rs +++ b/hemx-derive/src/lib.rs @@ -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(); + let syms_path = match handler_syms_path(syms_path()) { + Ok(path) => path, + Err(message) => { + 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"; - 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(); - } - 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)`" - ); - return quote!( - #function - compile_error!(#message); - ) - .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 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); + ); + } + }; 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 { }) } +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) -> Result { + 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 { + 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 { + 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 { 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() {} + ), + ] { + 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 { + name: String, + tags: Vec, + } + ); + 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); diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index 06eb4ec..4f9b73c 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -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