Skip to content

Commit 89850fb

Browse files
authored
Reorganize RecipeMarketplace to better support reading CSVs without l… (#6278)
1 parent 0c17381 commit 89850fb

36 files changed

Lines changed: 896 additions & 2072 deletions
Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 6. Recipe Marketplace CSV Format
22

3-
Date: 2025-01-27
3+
Date: 2025-11-14
44

55
## Status
66

@@ -24,24 +24,23 @@ We will use a CSV format for recipe marketplace data with the following structur
2424

2525
### Required Columns
2626

27+
- **`ecosystem`**: The package ecosystem (e.g., `maven`, `npm`, `yaml`)
28+
- **`packageName`**: The package identifier (e.g., `org.openrewrite:rewrite-java`)
2729
- **`name`**: The fully qualified recipe name (e.g., `org.openrewrite.java.cleanup.UnnecessaryParentheses`)
28-
- **`category1, category2, ..., categoryN`**: Zero or more category columns, read **left to right** with **left representing the deepest level category**
2930

30-
### Optional Recipe Columns
31+
### Optional Recipe Metadata Columns
3132

32-
- **`displayName`**: Human-readable recipe display name
33-
- **`description`**: Recipe description
33+
- **`displayName`**: Human-readable recipe display name (defaults to `name` if not specified)
34+
- **`description`**: Recipe description (defaults to empty string if not specified)
35+
- **`estimatedEffortPerOccurrence`**: ISO-8601 duration format (e.g., PT5M, PT1H)
36+
- **`category1, category2, ..., categoryN`**: Zero or more category columns, read **left to right** with **left representing the deepest level category**
3437
- **`option1Name, option1DisplayName, option1Description`**: First recipe option
3538
- **`option2Name, option2DisplayName, option2Description`**: Second recipe option
3639
- **`optionNName, optionNDisplayName, optionNDescription`**: Additional options following the same pattern
3740

3841
### Optional Bundle Columns
3942

40-
Bundle columns describe where a recipe can be installed from. When absent, the marketplace represents a minimal catalog:
41-
42-
- **`ecosystem`**: Package ecosystem (e.g., `Maven`, `npm`, `yaml`)
43-
- **`packageName`**: Package identifier (e.g., `org.openrewrite:rewrite-java`, npm package name)
44-
- **`version`**: Package version
43+
- **`version`**: Package version (optional, allows version-independent recipe catalogs)
4544
- **`team`**: Optional team identifier for marketplace partitioning
4645

4746
### Category Structure
@@ -57,46 +56,43 @@ This creates the hierarchy: `Best Practices > Java > Cleanup > UnnecessaryParent
5756

5857
The displayName of a category corresponds to the value in its category column.
5958

60-
### Epsilon Root
59+
### Root Category
6160

62-
When a CSV contains multiple top-level categories, a synthetic "epsilon root" (`ε`) is created similar to `moderne-organizations-format`. This root:
61+
The `RecipeMarketplace` maintains an internal root category that contains all top-level categories. The root is not exposed in the CSV format but allows the marketplace to support multiple top-level category trees.
6362

64-
- Uses the epsilon character (`\u03B5`) as its display name
65-
- Is identified via `RecipeMarketplace.isRoot()`
66-
- Is never written to CSV output
67-
- Allows the reader to return a single root when multiple disparate category trees exist
63+
### Version-Independent Catalogs
6864

69-
### Minimal vs. Enriched Marketplaces
70-
71-
**Minimal Marketplace**: Contains only recipe metadata (name, categories, options) without bundle information. Useful for describing what recipes exist and their categorization.
72-
73-
**Enriched Marketplace**: Includes bundle columns (ecosystem, packageName, version, team). Created when recipes are "installed" into an environment, combining the minimal catalog with actual bundle provenance.
65+
Since `version` is optional, marketplaces can represent version-independent recipe catalogs that describe what recipes exist and their categorization without tying them to specific package versions. Version information can be added later when recipes are installed or resolved in a specific environment.
7466

7567
### Implementation
7668

69+
- **Data Model**:
70+
- `RecipeBundle`: Simple data class containing ecosystem, packageName, version (optional), and team (optional)
71+
- `RecipeListing`: Represents a recipe with metadata (name, displayName, description, options) and an associated `RecipeBundle`
72+
- `RecipeMarketplace`: Hierarchical structure with nested `Category` instances and a list of `RecipeBundleResolver` instances
73+
7774
- **Reader**: `RecipeMarketplaceReader` (using univocity-parsers)
7875
- Parses CSV into `RecipeMarketplace` hierarchies
79-
- Accepts optional `RecipeBundleLoader` instances via constructor
80-
- Creates bundle instances via loaders when ecosystem, packageName, and version are present
81-
- Creates `RecipeOffering` instances with null bundles when bundle columns are absent or no loader is configured
82-
- Returns epsilon root when multiple top-level categories exist
76+
- Creates `RecipeListing` instances with `RecipeBundle` objects from CSV data
77+
- Requires `ecosystem` and `packageName` columns; `version` and `team` are optional
78+
- Dynamically detects category and option columns
8379

8480
- **Writer**: `RecipeMarketplaceWriter` (using univocity-parsers)
85-
- Dynamically determines required category and option columns
86-
- Filters epsilon root from output
87-
- Only includes bundle columns if at least one recipe has bundle information
88-
89-
- **Bundle Loaders**: Configurable implementations passed to the reader
90-
- `MavenRecipeBundleLoader` in `rewrite-maven`: Creates `MavenRecipeBundle` instances, requires `MavenExecutionContextView` and `MavenArtifactDownloader`
91-
- `NpmRecipeBundleLoader` in `rewrite-javascript`: Creates `NpmRecipeBundle` instances
92-
- `RecipeBundleLoader` interface allows additional ecosystems to be added without modifying core
93-
94-
- **Generator**: `MavenRecipeMarketplaceGenerator` in `rewrite-maven`
95-
- Generates `RecipeMarketplace` from recipe JARs by scanning classpath and extracting recipe metadata
96-
- Automatically determines categories from recipe package names and `CategoryDescriptor` annotations
97-
- Creates bundle information from GAV coordinates
98-
- Distinguishes between YAML-based declarative recipes and Java class-based recipes
99-
- Useful for generating initial CSV files from existing recipe JARs
81+
- Dynamically determines required category and option columns based on marketplace content
82+
- Always includes `ecosystem` and `packageName` columns
83+
- Only includes `version` column if at least one recipe has version information
84+
- Only includes `team` column if at least one recipe has team information
85+
86+
- **Bundle Resolution**: Two-phase resolution system
87+
- `RecipeBundleResolver`: Interface with `getEcosystem()` and `resolve(RecipeBundle)` methods
88+
- Ecosystem-specific resolvers are registered with the `RecipeMarketplace`
89+
- Examples: `MavenRecipeBundleResolver` in `rewrite-maven`, `NpmRecipeBundleResolver` in `rewrite-javascript`
90+
- `RecipeBundleReader`: Interface returned by resolvers with methods:
91+
- `getBundle()`: Returns the associated `RecipeBundle`
92+
- `read()`: Reads the bundle and returns a `RecipeMarketplace`
93+
- `describe(RecipeListing)`: Returns a `RecipeDescriptor` for a listing
94+
- `prepare(RecipeListing, Map<String, Object>)`: Creates a configured `Recipe` instance
95+
- `RecipeListing.resolve()`: Convenience method that finds the appropriate resolver and returns a `RecipeBundleReader`
10096

10197
- **Validators**: Tools for ensuring marketplace quality and completeness
10298
- `RecipeMarketplaceContentValidator` in `rewrite-core`: Validates content formatting rules
@@ -118,56 +114,63 @@ When a CSV contains multiple top-level categories, a synthetic "epsilon root" (`
118114

119115
1. **Human-editable**: CSV files can be created and modified in spreadsheet tools or text editors
120116
2. **Flexible schema**: Dynamic column detection accommodates varying numbers of categories and options
121-
3. **Separation of concerns**: Minimal marketplaces can describe recipes independently of their installation source
117+
3. **Version-independent catalogs**: Optional version column allows describing recipes independently of specific package versions
122118
4. **Composable**: Multiple marketplace CSVs can be combined by merging rows
123119
5. **Round-trip compatible**: Reader and writer preserve all information
124120
6. **Familiar pattern**: Mirrors `moderne-organizations-format` conventions, reducing learning curve
125-
7. **Extensible bundle loading**: RecipeBundleLoader interface allows new package ecosystems to be added without modifying core modules
126-
8. **Automated generation**: `MavenRecipeMarketplaceGenerator` can automatically create CSV files from recipe JARs
127-
9. **Quality assurance**: Validators ensure content quality (formatting) and completeness (CSV ↔ JAR synchronization)
121+
7. **Extensible bundle resolution**: Two-phase resolution (RecipeBundleResolver → RecipeBundleReader) allows new package ecosystems to be added without modifying core modules
122+
8. **Quality assurance**: Validators ensure content quality (formatting) and completeness (CSV ↔ JAR synchronization)
123+
9. **Lazy resolution**: RecipeListing stores bundle metadata but only resolves to actual Recipe instances when needed via resolve()
128124

129125
### Negative
130126

131127
1. **CSV limitations**: No native support for nested structures (mitigated by column naming conventions)
132128
2. **Sparse data**: Recipes with few options result in many empty cells in CSVs with high option counts
133-
3. **Manual maintenance**: Keeping bundle information synchronized with actual artifact versions requires tooling (though `MavenRecipeMarketplaceGenerator` and validators help address this)
129+
3. **Always requires bundle metadata**: Unlike earlier designs, ecosystem and packageName are always required, even for basic recipe catalogs
134130

135131
### Trade-offs
136132

137133
- **Left-to-right category ordering** (left = deepest): This matches the `moderne-organizations-format` convention but may be counterintuitive to some users who expect left-to-right to represent root-to-leaf
138-
- **Null bundles for minimal marketplaces**: Simplifies the model but means `RecipeOffering.describe()` and `prepare()` throw exceptions until bundles are associated
134+
- **Two-phase resolution**: Separating RecipeBundleResolver and RecipeBundleReader provides flexibility but adds complexity compared to a single interface
139135
- **Dynamic columns**: Provides flexibility but means schema varies between files, making generic CSV processing tools less effective
140-
- **Bundle loader configuration**: Bundle loaders require runtime dependencies (e.g., MavenExecutionContextView) that must be provided when constructing the loader and passed to RecipeMarketplaceReader constructor
136+
- **Resolver configuration**: RecipeBundleResolvers must be registered with the RecipeMarketplace instance before calling RecipeListing.resolve(), describe(), or prepare()
141137

142138
## Examples
143139

144-
### Minimal Marketplace
140+
### Basic Recipe Catalog (No Version)
145141

146142
```csv
147-
name,displayName,description,category
148-
org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses,Java Cleanup
143+
ecosystem,packageName,name,displayName,description,category1
144+
maven,org.openrewrite:rewrite-java,org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses.,Java Cleanup
149145
```
150146

151-
### With Options
147+
### With Version Information
152148

153149
```csv
154-
name,displayName,option1Name,option1DisplayName,option1Description,option2Name,option2DisplayName,option2Description,category
155-
org.openrewrite.maven.UpgradeDependencyVersion,Upgrade Dependency,groupId,Group ID,The group ID,artifactId,Artifact ID,The artifact ID,Maven
150+
ecosystem,packageName,version,name,displayName,description,category1
151+
maven,org.openrewrite:rewrite-java,8.0.0,org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses.,Java Cleanup
156152
```
157153

158-
### Enriched with Bundle Information
154+
### With Recipe Options
159155

160156
```csv
161-
name,displayName,category,ecosystem,packageName,version,team
162-
org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Java Cleanup,Maven,org.openrewrite:rewrite-java,8.0.0,java-team
157+
ecosystem,packageName,name,displayName,description,category1,option1Name,option1DisplayName,option1Description,option2Name,option2DisplayName,option2Description
158+
maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.UpgradeDependencyVersion,Upgrade Dependency,Upgrades a Maven dependency.,Maven,groupId,Group ID,The group ID.,artifactId,Artifact ID,The artifact ID.
159+
```
160+
161+
### With Team Partitioning
162+
163+
```csv
164+
ecosystem,packageName,version,name,displayName,description,category1,team
165+
maven,org.openrewrite:rewrite-java,8.0.0,org.openrewrite.java.cleanup.UnnecessaryParentheses,Remove Unnecessary Parentheses,Removes unnecessary parentheses.,Java Cleanup,java-team
163166
```
164167

165168
### Multi-level Categories
166169

167170
```csv
168-
name,category1,category2,category3
169-
org.openrewrite.java.cleanup.UnnecessaryParentheses,Cleanup,Java,Best Practices
170-
org.openrewrite.java.format.AutoFormat,Formatting,Java,Best Practices
171+
ecosystem,packageName,name,category1,category2,category3
172+
maven,org.openrewrite:rewrite-java,org.openrewrite.java.cleanup.UnnecessaryParentheses,Cleanup,Java,Best Practices
173+
maven,org.openrewrite:rewrite-java,org.openrewrite.java.format.AutoFormat,Formatting,Java,Best Practices
171174
```
172175

173176
Creates: `Best Practices > Java > Cleanup > UnnecessaryParentheses` and `Best Practices > Java > Formatting > AutoFormat`

rewrite-core/src/main/java/org/openrewrite/Recipe.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,16 @@ protected RecipeDescriptor createRecipeDescriptor() {
225225
}
226226
recipeList1.trimToSize();
227227

228+
URI recipeSource;
229+
try {
230+
recipeSource = getClass().getProtectionDomain().getCodeSource().getLocation().toURI();
231+
} catch (URISyntaxException e) {
232+
throw new RuntimeException(e);
233+
}
234+
228235
return new RecipeDescriptor(getName(), getDisplayName(), getInstanceName(), getDescription(), getTags(),
229236
getEstimatedEffortPerOccurrence(), options, recipeList1, getDataTableDescriptors(),
230-
getMaintainers(), getContributors(), getExamples(), null);
237+
getMaintainers(), getContributors(), getExamples(), recipeSource);
231238
}
232239

