From 141e1e8093605a8aa39e6bc1c51722be7a49bdaa Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 10:23:59 -0500 Subject: [PATCH 1/2] fix: honor non-interactive template defaults and Windows paths Return NOT_FOUND for unknown templates without prompting. Preserve interactive fallback and validate non-interactive overrides. Co-Authored-By: Codex (GPT-6) --- docs/internal/session-state.md | 18 ++++- .../io/qrun/qctl/e2e/InitCommandE2ETest.java | 76 +++++++++++++++++++ .../qrun/qctl/e2e/InitCommandFullE2ETest.java | 9 +-- .../java/io/qrun/qctl/qqq/InitCommand.java | 22 ++++++ .../qctl/qqq/template/TemplateEngine.java | 4 +- .../qctl/qqq/template/TemplateEngineTest.java | 18 +++++ 6 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/internal/session-state.md b/docs/internal/session-state.md index 596d6cb..5d16310 100644 --- a/docs/internal/session-state.md +++ b/docs/internal/session-state.md @@ -1,6 +1,22 @@ # Session State -Last updated: 2026-01-03 +Last updated: 2026-10-30 + +## Native CI qualification fixes (2026-10-30) + +Local native CI exposed three defects: `--no-prompt` omitted declared defaults, +unknown templates still entered interactive selection, and Windows backslashes +escaped Velocity path variables. Defaults now apply with validated explicit +overrides; unknown templates return NOT_FOUND without reading stdin; path +separators are normalized before interpolation. Interactive fallback remains +covered separately. + +Four non-interactive regressions and a Windows-path regression failed before the +fixes. On macOS27.2 ARM64/GraalVM21.0.12, full Maven verification, native packaging +and verification with the built binary passed235 tests across26 reports with +zero failures/errors/skips. Native tests must run after building the binary; +the earlier build-before-test arrangement silently skipped10 native E2E cases. +Exact-commit Gitea macOS/Windows verification and normal PR review remain pending. ## Current Status diff --git a/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandE2ETest.java b/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandE2ETest.java index 82e5374..5db448b 100644 --- a/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandE2ETest.java +++ b/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandE2ETest.java @@ -298,6 +298,82 @@ class InitCommandE2ETest + /*************************************************************************** + * Non-interactive generation applies defaults without consuming stdin. + * + * @throws IOException if the generated file cannot be read + * @since 0.2.1 + ***************************************************************************/ + @Test + void testInit_noPrompt_usesDefaults() throws IOException + { + Path outputDir = tempDir.resolve("default-values"); + harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", + "-o", outputDir.toString()); + + assertThat(harness.getExitCode()).as(harness.getStderr()).isEqualTo(ExitCodes.SUCCESS); + assertThat(Files.readString(outputDir.resolve("README.md"))) + .contains("my-project", "com.example"); + } + + + + /*************************************************************************** + * Explicit variables override defaults while omitted variables retain them. + * + * @throws IOException if the generated file cannot be read + * @since 0.2.1 + ***************************************************************************/ + @Test + void testInit_noPrompt_overridesOneDefault() throws IOException + { + Path outputDir = tempDir.resolve("override-default"); + harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", + "--var", "projectName=custom-project", "-o", outputDir.toString()); + + assertThat(harness.getExitCode()).as(harness.getStderr()).isEqualTo(ExitCodes.SUCCESS); + assertThat(Files.readString(outputDir.resolve("README.md"))) + .contains("custom-project", "com.example"); + } + + + + /*************************************************************************** + * An unknown template must not fall back to an interactive selection. + * + * @since 0.2.1 + ***************************************************************************/ + @Test + void testInit_noPrompt_unknownTemplateReturnsNotFound() + { + Path outputDir = tempDir.resolve("unknown-template"); + harness.execute("qqq", "init", "missing-template", "--no-prompt", + "-o", outputDir.toString()); + + assertThat(harness.getExitCode()).isEqualTo(ExitCodes.NOT_FOUND); + assertThat(outputDir).doesNotExist(); + } + + + + /*************************************************************************** + * Invalid required overrides must fail instead of prompting or using defaults. + * + * @since 0.2.1 + ***************************************************************************/ + @Test + void testInit_noPrompt_rejectsEmptyRequiredOverride() + { + Path outputDir = tempDir.resolve("invalid-override"); + harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", + "--var", "projectName=", "-o", outputDir.toString()); + + assertThat(harness.getExitCode()).isEqualTo(ExitCodes.VALIDATION); + assertThat(outputDir).doesNotExist(); + } + + + /*************************************************************************** * Test combined flags work together. * diff --git a/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandFullE2ETest.java b/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandFullE2ETest.java index aba3dac..484f8ca 100644 --- a/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandFullE2ETest.java +++ b/qctl-integration-tests/src/test/java/io/qrun/qctl/e2e/InitCommandFullE2ETest.java @@ -613,20 +613,19 @@ class InitCommandFullE2ETest /*************************************************************************** * Test behavior when requesting a template that doesn't exist. * - * With TestTemplateRegistry, non-existent templates cause the system - * to fall back to the first available template in --no-prompt mode. + * Interactive selection can accept the first available fallback template. * * @since 0.2.0 ***************************************************************************/ @Test - void testInit_unknownTemplate_fallsBackToFirst() throws IOException + void testInit_unknownTemplate_interactiveFallbackToFirst() throws IOException { Path outputDir = tempDir.resolve("unknown-template-test"); ////////////////////////////////////////////////////////////////////////// - // With --no-prompt, requesting unknown template uses first available // + // The empty input accepts the first interactive selection. // ////////////////////////////////////////////////////////////////////////// - harness.execute("qqq", "init", "non-existent-template", "--no-prompt", + harness.execute("qqq", "init", "non-existent-template", "-o", outputDir.toString()); ////////////////////////////////////////////////////////////////////////// diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/InitCommand.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/InitCommand.java index 1c0dec3..c1d99f1 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/InitCommand.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/InitCommand.java @@ -37,6 +37,7 @@ import io.qrun.qctl.qqq.template.ConsoleUI; import io.qrun.qctl.qqq.template.MergeOptions; import io.qrun.qctl.qqq.template.PostGenHookRunner; import io.qrun.qctl.qqq.template.PromptRunner; +import io.qrun.qctl.qqq.template.PromptValidator; import io.qrun.qctl.qqq.template.TemplateEngine; import io.qrun.qctl.qqq.template.TemplateManifest; import io.qrun.qctl.qqq.template.TemplateRenderException; @@ -172,6 +173,21 @@ public class InitCommand implements Callable // Step 4: Collect variables (prompts + CLI overrides) Map allVars = new LinkedHashMap<>(); + if(noPrompt && manifest.prompts() != null) + { + PromptValidator validator = new PromptValidator(); + for(TemplateManifest.Prompt prompt : manifest.prompts()) + { + String value = variables.getOrDefault(prompt.name(), prompt.defaultValue()); + PromptValidator.ValidationResult result = validator.validate(prompt, value); + if(!result.valid()) + { + ui.error(result.errorMessage()); + return ExitCodes.VALIDATION; + } + allVars.put(prompt.name(), value); + } + } if(!noPrompt && manifest.prompts() != null && !manifest.prompts().isEmpty()) { PromptRunner promptRunner = new PromptRunner(); @@ -318,6 +334,12 @@ public class InitCommand implements Callable List templateIds = templates.stream().map(TemplateInfo::id).toList(); SuggestionEngine suggestionEngine = new SuggestionEngine(); List suggestions = suggestionEngine.findSimilar(templateName, templateIds); + if(noPrompt) + { + throw new TemplateError(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND, + "Template '" + templateName + "' not found.", suggestions, + "Use 'qctl qqq list' to list available templates"); + } ui.error("Template '" + templateName + "' not found."); if(!suggestions.isEmpty()) diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateEngine.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateEngine.java index 5476d8d..dfc287f 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateEngine.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateEngine.java @@ -409,7 +409,9 @@ public class TemplateEngine // This allows templates to use __VARNAME__ in paths, which is safer // // for shells and build tools that might interpret $ as a variable. // ///////////////////////////////////////////////////////////////////////// - String normalizedPath = path.replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1"); + // Windows separators otherwise escape the following Velocity variable. + String normalizedPath = path.replace('\\', '/') + .replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1"); return renderContent(normalizedPath, variables); } diff --git a/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/TemplateEngineTest.java b/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/TemplateEngineTest.java index 5759cd0..35329ca 100644 --- a/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/TemplateEngineTest.java +++ b/qctl-qqq/src/test/java/io/qrun/qctl/qqq/template/TemplateEngineTest.java @@ -303,6 +303,24 @@ class TemplateEngineTest + /*************************************************************************** + * Windows separators must not escape path placeholders in Velocity. + * + * @throws TemplateRenderException if rendering fails + * @since 0.2.1 + ***************************************************************************/ + @Test + void testRenderWindowsPathVariables() throws TemplateRenderException + { + Map variables = Map.of("packagePath", "com/acme/orders"); + assertThat(engine.renderPath("src\\main\\java\\$packagePath\\App.java", variables)) + .isEqualTo("src/main/java/com/acme/orders/App.java"); + assertThat(engine.renderPath("src\\main\\java\\__packagePath__\\App.java", variables)) + .isEqualTo("src/main/java/com/acme/orders/App.java"); + } + + + /*************************************************************************** * Test binary files are copied without modification. ***************************************************************************/ -- 2.54.0 From a779db2c517f293beb0fa93b5b0f2b99847758a5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 10:43:01 -0500 Subject: [PATCH 2/2] fix: resolve remote template URLs on Windows Windows rejects HTTPS URLs as filesystem paths before Git can clone them. Fall through to supported Git URL validation when the local path probe is invalid. Co-Authored-By: Codex --- .../qctl/qqq/template/TemplateResolver.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateResolver.java b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateResolver.java index 33a2db9..b46ff00 100644 --- a/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateResolver.java +++ b/qctl-qqq/src/main/java/io/qrun/qctl/qqq/template/TemplateResolver.java @@ -15,6 +15,7 @@ package io.qrun.qctl.qqq.template; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -44,11 +45,19 @@ public class TemplateResolver public Path resolve(String source, String version) throws IOException { // Local path - Path localPath = Path.of(source); - if(Files.isDirectory(localPath)) + try { - System.out.println("Using local template: " + localPath.toAbsolutePath()); - return localPath.toAbsolutePath(); + Path localPath = Path.of(source); + if(Files.isDirectory(localPath)) + { + System.out.println("Using local template: " + localPath.toAbsolutePath()); + return localPath.toAbsolutePath(); + } + } + catch(InvalidPathException e) + { + // Remote URLs are not valid filesystem paths on Windows. + // Validate them below using the supported Git URL formats. } // Git URL or GitHub shorthand -- 2.54.0