Provenance. Generated 2026-05-03 by a Plan-type subagent (description: "OpenRewrite audit + JBang template plan") in session 7f36eae0-dc97-4699-a95f-27791031f4bd. The agent transcript is the original source; this file is its persisted form. Status markers ([ok] / [minor] / [missing] / [add]) replaced the agent's emoji column to comply with the project's no-emojis-in-docs rule.
Part B11.2 has shipped — see the Part B status section below. Part A findings have moved since the audit was written. Treat the inline statuses below as snapshots from 2026-05-03; the table here is the live view.
| Step | Status |
|---|---|
B11.1 — template/ payload + tests/ci-smoke.sh |
shipped |
B11.2 — JBang Init subcommand at jbang/RecipeScaffold.java |
shipped 2026-05-04 |
B11.2a — Repo-root CI (.github/workflows/ci.yml) running both bash and JBang flows |
shipped 2026-05-04 |
B11.2b — Template additions: LICENSE, AGENTS.md, .editorconfig, release.yml, wrapper-validation.yml, dependabot.yml |
shipped 2026-05-04 |
B11.3 — add-recipe <name> subcommand + template/snippets/*.template |
queued |
B11.4 — verify-gates subcommand |
queued |
B11.5+ — bump-versions, release, --upgrade-skills |
parked |
See BACKLOG.md for the full Queued/Parked breakdown.
| Finding | Then | Now |
|---|---|---|
A1 — Lombok @Value on recipes |
minor | shipped 0.8 |
A2 — Preconditions.check wrappers |
missing | shipped (all 5 leaf recipes) |
| A3 — Recipe ID / package consistency | minor | open, deferred to next major |
A4 — getTags() + getEstimatedEffortPerOccurrence() |
add | shipped 0.8 |
A5 — YAML tags: on composed recipes |
add | shipped 0.8 |
A6 — @Option nullability |
minor | closed: leave as-is |
A7 — @NullMarked |
ok | closed |
A8 — JavaIsoVisitor choice |
ok | closed |
A9 — JavaTemplate imports |
ok | closed |
A10 — MethodMatcher for SystemOut |
ok-ish | open: still string-compares select.toString(); convert when convenient |
A11 — JavaSourceSet marker |
ok | closed |
A12 — ScanningRecipe boolean accumulator |
ok | open observation; revisit if smoke surfaces a multi-build leak |
A13 — TypeValidation.none() scope |
ok | open cosmetic; narrow to afterTypeValidationOptions where possible |
A14 — recipe-library-base plugin |
minor | open — tracked as BACKLOG A14 (extract our own convention plugin) |
| A15 — Java release target | ok | closed |
| A16 — CI workflow shape | minor | partially shipped (CI runs check smokeTest); publish-on-tag workflow still queued under BACKLOG "Move publishAndReleaseToMavenCentral into CI" |
A17 — @RecipeDescriptor / Refaster |
ok | closed N/A |
A18 — recipes.csv + community-recipes PR |
add | open — visibility win, no recipes.csv yet |
| A19 — Idempotence | ok | closed |
A20 — JavaTemplate.builder shape |
ok | closed |
A21 — Hand-built LST in UseCatalogReferenceForDependency |
minor | closed with documented justification |
| A22 — BOM version pinning | ok | open — TOML still pins individual 8.79.6 entries the BOM should align |
Active items worth carrying into the next release cycle: A10, A14, A16 (publish workflow), A18, A22. Part B (the JBang scaffolder design) is unchanged — the scaffold surface only grew.
Notes on methodology: I cross-checked findings against (1) the official moderneinc/rewrite-recipe-starter build.gradle.kts and CI, (2) docs.openrewrite.org recipe-authoring pages, (3) the 0002-recipe-naming ADR, (4) two real recipes from openrewrite/rewrite-logging-frameworks (SystemPrintToLogging, Slf4jLogShouldBeConstant), and (5) the recipe-writing-lessons.md from rewrite-static-analysis.
- Status: [minor] mildly nonstandard
- What we do: Every recipe (
SystemOutToSlf4j.java:73-82,AddLombokSlf4jAnnotation.java:77-86,JulToSlf4j.java:114-123,PrintStackTraceToLog.java:65-74,ConvertManualLoggerToSlf4j.java:88-97,UseCatalogReferenceForDependency.java:76-85,AddVersionCatalogEntry.java:76-88) hand-rollsequals,hashCode, plus@SuppressWarnings("unused") public boolean isXxx()getters. No Lombok in the project at all. - Standard: The official starter uses
@Value @EqualsAndHashCode(callSuper = false)— seeSystemPrintToLogging.javainrewrite-logging-frameworksand therecipeequalsandhashcodecallsuperrecipe that flips authors'callSuper = truetofalse. Docs explicitly say "Recipes are value objects, so should use@EqualsAndHashCode(callSuper = false)" and "It is typical, but not required, that recipes use@lombok.Value." - Recommendation: Adding Lombok as
compileOnly+annotationProcessorwould delete ~15 lines per recipe (×7 recipes). The current code is correct, just verbose. Worth doing now while the surface is small. Use@Value+@EqualsAndHashCode(callSuper = false). Caveat: the project ships a Lombok-migration recipe — having Lombok in the recipe project itself is fine, but worth a one-line README note that this is intentional and not self-referential.
- Status: [missing] missing — this is the single biggest deviation
- What we do: Every leaf recipe returns a bare
JavaIsoVisitorwith no precondition wrapper. Class-presence checks happen inline insideLombokClasspathGate.isAvailable(getCursor())per node visit. - Standard:
Slf4jLogShouldBeConstantshows the canonical shape:return Preconditions.check(new UsesMethod<>(SLF4J_LOG), new JavaVisitor<…>() {...}). The conventions doc calls this out for performance: "Preconditions benefit recipe execution performance when they efficiently prevent unnecessary execution of a more computationally expensive visitor." - Recommendation: Wrap visitors in
Preconditions.check(...):SystemOutToSlf4j→Preconditions.check(Preconditions.or(new UsesMethod<>("java.io.PrintStream println(..)"), new UsesMethod<>("java.io.PrintStream print(..)"), new UsesMethod<>("java.io.PrintStream printf(..)")), …)JulToSlf4j→Preconditions.check(new UsesType<>("java.util.logging.Logger", false), …)PrintStackTraceToLog→Preconditions.check(new UsesMethod<>(PRINT_STACK_TRACE), …)ConvertManualLoggerToSlf4j→Preconditions.check(new UsesType<>("org.apache.logging.log4j.Logger", false), …)AddLombokSlf4jAnnotation→Preconditions.orover the three triggers.
- The Lombok-classpath gate is a separate concern (cursor-time check on a per-CU marker) and is correct; precondition wrapping is in addition, not instead of.
- Status: [minor] mildly nonstandard — but defensible
- What we do: Recipe IDs in
system-out-to-lombok.ymlareio.github.fiftieshousewife.SystemOutToSlf4jRecipeetc. Java recipe FQNs areio.github.fiftieshousewife.recipes.X. - Standard: ADR-0002 says "DO start every OpenRewrite recipe package with
org.openrewrite.<LANGUAGE>". That's the rule for OpenRewrite-org-internal recipes; community recipes universally use their own group (e.g.io.moderne.…,tech.picnic.…). What's worth flagging: the YAML composed-recipe IDs drop the.recipessegment (io.github.fiftieshousewife.SystemOutToSlf4jRecipe) while the Java leaf-recipe FQNs keep it (io.github.fiftieshousewife.recipes.SystemOutToSlf4j). Both forms are valid; the inconsistency is purely cosmetic. - Recommendation: Leave package as-is. Consider standardising YAML IDs to also live under
.recipes—io.github.fiftieshousewife.recipes.SystemOutToSlf4jRecipe— but this is breaking for downstream users so do at the next major bump only. Document the choice in CLAUDE.md if you don't change it.
- Status: [add] missing-but-could-add
- What we do: No recipe overrides either.
- Standard:
Slf4jLogShouldBeConstantincludesSet<String> tags = new HashSet<>(Arrays.asList("logging", "slf4j")). The static-analysis recipe-writing-lessons doc says "include RSPEC identifier ingetTags()".setdefaultestimatedeffortperoccurrenceis a real recipe that flags missing values. - Recommendation: Add
getTags()returning["logging", "lombok", "slf4j", "log4j"](subset per recipe) andgetEstimatedEffortPerOccurrence()returning aDuration.ofMinutes(N)estimate (5 for the leaf transforms is typical). Both can land on the YAML composed recipes too. Cheap discoverability win on docs.openrewrite.org if you ever submit to community-recipes.
- Status: [add] missing-but-could-add
- What we do:
system-out-to-lombok.ymlhasname/displayName/description/recipeListonly. - Standard: Real composed recipes (e.g.
slf4j.ymlinrewrite-logging-frameworks) addtags:and sometimespreconditions:. Both are optional but recommended. - Recommendation: Add
tags: [logging, lombok, slf4j, log4j]to each top-level composed recipe.preconditions:blocks (e.g.org.openrewrite.java.search.UsesType: java.io.PrintStream) are nice but lower priority — your composed recipes are user-invoked, so the cost of a no-op cycle is trivial.estimatedEffortPerOccurrenceis more about leaf recipes.
- Status: [minor] mildly nonstandard
- What we do: Boolean options (
requireLombokOnClasspath) are declared as primitivebooleanwithrequired = falseand a default offalsevia overloaded zero-arg constructor. - Standard:
SystemPrintToLogginguses@Nullable Boolean addLogger— boxed type, nullable, no overloaded constructor. This makes "unset" distinguishable from "explicitly false," which YAML-composed recipes can rely on. - Recommendation: Leave as-is. Your semantics genuinely are boolean — there's no third "unset" state to distinguish. The overloaded constructor is fine. If you ever add an option where "unset means default-from-environment," migrate to boxed-nullable.
- Status: [ok] standard, slightly ahead of curve
- What we do:
@NullMarkedfrom JSpecify on every recipe class. - Standard: Starter declares
parserClasspath("org.jspecify:jspecify:1.0.0");Log4jToSlf4j-family recipes use@Nullablefrom JSpecify selectively but not blanket@NullMarked. Your blanket-mark approach is more conservative. - Recommendation: Keep. It's strictly more rigorous than upstream.
- Status: [ok] standard
- What we do: All your visitors use
JavaIsoVisitorbecause your transforms return the same-type tree. - Standard:
recipe-writing-lessons.md: "UseJavaIsoVisitorwhen returning the same LST element type; useJavaVisitorwhen transforming to different types."Slf4jLogShouldBeConstantusesJavaVisitorbecause it sometimes returns a different node fromvisitMethodInvocation. - Recommendation: No change.
- Status: [ok] standard
- What we do: Use
imports(SLF4J.fqn())correctly inAddLombokSlf4jAnnotationandConvertManualLoggerToSlf4jpaired withmaybeAddImport. Don't pass a customjavaParser(...)— relying on context is fine becauselogand Lombok-generated symbols are resolved at runtime. - Standard: Lessons doc: "Always declare imports when templates introduce types." Custom
javaParser(...)is for when the template references a type the test parser can't see; you handle that withTypeValidation.none()instead, which is also acceptable. - Recommendation: No change. No
staticImportsopportunities visible — Lombok-generatedlogis a field, not a static import.
- Status: [ok] standard
- What we do:
private static finalmatchers at class top, fully-qualified signatures:new MethodMatcher("java.lang.Throwable printStackTrace(..)"), the JUL family inJulToSlf4j. - Standard: Identical pattern in
Slf4jLogShouldBeConstant. - Recommendation: One miss —
SystemOutToSlf4j.isSystemOutOrErr(...)does string-comparison onselect.toString()rather than usingMethodMatcheragainstjava.io.PrintStream println(..). A realMethodMatcherwould catch overloads correctly and would let you wire it as aUsesMethodprecondition (see A2). Consider switching.
- Status: [ok] standard
- What we do:
LombokClasspathGatereadsJavaSourceSetfrom compilation-unit markers and looks forlombok.extern.slf4j.Slf4jon the classpath. - Standard: This is the documented mechanism. Multi-module awareness via
JavaProjectis the alternative when state needs aggregating; you don't need that. - Recommendation: No change.
- Status: [ok] standard, well-applied
- What we do: Two-phase scan/visit — scan checks for
libs.versions.toml, visit phase no-ops (TreeVisitor.noop()) when absent. - Standard: Canonical
ScanningRecipe<Acc>shape. Conventions doc: "UseScanningRecipeaccumulators for cross-visitor data." - Recommendation: One observation —
Accumulator.catalogFoundis aboolean. The conventions doc warns "avoid boolean fields that should be per-project maps" for multi-module. In a multi-module Gradle build with one catalog at the root, your boolean is correct; but if a downstream user runs this on a workspace with multiple independent Gradle builds (composite or not), the boolean can leak. Worth adding aMap<JavaProject, Boolean>if a smoke test ever surfaces this.
- Status: [ok] standard escape hatch, used appropriately
- What we do:
SystemOutToSlf4jTest.java:18setsTypeValidation.none()because Lombok-generatedlogfield can't be resolved by the test parser. - Standard: FAQ: "If you're unable to resolve the missing types issue, you can disable the type validation through
RecipeSpec.afterTypeValidationOptionsorRecipeSpec.typeValidationOptions." RecommendsafterTypeValidationOptionsfor cases where only the post-recipe tree has unresolved types. - Recommendation: Several of your tests use the broader
typeValidationOptions(TypeValidation.none())(both before and after). Where the before-source has@Slf4jalready declared and parses cleanly, you can narrow toafterTypeValidationOptions(TypeValidation.none()). Cosmetic; not a bug.
A14. Build plugin choice: vanniktech/gradle-maven-publish-plugin vs org.openrewrite.build.recipe-library-base
- Status: [minor] mildly nonstandard (defensible)
- What we do:
com.vanniktech.maven.publish:0.36.0for Maven Central, plusgradle-versions-plugin,org.openrewrite.rewritefor self-test, and your own clean-code plugin. No OpenRewrite build plugins. - Standard: Starter applies
org.openrewrite.build.recipe-library-base,org.openrewrite.build.publish(Moderne Nexus),nebula.release,org.openrewrite.build.recipe-repositories. The recipe-library plugin sets compile target to Java 1.8 (per docs), wiresrecipeDependencies { parserClasspath(...) }, enablescreateTypeTableanddownloadRecipeDependenciestasks, and embeds arecipes.csvfor catalog discovery. - Recommendation: Two separable concerns:
- Java target: starter targets Java 1.8 to be runnable on widest user JDK; you target Java 17. That's a deliberate choice and is fine — JDK 8 is rare in 2026 and your README can state the floor. Leave at 17.
recipe-library-baseplugin: Worth adopting if you want (a)createTypeTable(precomputes parser classpaths for faster runtime), (b)recipeDependencies { parserClasspath(...) }(declarative classpath forJavaTemplateresolution at recipe-author time), (c) auto-generatedrecipes.csvfor marketplace discovery. None are required for Maven Central. The vanniktech plugin you use is the modern best-of-breed for direct Central publishing, andrecipe-library-basedoesn't replace it (the official starter pairsrecipe-library-basewith thepublishplugin and Nexus). My read: keep vanniktech, but adoptrecipe-library-basealongside it for the type-table + recipes.csv benefits. If they conflict (untested), treat the type-table as a "nice to have, skip it."
- Status: [ok] standard for community recipes
- What we do:
release=17for production code; tests at 25. - Standard: Starter sets toolchain to JDK 25 with no explicit
release, implying "newest."recipe-library-baseper docs targets JDK 1.8. - Recommendation: 17 is right. JDK 8 floor is overkill for a 2026 recipe targeting Lombok consumers (most of whom are on 17+ already).
- Status: [minor] mildly nonstandard
- What we do:
.github/workflows/gradle.ymlruns./gradlew buildon push to main + PRs, sets up JDK 21+25. - Standard: Starter CI runs
./gradlew build testplusmvn verify. Usesactions/setup-gradle@v5, JDK 25 only. - Recommendation: Worth adding
./gradlew check(your version pulls inintegrationTest+ JaCoCo + SpotBugs — the actual quality gate). Consider./gradlew dependencyUpdatesas a non-blocking informational step. You're missing a separate publishing workflow onv*tag push that runspublishAndReleaseToMavenCentral— currently this is operator-driven from local. Not a deviation per se but a gap given the smoke-gate is structural.
- Status: [ok] standard not-applicable
- What we do: Imperative recipes only.
- Standard:
rewrite-templatingenables@BeforeTemplate/@AfterTemplateRefaster-style. Pure convenience — generates Recipe classes from before/after method pairs. Best for "this method call → that method call" transforms with no surrounding context. - Recommendation: Refaster wouldn't help you. Your transforms are statement-level with structural changes (annotation insertion, field removal, import removal); Refaster is bad at those. Skip.
- Status: [add] missing
- What we do: No
recipes.csv, no community-recipes listing. - Standard: Per Moderne docs, "Most OpenRewrite recipe modules now include an embedded recipes.csv file" (auto-generated by the recipe-library plugin). Community-recipes page is manual PR-submission to docs.openrewrite.org. There's no "marketplace registry" you have to register with — discoverability is (a) Maven Central existence, (b) PR to community-recipes docs, (c) optional
recipes.csvfor tooling. - Recommendation: Submit a PR to https://github.com/openrewrite/rewrite-website to add yourself to community-recipes once 0.7 has settled. Adopt
recipe-library-basefor the auto-generatedrecipes.csv(see A14). Both cheap, both visibility wins.
- Status: [ok] standard
- What we do: All visitors are stateless; recipes are constructor-only; LST writes go through
with*builders. - Standard: Conventions doc — "A Recipe should never mutate a field on an LST." "Recipes receiving identical LST and configuration must produce identical results."
- Recommendation: One yellow flag —
SystemOutDetectorandJulCallDetectorinAddLombokSlf4jAnnotationare inner visitors with mutableboolean foundfields. They're scoped per-class-declaration and discarded immediately, so observably stateless. Pattern is fine. (If you want to be pedantic, replace withCursormessaging or stream-based detection.)
- Status: [ok] standard
- What we do:
JavaTemplate.builder(template).imports(...).build().apply(getCursor(), ...)everywhere. - Standard: Same pattern in
Slf4jLogShouldBeConstant. The.contextSensitive()modifier exists for templates that depend on lexical scope; you don't need it. - Recommendation: No change.
- Status: [minor] mildly nonstandard, with documented justification
- What we do: Build
J.FieldAccessandJ.Identifierby hand to producelibs.lombokbecauseJavaTemplatestruggles with bare expression fragments. - Standard: Conventions doc: "Avoid hand-constructed LSTs. Use
JavaTemplateor format-specific parsers instead." - Recommendation: This is a known sharp edge —
JavaTemplatecan't produce a field-access expression in argument position cleanly. Your skill file (new-recipe/SKILL.mdline 78) acknowledges this. Leave the hand-built tree, keep the comment in the skill explaining why. If a future OpenRewrite version adds expression-only template support, migrate.
- Status: [ok] standard
- What we do:
implementation(platform(libs.openrewrite.recipe.bom))then unversionedimplementation(libs.openrewrite.java)etc — but your TOML pins individual artifacts to8.79.6instead of letting the BOM manage them. - Standard: Starter uses
latest.releaseeverywhere. The BOM's job is to align versions; pinning individual versions in your TOML duplicates that role and can drift. - Recommendation: In
libs.versions.toml, dropversion.ref = "openrewrite-core"from the rewrite-* entries that the BOM manages, since the BOM already aligns them. Keep the BOM version pin. This will also makedependencyUpdatesquieter.
Reusable scaffold (template these, parameterize {group}, {artifact}, {rootPackage}, {javaTarget}, {rewriteVersion}):
build.gradle.ktsskeleton — plugin block, BOM dep, source-set wiring forintegrationTest+smokeTest, the JDK-21-launcher pinning logic, thepublishAndReleaseToMavenCentralsmoke gate, mavenPublishing pom block.gradle/libs.versions.tomlskeleton — version table and[libraries]block for the rewrite-* family, JUnit, AssertJ, JSpecify.gradle.properties(literal, no parameters).gradlew,gradlew.bat,gradle/wrapper/*(literal).settings.gradle.kts(rootProject.name = "{artifact}")..gitignore(literal — Gradle/IntelliJ/macOS)..github/workflows/gradle.ymland a newrelease.ymlfor tag-triggered publish.CLAUDE.mdskeleton — project-structure section, publication-workflow numbered list, coding-standards bullets. Needs{rootPackage}substitution.README.mdskeleton — title, install snippet, "Recipes" placeholder section, "Supported project shapes" placeholder.SMOKE_TEST.mdskeleton — §1/§2/§3 structure with placeholder cells.BACKLOG.mdskeleton — Shipped/Queued/Active/Parked headings..claude/skills/{new-gradle-project,new-recipe,recipe-testing,smoke-test}/SKILL.md— verbatim copies; the skills are project-agnostic.src/smokeTest/java/{rootPackage}/smoketest/*—Fixture.java,GradleRunner.java,ProjectShapeScaffolder.java,ProjectShapeSmokeTest.java,ProjectShapeVariant.java,RecipeResolutionSmokeTest.java,SmokeProject.java,SmokeTest.java,SmokeTestConfig.java,SmokeVariant.java. Need{rootPackage}substitution and the per-recipecell(...)matrix entries blanked or replaced with one example cell.src/integrationTest/java/{rootPackage}/recipes/— empty directory plus a sample integration test stub.src/test/java/{rootPackage}/recipes/matrix/MatrixTestSupport.java— generic Gradle marker helper.
Recipe-content-specific (NOT templated — these are the user's payload):
- The 14 recipe classes under
src/main/java/{rootPackage}/recipes/. src/main/resources/META-INF/rewrite/{name}.yml— per-project YAML compositions.- All
*Test.javaand*MethodTest.javafiles (one set per recipe). - The smoke-test cell matrix entries (the
cell(...)lines insideSmokeTest.matrix()andProjectShapeSmokeTest.matrix()). LoggerNames.java,LombokLoggingAnnotation.java,LombokClasspathGate.java, etc — these are domain-specific helpers.
The runner infrastructure (SmokeProject, GradleRunner, ProjectShapeScaffolder) is reusable; the matrix data is not. So the template ships the runner with one trivial example cell that the user replaces.
Recommendation: single JBang script, templates pulled from a separate Git repo at a //DEPS-pinned tag, NOT embedded heredocs.
Rationale:
- Embedding ~30 file templates as Java string literals turns the script into a 2,000-line mess and makes upgrades painful.
- JBang scripts can fetch resources at runtime via plain
HttpClient/URLcalls; Git tags give you reproducible "scaffold the v0.7-shaped project" semantics. - The official JBang
init --templatemechanism uses GitHub-hosted catalog templates — same model.
Layout: one script file recipescaffold.java with @Command(subcommands = {Init.class, AddRecipe.class, BumpVersions.class, VerifyGates.class}). JBang //DEPS line for picocli + the template-repo coordinate. The script downloads a release tarball of the template repo at the pinned tag, expands it into the target dir, runs string substitution on each file ({group}, {artifact}, {rootPackage}, {rewriteVersion}, {javaTarget}, {authorName}, {authorEmail}, {githubOrg}, {githubRepo}).
Subcommands (decreasing priority):
init— required. Args:--group,--artifact,--package,--author,--github-org,--github-repo,--java-target=17,--rewrite-version=8.79.6,--directory=.. Scaffolds a fresh project. Highest leverage.add-recipe <name>— high value. Args:--name,--type=java|yaml|scanning|toml,--with-tests. Drops a recipe class skeleton + test skeleton insrc/main/java/.../recipes/andsrc/test/java/.../recipes/. Reads the existing project'sbuild.gradle.ktsto discover{rootPackage}automatically. This is where thenew-recipeskill content gets operationalized.verify-gates— medium value. Runs./gradlew check integrationTest smokeTestin order, prints a summary table. Just a thin wrapper around Gradle but useful as the documented "release-readiness check" entrypoint.bump-versions— medium value. Readsgradle/libs.versions.toml, queries Maven Central for newer versions of each entry, prints a diff and (with--apply) rewrites the TOML. Subset of what Ben-Manes does but TOML-aware and one-step.release— nice to have. Runsverify-gates, opens an editor onBACKLOG.mdto confirm release notes, bumps version inbuild.gradle.kts, tags, pushes. Risky to automate — leave as a documented workflow until proven safe.
Skip release and bump-versions for v1; ship init + add-recipe + verify-gates.
Recommendation: separate GitHub repo named system-out-to-lombok-template (or recipescaffold to avoid implying it's an official Moderne template).
- Tagged releases (
v0.1,v0.2, …) — JBang script's//DEPSline embeds the tag. Each scaffolded project records the template tag it was created from in a comment inCLAUDE.mdso you can later diff against the template. - Separate repo (not a directory inside the recipe project) so:
- Template upgrades don't churn the recipe project's git history.
- The template can be developed in isolation with its own integration tests (a CI job that does
jbang initand./gradlew checkon the output).
- NOT a GitHub "template repo" (the green-button kind). Those work fine for one-off forks but lack version tagging — and the JBang flow gives users a much better UX than "click button, manually rename packages."
Skills are dual-purpose:
- Copied verbatim into the scaffolded project's
.claude/skills/so the user gets the same "invoke skill X" workflow you have. - Read by the JBang script for skeleton text when emitting a new recipe (
add-recipe). Specifically,new-recipe/SKILL.mdcontains the canonical visitor skeleton (theJavaIsoVisitorblock, theMethodMatcherblock, the YAML composition block). Theadd-recipesubcommand parses these blocks (delimited by triple-backtick fences) and emits them with the user's chosen recipe name substituted in.
This means the skill files are the source of truth for what a "good recipe" looks like — both the JBang script and Claude Code in the user's project read the same file. No drift.
Important: keep the skill files in the template repo. The JBang script vendors them at scaffold time. If you later improve a skill in the template repo, users on older scaffolds won't auto-get the update — they'd re-run init --upgrade-skills (a flag worth adding to init).
recipescaffold/
├── README.md # how to use the template
├── jbang/
│ └── recipescaffold.java # the JBang script itself
├── template/ # everything below this is scaffolded into the user's project
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── .gitignore
│ ├── CLAUDE.md
│ ├── README.md
│ ├── SMOKE_TEST.md
│ ├── BACKLOG.md
│ ├── gradle/
│ │ ├── libs.versions.toml
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── .github/workflows/
│ │ ├── gradle.yml
│ │ └── release.yml
│ ├── .claude/skills/
│ │ ├── new-gradle-project/SKILL.md
│ │ ├── new-recipe/SKILL.md
│ │ ├── recipe-testing/SKILL.md
│ │ └── smoke-test/SKILL.md
│ └── src/
│ ├── main/
│ │ ├── java/__ROOT_PACKAGE__/recipes/
│ │ │ └── ExampleRecipe.java # one demo recipe so a fresh project compiles
│ │ └── resources/META-INF/rewrite/
│ │ └── example.yml
│ ├── test/java/__ROOT_PACKAGE__/recipes/
│ │ ├── ExampleRecipeTest.java
│ │ └── matrix/MatrixTestSupport.java
│ ├── integrationTest/java/__ROOT_PACKAGE__/recipes/
│ │ └── ExampleIntegrationTest.java
│ └── smokeTest/
│ ├── java/__ROOT_PACKAGE__/smoketest/
│ │ ├── Fixture.java
│ │ ├── GradleRunner.java
│ │ ├── ProjectShapeScaffolder.java
│ │ ├── ProjectShapeSmokeTest.java
│ │ ├── ProjectShapeVariant.java
│ │ ├── RecipeResolutionSmokeTest.java
│ │ ├── SmokeProject.java
│ │ ├── SmokeTest.java
│ │ ├── SmokeTestConfig.java
│ │ └── SmokeVariant.java
│ └── resources/
│ └── gradle-wrapper.properties
├── snippets/ # source-of-truth fragments add-recipe inserts
│ ├── recipe-class-java.template
│ ├── recipe-class-scanning.template
│ ├── recipe-class-toml.template
│ ├── recipe-test.template
│ ├── recipe-method-test.template
│ └── yaml-composition-block.template
└── tests/ # CI: scaffold + ./gradlew check on the output
└── ci-smoke.sh
__ROOT_PACKAGE__ is the literal directory marker the script renames at scaffold time (Java source dirs are derived from package). File contents use {rootPackage}-style placeholders so substitution is a single pass.
Sample commands:
# One-time install
jbang trust add https://github.com/fiftiesHousewife/recipescaffold
jbang app install --name=recipescaffold \
https://github.com/fiftiesHousewife/recipescaffold/blob/v0.1/jbang/recipescaffold.java
# Scaffold a new recipe project
recipescaffold init \
--group=io.github.acme \
--artifact=acme-rewrite-recipes \
--package=io.github.acme.recipes \
--author="Jane Doe" \
--github-org=acme \
--github-repo=acme-rewrite-recipes \
--directory=./acme-rewrite-recipes
# Inside an existing scaffolded project
cd acme-rewrite-recipes
recipescaffold add-recipe --name=ConvertFooToBar --type=java --with-tests
recipescaffold verify-gatesThe init subcommand should be interactive when args are missing (picocli supports this via interactive = true on options, or just a fallback prompt loop). For automation, all options must be passable as flags.
Push-based with a manual sync step. The upstream recipe project (io.github.fiftieshousewife:system-out-to-lombok-log4j) is the source of truth for evolving the template content. When you fix a build-script issue or extend a skill there, you propagate it to the template repo manually:
recipe-project (canonical) ──(rsync + tag)──> template-repo (scaffold source)
Concrete process:
- Land the fix in the recipe project (test, smoke, ship).
- Cherry-pick the relevant files into the template repo, replacing concrete identifiers with
{group}/{rootPackage}placeholders. - CI in the template repo runs
jbang init --apply --directory=/tmp/scaffold-test ...and./gradlew checkagainst the result. - Tag the template repo (
v0.2). - Bump the JBang
app installURL in the template repo's README to point at the new tag.
Anti-pattern to avoid: trying to make the template repo a strict superset of the recipe project (e.g. via git subtree). Templates need to evolve independently — the recipe project will grow recipe-specific complexity that doesn't belong in a template.
A RECIPE_FROM_TEMPLATE_VERSION field at the top of the scaffolded CLAUDE.md lets users diff their project against the template they came from.
//DEPS info.picocli:picocli:4.7.7
//DEPS org.apache.commons:commons-compress:1.27.1 // tar/zip extraction
//SOURCES Init.java AddRecipe.java VerifyGates.java // multi-file JBang
@Command(name = "recipescaffold",
mixinStandardHelpOptions = true,
version = "0.1",
subcommands = {Init.class, AddRecipe.class, VerifyGates.class})
public class RecipeScaffold implements Runnable {
public void run() { CommandLine.usage(this, System.err); }
public static void main(String[] args) {
System.exit(new CommandLine(new RecipeScaffold()).execute(args));
}
}
@Command(name = "init", description = "Scaffold a new OpenRewrite recipe project.")
class Init implements Callable<Integer> {
@Option(names = "--group", required = true) String group;
@Option(names = "--artifact", required = true) String artifact;
@Option(names = "--package", required = true) String rootPackage;
@Option(names = "--author", required = true) String author;
@Option(names = "--github-org", required = true) String githubOrg;
@Option(names = "--github-repo", required = true) String githubRepo;
@Option(names = "--java-target", defaultValue = "17") int javaTarget;
@Option(names = "--rewrite-version", defaultValue = "8.79.6") String rewriteVersion;
@Option(names = "--template-version", defaultValue = "v0.1") String templateVersion;
@Option(names = "--directory", defaultValue = ".") Path directory;
@Option(names = "--upgrade-skills", description = "Only refresh .claude/skills/, leave everything else.") boolean upgradeSkillsOnly;
public Integer call() throws Exception { /* ... */ return 0; }
}- Use class-based subcommands (one class per command) rather than method-based — your subcommands have non-trivial logic and shared helpers; classes are easier to extract and test.
@Specinjection for shared spec access.- Skip
picocli-shell-jline3autocomplete for now —initis run once per project, the autocomplete value is low. Add later ifadd-recipebecomes a frequent inner-loop tool. - Help text via
description = ""per command. Picocli auto-generates-h/--help.
- JBang script size: even with templates externalized, the script will hit ~600–800 lines across
Init,AddRecipe,VerifyGates. JBang handles multi-file via//SOURCESbut multi-file JBang scripts are less convenient than single-file. Mitigation: ship as a single fat script for v1; if it grows, convert to a regular Maven module published as a fat jar that JBang downloads. - Tag→template-source coupling: the JBang script must download the template at the same tag the script was published at. Easy bug: shipping JBang
recipescaffold.java@v0.2that fetchestemplate/@v0.1. Mitigation: bake the tag into the script asprivate static final String TEMPLATE_TAG = "v0.2"and have a CI assert that the script's tag matchesTEMPLATE_TAG. - Wrapper jar:
gradle-wrapper.jaris a binary and changes occasionally. Don't try to template it — copy bytes-for-bytes from the template repo, refresh the template when you bump Gradle. - Smoke runner is hardcoded for Lombok:
SmokeProject.writeBuildhardcodesorg.projectlombok:lombok:1.18.44,org.slf4j:slf4j-api:2.0.17, etc. Templating these as// EDIT THIS BLOCK FOR YOUR RECIPE'S DEPScomments is the honest move — there's no clean abstraction here and pretending otherwise will produce a worse runner. add-recipepackage detection: needs to readbuild.gradle.ktsto findgroup =and infer{rootPackage}. Brittle. Mitigation: drop a.recipescaffold.ymlfile at scaffold time recording the chosen package, group, artifact, author —add-recipereads from there with no parsing.- JBang trust prompts: first invocation will prompt to trust the GitHub URL. Document this in the install snippet.
- Picocli + GraalVM: a future native-image build of the script is appealing for startup time but not necessary; JBang's caching gets you near-instant warm starts.
- Week 1: Create
recipescaffoldrepo. Manually copy current files from the upstream recipe project with placeholder substitution. Verify by hand:unzip + sed + ./gradlew check. No JBang yet. - Week 2: Write
InitJBang subcommand. Tag templatev0.1. Test:jbang initproduces a project that./gradlew checkpasses on a fresh machine. - Week 3: Write
AddRecipesubcommand using snippet templates. Test: scaffold a project, add three recipes,./gradlew checkstill passes. - Week 4: Write
VerifyGates(thin Gradle wrapper). Add CI to template repo that runs end-to-end scaffold + check on every PR. - Later:
BumpVersions,Release,--upgrade-skillsmode forinit.
Reference paths in the upstream io.github.fiftieshousewife:system-out-to-lombok-log4j checkout:
build.gradle.ktsgradle/libs.versions.tomlsrc/smokeTest/java/io/github/fiftieshousewife/smoketest/SmokeProject.java.claude/skills/new-recipe/SKILL.mdsrc/main/resources/META-INF/rewrite/system-out-to-lombok.yml
Part A audit cited:
- Recipe conventions and best practices | OpenRewrite Docs
- Use of @EqualsAndHashCode on Recipe | OpenRewrite Docs
- Writing a Java refactoring recipe | OpenRewrite Docs
- Recipe development environment | OpenRewrite Docs
- ADR-0002 recipe naming
- Frequently asked questions | OpenRewrite Docs
- recipe-writing-lessons.md (rewrite-static-analysis)
- moderneinc/rewrite-recipe-starter build.gradle.kts
- Gradle Plugin Portal: org.openrewrite.build.recipe-library
- openrewrite/rewrite-logging-frameworks: SystemPrintToLogging.java + Slf4jLogShouldBeConstant.java
- Community recipes | OpenRewrite Docs
Part B JBang/picocli grounding: