Skip to content

Commit 693ee0e

Browse files
authored
feat(kotlin): kapt opt-out and default-stage dedup across modules (#921)
* feat(kotlin): kapt opt-out and default-stage dedup across modules Address the two Kotlin-support follow-ups captured after the #918 fix in KOTLIN_SUPPORT_FOLLOWUPS.md. * Kapt auto-apply opt-out via `flamingock.autoApplyKapt` Gradle property (default true; opt out with literal case-insensitive `false`). Gradle property, not DSL: the `plugins { }` block evaluates before `flamingock { }`, so a DSL setter would fire too late. * Mixed Java+Kotlin modules no longer produce duplicate default-stage entries in the composite pipeline. `MetadataLoader.CompositePipelineBuilder` now applies the same id-deduplicated same-name collapse to default stages that legacy stages already used. Identity-field mismatches still WARN for default stages so genuine misconfigurations stay visible.
1 parent 735f98d commit 693ee0e

7 files changed

Lines changed: 370 additions & 43 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ plugins {
1919

2020
allprojects {
2121
group = "io.flamingock"
22-
val declaredVersion = "1.4.1-SNAPSHOT"
22+
val declaredVersion = "1.4.2-SNAPSHOT"
2323
version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release"))
2424

2525
extra["generalUtilVersion"] = "1.5.3"

core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/metadata/MetadataLoader.java

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,16 @@ FlamingockMetadata aggregate(List<FlamingockMetadata> modules) {
182182
static final class CompositePipelineBuilder {
183183
PreviewPipeline buildFrom(List<FlamingockMetadata> modules) {
184184
// Stage assembly preserves stage instances by reference — no defensive copies.
185-
// Default stages are kept as-is (duplicate names across modules surface a warning
186-
// because that's likely a configuration mistake). Legacy stages are merged
187-
// group-by-name via collapseLegacyStagesByName; same-name legacy stages across
188-
// modules are merged silently because that's the documented design.
185+
// Stages with the same name across modules are collapsed group-by-name via
186+
// collapseStagesByName for BOTH legacy and default stages. The collapse
187+
// id-deduplicates the change set across the group; this is what makes the
188+
// kapt + javac scenario in a mixed Java+Kotlin module produce one default stage
189+
// with the union of changes rather than two side-by-side. Identity-field
190+
// mismatches (different type / sourcesPackage / resourcesDir on stages that
191+
// share a name) still surface a WARN for default stages so genuine multi-module
192+
// misconfigurations stay visible; legacy stages stay silent because historical
193+
// Mongock setups frequently have multiple modules legitimately producing the
194+
// same legacy stage.
189195
List<PreviewStage> defaultStages = new ArrayList<>();
190196
List<PreviewStage> legacyStages = new ArrayList<>();
191197
SystemPreviewStage systemStage = null;
@@ -209,22 +215,12 @@ PreviewPipeline buildFrom(List<FlamingockMetadata> modules) {
209215
}
210216
}
211217

212-
// Warn on duplicate names among DEFAULT stages only — for LEGACY, collapsing
213-
// same-name stages is the intended outcome and would otherwise surface false
214-
// positives in any project with multiple Mongock-using modules.
215-
Set<String> seenDefaultNames = new HashSet<>();
216-
for (PreviewStage s : defaultStages) {
217-
if (!seenDefaultNames.add(s.getName())) {
218-
logger.warn("Duplicate stage name '{}' across modules — proceeding with both.",
219-
s.getName());
220-
}
221-
}
222-
223-
List<PreviewStage> collapsedLegacy = collapseLegacyStagesByName(legacyStages);
218+
List<PreviewStage> collapsedLegacy = collapseStagesByName(legacyStages);
219+
List<PreviewStage> collapsedDefault = collapseStagesByName(defaultStages);
224220

225221
List<PreviewStage> allStages = new ArrayList<>();
226222
allStages.addAll(collapsedLegacy);
227-
allStages.addAll(defaultStages);
223+
allStages.addAll(collapsedDefault);
228224

229225
// Surface the rare case where the system stage name clashes with any
230226
// resolved (post-legacy-collapse) stage name in the composite.
@@ -269,54 +265,83 @@ private static SystemPreviewStage mergeSystem(SystemPreviewStage soFar, PreviewS
269265
}
270266

271267
/**
272-
* Group legacy stages by name and merge each group independently. Returns one
273-
* {@link PreviewStage} per distinct legacy-stage name, in first-seen order across
274-
* modules (deterministic for a given module discovery order).
268+
* Group stages by name and merge each group independently. Returns one
269+
* {@link PreviewStage} per distinct stage name, in first-seen order across modules
270+
* (deterministic for a given module discovery order).
275271
*
276-
* <p>Stages with different names stay separate — historically Mongock was the only
277-
* producer (always {@code flamingock-legacy-stage}), but a future legacy source can
278-
* declare its own stage name and is preserved as a peer alongside Mongock's.
272+
* <p>Type-agnostic: handles both legacy and default stages with the same id-dedup
273+
* semantics. Same-name groups across modules are merged silently for LEGACY stages
274+
* (historical Mongock setups frequently have multiple modules producing the same
275+
* legacy stage) and with an identity-field mismatch WARN for DEFAULT stages (so the
276+
* "two modules declared 'mainStage' with different sourcesPackage" misconfiguration
277+
* stays visible).
279278
*/
280-
private static List<PreviewStage> collapseLegacyStagesByName(List<PreviewStage> legacyStages) {
281-
if (legacyStages.isEmpty()) return new ArrayList<>();
279+
private static List<PreviewStage> collapseStagesByName(List<PreviewStage> stages) {
280+
if (stages.isEmpty()) return new ArrayList<>();
282281
LinkedHashMap<String, List<PreviewStage>> byName = new LinkedHashMap<>();
283-
for (PreviewStage s : legacyStages) {
282+
for (PreviewStage s : stages) {
284283
byName.computeIfAbsent(s.getName(), k -> new ArrayList<>()).add(s);
285284
}
286285
List<PreviewStage> result = new ArrayList<>(byName.size());
287286
for (List<PreviewStage> group : byName.values()) {
288-
result.add(group.size() == 1 ? group.get(0) : mergeSameNameLegacyStages(group));
287+
result.add(group.size() == 1 ? group.get(0) : mergeSameNameStages(group));
289288
}
290289
return result;
291290
}
292291

293292
/**
294-
* id-deduplicated union of changes for legacy stages that already share a name. The
295-
* first stage's name/description/sourcesPackage/resourcesDir wins; subsequent stages
293+
* id-deduplicated union of changes for stages that already share a name. The first
294+
* stage's name/type/description/sourcesPackage/resourcesDir wins; subsequent stages
296295
* contribute only changes whose ids haven't been seen yet.
296+
*
297+
* <p>Emits a WARN on identity-field mismatch (different {@code type},
298+
* {@code sourcesPackage}, or {@code resourcesDir}) for DEFAULT stages — this is
299+
* almost certainly a configuration mistake worth surfacing. LEGACY stages stay
300+
* silent on mismatch by design: existing Mongock-using modules sometimes
301+
* legitimately diverge on these fields without it being a real problem.
297302
*/
298-
private static PreviewStage mergeSameNameLegacyStages(List<PreviewStage> sameName) {
303+
private static PreviewStage mergeSameNameStages(List<PreviewStage> sameName) {
299304
PreviewStage first = sameName.get(0);
300305
List<AbstractPreviewChange> merged = new ArrayList<>();
301306
Set<String> seenIds = new HashSet<>();
302307
if (first.getChanges() != null) {
303308
first.getChanges().forEach(c -> { merged.add(c); seenIds.add(c.getId()); });
304309
}
310+
boolean identityMismatch = false;
305311
for (int i = 1; i < sameName.size(); i++) {
306312
PreviewStage extra = sameName.get(i);
313+
if (!identityMismatch && hasIdentityMismatch(first, extra)) {
314+
identityMismatch = true;
315+
}
307316
if (extra.getChanges() == null) continue;
308317
for (AbstractPreviewChange c : extra.getChanges()) {
309318
if (seenIds.add(c.getId())) {
310319
merged.add(c);
311320
} else {
312-
logger.debug("Deduplicated legacy change id '{}' in stage '{}' (already contributed by an earlier module)",
321+
logger.debug("Deduplicated change id '{}' in stage '{}' (already contributed by an earlier module)",
313322
c.getId(), first.getName());
314323
}
315324
}
316325
}
326+
if (identityMismatch && first.getType() != StageType.LEGACY) {
327+
logger.warn("Stage '{}' is declared by multiple modules with mismatched identity (type / sourcesPackage / resourcesDir). "
328+
+ "Merging change sets; first-seen identity wins — verify your @EnableFlamingock declarations.",
329+
first.getName());
330+
}
317331
return new PreviewStage(first.getName(), first.getType(), first.getDescription(),
318332
first.getSourcesPackage(), first.getResourcesDir(), merged);
319333
}
334+
335+
/**
336+
* Two same-name stages have a real configuration mismatch when their type,
337+
* sourcesPackage, or resourcesDir differ. Description is excluded — it's free-text
338+
* and can reasonably differ across modules without indicating a problem.
339+
*/
340+
private static boolean hasIdentityMismatch(PreviewStage a, PreviewStage b) {
341+
return a.getType() != b.getType()
342+
|| !java.util.Objects.equals(a.getSourcesPackage(), b.getSourcesPackage())
343+
|| !java.util.Objects.equals(a.getResourcesDir(), b.getResourcesDir());
344+
}
320345
}
321346

322347
/** Union map; later modules win on key clash. */
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.internal.common.core.metadata;
17+
18+
import io.flamingock.api.StageType;
19+
import io.flamingock.internal.common.core.preview.AbstractPreviewChange;
20+
import io.flamingock.internal.common.core.preview.CodePreviewChange;
21+
import io.flamingock.internal.common.core.preview.PreviewPipeline;
22+
import io.flamingock.internal.common.core.preview.PreviewStage;
23+
import io.flamingock.internal.common.core.preview.SystemPreviewStage;
24+
import org.junit.jupiter.api.DisplayName;
25+
import org.junit.jupiter.api.Test;
26+
27+
import java.util.ArrayList;
28+
import java.util.Arrays;
29+
import java.util.Collections;
30+
import java.util.LinkedHashSet;
31+
import java.util.List;
32+
import java.util.Set;
33+
import java.util.stream.Collectors;
34+
35+
import static org.junit.jupiter.api.Assertions.assertEquals;
36+
import static org.junit.jupiter.api.Assertions.assertNotNull;
37+
38+
/**
39+
* Unit tests for {@code MetadataLoader.CompositePipelineBuilder}, focused on the
40+
* stage-collapse logic that was generalised to cover default stages (in addition to legacy
41+
* stages) so that mixed Java + Kotlin modules — where kapt and javac each produce their own
42+
* metadata provider with the same default stages — converge on a single default stage in the
43+
* composite instead of two side-by-side entries.
44+
*/
45+
class CompositePipelineBuilderTest {
46+
47+
@Test
48+
@DisplayName("same-name default stages across modules collapse into one with merged change list")
49+
void defaultStagesWithSameNameAreCollapsed() {
50+
// Mirrors the kapt + javac scenario: two providers, same default stage, disjoint
51+
// changes (kapt contributed the Kotlin changes, javac contributed the Java ones).
52+
FlamingockMetadata moduleKapt = metadataWithDefaultStage(
53+
"main-stage", "com.example", changes("KotlinChangeA", "KotlinChangeB"));
54+
FlamingockMetadata moduleJavac = metadataWithDefaultStage(
55+
"main-stage", "com.example", changes("JavaChangeC"));
56+
57+
PreviewPipeline composite = new MetadataLoader.CompositePipelineBuilder()
58+
.buildFrom(Arrays.asList(moduleKapt, moduleJavac));
59+
60+
List<PreviewStage> stages = new ArrayList<>(composite.getStages());
61+
assertEquals(1, stages.size(),
62+
"Two providers contributing the same default stage must yield exactly one stage in the composite");
63+
PreviewStage merged = stages.get(0);
64+
assertEquals("main-stage", merged.getName());
65+
assertEquals(StageType.DEFAULT, merged.getType());
66+
67+
Set<String> changeIds = changeIds(merged);
68+
assertEquals(setOf("KotlinChangeA", "KotlinChangeB", "JavaChangeC"), changeIds,
69+
"Merged stage must contain the id-union of both providers' changes");
70+
}
71+
72+
@Test
73+
@DisplayName("same-name default stages with different sourcesPackage still collapse (first identity wins)")
74+
void defaultStagesWithMismatchedIdentityStillCollapse() {
75+
// The mismatch case: two modules declared a default stage with the same name but
76+
// different sourcesPackage. We still merge — the alternative (keep both stages) was
77+
// worse for any real use case — and first-seen identity wins. The behaviour change
78+
// also emits a WARN at runtime; we don't assert on logging here (no in-module log
79+
// capture infrastructure) and rely on code review for the WARN content.
80+
FlamingockMetadata moduleA = metadataWithDefaultStage(
81+
"shared-name", "com.example.a", changes("A1"));
82+
FlamingockMetadata moduleB = metadataWithDefaultStage(
83+
"shared-name", "com.example.b", changes("B1"));
84+
85+
PreviewPipeline composite = new MetadataLoader.CompositePipelineBuilder()
86+
.buildFrom(Arrays.asList(moduleA, moduleB));
87+
88+
List<PreviewStage> stages = new ArrayList<>(composite.getStages());
89+
assertEquals(1, stages.size(),
90+
"Mismatched-identity same-name stages must still collapse to a single stage");
91+
PreviewStage merged = stages.get(0);
92+
assertEquals("com.example.a", merged.getSourcesPackage(),
93+
"First-seen sourcesPackage must win on identity-mismatch");
94+
assertEquals(setOf("A1", "B1"), changeIds(merged),
95+
"Change sets must still be merged despite the identity mismatch");
96+
}
97+
98+
@Test
99+
@DisplayName("legacy stage collapse behaviour is preserved by the refactor")
100+
void legacyStagesStillCollapse() {
101+
// Regression guard: the legacy-stage collapse used to live in
102+
// mergeSameNameLegacyStages; after generalisation it routes through the same
103+
// mergeSameNameStages path. Verify same-name legacy stages still merge with id-dedup.
104+
FlamingockMetadata m1 = metadataWithStage(StageType.LEGACY,
105+
"flamingock-legacy-stage", null, changes("L1", "L2"));
106+
FlamingockMetadata m2 = metadataWithStage(StageType.LEGACY,
107+
"flamingock-legacy-stage", null, changes("L2", "L3"));
108+
109+
PreviewPipeline composite = new MetadataLoader.CompositePipelineBuilder()
110+
.buildFrom(Arrays.asList(m1, m2));
111+
112+
List<PreviewStage> stages = new ArrayList<>(composite.getStages());
113+
assertEquals(1, stages.size());
114+
PreviewStage merged = stages.get(0);
115+
assertEquals(StageType.LEGACY, merged.getType());
116+
assertEquals(setOf("L1", "L2", "L3"), changeIds(merged),
117+
"Duplicate legacy change ids across modules must be id-deduplicated");
118+
}
119+
120+
@Test
121+
@DisplayName("system stage id-dedup behaviour is preserved (separate code path, regression guard)")
122+
void systemStageDedupUnchanged() {
123+
// The system-stage merger (CompositePipelineBuilder#mergeSystem) is a different path
124+
// from collapseStagesByName and is unchanged by this refactor. Sanity-check it.
125+
SystemPreviewStage sysA = new SystemPreviewStage("system-stage", "desc", null, null,
126+
new ArrayList<>(changes("S1", "S2")));
127+
SystemPreviewStage sysB = new SystemPreviewStage("system-stage", "desc", null, null,
128+
new ArrayList<>(changes("S2", "S3")));
129+
FlamingockMetadata m1 = new FlamingockMetadata();
130+
m1.setPipeline(new PreviewPipeline(sysA, Collections.emptyList()));
131+
FlamingockMetadata m2 = new FlamingockMetadata();
132+
m2.setPipeline(new PreviewPipeline(sysB, Collections.emptyList()));
133+
134+
PreviewPipeline composite = new MetadataLoader.CompositePipelineBuilder()
135+
.buildFrom(Arrays.asList(m1, m2));
136+
137+
PreviewStage system = composite.getSystemStage();
138+
assertNotNull(system, "Composite must carry a system stage when contributors had one");
139+
assertEquals(setOf("S1", "S2", "S3"), changeIds(system),
140+
"Duplicate system-stage change ids across modules must be id-deduplicated");
141+
}
142+
143+
@Test
144+
@DisplayName("default stages with distinct names remain distinct (sanity: not over-collapsing)")
145+
void distinctNameDefaultStagesAreKeptSeparate() {
146+
// Guard against the lazy implementation that would collapse everything into one
147+
// group. Distinct names must stay distinct.
148+
FlamingockMetadata m1 = metadataWithDefaultStage("alpha", "com.alpha", changes("a1"));
149+
FlamingockMetadata m2 = metadataWithDefaultStage("beta", "com.beta", changes("b1"));
150+
151+
PreviewPipeline composite = new MetadataLoader.CompositePipelineBuilder()
152+
.buildFrom(Arrays.asList(m1, m2));
153+
154+
List<PreviewStage> stages = new ArrayList<>(composite.getStages());
155+
assertEquals(2, stages.size(), "Distinct-name stages must remain separate");
156+
Set<String> names = stages.stream().map(PreviewStage::getName).collect(Collectors.toSet());
157+
assertEquals(setOf("alpha", "beta"), names);
158+
}
159+
160+
// ----------------------------------------------------------------------
161+
// Fixture helpers
162+
// ----------------------------------------------------------------------
163+
164+
private static FlamingockMetadata metadataWithDefaultStage(String name,
165+
String sourcesPackage,
166+
List<AbstractPreviewChange> changes) {
167+
return metadataWithStage(StageType.DEFAULT, name, sourcesPackage, changes);
168+
}
169+
170+
private static FlamingockMetadata metadataWithStage(StageType type,
171+
String name,
172+
String sourcesPackage,
173+
List<AbstractPreviewChange> changes) {
174+
PreviewStage stage = new PreviewStage(name, type, null, sourcesPackage, null, changes);
175+
FlamingockMetadata md = new FlamingockMetadata();
176+
md.setPipeline(new PreviewPipeline(Collections.singletonList(stage)));
177+
return md;
178+
}
179+
180+
private static List<AbstractPreviewChange> changes(String... ids) {
181+
List<AbstractPreviewChange> result = new ArrayList<>(ids.length);
182+
for (String id : ids) {
183+
CodePreviewChange change = new CodePreviewChange();
184+
change.setId(id);
185+
result.add(change);
186+
}
187+
return result;
188+
}
189+
190+
private static Set<String> changeIds(PreviewStage stage) {
191+
if (stage.getChanges() == null) return Collections.emptySet();
192+
Set<String> ids = new LinkedHashSet<>();
193+
for (AbstractPreviewChange c : stage.getChanges()) {
194+
ids.add(c.getId());
195+
}
196+
return ids;
197+
}
198+
199+
private static Set<String> setOf(String... values) {
200+
return new LinkedHashSet<>(Arrays.asList(values));
201+
}
202+
}

0 commit comments

Comments
 (0)