From a322e519d3afece1b31da9e77eb24fb44fc4e0ee Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 17 Jul 2026 05:57:29 +0200 Subject: [PATCH] fix(build): fail closed on invalid loop locals Recognize only one valid Rust identifier bound directly to a self vector field as a template local; reject malformed and non-vector bindings, preserve unknown element locals without fabricated fields, and deduplicate repeated locals. req: diagnostics/006 req: surface/008 req: test/021 --- AGENTS.md | 2 +- PLAN.md | 2 +- REQUIREMENTS.md | 2 +- hemx-build/src/lib.rs | 143 ++++++++++++++++++++++++++++++------------ 4 files changed, 106 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20bce19..07f719a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 -- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003 +- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003 - JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 - Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012 - Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002 diff --git a/PLAN.md b/PLAN.md index d54f814..5a56322 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 39 template-context path, Cargo-root, Rust-source, struct-field, and exact `Hemplate` derive-recognition mutants; qualified derives work while similarly named derives fail closed instead of granting context authority. The remaining package frontier starts at loop-local context inference and placement/attribute 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 31 loop-local inference mutants: only one Rust identifier bound directly to a `self` vector field yields facts, malformed/non-vector bindings fail closed, unknown element types preserve an empty local, and repeated locals deduplicate. The remaining package frontier starts at placement/attribute 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. - **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/REQUIREMENTS.md b/REQUIREMENTS.md index 0883e1c..ef17d0b 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -650,7 +650,7 @@ what a valid business email is. [north_star] 005 `.heml` editor startup for VS Code, Cursor, and Neovim must preserve normal HTML or tree-sitter HTML highlighting while using repo-owned `hemx-build` diagnostics through `hemx-lsp` as the shared authority for hemplate-specific feedback. [north_star] -006 `hemx-lsp` completion and hover for `.heml` Rust-shaped expressions uses hemx-owned compiler/build facts for derive-known template context fields and simple `h-for` locals. Missing or stale facts fall back to syntax/document completions. [north_star] +006 `hemx-lsp` completion and hover for `.heml` Rust-shaped expressions uses hemx-owned compiler/build facts for derive-known template context fields and simple `h-for` locals. A simple local is one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields do not produce local facts. Missing or stale facts fall back to syntax/document completions. [north_star] 008 `hemx-lsp` must not proxy rust-analyzer or own a second Rust type system. [north_star] diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 7e0c63c..1578cff 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1994,37 +1994,36 @@ fn loop_locals_for_surface( ) -> Vec { let mut locals = Vec::new(); for node in &surface.nodes { - for attr in &node.attrs { - if attr.name != "h-for" { - continue; - } - let Some(value) = &attr.value else { - continue; - }; - let Some((local, field)) = h_for_local_and_self_field(value) else { - continue; - }; - let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field) - else { - continue; - }; - let Some(type_name) = vec_element_type(&self_field.type_name) else { - continue; - }; - let fields = structs - .get(&type_name) - .map(|fact| fact.fields.clone()) - .unwrap_or_default(); - if !locals - .iter() - .any(|existing: &TemplateLocalFact| existing.name == local) - { - locals.push(TemplateLocalFact { - name: local, - type_name, - fields, - }); - } + let Some(value) = node + .attrs + .iter() + .find(|attr| attr.name == "h-for") + .and_then(|attr| attr.value.as_deref()) + else { + continue; + }; + let Some((local, field)) = h_for_local_and_self_field(value) else { + continue; + }; + let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field) else { + continue; + }; + let Some(type_name) = vec_element_type(&self_field.type_name) else { + continue; + }; + let fields = structs + .get(&type_name) + .map(|fact| fact.fields.clone()) + .unwrap_or_default(); + if !locals + .iter() + .any(|existing: &TemplateLocalFact| existing.name == local) + { + locals.push(TemplateLocalFact { + name: local, + type_name, + fields, + }); } } locals @@ -2032,17 +2031,14 @@ fn loop_locals_for_surface( fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { let (local, expr) = value.split_once(" in ")?; - let local = local.trim(); - if local.is_empty() || local.contains(['(', ',', ' ']) { - return None; - } + let local = rust_ident(local.trim())?; let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); let field = expr .strip_prefix("self.")? .split(['.', '(', '[']) - .next()? - .trim(); - (!field.is_empty()).then(|| (local.to_owned(), field.to_owned())) + .next() + .filter(|field| !field.is_empty())?; + Some((local, field.to_owned())) } fn vec_element_type(type_name: &str) -> Option { @@ -3500,12 +3496,28 @@ fn main() {{ pub struct ExercisePlan { pub name: String, pub kg: f32 } #[derive(Hemplate)] - pub struct Workout { pub plan: Vec, pub progress: String } + pub struct Workout { + pub plan: Vec, + pub alternate: std::vec::Vec, + pub mysteries: Vec, + pub progress: String, + } "#, ) .expect("write lib"); let template = dir.join("workout.heml"); - let source = r#"

{+ self.progress +}

  • {+ exercise.name +}
  • "#; + let source = r#" +
    +

    {+ self.progress +}

    +

    ignored

    +

    ignored

    +

    ignored

    +
  • {+ exercise.name +}
  • +
  • {+ exercise.kg +}
  • +
  • {+ mystery.anything +}
  • +
  • {+ later.name +}
  • +
    + "#; std::fs::write(&template, source).expect("write heml"); let facts = template_context_facts_for_heml_source(&template, source) @@ -3527,6 +3539,57 @@ fn main() {{ .fields .iter() .any(|field| field.name == "name" && field.type_name == "String")); + assert_eq!( + facts + .locals + .iter() + .filter(|local| local.name == "exercise") + .count(), + 1 + ); + let mystery = facts + .locals + .iter() + .find(|local| local.name == "mystery") + .expect("unknown element type still exposes the local"); + assert_eq!(mystery.type_name, "MissingFact"); + assert!(mystery.fields.is_empty()); + assert!(facts.locals.iter().any(|local| { + local.name == "later" + && local.type_name == "ExercisePlan" + && local.fields.iter().any(|field| field.name == "kg") + })); + for rejected in ["ghost", "tuple", "item", "unknown", "scalar"] { + assert!( + facts.locals.iter().all(|local| local.name != rejected), + "unexpected loop local {rejected}" + ); + } + + assert_eq!( + h_for_local_and_self_field(" item in &self.plan "), + Some(("item".into(), "plan".into())) + ); + assert_eq!( + h_for_local_and_self_field("item in self.plan.iter()"), + Some(("item".into(), "plan".into())) + ); + for invalid in [ + "item:self.plan", + " in self.plan", + "1item in self.plan", + "item-name in self.plan", + "(item, index) in self.plan", + "item in plan", + "item in &plan", + "item in self.", + ] { + assert_eq!( + h_for_local_and_self_field(invalid), + None, + "accepted invalid h-for {invalid:?}" + ); + } } #[test]