diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c45e8db..ff80e0e 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -20,6 +20,9 @@ # non-terminating; sorted, deduplicated, boundary-aware outputs are asserted. # - context path words are filtered non-empty before extracting their first char; # `?` and `unwrap` are equivalent under that local iterator invariant. +# - Rust-fact named fields always carry identifiers by syn's type contract. Per-entry +# and recursive read_dir errors cannot be injected portably after the parent opens; +# parent-open, source-read, and parse failures remain explicitly proven. exclude_re = [ "test_process_try_wait", "test_process_poll_delay", @@ -41,4 +44,6 @@ exclude_re = [ "replace \\+= with (?:-=|\\*=) in stylesheet_class_tokens", "replace 1 with 0 in stylesheet_class_tokens", "replace chars.next\\(\\)\\? with chars.next\\(\\).unwrap\\(\\) in context_type_for_heml_path", + "replace entry\\? with entry.unwrap\\(\\) in collect_rust_struct_facts", + "replace collect_rust_struct_facts.*\\? with collect_rust_struct_facts.*unwrap\\(\\) in collect_rust_struct_facts", ] diff --git a/PLAN.md b/PLAN.md index 27bb338..0d9d9e2 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, propagates mutest failure, and mutation-tests `hemx-axum`, `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-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts. The current `hemx-build` frontier now also mutation-proves ordered cross-kind generated-target deduplication and identifier/literal/canonical-symbol/resource-insertion boundaries, including invalid UTF-8 paths, exact collisions, missing values, and stable first-seen ordering. The remaining package frontier is concentrated in Rust-fact recursive collection and three `Resources::add_surface` propagation/default branches before another full package gate. The complete 470-mutant `hemx-axum` package gate now 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-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts. The current `hemx-build` frontier now also mutation-proves all 38 recursive Rust-fact collection mutants and all 115 `Resources::add_surface` mutants, including nested/inline modules, ignored non-Rust/out-of-tree files, invalid parse propagation, atom/handle collisions, and missing form-fact defaults. The focused survivor frontier is closed; rerun the full package mutation gate to expose any remaining uncaught functions. The complete 470-mutant `hemx-axum` package gate now 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-build/src/lib.rs b/hemx-build/src/lib.rs index 453f59b..b095279 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1890,8 +1890,7 @@ fn collect_rust_struct_facts( return Ok(()); }; for entry in entries { - let entry = entry?; - let path = entry.path(); + let path = entry?.path(); if path.is_dir() { collect_rust_struct_facts(&path, facts)?; } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { @@ -1906,8 +1905,10 @@ fn collect_rust_struct_facts_from_file( facts: &mut HashMap, ) -> io::Result<()> { let source = std::fs::read_to_string(path)?; - let file = - syn::parse_file(&source).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let file = match syn::parse_file(&source) { + Ok(file) => file, + Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), + }; collect_rust_struct_facts_from_items(&file.items, facts); Ok(()) } @@ -1926,11 +1927,9 @@ fn collect_rust_struct_facts_from_items( fields: fields .named .iter() - .filter_map(|field| { - Some(TemplateFieldFact { - name: field.ident.as_ref()?.to_string(), - type_name: compact_tokens(&field.ty), - }) + .map(|field| TemplateFieldFact { + name: field.ident.as_ref().unwrap().to_string(), + type_name: compact_tokens(&field.ty), }) .collect(), derives_hemplate: derives_hemplate(&item.attrs), @@ -2289,7 +2288,20 @@ mod tests { .unwrap(); std::fs::write( crate_root.join("src/lib.rs"), - "#[derive(Hemplate)] struct Profile { title: String }\n#[derive(Clone, hemplate::Hemplate)] struct ProfileCard { card_title: String }\n#[derive(NotHemplate)] struct ProfileDetails { hidden: String }\n#[derive(HemplateExtra)] struct Extra { hidden: String }\nstruct Plain { title: String }", + "#[derive(Hemplate)] struct Profile { title: String }\n#[derive(Clone, hemplate::Hemplate)] struct ProfileCard { card_title: String }\n#[derive(NotHemplate)] struct ProfileDetails { hidden: String }\n#[derive(HemplateExtra)] struct Extra { hidden: String }\nmod inline { #[derive(Hemplate)] pub struct InlineProfile { pub inline_title: String } }\nmod external;\nstruct Plain { title: String }", + ) + .unwrap(); + std::fs::create_dir_all(crate_root.join("src/nested")).unwrap(); + std::fs::write( + crate_root.join("src/nested/profile.rs"), + "#[derive(Hemplate)] struct NestedProfile { nested_title: String }", + ) + .unwrap(); + std::fs::write(crate_root.join("src/external.rs"), "struct External;").unwrap(); + std::fs::write(crate_root.join("src/ignored.txt"), [0xff]).unwrap(); + std::fs::write( + crate_root.join("outside.rs"), + "#[derive(Hemplate)] struct OutsideProfile { outside: String }", ) .unwrap(); let profile = templates.join("profile.heml"); @@ -2302,6 +2314,29 @@ mod tests { assert_eq!(facts.context_type, "Profile"); assert_eq!(facts.self_fields[0].name, "title"); + for (template, context_type, field) in [ + ("inline_profile.heml", "InlineProfile", "inline_title"), + ("nested_profile.heml", "NestedProfile", "nested_title"), + ] { + let nested_facts = template_context_facts_for_heml_source( + templates.join(template), + format!("
{{{{ self.{field} }}}}
"), + ) + .unwrap() + .expect("recursive Rust struct fact"); + assert_eq!(nested_facts.context_type, context_type); + assert_eq!(nested_facts.self_fields[0].name, field); + } + assert_eq!( + template_context_facts_for_heml_source( + templates.join("outside_profile.heml"), + "
{{ self.outside }}
".to_owned(), + ) + .unwrap(), + None, + "Rust facts outside src must not become template authority" + ); + let card_facts = template_context_facts_for_heml_source( templates.join("profile-card.heml"), "
{{ self.card_title }}
".to_owned(), @@ -2364,6 +2399,29 @@ mod tests { // test req: diagnostics/004 req: diagnostics/006 req: surface/008 } + #[test] + fn rust_fact_collection_propagates_parse_and_recursive_directory_errors() { + let base = test_dir("hemx-build-rust-fact-errors"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("src")).unwrap(); + let mut facts = HashMap::new(); + std::fs::write(base.join("src/broken.rs"), "struct {").unwrap(); + assert_eq!( + collect_rust_struct_facts_from_file(&base.join("src/broken.rs"), &mut facts) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + assert!(facts.is_empty()); + std::fs::remove_file(base.join("src/broken.rs")).unwrap(); + + assert!(rust_struct_facts_in(&base.join("missing")) + .unwrap() + .is_empty()); + let _ = std::fs::remove_dir_all(base); + // test req: diagnostics/006 + } + #[test] fn identifier_and_literal_helpers_fail_closed_at_boundaries() { assert_eq!(rust_ident("alpha_9"), Some("alpha_9".into())); @@ -2459,6 +2517,54 @@ mod tests { io::ErrorKind::InvalidData ); + for (kind, source) in [ + ("atom", r#""#), + ("handle", r#""#), + ] { + let first = + surface_for_heml_source(Path::new("first.heml"), source.to_owned()).unwrap(); + let second = + surface_for_heml_source(Path::new("second.heml"), source.to_owned()).unwrap(); + let mut duplicate = Resources::default(); + duplicate + .add_surface( + Path::new("templates"), + Path::new("templates/first.heml"), + &first, + ) + .unwrap(); + assert_eq!( + duplicate + .add_surface( + Path::new("templates"), + Path::new("templates/second.heml"), + &second, + ) + .unwrap_err() + .to_string(), + format!( + "duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`" + ), + "{kind} collision must propagate" + ); + } + + let mut form_without_controls = surface_for_heml_source( + Path::new("form.heml"), + r#"
"#.to_owned(), + ) + .unwrap(); + form_without_controls.forms.clear(); + let mut form_resources = Resources::default(); + form_resources + .add_surface( + Path::new("templates"), + Path::new("templates/form.heml"), + &form_without_controls, + ) + .unwrap(); + assert!(form_resources.forms["empty"].controls.is_empty()); + #[cfg(unix)] { use std::os::unix::ffi::OsStrExt;