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
This commit is contained in:
slhx agent
2026-07-17 05:57:29 +02:00
parent 2e39fb17a1
commit a322e519d3
4 changed files with 106 additions and 43 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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]
+103 -40
View File
@@ -1994,37 +1994,36 @@ fn loop_locals_for_surface(
) -> Vec<TemplateLocalFact> {
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<String> {
@@ -3500,12 +3496,28 @@ fn main() {{
pub struct ExercisePlan { pub name: String, pub kg: f32 }
#[derive(Hemplate)]
pub struct Workout { pub plan: Vec<ExercisePlan>, pub progress: String }
pub struct Workout {
pub plan: Vec<ExercisePlan>,
pub alternate: std::vec::Vec<ExercisePlan>,
pub mysteries: Vec<MissingFact>,
pub progress: String,
}
"#,
)
.expect("write lib");
let template = dir.join("workout.heml");
let source = r#"<main><p>{+ self.progress +}</p><li h-for="exercise in &self.plan">{+ exercise.name +}</li></main>"#;
let source = r#"
<main title="ghost in self.plan">
<p h-for="malformed">{+ self.progress +}</p>
<p h-for="(tuple, item) in self.plan">ignored</p>
<p h-for="unknown in self.unknown">ignored</p>
<p h-for="scalar in self.progress">ignored</p>
<li h-for="exercise in &self.plan">{+ exercise.name +}</li>
<li h-for="exercise in self.alternate">{+ exercise.kg +}</li>
<li h-for="mystery in self.mysteries">{+ mystery.anything +}</li>
<li h-for="later in self.plan[0]">{+ later.name +}</li>
</main>
"#;
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]