From a5509608ae92e151b3700e01727d227ce80f0f86 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 09:48:55 -0300 Subject: [PATCH 01/10] feat(command): show apply plan dependency status --- README.md | 2 +- documentation/Commands.md | 30 ++++ .../primary/ApplyModuleDependencyPlan.java | 9 + .../ApplyModuleDependencyPlanLine.java | 3 + .../primary/ApplyModuleDependencyPlanner.java | 170 ++++++++++++++++++ .../primary/ApplyModulePlanRenderer.java | 15 +- .../primary/ApplyModuleSubCommand.java | 13 +- .../primary/Seed4JCommandsFactoryTest.java | 91 ++++++++++ 8 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java create mode 100644 src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java create mode 100644 src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java diff --git a/README.md b/README.md index bbd4ac00..447be31f 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ To inspect module parameters before applying a module, add `--plan`: seed4j apply init --plan ``` -The plan prints the resolved text values and shows whether each value came from the current CLI input, project history, or a module metadata default. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. +The plan first prints a `Dependency plan`, then the resolved text values and whether each value came from the current CLI input, project history, or a module metadata default. Dependency statuses show direct module dependencies as `already applied` or `pending`; feature dependencies show either `satisfied by ` or `pending choice` with visible candidate modules. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. To install Bash completion for the active runtime: diff --git a/documentation/Commands.md b/documentation/Commands.md index 20ff5c8c..a80d3ac2 100644 --- a/documentation/Commands.md +++ b/documentation/Commands.md @@ -125,6 +125,10 @@ which options to pass before applying the module. Plan for module: init Project path: . +Dependency plan: + +No dependencies. + Resolved parameters: endOfLine: lf @@ -145,6 +149,10 @@ baseName: CLI option: --base-name Note: pass this option or apply a module that records it in project history. +nodePackageManager: + CLI option: --node-package-manager + Note: pass this option or apply a module that records it in project history. + No changes were applied. ``` @@ -164,6 +172,10 @@ seed4j apply maven-java --plan Plan for module: maven-java Project path: . +Dependency plan: + +module:init - already applied + Resolved parameters: projectName: Seed4J Sample Application @@ -191,6 +203,24 @@ Pass the missing option when applying the module: seed4j apply maven-java --package-name com.mycompany.myapp ``` +Dependency status labels mean: + +- `module: - already applied`: the module dependency is already recorded in project history +- `module: - pending`: the module dependency is not recorded in project history +- `feature: - satisfied by `: an applied module provides the required feature +- `feature: - pending choice: , `: no applied module provides the feature yet; choose one of + the sorted visible candidates + +For example, after applying `maven-java`, planning `sonarqube-java-backend` can show one satisfied feature and one feature +that still needs a choice: + +```text +Dependency plan: + +feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check +feature:java-build-tool - satisfied by maven-java +``` + Plan source labels mean: - `explicit CLI input`: the option was passed in the current command diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java new file mode 100644 index 00000000..409fdda0 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java @@ -0,0 +1,9 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import java.util.List; + +record ApplyModuleDependencyPlan(List lines) { + boolean empty() { + return lines.isEmpty(); + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java new file mode 100644 index 00000000..cde38c91 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java @@ -0,0 +1,3 @@ +package com.seed4j.cli.command.infrastructure.primary; + +record ApplyModuleDependencyPlanLine(String dependency, String status) {} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java new file mode 100644 index 00000000..db9fa04e --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -0,0 +1,170 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import com.seed4j.module.domain.landscape.Seed4JLandscapeDependency; +import com.seed4j.module.domain.resource.Seed4JModuleResource; +import com.seed4j.module.domain.resource.Seed4JModulesResources; +import com.seed4j.project.domain.history.ProjectAction; +import com.seed4j.project.domain.history.ProjectHistory; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +class ApplyModuleDependencyPlanner { + + ApplyModuleDependencyPlan plan(Seed4JModuleResource module, Seed4JModulesResources resources, ProjectHistory history) { + Set appliedModules = appliedModules(history); + List visibleModules = resources.stream().sorted(byModuleSlug()).toList(); + Map modulesBySlug = visibleModules + .stream() + .collect(Collectors.toMap(resource -> resource.slug().get(), Function.identity())); + List lines = new ArrayList<>(); + Set plannedDependencies = new LinkedHashSet<>(); + + appendDependencies(module, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, new LinkedHashSet<>()); + + return new ApplyModuleDependencyPlan(List.copyOf(lines)); + } + + private static Set appliedModules(ProjectHistory history) { + return history + .actions() + .stream() + .map(ProjectAction::module) + .map(module -> module.get()) + .collect(Collectors.toUnmodifiableSet()); + } + + private static Comparator byModuleSlug() { + return Comparator.comparing(module -> module.slug().get()); + } + + private static void appendDependencies( + Seed4JModuleResource module, + Map modulesBySlug, + List visibleModules, + Set appliedModules, + List lines, + Set plannedDependencies, + Set visitedModules + ) { + if (!visitedModules.add(module.slug().get())) { + return; + } + + module + .organization() + .dependencies() + .stream() + .sorted(byDependencyToken()) + .forEach(dependency -> + appendDependency(dependency, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, visitedModules) + ); + } + + private static Comparator byDependencyToken() { + return Comparator.comparing(ApplyModuleDependencyPlanner::dependencyToken); + } + + private static void appendDependency( + Seed4JLandscapeDependency dependency, + Map modulesBySlug, + List visibleModules, + Set appliedModules, + List lines, + Set plannedDependencies, + Set visitedModules + ) { + switch (dependency.type()) { + case MODULE -> appendModuleDependency( + dependency, + modulesBySlug, + visibleModules, + appliedModules, + lines, + plannedDependencies, + visitedModules + ); + case FEATURE -> appendFeatureDependency(dependency, visibleModules, appliedModules, lines, plannedDependencies); + } + } + + private static void appendModuleDependency( + Seed4JLandscapeDependency dependency, + Map modulesBySlug, + List visibleModules, + Set appliedModules, + List lines, + Set plannedDependencies, + Set visitedModules + ) { + String dependencySlug = dependency.slug().get(); + String token = dependencyToken(dependency); + if (plannedDependencies.add(token)) { + lines.add(new ApplyModuleDependencyPlanLine(token, moduleStatus(dependencySlug, appliedModules))); + } + + Optional.ofNullable(modulesBySlug.get(dependencySlug)).ifPresent(resource -> + appendDependencies(resource, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, visitedModules) + ); + } + + private static String moduleStatus(String dependencySlug, Set appliedModules) { + if (appliedModules.contains(dependencySlug)) { + return "already applied"; + } + + return "pending"; + } + + private static void appendFeatureDependency( + Seed4JLandscapeDependency dependency, + List visibleModules, + Set appliedModules, + List lines, + Set plannedDependencies + ) { + String token = dependencyToken(dependency); + if (!plannedDependencies.add(token)) { + return; + } + + List candidates = featureCandidates(dependency.slug().get(), visibleModules); + Optional appliedCandidate = candidates.stream().filter(appliedModules::contains).findFirst(); + String status = appliedCandidate.map(candidate -> "satisfied by " + candidate).orElseGet(() -> pendingChoiceStatus(candidates)); + + lines.add(new ApplyModuleDependencyPlanLine(token, status)); + } + + private static List featureCandidates(String featureSlug, List visibleModules) { + return visibleModules + .stream() + .filter(module -> + module + .organization() + .feature() + .map(feature -> feature.get().equals(featureSlug)) + .orElse(false) + ) + .map(module -> module.slug().get()) + .sorted() + .toList(); + } + + private static String pendingChoiceStatus(List candidates) { + if (candidates.isEmpty()) { + return "pending choice: no visible candidates"; + } + + return "pending choice: " + String.join(", ", candidates); + } + + private static String dependencyToken(Seed4JLandscapeDependency dependency) { + return dependency.type().name().toLowerCase() + ":" + dependency.slug().get(); + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java index f0bdba8c..3ffc22f0 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java @@ -5,10 +5,23 @@ class ApplyModulePlanRenderer { private static final String PROJECT_HISTORY_NOTE = "already selected by project history; omit this option to keep it."; private static final String MISSING_REQUIRED_NOTE = "pass this option or apply a module that records it in project history."; - String render(String moduleSlug, String projectPath, ResolvedModuleParameters parameters) { + String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan dependencyPlan, ResolvedModuleParameters parameters) { StringBuilder plan = new StringBuilder(); plan.append("Plan for module: ").append(moduleSlug).append('\n'); plan.append("Project path: ").append(projectPath).append('\n'); + plan.append('\n'); + plan.append("Dependency plan:").append('\n'); + + if (dependencyPlan.empty()) { + plan.append('\n'); + plan.append("No dependencies.").append('\n'); + } else { + plan.append('\n'); + for (ApplyModuleDependencyPlanLine line : dependencyPlan.lines()) { + plan.append(line.dependency()).append(" - ").append(line.status()).append('\n'); + } + } + plan.append('\n'); plan.append("Resolved parameters:").append('\n'); diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java index cb7c512d..1bba40c6 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java @@ -39,6 +39,7 @@ class ApplyModuleSubCommand implements Callable { private final ProjectsApplicationService projects; private final KnownModulePropertyCompletionCandidates knownCompletionCandidates = new KnownModulePropertyCompletionCandidates(); private final ApplyModuleParameterResolver parameterResolver = new ApplyModuleParameterResolver(); + private final ApplyModuleDependencyPlanner dependencyPlanner = new ApplyModuleDependencyPlanner(); private final ApplyModulePlanRenderer planRenderer = new ApplyModulePlanRenderer(); public ApplyModuleSubCommand(Seed4JModulesApplicationService modules, Seed4JModuleResource module, ProjectsApplicationService projects) { @@ -171,7 +172,12 @@ public Integer call() { explicitParameters, historyParameters.get() ); - System.out.print(planRenderer.render(module.slug().get(), projectPath, resolvedParameters)); + ApplyModuleDependencyPlan dependencyPlan = dependencyPlanner.plan( + module, + modules.resources(), + projects.getHistory(new ProjectPath(projectPath)) + ); + System.out.print(planRenderer.render(module.slug().get(), projectPath, dependencyPlan, resolvedParameters)); return ExitCode.OK; } @@ -241,7 +247,10 @@ private void validateRequiredOptions(ModuleParameters moduleParameters) { throw new MissingParameterException( commandSpec.commandLine(), - missingOptions.stream().map(ArgSpec.class::cast).toList(), + missingOptions + .stream() + .map(ArgSpec.class::cast) + .toList(), "Missing required options: %s".formatted(missingOptionsDescription) ); } diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index a1c95bcf..c3dcfdf4 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -343,6 +343,10 @@ void shouldPlanInitModuleWithExplicitAndDefaultParameterSources(CapturedOutput o Plan for module: init Project path: %s + Dependency plan: + + No dependencies. + Resolved parameters: projectName: Seed4J Sample Application @@ -420,6 +424,85 @@ void shouldPlanInitModuleWithHistorySourcesAndExplicitOverrides(CapturedOutput o .doesNotContain("Missing required parameters:"); } + @Test + void shouldPlanModuleDependencyStatuses(CapturedOutput output) throws IOException { + Path projectPath = setupProjectTestFolder(); + String[] applyArgs = { + "apply", + "init", + "--project-path", + projectPath.toString(), + "--base-name", + "seed4jSampleApplication", + "--project-name", + "Seed4J Sample Application", + "--node-package-manager", + "npm", + }; + int applyExitCode = commandLine(modules, projects).execute(applyArgs); + assertThat(applyExitCode).isZero(); + int historyActionsBeforePlan = projects.getHistory(new ProjectPath(projectPath.toString())).actions().size(); + String commitsBeforePlan = GitTestUtil.getCommits(projectPath); + String[] planArgs = { "apply", "angular-core", "--project-path", projectPath.toString(), "--plan" }; + + int exitCode = commandLine(modules, projects).execute(planArgs); + + assertThat(exitCode).isZero(); + assertThat(projects.getHistory(new ProjectPath(projectPath.toString())).actions()).hasSize(historyActionsBeforePlan); + assertThat(GitTestUtil.getCommits(projectPath)).isEqualTo(commitsBeforePlan); + assertThat(output).contains( + """ + Dependency plan: + + module:init - already applied + module:prettier - pending + + Resolved parameters: + """ + ); + } + + @Test + void shouldPlanFeatureDependencyStatuses(CapturedOutput output) throws IOException { + Path projectPath = setupProjectTestFolder(); + String[] initArgs = { + "apply", + "init", + "--project-path", + projectPath.toString(), + "--base-name", + "seed4jSampleApplication", + "--project-name", + "Seed4J Sample Application", + "--node-package-manager", + "npm", + }; + int initExitCode = commandLine(modules, projects).execute(initArgs); + assertThat(initExitCode).isZero(); + String[] mavenJavaArgs = { "apply", "maven-java", "--project-path", projectPath.toString(), "--package-name", "com.mycompany.myapp" }; + int mavenJavaExitCode = commandLine(modules, projects).execute(mavenJavaArgs); + assertThat(mavenJavaExitCode).isZero(); + int historyActionsBeforePlan = projects.getHistory(new ProjectPath(projectPath.toString())).actions().size(); + String commitsBeforePlan = GitTestUtil.getCommits(projectPath); + String[] planArgs = { "apply", "sonarqube-java-backend", "--project-path", projectPath.toString(), "--plan" }; + + int exitCode = commandLine(modules, projects).execute(planArgs); + + assertThat(exitCode).isZero(); + assertThat(projects.getHistory(new ProjectPath(projectPath.toString())).actions()).hasSize(historyActionsBeforePlan); + assertThat(GitTestUtil.getCommits(projectPath)).isEqualTo(commitsBeforePlan); + assertThat(output).contains( + """ + Dependency plan: + + feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check + feature:java-build-tool - satisfied by maven-java + + Resolved parameters: + """ + ); + } + @Test void shouldNotApplyInitModuleMissingRequiredOptions(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); @@ -453,6 +536,10 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO Plan for module: init Project path: %s + Dependency plan: + + No dependencies. + Resolved parameters: """.formatted(projectPath) ) @@ -488,6 +575,10 @@ void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOE Plan for module: checkstyle Project path: %s + Dependency plan: + + feature:java-build-tool - pending choice: gradle-java, maven-java + Resolved parameters: No changes were applied. From 35d1d0c2d9a20cd7c35682401db8d46a1259254a Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:01:18 -0300 Subject: [PATCH 02/10] test(command): isolate apply plan feature dependency coverage --- .../infrastructure/primary/Seed4JCommandsFactoryTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index c3dcfdf4..a976ef2c 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -562,7 +562,7 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO @Test void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); - String[] args = { "apply", "checkstyle", "--project-path", projectPath.toString(), "--plan" }; + String[] args = { "apply", "front-hexagonal-architecture", "--project-path", projectPath.toString(), "--plan" }; int exitCode = commandLine(modules, projects).execute(args); @@ -572,12 +572,12 @@ void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOE assertThat(output) .contains( """ - Plan for module: checkstyle + Plan for module: front-hexagonal-architecture Project path: %s Dependency plan: - feature:java-build-tool - pending choice: gradle-java, maven-java + No dependencies. Resolved parameters: From a52db1aca7f0d56fa1bc52e1fc01b0f4e39b50df Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:06:41 -0300 Subject: [PATCH 03/10] refactor(command): simplify applied module slug mapping --- .../primary/ApplyModuleDependencyPlanner.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java index db9fa04e..d618679a 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -3,6 +3,7 @@ import com.seed4j.module.domain.landscape.Seed4JLandscapeDependency; import com.seed4j.module.domain.resource.Seed4JModuleResource; import com.seed4j.module.domain.resource.Seed4JModulesResources; +import com.seed4j.project.domain.ModuleSlug; import com.seed4j.project.domain.history.ProjectAction; import com.seed4j.project.domain.history.ProjectHistory; import java.util.ArrayList; @@ -32,12 +33,7 @@ ApplyModuleDependencyPlan plan(Seed4JModuleResource module, Seed4JModulesResourc } private static Set appliedModules(ProjectHistory history) { - return history - .actions() - .stream() - .map(ProjectAction::module) - .map(module -> module.get()) - .collect(Collectors.toUnmodifiableSet()); + return history.actions().stream().map(ProjectAction::module).map(ModuleSlug::get).collect(Collectors.toUnmodifiableSet()); } private static Comparator byModuleSlug() { From 76f9d098a7d8ec3995580716535e822be7d7536d Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:20:02 -0300 Subject: [PATCH 04/10] refactor(command): remove dependency planner out-parameters --- .../primary/ApplyModuleDependencyPlanner.java | 149 ++++++++++-------- 1 file changed, 86 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java index d618679a..06497bd0 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -7,6 +7,7 @@ import com.seed4j.project.domain.history.ProjectAction; import com.seed4j.project.domain.history.ProjectHistory; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; @@ -24,12 +25,10 @@ ApplyModuleDependencyPlan plan(Seed4JModuleResource module, Seed4JModulesResourc Map modulesBySlug = visibleModules .stream() .collect(Collectors.toMap(resource -> resource.slug().get(), Function.identity())); - List lines = new ArrayList<>(); - Set plannedDependencies = new LinkedHashSet<>(); + DependencyPlanningContext context = new DependencyPlanningContext(modulesBySlug, visibleModules, appliedModules); + DependencyPlanningProgress progress = appendDependencies(module, context, DependencyPlanningProgress.empty()); - appendDependencies(module, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, new LinkedHashSet<>()); - - return new ApplyModuleDependencyPlan(List.copyOf(lines)); + return new ApplyModuleDependencyPlan(progress.lines()); } private static Set appliedModules(ProjectHistory history) { @@ -40,74 +39,58 @@ private static Comparator byModuleSlug() { return Comparator.comparing(module -> module.slug().get()); } - private static void appendDependencies( + private static DependencyPlanningProgress appendDependencies( Seed4JModuleResource module, - Map modulesBySlug, - List visibleModules, - Set appliedModules, - List lines, - Set plannedDependencies, - Set visitedModules + DependencyPlanningContext context, + DependencyPlanningProgress progress ) { - if (!visitedModules.add(module.slug().get())) { - return; + if (progress.visitedModules().contains(module.slug().get())) { + return progress; } - module - .organization() - .dependencies() - .stream() - .sorted(byDependencyToken()) - .forEach(dependency -> - appendDependency(dependency, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, visitedModules) - ); + DependencyPlanningProgress currentProgress = progress.withVisitedModule(module.slug().get()); + List dependencies = module.organization().dependencies().stream().sorted(byDependencyToken()).toList(); + for (Seed4JLandscapeDependency dependency : dependencies) { + currentProgress = appendDependency(dependency, context, currentProgress); + } + return currentProgress; } private static Comparator byDependencyToken() { return Comparator.comparing(ApplyModuleDependencyPlanner::dependencyToken); } - private static void appendDependency( + private static DependencyPlanningProgress appendDependency( Seed4JLandscapeDependency dependency, - Map modulesBySlug, - List visibleModules, - Set appliedModules, - List lines, - Set plannedDependencies, - Set visitedModules + DependencyPlanningContext context, + DependencyPlanningProgress progress ) { - switch (dependency.type()) { - case MODULE -> appendModuleDependency( - dependency, - modulesBySlug, - visibleModules, - appliedModules, - lines, - plannedDependencies, - visitedModules - ); - case FEATURE -> appendFeatureDependency(dependency, visibleModules, appliedModules, lines, plannedDependencies); - } + return switch (dependency.type()) { + case MODULE -> appendModuleDependency(dependency, context, progress); + case FEATURE -> appendFeatureDependency(dependency, context, progress); + }; } - private static void appendModuleDependency( + private static DependencyPlanningProgress appendModuleDependency( Seed4JLandscapeDependency dependency, - Map modulesBySlug, - List visibleModules, - Set appliedModules, - List lines, - Set plannedDependencies, - Set visitedModules + DependencyPlanningContext context, + DependencyPlanningProgress progress ) { String dependencySlug = dependency.slug().get(); String token = dependencyToken(dependency); - if (plannedDependencies.add(token)) { - lines.add(new ApplyModuleDependencyPlanLine(token, moduleStatus(dependencySlug, appliedModules))); + DependencyPlanningProgress currentProgress = progress; + if (!currentProgress.plannedDependencies().contains(token)) { + currentProgress = currentProgress.withPlanLine( + new ApplyModuleDependencyPlanLine(token, moduleStatus(dependencySlug, context.appliedModules())) + ); } - Optional.ofNullable(modulesBySlug.get(dependencySlug)).ifPresent(resource -> - appendDependencies(resource, modulesBySlug, visibleModules, appliedModules, lines, plannedDependencies, visitedModules) - ); + Optional dependencyModule = Optional.ofNullable(context.modulesBySlug().get(dependencySlug)); + if (dependencyModule.isEmpty()) { + return currentProgress; + } + + return appendDependencies(dependencyModule.get(), context, currentProgress); } private static String moduleStatus(String dependencySlug, Set appliedModules) { @@ -118,23 +101,21 @@ private static String moduleStatus(String dependencySlug, Set appliedMod return "pending"; } - private static void appendFeatureDependency( + private static DependencyPlanningProgress appendFeatureDependency( Seed4JLandscapeDependency dependency, - List visibleModules, - Set appliedModules, - List lines, - Set plannedDependencies + DependencyPlanningContext context, + DependencyPlanningProgress progress ) { String token = dependencyToken(dependency); - if (!plannedDependencies.add(token)) { - return; + if (progress.plannedDependencies().contains(token)) { + return progress; } - List candidates = featureCandidates(dependency.slug().get(), visibleModules); - Optional appliedCandidate = candidates.stream().filter(appliedModules::contains).findFirst(); + List candidates = featureCandidates(dependency.slug().get(), context.visibleModules()); + Optional appliedCandidate = candidates.stream().filter(context.appliedModules()::contains).findFirst(); String status = appliedCandidate.map(candidate -> "satisfied by " + candidate).orElseGet(() -> pendingChoiceStatus(candidates)); - lines.add(new ApplyModuleDependencyPlanLine(token, status)); + return progress.withPlanLine(new ApplyModuleDependencyPlanLine(token, status)); } private static List featureCandidates(String featureSlug, List visibleModules) { @@ -163,4 +144,46 @@ private static String pendingChoiceStatus(List candidates) { private static String dependencyToken(Seed4JLandscapeDependency dependency) { return dependency.type().name().toLowerCase() + ":" + dependency.slug().get(); } + + private record DependencyPlanningContext( + Map modulesBySlug, + List visibleModules, + Set appliedModules + ) { + private DependencyPlanningContext { + modulesBySlug = Map.copyOf(modulesBySlug); + visibleModules = List.copyOf(visibleModules); + appliedModules = Set.copyOf(appliedModules); + } + } + + private record DependencyPlanningProgress( + List lines, + Set plannedDependencies, + Set visitedModules + ) { + private DependencyPlanningProgress { + lines = List.copyOf(lines); + plannedDependencies = Collections.unmodifiableSet(new LinkedHashSet<>(plannedDependencies)); + visitedModules = Collections.unmodifiableSet(new LinkedHashSet<>(visitedModules)); + } + + private static DependencyPlanningProgress empty() { + return new DependencyPlanningProgress(List.of(), Set.of(), Set.of()); + } + + private DependencyPlanningProgress withVisitedModule(String moduleSlug) { + Set nextVisitedModules = new LinkedHashSet<>(visitedModules); + nextVisitedModules.add(moduleSlug); + return new DependencyPlanningProgress(lines, plannedDependencies, nextVisitedModules); + } + + private DependencyPlanningProgress withPlanLine(ApplyModuleDependencyPlanLine line) { + List nextLines = new ArrayList<>(lines); + Set nextPlannedDependencies = new LinkedHashSet<>(plannedDependencies); + nextLines.add(line); + nextPlannedDependencies.add(line.dependency()); + return new DependencyPlanningProgress(nextLines, nextPlannedDependencies, visitedModules); + } + } } From 872693ee39a37fb609645d14f47264385ba800ba Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:20:53 -0300 Subject: [PATCH 05/10] docs(agents): document mutable out-parameter guidance --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cc030573..9cb3decd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Agents may run smaller, targeted checks such as `./mvnw test`, specific Maven te ## Coding Style & Naming Conventions -Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. +Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Do not use mutable out-parameters, such as passing `List`, `Set`, or holder objects into helpers so they can append progress by side effect. Java passes object references by value; the issue is hidden mutation of shared accumulators. Prefer returning an explicit result or progress type and threading it through loops or recursion. Use private records for implementation-local progress/context types, and promote them only when they represent real domain concepts. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. ## Testing Guidelines From 5324e20d9e2c25899b6ac30cea1a5ee9ea993682 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:55:42 -0300 Subject: [PATCH 06/10] refactor(command): compose dependency planning progress --- .../primary/ApplyModuleDependencyPlanner.java | 96 ++++++++++++------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java index 06497bd0..686dce14 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -20,13 +20,11 @@ class ApplyModuleDependencyPlanner { ApplyModuleDependencyPlan plan(Seed4JModuleResource module, Seed4JModulesResources resources, ProjectHistory history) { - Set appliedModules = appliedModules(history); - List visibleModules = resources.stream().sorted(byModuleSlug()).toList(); - Map modulesBySlug = visibleModules - .stream() - .collect(Collectors.toMap(resource -> resource.slug().get(), Function.identity())); - DependencyPlanningContext context = new DependencyPlanningContext(modulesBySlug, visibleModules, appliedModules); - DependencyPlanningProgress progress = appendDependencies(module, context, DependencyPlanningProgress.empty()); + DependencyPlanningProgress progress = appendDependencies( + module, + DependencyPlanningContext.from(resources, history), + DependencyPlanningProgress.empty() + ); return new ApplyModuleDependencyPlan(progress.lines()); } @@ -44,22 +42,32 @@ private static DependencyPlanningProgress appendDependencies( DependencyPlanningContext context, DependencyPlanningProgress progress ) { - if (progress.visitedModules().contains(module.slug().get())) { + String moduleSlug = module.slug().get(); + if (progress.visitedModules().contains(moduleSlug)) { return progress; } - DependencyPlanningProgress currentProgress = progress.withVisitedModule(module.slug().get()); - List dependencies = module.organization().dependencies().stream().sorted(byDependencyToken()).toList(); - for (Seed4JLandscapeDependency dependency : dependencies) { - currentProgress = appendDependency(dependency, context, currentProgress); - } - return currentProgress; + return module + .organization() + .dependencies() + .stream() + .sorted(byDependencyToken()) + .map(dependency -> dependencyPlanningStep(dependency, context)) + .reduce(Function.identity(), Function::andThen) + .apply(progress.withVisitedModule(moduleSlug)); } private static Comparator byDependencyToken() { return Comparator.comparing(ApplyModuleDependencyPlanner::dependencyToken); } + private static Function dependencyPlanningStep( + Seed4JLandscapeDependency dependency, + DependencyPlanningContext context + ) { + return progress -> appendDependency(dependency, context, progress); + } + private static DependencyPlanningProgress appendDependency( Seed4JLandscapeDependency dependency, DependencyPlanningContext context, @@ -77,20 +85,14 @@ private static DependencyPlanningProgress appendModuleDependency( DependencyPlanningProgress progress ) { String dependencySlug = dependency.slug().get(); - String token = dependencyToken(dependency); - DependencyPlanningProgress currentProgress = progress; - if (!currentProgress.plannedDependencies().contains(token)) { - currentProgress = currentProgress.withPlanLine( - new ApplyModuleDependencyPlanLine(token, moduleStatus(dependencySlug, context.appliedModules())) - ); - } - - Optional dependencyModule = Optional.ofNullable(context.modulesBySlug().get(dependencySlug)); - if (dependencyModule.isEmpty()) { - return currentProgress; - } - - return appendDependencies(dependencyModule.get(), context, currentProgress); + DependencyPlanningProgress nextProgress = progress.withPlanLineIfMissing( + new ApplyModuleDependencyPlanLine(dependencyToken(dependency), moduleStatus(dependencySlug, context.appliedModules())) + ); + + return context + .module(dependencySlug) + .map(resource -> appendDependencies(resource, context, nextProgress)) + .orElse(nextProgress); } private static String moduleStatus(String dependencySlug, Set appliedModules) { @@ -106,16 +108,19 @@ private static DependencyPlanningProgress appendFeatureDependency( DependencyPlanningContext context, DependencyPlanningProgress progress ) { - String token = dependencyToken(dependency); - if (progress.plannedDependencies().contains(token)) { - return progress; - } + return progress.withPlanLineIfMissing( + new ApplyModuleDependencyPlanLine(dependencyToken(dependency), featureStatus(dependency, context)) + ); + } + private static String featureStatus(Seed4JLandscapeDependency dependency, DependencyPlanningContext context) { List candidates = featureCandidates(dependency.slug().get(), context.visibleModules()); - Optional appliedCandidate = candidates.stream().filter(context.appliedModules()::contains).findFirst(); - String status = appliedCandidate.map(candidate -> "satisfied by " + candidate).orElseGet(() -> pendingChoiceStatus(candidates)); - - return progress.withPlanLine(new ApplyModuleDependencyPlanLine(token, status)); + return candidates + .stream() + .filter(context.appliedModules()::contains) + .findFirst() + .map(candidate -> "satisfied by " + candidate) + .orElseGet(() -> pendingChoiceStatus(candidates)); } private static List featureCandidates(String featureSlug, List visibleModules) { @@ -155,6 +160,19 @@ private record DependencyPlanningContext( visibleModules = List.copyOf(visibleModules); appliedModules = Set.copyOf(appliedModules); } + + private static DependencyPlanningContext from(Seed4JModulesResources resources, ProjectHistory history) { + List visibleModules = resources.stream().sorted(byModuleSlug()).toList(); + return new DependencyPlanningContext( + visibleModules.stream().collect(Collectors.toMap(resource -> resource.slug().get(), Function.identity())), + visibleModules, + ApplyModuleDependencyPlanner.appliedModules(history) + ); + } + + private Optional module(String slug) { + return Optional.ofNullable(modulesBySlug.get(slug)); + } } private record DependencyPlanningProgress( @@ -178,7 +196,11 @@ private DependencyPlanningProgress withVisitedModule(String moduleSlug) { return new DependencyPlanningProgress(lines, plannedDependencies, nextVisitedModules); } - private DependencyPlanningProgress withPlanLine(ApplyModuleDependencyPlanLine line) { + private DependencyPlanningProgress withPlanLineIfMissing(ApplyModuleDependencyPlanLine line) { + if (plannedDependencies.contains(line.dependency())) { + return this; + } + List nextLines = new ArrayList<>(lines); Set nextPlannedDependencies = new LinkedHashSet<>(plannedDependencies); nextLines.add(line); From ed4f4e99f413086eb433abd51a7bb1d80534a400 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 10:56:53 -0300 Subject: [PATCH 07/10] docs(agents): document stream fold guidance --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9cb3decd..31307cde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Agents may run smaller, targeted checks such as `./mvnw test`, specific Maven te ## Coding Style & Naming Conventions -Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Do not use mutable out-parameters, such as passing `List`, `Set`, or holder objects into helpers so they can append progress by side effect. Java passes object references by value; the issue is hidden mutation of shared accumulators. Prefer returning an explicit result or progress type and threading it through loops or recursion. Use private records for implementation-local progress/context types, and promote them only when they represent real domain concepts. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. +Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Do not use mutable out-parameters, such as passing `List`, `Set`, or holder objects into helpers so they can append progress by side effect. Java passes object references by value; the issue is hidden mutation of shared accumulators. Prefer returning an explicit result or progress type and threading it through loops or recursion. Use private records for implementation-local progress/context types, and promote them only when they represent real domain concepts. When a helper needs to apply an ordered sequence of transformations to an immutable progress/result object, prefer a named fold over `forEach` with external state. A stream can be appropriate when each element is mapped to a clearly named `Function` and composed with `Function::andThen` or an equally explicit fold. Use this only when the helper names make the data flow clearer than an explicit loop; do not use streams to hide mutation. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. ## Testing Guidelines From 9735f82d9b36e971ec7fde7f861223fa76178877 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 11:08:51 -0300 Subject: [PATCH 08/10] refactor(command): model apply dependency status --- .../ApplyModuleDependencyPlanLine.java | 2 +- .../primary/ApplyModuleDependencyPlanner.java | 20 +++----- .../primary/ApplyModuleDependencyStatus.java | 49 +++++++++++++++++++ .../primary/ApplyModulePlanRenderer.java | 2 +- .../primary/Seed4JCommandsFactoryTest.java | 21 ++++++++ 5 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java index cde38c91..edfa5111 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java @@ -1,3 +1,3 @@ package com.seed4j.cli.command.infrastructure.primary; -record ApplyModuleDependencyPlanLine(String dependency, String status) {} +record ApplyModuleDependencyPlanLine(String dependency, ApplyModuleDependencyStatus status) {} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java index 686dce14..03ee3c5d 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -95,12 +95,12 @@ private static DependencyPlanningProgress appendModuleDependency( .orElse(nextProgress); } - private static String moduleStatus(String dependencySlug, Set appliedModules) { + private static ApplyModuleDependencyStatus moduleStatus(String dependencySlug, Set appliedModules) { if (appliedModules.contains(dependencySlug)) { - return "already applied"; + return ApplyModuleDependencyStatus.alreadyApplied(); } - return "pending"; + return ApplyModuleDependencyStatus.pending(); } private static DependencyPlanningProgress appendFeatureDependency( @@ -113,14 +113,14 @@ private static DependencyPlanningProgress appendFeatureDependency( ); } - private static String featureStatus(Seed4JLandscapeDependency dependency, DependencyPlanningContext context) { + private static ApplyModuleDependencyStatus featureStatus(Seed4JLandscapeDependency dependency, DependencyPlanningContext context) { List candidates = featureCandidates(dependency.slug().get(), context.visibleModules()); return candidates .stream() .filter(context.appliedModules()::contains) .findFirst() - .map(candidate -> "satisfied by " + candidate) - .orElseGet(() -> pendingChoiceStatus(candidates)); + .map(ApplyModuleDependencyStatus::satisfiedBy) + .orElseGet(() -> ApplyModuleDependencyStatus.pendingChoice(candidates)); } private static List featureCandidates(String featureSlug, List visibleModules) { @@ -138,14 +138,6 @@ private static List featureCandidates(String featureSlug, List candidates) { - if (candidates.isEmpty()) { - return "pending choice: no visible candidates"; - } - - return "pending choice: " + String.join(", ", candidates); - } - private static String dependencyToken(Seed4JLandscapeDependency dependency) { return dependency.type().name().toLowerCase() + ":" + dependency.slug().get(); } diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java new file mode 100644 index 00000000..98e4a092 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java @@ -0,0 +1,49 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import java.util.List; + +record ApplyModuleDependencyStatus(StatusKind kind, String moduleSlug, List candidates) { + ApplyModuleDependencyStatus { + candidates = List.copyOf(candidates); + } + + static ApplyModuleDependencyStatus alreadyApplied() { + return new ApplyModuleDependencyStatus(StatusKind.ALREADY_APPLIED, "", List.of()); + } + + static ApplyModuleDependencyStatus pending() { + return new ApplyModuleDependencyStatus(StatusKind.PENDING, "", List.of()); + } + + static ApplyModuleDependencyStatus satisfiedBy(String moduleSlug) { + return new ApplyModuleDependencyStatus(StatusKind.SATISFIED_BY, moduleSlug, List.of()); + } + + static ApplyModuleDependencyStatus pendingChoice(List candidates) { + return new ApplyModuleDependencyStatus(StatusKind.PENDING_CHOICE, "", candidates); + } + + String displayLabel() { + return switch (kind) { + case ALREADY_APPLIED -> "already applied"; + case PENDING -> "pending"; + case SATISFIED_BY -> "satisfied by " + moduleSlug; + case PENDING_CHOICE -> pendingChoiceDisplayLabel(); + }; + } + + private String pendingChoiceDisplayLabel() { + if (candidates.isEmpty()) { + return "pending choice: no visible candidates"; + } + + return "pending choice: " + String.join(", ", candidates); + } + + private enum StatusKind { + ALREADY_APPLIED, + PENDING, + SATISFIED_BY, + PENDING_CHOICE, + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java index 3ffc22f0..abce49d5 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java @@ -18,7 +18,7 @@ String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan d } else { plan.append('\n'); for (ApplyModuleDependencyPlanLine line : dependencyPlan.lines()) { - plan.append(line.dependency()).append(" - ").append(line.status()).append('\n'); + plan.append(line.dependency()).append(" - ").append(line.status().displayLabel()).append('\n'); } } diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index a976ef2c..365d567b 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -13,6 +13,7 @@ import com.seed4j.project.domain.history.ProjectHistory; import java.io.IOException; import java.nio.file.Path; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -503,6 +504,26 @@ void shouldPlanFeatureDependencyStatuses(CapturedOutput output) throws IOExcepti ); } + @Test + void shouldRenderFeatureDependencyWithoutVisibleCandidatesInPlan() { + ApplyModuleDependencyPlan dependencyPlan = new ApplyModuleDependencyPlan( + List.of(new ApplyModuleDependencyPlanLine("feature:missing-feature", ApplyModuleDependencyStatus.pendingChoice(List.of()))) + ); + ResolvedModuleParameters parameters = new ResolvedModuleParameters(List.of(), List.of()); + + String output = new ApplyModulePlanRenderer().render("sample-module", "/tmp/sample", dependencyPlan, parameters); + + assertThat(output).contains( + """ + Dependency plan: + + feature:missing-feature - pending choice: no visible candidates + + Resolved parameters: + """ + ); + } + @Test void shouldNotApplyInitModuleMissingRequiredOptions(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); From 083fd1eefa9e697545bd892d1d7342835223b1c8 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 11:27:01 -0300 Subject: [PATCH 09/10] fix(command): reject empty apply dependency choices --- .../primary/ApplyModuleDependencyStatus.java | 13 ++++-------- .../primary/Seed4JCommandsFactoryTest.java | 21 ------------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java index 98e4a092..9bf3850a 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java @@ -1,5 +1,6 @@ package com.seed4j.cli.command.infrastructure.primary; +import com.seed4j.cli.shared.error.domain.Assert; import java.util.List; record ApplyModuleDependencyStatus(StatusKind kind, String moduleSlug, List candidates) { @@ -20,6 +21,8 @@ static ApplyModuleDependencyStatus satisfiedBy(String moduleSlug) { } static ApplyModuleDependencyStatus pendingChoice(List candidates) { + Assert.notEmpty("candidates", candidates); + return new ApplyModuleDependencyStatus(StatusKind.PENDING_CHOICE, "", candidates); } @@ -28,18 +31,10 @@ String displayLabel() { case ALREADY_APPLIED -> "already applied"; case PENDING -> "pending"; case SATISFIED_BY -> "satisfied by " + moduleSlug; - case PENDING_CHOICE -> pendingChoiceDisplayLabel(); + case PENDING_CHOICE -> "pending choice: " + String.join(", ", candidates); }; } - private String pendingChoiceDisplayLabel() { - if (candidates.isEmpty()) { - return "pending choice: no visible candidates"; - } - - return "pending choice: " + String.join(", ", candidates); - } - private enum StatusKind { ALREADY_APPLIED, PENDING, diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index 365d567b..a976ef2c 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -13,7 +13,6 @@ import com.seed4j.project.domain.history.ProjectHistory; import java.io.IOException; import java.nio.file.Path; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -504,26 +503,6 @@ void shouldPlanFeatureDependencyStatuses(CapturedOutput output) throws IOExcepti ); } - @Test - void shouldRenderFeatureDependencyWithoutVisibleCandidatesInPlan() { - ApplyModuleDependencyPlan dependencyPlan = new ApplyModuleDependencyPlan( - List.of(new ApplyModuleDependencyPlanLine("feature:missing-feature", ApplyModuleDependencyStatus.pendingChoice(List.of()))) - ); - ResolvedModuleParameters parameters = new ResolvedModuleParameters(List.of(), List.of()); - - String output = new ApplyModulePlanRenderer().render("sample-module", "/tmp/sample", dependencyPlan, parameters); - - assertThat(output).contains( - """ - Dependency plan: - - feature:missing-feature - pending choice: no visible candidates - - Resolved parameters: - """ - ); - } - @Test void shouldNotApplyInitModuleMissingRequiredOptions(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); From b6560b26804220866c075cf83f24b2892de01649 Mon Sep 17 00:00:00 2001 From: Renan Franca Date: Fri, 26 Jun 2026 12:05:03 -0300 Subject: [PATCH 10/10] feat(command): mark apply plan item status --- README.md | 2 +- documentation/Commands.md | 35 ++++++++++--------- .../primary/ApplyModuleDependencyStatus.java | 7 ++++ .../primary/ApplyModulePlanItemMarker.java | 16 +++++++++ .../primary/ApplyModulePlanRenderer.java | 18 +++++++--- .../primary/Seed4JCommandsFactoryTest.java | 32 ++++++++--------- 6 files changed, 72 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java diff --git a/README.md b/README.md index 447be31f..2c45fcdd 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ To inspect module parameters before applying a module, add `--plan`: seed4j apply init --plan ``` -The plan first prints a `Dependency plan`, then the resolved text values and whether each value came from the current CLI input, project history, or a module metadata default. Dependency statuses show direct module dependencies as `already applied` or `pending`; feature dependencies show either `satisfied by ` or `pending choice` with visible candidate modules. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. +The plan first prints a `Dependency plan`, then the resolved text values and whether each value came from the current CLI input, project history, or a module metadata default. Lines marked with `✓` are resolved, and lines marked with `○` are pending or missing. Dependency statuses show direct module dependencies as `already applied` or `pending`; feature dependencies show either `satisfied by ` or `pending choice` with visible candidate modules. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. To install Bash completion for the active runtime: diff --git a/documentation/Commands.md b/documentation/Commands.md index a80d3ac2..37d86396 100644 --- a/documentation/Commands.md +++ b/documentation/Commands.md @@ -119,7 +119,8 @@ seed4j apply init --plan The plan is text-only and exits without generated files, history entries, or commits. Required parameters can come from the current command or project history. When required parameters are still missing, the plan exits successfully and shows -which options to pass before applying the module. +which options to pass before applying the module. Lines marked with `✓` are resolved, and lines marked with `○` are +pending or missing. ```text Plan for module: init @@ -127,29 +128,29 @@ Project path: . Dependency plan: -No dependencies. +✓ No dependencies. Resolved parameters: -endOfLine: lf +✓ endOfLine: lf Source: default CLI option: --end-of-line -indentSize: 2 +✓ indentSize: 2 Source: default CLI option: --indent-size Missing required parameters: -projectName: +○ projectName: CLI option: --project-name Note: pass this option or apply a module that records it in project history. -baseName: +○ baseName: CLI option: --base-name Note: pass this option or apply a module that records it in project history. -nodePackageManager: +○ nodePackageManager: CLI option: --node-package-manager Note: pass this option or apply a module that records it in project history. @@ -174,23 +175,23 @@ Project path: . Dependency plan: -module:init - already applied +✓ module:init - already applied Resolved parameters: -projectName: Seed4J Sample Application +✓ projectName: Seed4J Sample Application Source: project history CLI option: --project-name Note: already selected by project history; omit this option to keep it. -baseName: seed4jSampleApplication +✓ baseName: seed4jSampleApplication Source: project history CLI option: --base-name Note: already selected by project history; omit this option to keep it. Missing required parameters: -packageName: +○ packageName: CLI option: --package-name Note: pass this option or apply a module that records it in project history. @@ -205,10 +206,10 @@ seed4j apply maven-java --package-name com.mycompany.myapp Dependency status labels mean: -- `module: - already applied`: the module dependency is already recorded in project history -- `module: - pending`: the module dependency is not recorded in project history -- `feature: - satisfied by `: an applied module provides the required feature -- `feature: - pending choice: , `: no applied module provides the feature yet; choose one of +- `✓ module: - already applied`: the module dependency is already recorded in project history +- `○ module: - pending`: the module dependency is not recorded in project history +- `✓ feature: - satisfied by `: an applied module provides the required feature +- `○ feature: - pending choice: , `: no applied module provides the feature yet; choose one of the sorted visible candidates For example, after applying `maven-java`, planning `sonarqube-java-backend` can show one satisfied feature and one feature @@ -217,8 +218,8 @@ that still needs a choice: ```text Dependency plan: -feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check -feature:java-build-tool - satisfied by maven-java +○ feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check +✓ feature:java-build-tool - satisfied by maven-java ``` Plan source labels mean: diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java index 9bf3850a..e499f05d 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java @@ -35,6 +35,13 @@ String displayLabel() { }; } + ApplyModulePlanItemMarker planMarker() { + return switch (kind) { + case ALREADY_APPLIED, SATISFIED_BY -> ApplyModulePlanItemMarker.RESOLVED; + case PENDING, PENDING_CHOICE -> ApplyModulePlanItemMarker.PENDING; + }; + } + private enum StatusKind { ALREADY_APPLIED, PENDING, diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java new file mode 100644 index 00000000..c3503014 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java @@ -0,0 +1,16 @@ +package com.seed4j.cli.command.infrastructure.primary; + +enum ApplyModulePlanItemMarker { + RESOLVED("✓"), + PENDING("○"); + + private final String symbol; + + ApplyModulePlanItemMarker(String symbol) { + this.symbol = symbol; + } + + String prefix() { + return symbol + " "; + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java index abce49d5..b435e058 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java @@ -14,11 +14,16 @@ String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan d if (dependencyPlan.empty()) { plan.append('\n'); - plan.append("No dependencies.").append('\n'); + plan.append(ApplyModulePlanItemMarker.RESOLVED.prefix()).append("No dependencies.").append('\n'); } else { plan.append('\n'); for (ApplyModuleDependencyPlanLine line : dependencyPlan.lines()) { - plan.append(line.dependency()).append(" - ").append(line.status().displayLabel()).append('\n'); + plan + .append(line.status().planMarker().prefix()) + .append(line.dependency()) + .append(" - ") + .append(line.status().displayLabel()) + .append('\n'); } } @@ -28,7 +33,12 @@ String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan d if (!parameters.empty()) { for (ResolvedModuleParameter parameter : parameters.parameters()) { plan.append('\n'); - plan.append(parameter.name()).append(": ").append(parameter.value()).append('\n'); + plan + .append(ApplyModulePlanItemMarker.RESOLVED.prefix()) + .append(parameter.name()) + .append(": ") + .append(parameter.value()) + .append('\n'); plan.append(" Source: ").append(parameter.source().displayLabel()).append('\n'); plan.append(" CLI option: ").append(parameter.cliOption()).append('\n'); if (parameter.source().projectHistory()) { @@ -42,7 +52,7 @@ String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan d plan.append("Missing required parameters:").append('\n'); for (MissingRequiredModuleParameter parameter : parameters.missingRequiredParameters()) { plan.append('\n'); - plan.append(parameter.name()).append(":").append('\n'); + plan.append(ApplyModulePlanItemMarker.PENDING.prefix()).append(parameter.name()).append(":").append('\n'); plan.append(" CLI option: ").append(parameter.cliOption()).append('\n'); plan.append(" Note: ").append(MISSING_REQUIRED_NOTE).append('\n'); } diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index a976ef2c..06d20070 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -345,26 +345,26 @@ void shouldPlanInitModuleWithExplicitAndDefaultParameterSources(CapturedOutput o Dependency plan: - No dependencies. + ✓ No dependencies. Resolved parameters: - projectName: Seed4J Sample Application + ✓ projectName: Seed4J Sample Application Source: explicit CLI input CLI option: --project-name - baseName: seed4jSampleApplication + ✓ baseName: seed4jSampleApplication Source: explicit CLI input CLI option: --base-name - nodePackageManager: pnpm + ✓ nodePackageManager: pnpm Source: explicit CLI input CLI option: --node-package-manager """.formatted(projectPath) ) .contains( """ - endOfLine: lf + ✓ endOfLine: lf Source: default CLI option: --end-of-line """ @@ -402,19 +402,19 @@ void shouldPlanInitModuleWithHistorySourcesAndExplicitOverrides(CapturedOutput o assertThat(output) .contains( """ - projectName: Seed4J Sample Application + ✓ projectName: Seed4J Sample Application Source: project history CLI option: --project-name Note: already selected by project history; omit this option to keep it. - baseName: explicitOverride + ✓ baseName: explicitOverride Source: explicit CLI input CLI option: --base-name """ ) .contains( """ - nodePackageManager: npm + ✓ nodePackageManager: npm Source: project history CLI option: --node-package-manager Note: already selected by project history; omit this option to keep it. @@ -454,8 +454,8 @@ void shouldPlanModuleDependencyStatuses(CapturedOutput output) throws IOExceptio """ Dependency plan: - module:init - already applied - module:prettier - pending + ✓ module:init - already applied + ○ module:prettier - pending Resolved parameters: """ @@ -495,8 +495,8 @@ void shouldPlanFeatureDependencyStatuses(CapturedOutput output) throws IOExcepti """ Dependency plan: - feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check - feature:java-build-tool - satisfied by maven-java + ○ feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check + ✓ feature:java-build-tool - satisfied by maven-java Resolved parameters: """ @@ -538,7 +538,7 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO Dependency plan: - No dependencies. + ✓ No dependencies. Resolved parameters: """.formatted(projectPath) @@ -547,11 +547,11 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO """ Missing required parameters: - projectName: + ○ projectName: CLI option: --project-name Note: pass this option or apply a module that records it in project history. - baseName: + ○ baseName: CLI option: --base-name Note: pass this option or apply a module that records it in project history. """ @@ -577,7 +577,7 @@ void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOE Dependency plan: - No dependencies. + ✓ No dependencies. Resolved parameters: