From 4058d05f20dbe894302167a0c6a74cd7a0b572bc Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 17 Jul 2026 05:03:03 +0200 Subject: [PATCH] test(build): close app input collection mutants Traverse inputs once, preserve deterministic HE/ML-before-stylesheet lowering, and prove nested discovery, explicit surfaces, OUT_DIR fallback, missing inputs, invalid UTF-8, permission failures, output failures, and stable collision ordering through AppBuilder::run. req: build/003 req: build/004 req: build/009 req: diagnostics/004 req: test/021 --- .cargo/mutants.toml | 5 + PLAN.md | 2 +- hemx-build/src/lib.rs | 213 +++++++++++++++++++++++++++++++++--------- 3 files changed, 175 insertions(+), 45 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 8475cd0..76876fa 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -10,6 +10,9 @@ # are behaviorally equivalent at these validated boundaries. # - hemx-build source inspection delegates to hemplate's currently infallible # Surface parser; file I/O and invalid Rust-context errors remain explicitly proven. +# - AppBuilder reuses that same parser seam. Directory-open and recursive errors are +# proven, while a per-entry readdir fault cannot be injected portably after a +# successful read_dir; its unwrap mutant is classified as infrastructure-only. # - write_if_changed propagates non-NotFound read errors; for ordinary filesystem # paths, attempting the same write returns the same OS error, so the guard mutant # is externally equivalent while create/update/no-op behavior is mutation-proven. @@ -28,5 +31,7 @@ exclude_re = [ "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in diagnostics_for_heml_source", "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in generated_targets_for_heml_source", "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in template_context_facts_for_heml_source", + "replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in AppBuilder::run", + "replace entry\\? with entry.unwrap\\(\\) in collect_input_files_into", "replace match guard error.kind\\(\\) == io::ErrorKind::NotFound with true in write_if_changed", ] diff --git a/PLAN.md b/PLAN.md index 3a78160..f013203 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 mutation-proves resource extraction plus canonical generated Rust/global exports/symbol manifests across keyed/component/collision/deduplication branches; the full 1,368-mutant package gate was runnable but exceeded 30 minutes after exposing 187 remaining survivors, led by `AppBuilder::run`, collection/error propagation, stylesheet parsing, and template-context 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 mutation-proves resource extraction, canonical generated contracts, and all 51 `AppBuilder::run`/input-collection mutants, including nested HE/ML/CSS/SCSS discovery, explicit-surface mode, `OUT_DIR`, stable ordering, missing inputs, output failures, invalid UTF-8, and recursive permission errors; the remaining package frontier starts at stylesheet parsing and template-context 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/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 7da79af..3418c93 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -202,15 +202,14 @@ impl AppBuilder { std::fs::create_dir_all(&out_dir)?; let mut resources = Resources::default(); + let input_paths = collect_input_files(&self.template_dir)?; if self.surfaces.is_empty() { - for path in collect_heml(&self.template_dir)? { - let source = std::fs::read_to_string(&path)?; - let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(&path, err))?; - let Some(doc) = doc else { - continue; - }; - let surface = extract_surface(&doc); - resources.add_surface(&self.template_dir, &path, &surface)?; + for path in input_paths.iter().filter(|path| { + path.extension().and_then(|extension| extension.to_str()) == Some("heml") + }) { + let source = std::fs::read_to_string(path)?; + let surface = surface_for_heml_source(path, source)?; + resources.add_surface(&self.template_dir, path, &surface)?; } } else { // req: surface/008 @@ -218,9 +217,14 @@ impl AppBuilder { resources.add_surface(&self.template_dir, path, surface)?; } } - for path in collect_stylesheets(&self.template_dir)? { - let source = std::fs::read_to_string(&path)?; - resources.add_stylesheet(&self.template_dir, &path, &source)?; + for path in input_paths.iter().filter(|path| { + matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("css" | "scss") + ) + }) { + let source = std::fs::read_to_string(path)?; + resources.add_stylesheet(&self.template_dir, path, &source)?; } write_if_changed( @@ -1344,49 +1348,22 @@ fn make_resource( }) } -fn collect_heml(root: &Path) -> io::Result> { +fn collect_input_files(root: &Path) -> io::Result> { let mut paths = Vec::new(); if !root.exists() { return Ok(paths); } - collect_heml_into(root, &mut paths)?; + collect_input_files_into(root, &mut paths)?; paths.sort(); Ok(paths) } -fn collect_heml_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { +fn collect_input_files_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); + let path = entry?.path(); if path.is_dir() { - collect_heml_into(&path, paths)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("heml") { - paths.push(path); - } - } - Ok(()) -} - -fn collect_stylesheets(root: &Path) -> io::Result> { - let mut paths = Vec::new(); - if !root.exists() { - return Ok(paths); - } - collect_stylesheets_into(root, &mut paths)?; - paths.sort(); - Ok(paths) -} - -fn collect_stylesheets_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_stylesheets_into(&path, paths)?; - } else if matches!( - path.extension().and_then(|ext| ext.to_str()), - Some("css" | "scss") - ) { + collect_input_files_into(&path, paths)?; + } else { paths.push(path); } } @@ -2282,6 +2259,8 @@ fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error { mod tests { use super::*; + static OUT_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn public_heml_inspection_entry_points_preserve_paths_and_io_diagnostics() { let root = std::env::temp_dir().join(format!( @@ -2649,6 +2628,152 @@ mod tests { // test req: build/004 req: client_local/011 req: diagnostics/004 } + #[test] + fn app_builder_discovers_nested_inputs_and_propagates_collection_errors() { + let base = test_dir("hemx-build-collection"); + let templates = base.join("templates"); + let nested = templates.join("nested"); + let out = base.join("out"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(templates.join("a_empty.heml"), "").unwrap(); + std::fs::write( + nested.join("panel.heml"), + r#"
"#, + ) + .unwrap(); + std::fs::write(nested.join("panel.css"), ".nested-css {}").unwrap(); + std::fs::write(nested.join("theme.scss"), ".nested-scss {}").unwrap(); + std::fs::write(nested.join("ignored.txt"), [0xff]).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\tnested/panel.heml::nested_panel\tnested_panel\t")); + assert!(syms.contains("class\tnested/panel.css::nested-css\tnested_css\tnested-css\n")); + assert!(syms.contains("class\tnested/theme.scss::nested-scss\tnested_scss\tnested-scss\n")); + + let missing = base.join("missing"); + let missing_out = base.join("missing-out"); + app() + .template_dir(&missing) + .out_dir(&missing_out) + .run() + .unwrap(); + assert_eq!( + std::fs::read_to_string(missing_out.join("hemx.syms")).unwrap(), + "hemx-syms-v1\n" + ); + + let _out_dir_lock = OUT_DIR_LOCK.lock().unwrap(); + let previous_out_dir = std::env::var_os("OUT_DIR"); + let env_out = base.join("env-out"); + std::env::set_var("OUT_DIR", &env_out); + let env_result = app().template_dir(&missing).run(); + match previous_out_dir { + Some(value) => std::env::set_var("OUT_DIR", value), + None => std::env::remove_var("OUT_DIR"), + } + env_result.unwrap(); + assert!(env_out.join("hemx.generated.rs").is_file()); + + let template_file = base.join("not-a-directory"); + std::fs::write(&template_file, "not a directory").unwrap(); + let error = app() + .template_dir(&template_file) + .out_dir(base.join("file-root-out")) + .run() + .unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::NotADirectory | io::ErrorKind::Other + )); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let restricted_root = base.join("restricted-root"); + let restricted_nested = restricted_root.join("nested"); + std::fs::create_dir_all(&restricted_nested).unwrap(); + std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o000)) + .unwrap(); + let error = app() + .template_dir(&restricted_root) + .out_dir(base.join("restricted-out")) + .run() + .unwrap_err(); + std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + let invalid_heml = base.join("invalid-heml"); + std::fs::create_dir_all(&invalid_heml).unwrap(); + std::fs::write(invalid_heml.join("bad.heml"), [0xff]).unwrap(); + assert_eq!( + app() + .template_dir(&invalid_heml) + .out_dir(base.join("invalid-heml-out")) + .run() + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + + let invalid_css = base.join("invalid-css"); + std::fs::create_dir_all(&invalid_css).unwrap(); + std::fs::write(invalid_css.join("bad.css"), [0xff]).unwrap(); + assert_eq!( + app() + .template_dir(&invalid_css) + .out_dir(base.join("invalid-css-out")) + .run() + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + + let blocked_out = base.join("blocked-out"); + std::fs::write(&blocked_out, "not a directory").unwrap(); + assert!(app() + .template_dir(&missing) + .out_dir(&blocked_out) + .run() + .is_err()); + + let explicit_surface = surface_for_heml_source( + Path::new("explicit.heml"), + r#"
"#.to_owned(), + ) + .unwrap(); + assert_eq!( + app() + .surface("explicit.heml", explicit_surface) + .out_dir(base.join("explicit-out")) + .run() + .unwrap_err() + .to_string(), + "invalid hemx slot name `123`; expected a Rust identifier" + ); + + let ordered = base.join("ordered"); + std::fs::create_dir_all(&ordered).unwrap(); + std::fs::write(ordered.join("a.css"), ".foo-bar {}").unwrap(); + std::fs::write(ordered.join("b.css"), ".foo_bar {}").unwrap(); + assert_eq!( + app() + .template_dir(&ordered) + .out_dir(base.join("ordered-out")) + .run() + .unwrap_err() + .to_string(), + "duplicate generated class identifier `foo_bar` for CSS classes `foo-bar` and `foo_bar`" + ); + + let _ = std::fs::remove_dir_all(base); + // test req: build/003 req: build/004 req: build/009 req: diagnostics/004 + } + #[test] fn no_op_build_preserves_generated_artifact_timestamps() { // req: build/009