feat(generate): gombit generate + --check (Program Mode) — RESGEN-1 slice 5a (#352) - #368
Conversation
…lice 5a (#352) Add `gombit generate`: regenerate a gombit app's model-first resource files from its REAL compiled GORM models, with `--check` verifying they are current without mutating anything (a drift gate). This is the synchronization mechanism ADR-016 relies on — regeneration, not inspection. - resourcegen.RenderResource(model, pkg) is the shared generation primitive both gombit generate (now) and make resource (slice 5b) call, so a resource's files are only ever derived one way. It returns []GeneratedArtifact{Path, Content, Ownership}: the DTOs + handler are GeneratorOwned (*.gen.go), the hooks file is SeedOnce (human-owned). Also exports ReadModulePath/ValidateAppLayout. - new generate package: Program Mode like migrations (ADR-012). It enumerates the app's own resources (AutoMigrate models under <module>/internal/…, framework models filtered out), writes a throwaway loader main into the app module and `go run`s it; the loader imports resourcegen + the model packages, calls RenderResource per model, and emits the artifacts as JSON. The loader is a pure function with no write access to the app tree — the parent owns all filesystem policy: * GeneratorOwned: overwritten on write; compared on --check. * SeedOnce (hooks): written only when absent; never overwritten, never compared (it is the human-owned customization surface). It fails closed BEFORE running or writing anything if a resource still has the legacy human-owned handler.go, so the two layouts never coexist half-migrated. - cli: `gombit generate [--check] [--dry-run]`, registered in the root tree. Tests: fake-runner unit tests for check-pass/stale/missing, hooks ignored by --check, write + seed-once-never-overwritten + stale-overwrite, dry-run, legacy-layout fail-closed (before the loader runs), app-model filtering, and a loaderSource parse/wiring check; plus a real Program-Mode end-to-end test that builds a temp app, runs the loader via `go run`, and proves --check passes when fresh, fails after the model gains a column, and passes again after regenerating. Not here (slice 5b): rewiring make resource to bootstrap this layout, and the CI `generate --check` job (no in-repo app uses the new layout yet). Slice 6 removes resourcecheck + adds the migration guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
leo-aa88
left a comment
There was a problem hiding this comment.
REQUEST CHANGES
Program Mode itself is done right: the loader is a genuinely pure function (no filesystem access, emits JSON to stdout), all write/overwrite/seed-once policy lives in the parent, the legacy-layout guard runs before the loader touches anything, and TestGenerateProgramModeEndToEnd actually runs go run against a real temp app through three real states (fresh/stale/regenerated) rather than mocking it away. That's the right shape for a command whose whole job is keeping generated code trustworthy.
But the destination package name this whole pipeline writes files under is derived the same way PR #359 spent three review rounds proving is unsafe for referencing types — and here it's unsafe for something worse: it decides what package clause a brand-new file on disk declares. Confirmed by direct reproduction, not just reasoning — see inline comment. gombit generate (write mode) can silently leave an app not compiling and still print success.
Also one architectural question, not a defect: QUESTION — appResourceModels treats every AutoMigrate'd model under <module>/internal/… as a model-first CRUD resource, with no apparent way to mark one as "persisted but not a generated-API resource" (a join table, an audit-log model, a token/session model). Is that intentional for this framework's conventions (every model gets a generated CRUD surface unless removed from AutoMigrate entirely), or is an opt-out coming in a later slice? Worth confirming before make resource (5b) makes this the default path apps are built on, since retrofitting an exclusion mechanism after real apps depend on "every model is a resource" is more disruptive than deciding it now.
VERDICT
The missing piece is the same one PR #359's own code comments already promised: buildModelResource's doc says cross-checking the destination package against "the package clause already on disk beside model.go" is deferred to "the caller, once a real destination file exists." This is that caller — the first slice in the epic with real filesystem access — and the check still isn't there. The fix is small (read the package clause of an existing file in the target directory, or of model.go specifically, and fail closed on a mismatch before running the loader) and the failure mode without it is exactly the kind of silent, mistrust-the-tool outcome this whole epic exists to eliminate.
| b.WriteString("\tvar all []resourcegen.GeneratedArtifact\n") | ||
| for i, m := range models { | ||
| fmt.Fprintf(&b, "\t{\n") | ||
| fmt.Fprintf(&b, "\t\tarts, err := resourcegen.RenderResource(&model%d.%s{}, %q)\n", i, m.TypeName, path.Base(m.ImportPath)) |
There was a problem hiding this comment.
BLOCKING — confirmed by reproduction, not just reasoning.
path.Base(m.ImportPath) is the directory's basename, not the actual package clause of the files already in that directory. RenderResource uses it verbatim as the package line of the new dto.gen.go / handler.gen.go / hooks.go it writes (resourcegen/render.go: dir := "internal/" + pkg, and renderModelDTOs/renderModelHandler/renderModelHooks all emit package " + r.Package). Nothing anywhere in this PR reads the package clause of an existing file in that directory to confirm they'd agree.
This is the exact class of bug PR #359 spent three review rounds closing for the model's own referenced types ("reflect.Type.PkgPath is an import path, not the package's declared name, and the two can differ") — and buildModelResource's own doc comment explicitly flags this exact gap for the destination package too: "Cross-checking it against the package clause already on disk beside model.go — the stronger guarantee once a real destination file exists — is [the future filesystem-writing caller]'s job: this pure emitter never touches a filesystem." This PR is that caller, and the cross-check still isn't here.
I reproduced it: a temp app with internal/book/book.go declaring package books (directory book, clause books — a plausible state after a hand-rename that didn't touch every file, or a copy-paste into a differently-named directory) registered in database.go. Running Generate in write mode:
- returns
err == nil— reports success. - writes
internal/book/dto.gen.gowithpackage book(frompath.Base) alongside the existingbook.go'spackage books. - the app then fails
go build ./...with:found packages books (book.go) and book (dto.gen.go) in .../internal/book.
So the tool whose entire purpose is "regeneration keeps the app in sync and buildable" can itself put the app into a state that doesn't build, and tell the developer it succeeded. The same path.Base(m.ImportPath) pattern is used for the legacy-layout check too (line ~160, pkg := path.Base(m.ImportPath) in ensureNoLegacyLayout) — that one only affects which directory gets stat'd, so it's lower-stakes, but it's the same unvalidated assumption.
Fix: before running the loader (same place ensureNoLegacyLayout already fails closed), parse the package clause of an existing .go file in each resource's target directory (e.g. go/parser.ParseFile in PackageClauseOnly mode is cheap) and compare it against the derived pkg; fail closed with a clear error naming the mismatch instead of silently writing a file under the wrong package name.
…basename (#352) RenderResource writes the *.gen.go and hooks.go as `package <basename>`, where basename = path.Base(importPath) — the directory name. But reflect exposes the import path, not the declared package name, and the two can differ (a hand-rename that missed a file, a copy into a differently named directory). When they did, generate wrote `package book` files next to a `package books` model, left the app failing `go build` (two package names in one directory), and reported success — the exact mistrust-the-tool outcome this epic exists to prevent, and the gap buildModelResource's own doc comment deferred to "the caller once a real filesystem-writing destination exists." This is that caller. ensurePackageMatchesDir parses the package clause (parser.PackageClauseOnly) of every existing non-test .go file in each resource directory and fails closed, before the loader runs or anything is written, if any declares a package other than the basename. Mirrors ensureNoLegacyLayout's fail-before-write placement. Test: TestFailsClosedOnPackageMismatch (dir book, model `package books` → error naming the mismatch, loader never runs, nothing written). Canned fixture content updated to valid Go with a package clause, since existing files are now parsed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rate (#352) AutoMigrate means "this model is persisted", not "expose a generated CRUD API for it" — a join table, an audit-log or token/session model is persisted but is not a resource. Treating every internal/ AutoMigrate model as a resource would force an HTTP surface on all of them and be disruptive to walk back later (PR #368 review QUESTION). Discover resources by an explicit, durable marker instead. - resourcegen.ResourceMarkerFile (".gombit-resource"): a feature package is a model-first resource iff it carries this sentinel. make resource writes it at bootstrap (slice 5b); generate only reads it. It is deliberately NOT a *.gen.go — a generated file going missing must read as drift, never make the resource vanish from discovery. - generate discovers marked packages under internal/, then pairs each with its persisted model from the AutoMigrate call. Because generated files are package-level, a marked package must resolve to EXACTLY ONE AutoMigrate model: zero (no persisted model registered) or several (ambiguous target) fail closed with a diagnostic. An unmarked package is skipped. Tests: unmarked package skipped, marked-with-no-model and marked-with-multiple fail closed, plus the existing suite and the end-to-end test updated to write the marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
leo-aa88
left a comment
There was a problem hiding this comment.
APPROVE
Both points from the previous round are addressed, and the second one better than I expected.
The BLOCKING finding (package/directory mismatch) is fixed with ensurePackageMatchesDir (553a279): it parses the actual package clause of every non-test .go file already in each resource directory (go/parser in PackageClauseOnly mode — cheap, no function bodies) and fails closed, before the loader runs or anything is written, if it disagrees with the directory basename. I re-ran my exact original reproduction (internal/book/book.go declaring package books) against this commit and it now fails closed with a clear, actionable error instead of silently writing a broken dto.gen.go and reporting success:
generate: internal/book/book.go declares package "books", but model-first generation writes files as package "book" (the directory name); rename the package to match the directory before regenerating
TestFailsClosedOnPackageMismatch covers the same scenario directly, and asserts both that the loader never ran and that nothing was written — not just that an error came back.
The QUESTION (is every AutoMigrate'd model treated as a CRUD resource) got a real answer, not just a reply: 993e801 replaces AutoMigrate-membership discovery with an explicit ResourceMarkerFile (.gombit-resource) that make resource will write at bootstrap (slice 5b) and generate only reads. This is the right fix, not a patch — it cleanly separates "persisted" from "generated API surface" (a join table or audit-log model stays out of discovery entirely), and it closes the ambiguity case I hadn't even asked about: a marked package must resolve to exactly one AutoMigrate model, failing closed with a clear diagnostic on zero or several. The reasoning for making the marker a durable sentinel rather than inferring resource-ness from a .gen.go file's presence is exactly right too: a missing .gen.go needs to read as drift (caught by --check), not make the resource silently disappear from discovery.
Verified independently: checked out 993e801badd436a380f3c8c3263ac53bbcd5425f in a worktree, ran go build ./..., go vet, and the full generate/cli/resourcegen test suites (no -short, so the real Program-Mode end-to-end test executes) — all clean. Re-ran my original repro against the new code and confirmed it now fails closed as shown above.
VERDICT
Both issues closed with real fixes and real tests, and the second one turned into a better design than what I asked a question about, not just a bolted-on flag. Good to merge.
Summary
Adds
gombit generate— regenerate a gombit app's model-first resource files (*.gen.go) from its real compiled GORM models, with--checkverifying they're current without mutating anything (a drift gate). This is ADR-016's synchronization mechanism: regeneration, not inspection.resourcegen.RenderResource(model, pkg)— the shared generation primitive bothgombit generate(now) andmake resource(slice 5b) will call, so a resource's files are only ever derived one way. Returns[]GeneratedArtifact{Path, Content, Ownership}: DTOs + handler areGeneratorOwned(*.gen.go), the hooks file isSeedOnce(human-owned).generatepackage — Program Mode, mirroring migrations (ADR-012): discover the app's model-first resources by an explicit marker (resourcegen.ResourceMarkerFile=.gombit-resource, written bymake resourcein 5b) — not by AutoMigrate membership (AutoMigrate means "persisted", which a join table/audit model also is). Each marked package is paired with its one AutoMigrate model (exactly one, or fail closed). It writes a throwaway loadermaininto the app module andgo runs it; the loader importsresourcegen+ the model packages, callsRenderResourceper model, and emits the artifacts as JSON. The loader is a pure function with no write access to the app tree — the parent owns all filesystem policy.cli:gombit generate [--check] [--dry-run].Related #352
Ownership policy (the three constraints from review direction)
--checkcompares only generator-owned.gen.go. The hooks file isSeedOnce: written only when absent, never overwritten, and never drift-checked — it's the human-owned customization surface.handler.gois refused before the loader runs or anything is written, so the old and new layouts never coexist half-migrated.generateand (later)make resourceboth go throughRenderResource; the loader answers "what should exist?", the parent decides how the tree changes.Acceptance criteria (epic #352)
generate --check), not AST inspection.generatere-derives DTOs/mappers/handler from the model..gen.gooverwritten); customization is the seed-once hooks file (never clobbered).Scope notes
make resourceto bootstrap this layout (model + seed-once hooks + register) and invokeRenderResource, dropping the legacy human-ownedhandler.go, updating goldentest/frontend/routes. The CIgenerate --checkjob lands there too — there is no in-repo example app using the new layout yet, so there's nothing for a CI gate to check until make resource emits it.resourcecheck+ the breaking-change migration guide.Validation
go test ./generate/ ./resourcegen/ ./cli/— pass, incl. a real Program-Mode end-to-end test (go runa loader in a temp app:--checkclean when fresh, fails after the model gains a column, clean again after regenerating)go build ./...,go vet,golangci-lint run ./generate/ ./cli/ ./resourcegen/— clean (the twocreatesuperuser.gogosec G115 are pre-existing and unflagged by CI)Working agreement checklist
go runintegration test; the executed DB-independent (schema parsing only)generaterefuses to run against it.gen.goregenerable, hooks seed-once/never-clobbered; deterministic output