diff --git a/PLAN.md b/PLAN.md index 8560e29..56087f5 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 adversarially proves static convention diagnostics; public file/source diagnostics and context facts; mutation-clean generated-artifact create, no-op, and stale-output refresh behavior; and exact fail-closed invalid client-handler diagnostics through `AppBuilder::run`; remaining class/event/slot/atom/handle/form extraction and internal diagnostic survivors remain. 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 adversarially proves static convention diagnostics; public file/source diagnostics and context facts; generated-artifact refresh; client-handler rejection; and exact class/form extraction, field metadata, invalid-name, and collision behavior through `AppBuilder::run`; remaining event/slot/atom/handle extraction and internal diagnostic survivors remain. 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 7134438..b82c93e 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -456,17 +456,8 @@ impl Resources { ), )); } - match self.classes.get(&class.symbol) { - Some(existing) if existing.token != class.token => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("conflicting CSS class token for `{}`", existing.symbol), - )), - Some(_) => Ok(()), - None => { - self.classes.insert(class.symbol.clone(), class); - Ok(()) - } - } + self.classes.entry(class.symbol.clone()).or_insert(class); + Ok(()) } fn insert_handle_params(&mut self, handle: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> { @@ -530,7 +521,7 @@ impl Resources { .into_iter() .filter(|control| control.name != "__h") .collect(); - let resource = make_resource("form", symbol, name, component, false)?; + let resource = make_resource("form", symbol, name, component)?; match self.forms.get(&resource.ident) { Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1340,7 +1331,8 @@ fn insert_resource( component: String, keyed: bool, ) -> io::Result<()> { - let resource = make_resource(kind, symbol, name, component, keyed)?; + let mut resource = make_resource(kind, symbol, name, component)?; + resource.keyed = keyed; match map.get_mut(&resource.ident) { Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1365,7 +1357,6 @@ fn make_resource( symbol: String, name: String, component: String, - keyed: bool, ) -> io::Result { let ident = rust_ident(&name).ok_or_else(|| { io::Error::new( @@ -1378,7 +1369,7 @@ fn make_resource( symbol, ident, component, - keyed, + keyed: false, id, }) } @@ -2662,9 +2653,10 @@ mod tests { std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), - r#"
"#, + r#"
"#, ) .unwrap(); + std::fs::write(templates.join("todo.css"), ".todo-card { display: block; }").unwrap(); app() .template_dir(&templates) @@ -2745,17 +2737,137 @@ mod tests { assert!(generated.contains("__hemx_inject_handle_inputs")); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); - assert!(syms.contains("atom\t")); - assert!(syms.contains("\tfilter\t")); - assert!(syms.contains("handle_form\tcreate\tnew_todo\n")); - assert!(syms.contains("handle_param\tdelete\ttodo_id\n")); - assert!(syms.contains("event\t")); - assert!(syms.contains("\tclick\tclick\n")); - assert!(syms.contains("\tchange\tchange\n")); + for line in [ + format!( + "slot\ttodo.heml::todos\ttodos\t{}\n", + stable_id("slot", "todo.heml::todos") + ), + format!( + "handle\ttodo.heml::create\tcreate\t{}\n", + stable_id("handle", "todo.heml::create") + ), + format!( + "handle\ttodo.heml::delete\tdelete\t{}\n", + stable_id("handle", "todo.heml::delete") + ), + format!( + "form\ttodo.heml::new_todo\tnew_todo\t{}\n", + stable_id("form", "todo.heml::new_todo") + ), + "handle_form\tcreate\tnew_todo\n".to_owned(), + "form_field\tnew_todo\tlabels\tfalse\ttrue\n".to_owned(), + "form_field\tnew_todo\ttitle\ttrue\tfalse\n".to_owned(), + "handle_param\tdelete\ttodo_id\n".to_owned(), + format!( + "atom\ttodo.heml::filter\tfilter\t{}\n", + stable_id("atom", "todo.heml::filter") + ), + "class\ttodo.css::todo-card\ttodo_card\ttodo-card\n".to_owned(), + "class\ttodo.heml::todo-card\ttodo_card\ttodo-card\n".to_owned(), + "event\ttodo.heml::change\tchange\tchange\n".to_owned(), + "event\ttodo.heml::click\tclick\tclick\n".to_owned(), + ] { + assert!( + syms.contains(&line), + "missing symbol line {line:?} in {syms}" + ); + } + assert!(!syms.contains("form_field\tnew_todo\t__h\t")); let _ = std::fs::remove_dir_all(&base); } + #[test] + fn class_and_form_resource_contracts_fail_closed_and_deduplicate() { + let base = test_dir("hemx-build-resource-collisions"); + let templates = base.join("templates"); + let out = base.join("out"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&templates).unwrap(); + let invalid_class_template = templates.join("invalid_class.heml"); + std::fs::write(&invalid_class_template, r#"
"#).unwrap(); + let invalid_class = app() + .template_dir(&templates) + .out_dir(&out) + .run() + .unwrap_err(); + assert_eq!(invalid_class.kind(), io::ErrorKind::InvalidData); + assert_eq!( + invalid_class.to_string(), + "invalid CSS class `123`; expected an ASCII class token usable from Rust" + ); + std::fs::remove_file(invalid_class_template).unwrap(); + + let stylesheet = templates.join("app.css"); + std::fs::write(&stylesheet, ".foo-bar {} .foo_bar {}").unwrap(); + let class_error = app() + .template_dir(&templates) + .out_dir(&out) + .run() + .unwrap_err(); + assert_eq!(class_error.kind(), io::ErrorKind::InvalidData); + assert_eq!( + class_error.to_string(), + "duplicate generated class identifier `foo_bar` for CSS classes `foo-bar` and `foo_bar`" + ); + + std::fs::remove_file(stylesheet).unwrap(); + let first_template = templates.join("a.heml"); + std::fs::write( + &first_template, + r#"
"#, + ) + .unwrap(); + let invalid_form = app() + .template_dir(&templates) + .out_dir(&out) + .run() + .unwrap_err(); + assert_eq!(invalid_form.kind(), io::ErrorKind::InvalidData); + assert_eq!( + invalid_form.to_string(), + "invalid hemx form name `profile-card`; expected a Rust identifier" + ); + + std::fs::write( + &first_template, + r#"
"#, + ) + .unwrap(); + std::fs::write( + templates.join("b.heml"), + r#"
"#, + ) + .unwrap(); + let form_error = app() + .template_dir(&templates) + .out_dir(&out) + .run() + .unwrap_err(); + assert_eq!(form_error.kind(), io::ErrorKind::InvalidData); + assert_eq!( + form_error.to_string(), + "duplicate generated identifier `profile` for `a.heml::profile` and `b.heml::profile`" + ); + + std::fs::remove_file(templates.join("b.heml")).unwrap(); + std::fs::write( + templates.join("a.heml"), + r#"
"#, + ) + .unwrap(); + app().template_dir(&templates).out_dir(&out).run().unwrap(); + let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); + assert_eq!( + syms.matches("form_field\tprofile\tname\ttrue\tfalse\n") + .count(), + 1 + ); + + let _ = std::fs::remove_dir_all(&base); + // test req: style/001 req: form/001 req: diagnostics/004 + } + #[test] fn duplicate_collection_slot_upgrades_to_keyed_target() { // req: codegen/003 req: list/002