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/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 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. ***************************************************************************/