Repository navigation
feat(java): make entity and extractor generators ejectable - #425
Merged
Merged
Conversation
"Everything should be ejectable." An audit of the stock generators in all five ports found eight that could not be ejected; this is the Java part, `entity` (JavaObjectCodeGenerator) and `extractor` (ExtractorCodeGenerator). Every Java generator can now be ejected except `template` (ADR-0034 Amendment 6). Why they could not be ejected - entity compiled only inside its writers' package: it called two protected naming steps on a writer it does not extend, and named its siblings by bare name. The package line is the one thing eject rewrites, so a copy did not compile (39 errors). - extractor was not a generator. It was a step entity ran, so no <generator> entry could name an owned copy and the drift check had nothing to run. What the owned copy carries, and what stays in the package - The owned entity is JavaObjectCodeGenerator.java alone: flavor selection, the writer factory (createWriter), the ObjectClassBindingProvider source, its META-INF/services registration, and whether the extractors are written there. - The class body stays in codegen-base's flavor writers (JavaCodeWriter, PojoAwareCodeWriter, ValueObjectCodeWriter), now named as supported surface: subclass one, override a protected step, return it from createWriter. They are not copied because every generated class's header names the writer class that wrote it, so a copied writer would change every generated file, and because a change to the class body is one overridden step. - JavaCodeWriter gains javaPackageOf(mo) and javaClassNameOf(mo), public views of the naming steps. The steps stay protected: widening them would break every subclass that overrides one as protected. - The owned extractor holds all of its emit. extractor as a generator of its own - ExtractorCodeGenerator implements Generator and takes the naming args entity takes. entity still writes the extractors when no separate extractor is in the run, so a pom that wires only entity emits byte for byte what it did. - When one is in <generators>, the Maven plugin sets emitExtractors=false on the others (EmitsObjectExtractors, derived where useNames is), so each file has one emitter. An explicit value in the pom wins. Eject - codegen-base ships the two reference sources. The registry gives both an eject path, and extractor a wiring note the eject report prints, since a pom may have no <generator> entry for it to change. - Behaviour change: `-Dnames=entity` and `-Dnames=extractor` are now ejectable on both JVM ports, so with no port implied by the project's dependencies the goal asks for -Dport, as it does for `names`. It used to resolve to Kotlin. Proof - EjectedBaseGeneratorsCompileTest: both copies compile package-renamed against the module's public API. - EntityExtractorEjectRoundTripTest: ejects both through the mojo, compiles them, and holds the unchanged copies to the packaged generators' output over the persistence corpus in every flavor; an edit to each copy shows in its output; -Dlist marks the copies identical or DIFFERS; mvn metaobjects:verify runs the owned copies and reports what they no longer produce. - StandaloneExtractorGeneratorTest, EmitExtractorsDerivationTest and a second compile-gate selection hold the split between the two generators. The model tier's class header still carries a wall-clock "Generated On:" line, so the entity comparisons leave that one line out and the drift proof re-runs a pass that crossed a second. Both go once that line is removed. Docs: ADR-0034 Amendment 6, own-your-codegen, the Java port page and README matrix, the codegen skill's Java reference (with regenerated agent-context goldens), the module README and the CHANGELOG.
…d test it Pre-gate review found that nothing told an adopter the separate extractor generator has to select the same objects as entity. Each <generator> merges its own <filters>, so an extractor wired without entity's filters writes a <Name>Extractor for an object entity wrote no class for, which does not compile. - The eject wiring note, ExtractorCodeGenerator's javadoc, own-your-codegen, the Java port page, the codegen skill's Java reference (goldens regenerated) and the CHANGELOG now name <filters> beside the naming args. - StandaloneExtractorGeneratorTest holds that the two generators, given the same filters, select the same objects. - EjectRuntimeRoundTripTest runs every ejectable Java generator off the registry; entity is one now, so its shared args carry entity's required type and flavor. That test also holds that neither generator's output imports a codegen helper, so ejecting them copies no runtime.
…hange - The eject mojo reads each wiring note from the resolved entries when it prints, instead of collecting them in a second list during the copy loop. - EjectedBaseGeneratorsCompileTest compiles through JavaCompileTestSupport, the module's shared compile helper, which also dumps the sources on failure. - EmitExtractorsDerivationTest reuses UseNamesDerivationTest's recording generator instead of carrying a copy of it. - A literal "false" and an imported StringWriter; no output changes.
…gle extractor emitter Assisted-by: no-mistakes:claude:claude-opus-5-5
…verloads Assisted-by: no-mistakes:claude:claude-opus-5-5
Assisted-by: no-mistakes:claude:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Changed
mvn metaobjects:eject -Dnames=entity,extractor -Dport=javanow copies the Javaentitygenerator (seven files, including the class-body writers) and theextractorgenerator into the adopter'scodegen/module.GeneratorRegistrygainsGeneratorInfo.ejectWith(), and theEmitsObjectExtractorsmarker plusARG_EMIT_EXTRACTORSletextractorrun as its own<generator>withemitExtractors=falseset onentity.entityno longer writes theObjectCodeWriter:andGenerated On:header lines, so regenerated class files change once in the header comment andmvn metaobjects:verifyno longer depends on the clock.mvn metaobjects:eject -Dnames=entity|extractorwith neither or both ofmetaobjects-codegen-springandmetaobjects-codegen-kotlindeclared now refuses with an ambiguous-port error and asks for-Dport=java|kotlin, instead of silently resolving to Kotlin.Gate runs
One validation run. It took three review fix rounds and one lint re-run: the first lint attempt was terminated by host load, with no gate failing an assertion.
Before merge
f297a6d8: Java and Kotlin conformance, the full Maven reactor and both integration lanes pass.fm/mo-1-1-0-pl-fixesalso removes theGenerated On:header line from the same writer, so whichever of the two lands second needs a rebase over that hunk.Validation
Risk Assessment
✅ Low: No defect found in a full pass: the three fix rounds landed as decided, existing poms change only in the documented header comment, and the one residual is that this pipeline's test step runs TypeScript lanes only, so the Java changes rest on the fix rounds' local mvn runs.
Testing
Java side driven live: built and installed the plugin, then ran generate, verify, eject, owned-writer edit, and compile against a disposable two-entity Maven project. All scenarios passed, including the adversarial drift check. Java module tests and SDK agent-context goldens pass. Disposable project and local m2 artifacts removed; evidence logs saved. Overall: go.
Evidence: Java module build and tests log
Evidence: generate on packaged entity+extractor
Evidence: verify on unchanged model
Evidence: verify fails on tampered generated file
Evidence: eject entity and extractor
Evidence: generate with owned writer edit
Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
spec/decisions/ADR-0034-codegen-scaffold-and-own.md:371- Intent: "Yes, everything should be ejectable" / "Go with recommended extraction, everything except template", withtemplateexcluded only because it has "no emit logic to own", plus "Don't ever do workarounds just to get a release out". The change makes Javaentityejectable as the 271-lineJavaObjectCodeGenerator.javaonly. The class-body emit logic (about 930 lines:BaseObjectCodeWriter,JavaCodeWriter,PojoAwareCodeWriter,ValueObjectCodeWriter) is explicitly not ejectable: ADR-0034 Amendment 6 Decision 1 ("the class body stays in the package"), and server/java/codegen-base/pom.xml:103 ("(JavaCodeWriter, PojoAwareCodeWriter, ValueObjectCodeWriter) are NOT shipped"). An adopter who ejectsentityowns flavor selection, the binding provider and the services file, but can change a field, constructor or accessor only by subclassing a packaged writer. Every other ejected Java generator carries its own emit (for exampleSpringDtoGenerator, 1400 lines). Decision 1 is the author's, not part of the quoted maintainer ruling. Its first stated reason (a copied writer changes theObjectCodeWriter:header line in every generated file) applies equally to the recommended subclass path, since BaseObjectCodeWriter.java:221 printsgetClass().getName(). Needs a maintainer ruling: is a generator-shell eject with the writers as supported surface acceptable for 1.1, or must the writers eject too?server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java:430-generatedInSyncretries generate+verify up to 5 times and treats drift as acceptable when the wall clock crossed a second. Cause: everyentityclass header carriesGenerated On: new Date()at second resolution (BaseObjectCodeWriter.java:222, unchanged), andMetaDataVerifyMojo.compareTreescompares raw bytes (MetaDataVerifyMojo.java:421). Two consequences. (1) The test is timing dependent: it fails whenever all 5 attempts span a second boundary, which is certain on a runner where generate+verify of the fitness corpus takes 1 s or more. (2) The retry hides thatmvn metaobjects:verifyreports[content-differs]for every class file of any pom that wiresentity(packaged or owned) unless verify runs in the same second as generate. The CHANGELOG entry ("mvn metaobjects:verifyruns them when your pom names them") and the ADR-0034 Amendment 6 "Proof" paragraph present verify as working for the ownedentity. The same clock is stripped in the comparisons at line 73 and in ReportingInertTest. The defect is pre-existing, but this change adds a test that works around it and a doc claim that depends on it, under the instruction "Don't ever do workarounds just to get a release out". The smallest honest remedy is to remove or normalise the clock line in the writer header (or in verify's compare). That changes packaged output for every adopter, so the remedy, not the defect, needs authorization.server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java:46- Simplification: theargs.containsKey(ARG_EMIT_EXTRACTORS)branch ("an explicit value in the pom always wins") is not required by the intent. The arg must exist for the plugin to pass the derived value and for non-Maven callers, who set it directly and never reach this method. The explicit-wins precedence adds one reachable outcome in Maven:<emitExtractors>true</emitExtractors>onentity(or in global args) beside a separateextractorrestores two emitters of each<Name>Extractor, which is the exact state the marker was added to prevent (identical bytes hide it; the first edit to the owned extractor fails asOutput path collision). No use case for that combination is stated. Recommend removing the precedence branch so the derivation always setsfalsewhen anEmitsObjectExtractorsgenerator is in the run. Sibling sites that assert it: EmitExtractorsDerivationTest.java:76 (an_explicit_pom_value_wins_over_the_derivation), StandaloneExtractorGeneratorTest.java:215, and the ADR/CHANGELOG sentences.server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java:79- Simplification:JavaObjectCodeGenerator.ARG_EMIT_EXTRACTORSis a second public name forEmitsObjectExtractors.ARG_EMIT_EXTRACTORS, and the CHANGELOG lists it as new public API. No intent requirement needs two names for one arg, and the copy becomes supported surface that an ejectedentityand adopters can bind to. Recommend removing the alias and readingEmitsObjectExtractors.ARG_EMIT_EXTRACTORSdirectly (the file already imports the interface); update the two test call sites and the CHANGELOG bullet.🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:
CHANGELOG.md:52- The round 1 decision says: "CHANGELOG [1.1.0] and docs/ports/java.md: state the one-time header change in every regenerated Java class, and that verify is now clock-independent." The intent also says "ejectable in 1.1.0". The round 1 fix put every entry for this change under## [Unreleased](CHANGELOG.md:11): the Added block at line 15, the header change at line 52 and the-Dportchange at line 63.## [1.1.0] — 2026-10-10(line 82) is untouched. The fixer followed whatmaindoes afterchore(release): prepare 1.1.0(chore(release): prepare 1.1.0 (npm/PyPI/NuGet 1.1.0, Maven 8.1.0) #417), where later entries (fix(verify,cli): verify --docs catches orphan model pages; hand-rolled-aggregate advice names object.report #420) also sit under [Unreleased]. The source cannot show whether 1.1.0 is still open. Decide: move the three entries into [1.1.0] (if this ships in 1.1.0), or confirm [Unreleased] (if 1.1.0 is already published). docs/ports/java.md:528 states the header change without a version, so it is correct either way.server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java:233- Simplification:JavaCodeWriter.javaPackageOf(mo)andjavaClassNameOf(mo)are new public API that no intent requirement needs any more. The original commit added them because a lone ejectedJavaObjectCodeGeneratorhad to callprotectednaming steps on a packaged writer in another package. The round 1 fix copiesJavaCodeWriterwith both generators, so each caller is always in the same package as itsJavaCodeWriter, packaged or owned, andprotectedaccess compiles there (the base commit callednamer.getLanguagePackage(mo)/namer.getClassName(mo)directly from that package). Round 1 left the views and their justification behind. Recommend removing both methods and calling theprotectedsteps directly. Sites to change in the same pass: JavaObjectCodeGenerator.java:162-163, ExtractorCodeGenerator.java:101, CHANGELOG.md:43 ("New public API" bullet), ADR-0034 Amendment 6 Decision 2 (spec/decisions/ADR-0034-codegen-scaffold-and-own.md:403), README-flavored-objects.md:113 (deviation 6). This removes a documented public API, so the author decides.server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java:143- Three new overloads only forward to the next one and have no other caller: the publicGeneratorInfo(..., ejectRuntime, ejectNote)constructor (GeneratorRegistry.java:141-146), the privateregister(..., ejectRuntime, ejectNote)(GeneratorRegistry.java:366-372), and the publicEjectSupport.Entry(..., runtime, note)constructor (EjectSupport.java:81-84). The only real call sites pass the full argument list (GeneratorRegistry.java:378, EjectSupport.java:147) or the pre-existing shorter forms. Remove the three and chain the pre-existing overloads straight to the full one.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java:233- Simplification:JavaCodeWriter.javaPackageOf(mo)andjavaClassNameOf(mo)are new public API that no intent requirement needs any more. The original commit added them because a lone ejectedJavaObjectCodeGeneratorhad to callprotectednaming steps on a packaged writer in another package. The round 1 fix copiesJavaCodeWriterwith both generators, so each caller is always in the same package as itsJavaCodeWriter, packaged or owned, andprotectedaccess compiles there (the base commit callednamer.getLanguagePackage(mo)/namer.getClassName(mo)directly from that package). Round 1 left the views and their justification behind. Recommend removing both methods and calling theprotectedsteps directly. Sites to change in the same pass: JavaObjectCodeGenerator.java:162-163, ExtractorCodeGenerator.java:101, CHANGELOG.md:43 ("New public API" bullet), ADR-0034 Amendment 6 Decision 2 (spec/decisions/ADR-0034-codegen-scaffold-and-own.md:403), README-flavored-objects.md:113 (deviation 6). This removes a documented public API, so the author decides.server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java:47- Tradeoff of the round 1 decision to remove the explicit-wins branch, recorded for awareness only.deriveEmitExtractorssetsemitExtractors=falseon every generator in the run when any oneEmitsObjectExtractorsgenerator is present. Trace: a pom with twoentityentries (entity A: filters pkgA, outputDir X; entity B: filters pkgB, outputDir Y) plus oneextractorentry given entity A's args and filters.AbstractMetaDataMojo.buildGenerators(line 208-213) tells both entity generators to stop, so the<Name>Extractorfiles for pkgB are no longer written and nothing reports it at generate time. The supported remedy already exists: add oneextractorentry perentityentry. The docs say "the same args as your entity generator" in the singular (docs/ports/java.md:297-305, docs/features/own-your-codegen.md:581-597, the eject note in GeneratorRegistry.java:295-300) and do not state the one-per-entity rule. No code change is recommended; a pom with a singleentityentry is not affected.🔧 Fix applied.
1 warning still open:
server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaCodeWriter.java:233- Simplification:JavaCodeWriter.javaPackageOf(mo)andjavaClassNameOf(mo)are new public API that no intent requirement needs any more. The original commit added them because a lone ejectedJavaObjectCodeGeneratorhad to callprotectednaming steps on a packaged writer in another package. The round 1 fix copiesJavaCodeWriterwith both generators, so each caller is always in the same package as itsJavaCodeWriter, packaged or owned, andprotectedaccess compiles there (the base commit callednamer.getLanguagePackage(mo)/namer.getClassName(mo)directly from that package). Round 1 left the views and their justification behind. Recommend removing both methods and calling theprotectedsteps directly. Sites to change in the same pass: JavaObjectCodeGenerator.java:162-163, ExtractorCodeGenerator.java:101, CHANGELOG.md:43 ("New public API" bullet), ADR-0034 Amendment 6 Decision 2 (spec/decisions/ADR-0034-codegen-scaffold-and-own.md:403), README-flavored-objects.md:113 (deviation 6). This removes a documented public API, so the author decides.✅ **Test** - passed
✅ No issues found.
scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchainscd server/java && mvn -Dmaven.repo.local=$HOME/.m2-ci/repository -pl codegen-base,codegen-spring,maven-plugin -am install -Djacoco.skip=true (BUILD SUCCESS, exit 0)cd server/typescript && bun test packages/sdk (329 pass, 0 fail; agent-context goldens)Disposable Maven project in /tmp with entity+extractor generators: mvn -o metaobjects:generate (packaged, 8.1.0)Re-generate after 2s sleep, diff -r against first output (byte-identical); grep for 'Generated On' header (none)mvn -o metaobjects:verify on unchanged model (clean); after tampering Product.java (drift reported, BUILD FAILURE)mvn -o metaobjects:eject -Dnames=entity,extractor -Dport=java (7 owned files: JavaObjectCodeGenerator, JavaCodeGenerator, BaseObjectCodeGenerator, BaseObjectCodeWriter, JavaCodeWriter, PojoAwareCodeWriter, ValueObjectCodeWriter, ExtractorCodeGenerator)Owned codegen module mvn -o install, pom rewired to owned classnames + plugin dependency; generate output diff -r against packaged output (identical)Added writeComment marker in owned PojoAwareCodeWriter, rebuilt, regenerated: marker present in Product.java and Order.javamvn -o compile on packaged and owned projects (both exit 0)Packaged pom with <emitExtractors>true</emitExtractors> on entity plus extractor entry: generate exit 0, output identical to baselineEvidence logs in ~/.no-mistakes/evidence/01M4NK4YJV84WSN9JNKQPFG7W4 (00-java-module-build-tests.log, 01-generate-packaged.log, 02-verify-unchanged.log, 03-verify-tampered-fails.log, 04-eject-entity-extractor.log, 05-generate-owned-with-writer-edit.log)Regression: full Java reactor scope above; TS lanes from baseline (ts-fast, ts-unit) already green. Not run: full scripts/ci-local.sh (C#/Kotlin/Python lanes untouched by this diff).✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → no changes applied ✅
🔧 No changes applied.
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.