diff --git a/PLAN.md b/PLAN.md index ed58428..bfd85d5 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 all 59 placement-diagnostic mutants with exact `InvalidData` paths, attributes, and guidance for navigation, boost containers, client modules, and SSE roots, plus their valid placements. The remaining package frontier starts at static attribute-value and keyed-scope diagnostics. 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 112 static attribute-value mutants and all 21 keyed-scope ancestry mutants: exact `InvalidData` diagnostics cover missing/empty/invalid values, valid boundaries reach `AppBuilder::run`, nested keyed resources lower as keyed, and invalid/sibling scopes stay unkeyed or fail closed. The remaining package frontier starts at unknown/selector attributes and unkeyed-loop diagnostic formatting. 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 89643ce..23d8cad 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -3639,6 +3639,18 @@ fn main() {{ "data-hemx-handle=\"delete\"", "ui::delete", ), + ( + "nested-handle", + r#""#, + "data-hemx-handle=\"delete\"", + "ui::delete", + ), + ( + "atom", + r#""#, + "data-hemx-atom=\"selected\"", + "ui::selected", + ), ] { let base = test_dir(&format!("hemx-build-unkeyed-for-{case}-test")); let templates = base.join("templates"); @@ -3662,6 +3674,87 @@ fn main() {{ let _ = std::fs::remove_dir_all(&base); } + + let base = test_dir("hemx-build-keyed-nested-resources-test"); + 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 keyed_source = r#" +
+ + +
+ "#; + std::fs::write(templates.join("todo.heml"), keyed_source).unwrap(); + app().template_dir(&templates).out_dir(&out).run().unwrap(); + let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); + assert!(syms.contains("slot\ttodo.heml::todos\ttodos\t")); + assert!(syms.contains("slot\ttodo.heml::todo_row\ttodo_row\t")); + assert!(syms.contains("handle\ttodo.heml::delete\tdelete\t")); + assert!(syms.contains("atom\ttodo.heml::selected\tselected\t")); + assert!(syms.contains("slot\ttodo.heml::summary\tsummary\t")); + let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap(); + assert!(generated.contains("pub const todo_row: ::hemx::advanced::KeyedSlot")); + assert!(generated.contains("pub const todos: ::hemx::advanced::KeyedSlot")); + assert!(generated.contains("pub const summary: ::hemx::advanced::Slot")); + assert!(!generated.contains("pub const summary: ::hemx::advanced::KeyedSlot")); + + let surface = + surface_for_heml_source(Path::new("todo.heml"), keyed_source.to_owned()).unwrap(); + let keyed_scope = surface + .scopes + .iter() + .position(|scope| { + matches!( + scope.kind, + ScopeKind::For { + key_expr: Some(_), + .. + } + ) + }) + .map(|index| ScopeId(index as u32)) + .unwrap(); + let nested_scope = surface + .scopes + .iter() + .enumerate() + .find(|(_, scope)| scope.parent == Some(keyed_scope)) + .map(|(index, _)| ScopeId(index as u32)) + .unwrap(); + let sibling_scope = surface + .scopes + .iter() + .enumerate() + .find(|(_, scope)| { + matches!(scope.kind, ScopeKind::For { key_expr: None, .. }) + && scope.parent != Some(keyed_scope) + }) + .map(|(index, _)| ScopeId(index as u32)) + .unwrap(); + assert!(is_descendant_scope(&surface, nested_scope, keyed_scope)); + assert!(!is_descendant_scope(&surface, keyed_scope, keyed_scope)); + assert!(!is_descendant_scope(&surface, sibling_scope, keyed_scope)); + assert!(!is_descendant_scope( + &surface, + ScopeId(u32::MAX), + keyed_scope + )); + assert!(is_inside_keyed_for(&surface, nested_scope)); + assert!(!is_inside_keyed_for(&surface, sibling_scope)); + assert!(!is_inside_keyed_for(&surface, ScopeId(u32::MAX))); + let _ = std::fs::remove_dir_all(base); } #[test] @@ -3757,6 +3850,12 @@ fn main() {{ "data-hemx-policy", "latest", ), + ( + "policy-missing", + r#""#, + "data-hemx-policy", + "latest", + ), ( "empty-client", r#""#, @@ -3895,30 +3994,137 @@ fn main() {{ let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); - std::fs::write(templates.join("invalid.heml"), template).unwrap(); + let template_path = templates.join("invalid.heml"); + std::fs::write(&template_path, template).unwrap(); let err = app() .template_dir(&templates) .out_dir(&out) .run() .unwrap_err(); - assert!( - err.to_string().contains(attr), - "missing attr in diagnostic: {err}" - ); - assert!( - err.to_string().contains("invalid"), - "missing invalid-value diagnostic: {err}" - ); - assert!( - err.to_string().contains(guidance), - "missing fix guidance {guidance:?}: {err}" + let surface = surface_for_heml_source(&template_path, template.to_owned()).unwrap(); + let value = surface + .nodes + .iter() + .flat_map(|node| &node.attrs) + .find(|candidate| candidate.name == attr) + .and_then(|candidate| candidate.value.as_deref()) + .unwrap_or(""); + let expectation = match attr { + "data-hemx-policy" => { + "expected one of `latest`, `queue`, `drop`, or `parallel`" + } + "data-hemx-history" => { + "expected `push`, `replace`, or empty for the default push behavior" + } + "data-hemx-client" => "expected a non-empty client handler name", + "data-hemx-client-policy" => "expected `latest` or `drop`", + "data-hemx-client-module" => { + "expected a same-origin module specifier beginning with `/`, `./`, or `../`" + } + "data-hemx-client-event" => "expected a runtime-supported event", + "data-hemx-client-state-version" => { + "expected a positive client state ABI version" + } + "data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`", + "data-hemx-confirm" => "expected a non-empty confirmation message", + "data-hemx-sse" => "expected a non-empty same-origin SSE URL", + "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" + | "data-hemx-every" | "data-hemx-interval" => { + "expected milliseconds like `250`/`250ms` or seconds like `1s`" + } + _ => panic!("missing expectation for {attr}"), + }; + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!( + err.to_string(), + format!( + "{}: invalid {attr} value `{value}`; {expectation}", + template_path.display() + ), + "guidance anchor {guidance:?}" ); let _ = std::fs::remove_dir_all(&base); } } + #[test] + fn static_attribute_value_validators_cover_every_boundary() { + for valid in ["latest", " queue ", "drop", "parallel"] { + assert!(valid_policy(valid), "rejected policy {valid:?}"); + } + for invalid in ["", "newest", "latest queue", "LATEST"] { + assert!(!valid_policy(invalid), "accepted policy {invalid:?}"); + } + + for valid in ["/app.js", " ./app.js ", "../app.js"] { + assert!(valid_client_module(valid), "rejected module {valid:?}"); + } + for invalid in [ + "", + " ", + "//cdn/app.js", + "https://example.com/app.js", + "app.js", + ] { + assert!(!valid_client_module(invalid), "accepted module {invalid:?}"); + } + + for valid in [ + "click", + "submit input change keydown dragstart dragover drop", + " click change ", + ] { + assert!(valid_event_list(valid), "rejected event list {valid:?}"); + } + for invalid in ["", " ", "blur", "click blur"] { + assert!( + !valid_event_list(invalid), + "accepted event list {invalid:?}" + ); + } + for valid in [ + "click", + "submit", + "input", + "change", + "keydown", + "dragstart", + "dragover", + "drop", + ] { + assert!(valid_runtime_event(valid), "rejected event {valid:?}"); + } + for invalid in ["", "blur", "Click"] { + assert!(!valid_runtime_event(invalid), "accepted event {invalid:?}"); + } + + for valid in ["0", "250", "250ms", "1s", " 5s "] { + assert!(valid_duration(valid), "rejected duration {valid:?}"); + } + for invalid in ["", " ", "ms", "s", "-1", "1sec", "1.5s", "1 ms"] { + assert!(!valid_duration(invalid), "accepted duration {invalid:?}"); + } + + let valid_source = r#" +
+ + +
+
+ "#; + let base = test_dir("hemx-build-valid-static-values"); + let templates = base.join("templates"); + let out = base.join("out"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&templates).unwrap(); + std::fs::write(templates.join("valid.heml"), valid_source).unwrap(); + app().template_dir(&templates).out_dir(&out).run().unwrap(); + let _ = std::fs::remove_dir_all(base); + // test req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 + } + #[test] fn placement_contracts_fail_closed_with_exact_diagnostics() { // req: page_swap/001 req: page_swap/007 req: push/006 req: diagnostics/002