233240
private List<OptionDescriptor> getOptionDescriptors() {
@@ -247,7 +254,6 @@ private List<OptionDescriptor> getOptionDescriptors() {
247254
value = null;
248255
}
249256
Option option = field.getAnnotation(Option.class);
250-
//noinspection ConstantValue
251257
if (option != null) {
252258
options.add(new OptionDescriptor(field.getName(),
253259
field.getType().getSimpleName(),
@@ -263,7 +269,6 @@ private List<OptionDescriptor> getOptionDescriptors() {
263269
for (Method method : recipe.getClass().getDeclaredMethods()) {
264270
if (method.getName().startsWith("get") && method.getParameterCount() == 0) {
265271
Option option = method.getAnnotation(Option.class);
266-
//noinspection ConstantValue
267272
if (option != null) {
268273
options.add(new OptionDescriptor(StringUtils.uncapitalize(method.getName().substring(3)),
269274
method.getReturnType().getSimpleName(),
@@ -280,7 +285,6 @@ private List<OptionDescriptor> getOptionDescriptors() {
280285
Constructor<?> c = RecipeIntrospectionUtils.getPrimaryConstructor(getClass());
281286
for (Parameter parameter : c.getParameters()) {
282287
Option option = parameter.getAnnotation(Option.class);
283-
//noinspection ConstantValue
284288
if (option != null) {
285289
options.add(new OptionDescriptor(parameter.getName(),
286290
parameter.getType().getSimpleName(),
@@ -504,9 +508,9 @@ public int hashCode() {
504508
}
505509

506510
@Override
507-
public Object clone() {
511+
public Recipe clone() {
508512
try {
509-
return super.clone();
513+
return (Recipe) super.clone();
510514
} catch (CloneNotSupportedException e) {
511515
throw new RuntimeException(e);
512516
}

rewrite-core/src/main/java/org/openrewrite/ScanningRecipe.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
*
3434
* @param <T> The type of the accumulator where scanning data is held until the transformation phase.
3535
*/
36+
@SuppressWarnings("ALL")
3637
public abstract class ScanningRecipe<T> extends Recipe {
3738
@Nullable
3839
private String recipeAccMessage;
@@ -128,4 +129,11 @@ public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) {
128129
}
129130
};
130131
}
132+
133+
@Override
134+
public ScanningRecipe<T> clone() {
135+
ScanningRecipe<T> cloned = (ScanningRecipe<T>) super.clone();
136+
cloned.recipeAccMessage = "org.openrewrite.recipe.acc." + UUID.randomUUID();
137+
return cloned;
138+
}
131139
}

rewrite-core/src/main/java/org/openrewrite/config/DeclarativeRecipe.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ public void initialize(Collection<Recipe> availableRecipes) {
9999
}
100100

101101
@Deprecated
102-
public void initialize(Collection<Recipe> availableRecipes, Map<String, List<Contributor>> recipeToContributors) {
102+
public void initialize(Collection<Recipe> availableRecipes,
103+
@SuppressWarnings("unused") Map<String, List<Contributor>> recipeToContributors) {
103104
this.initialize(availableRecipes);
104105
}
105106

@@ -109,9 +110,9 @@ public void initialize(Function<String, @Nullable Recipe> availableRecipes) {
109110
initialize(uninitializedPreconditions, preconditions, availableRecipes, initializingRecipes);
110111
}
111112

112-
@SuppressWarnings("unused")
113113
@Deprecated
114-
public void initialize(Function<String, @Nullable Recipe> availableRecipes, Map<String, List<Contributor>> recipeToContributors) {
114+
public void initialize(Function<String, @Nullable Recipe> availableRecipes,
115+
@SuppressWarnings("unused") Map<String, List<Contributor>> recipeToContributors) {
115116
this.initialize(availableRecipes);
116117
}
117118

@@ -145,7 +146,7 @@ private void initialize(List<Recipe> uninitialized, List<Recipe> initialized, Fu
145146
}
146147

147148
private void initializeDeclarativeRecipe(DeclarativeRecipe declarativeRecipe, String recipeIdentifier,
148-
Function<String, @Nullable Recipe> availableRecipes, Set<String> initializingRecipes) {
149+
Function<String, @Nullable Recipe> availableRecipes, Set<String> initializingRecipes) {
149150
String recipeName = declarativeRecipe.getName();
150151
if (initializingRecipes.contains(recipeName)) {
151152
// Cycle detected - throw exception to fail fast
@@ -473,7 +474,7 @@ protected RecipeDescriptor createRecipeDescriptor() {
473474
return new RecipeDescriptor(getName(), getDisplayName(), getInstanceName(), getDescription() != null ? getDescription() : "",
474475
getTags(), getEstimatedEffortPerOccurrence(),
475476
emptyList(), recipeList, getDataTableDescriptors(), getMaintainers(), getContributors(),
476-
getExamples(), null);
477+
getExamples(), source);
477478
}
478479

479480
@Override

rewrite-core/src/main/java/org/openrewrite/config/Environment.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ public Recipe activateRecipes(Collection<String> activeRecipes) {
156156
}
157157
}
158158
if (!recipesNotFound.isEmpty()) {
159-
@SuppressWarnings("deprecation")
160159
List<String> suggestions = recipesNotFound.stream()
161160
.map(r -> recipesByName.keySet().stream()
162161
.min(comparingInt(a -> LevenshteinDistance.getDefaultInstance().apply(a, r)))

rewrite-core/src/main/java/org/openrewrite/config/OptionDescriptor.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,22 @@
1919
import lombok.Value;
2020
import org.jspecify.annotations.Nullable;
2121
import org.openrewrite.NlsRewrite;
22-
import org.openrewrite.marketplace.RecipeListing;
2322

2423
import java.util.List;
2524

2625
@Value
2726
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
28-
public class OptionDescriptor implements RecipeListing.Option {
27+
public class OptionDescriptor {
2928

3029
@EqualsAndHashCode.Include
3130
String name;
3231

3332
@EqualsAndHashCode.Include
3433
String type;
3534

36-
@Nullable
3735
@NlsRewrite.DisplayName
3836
String displayName;
3937

40-
@Nullable
4138
@NlsRewrite.Description
4239
String description;
4340

0 commit comments

Comments
 (0)