Skip to content

feat(generate): gombit generate + --check (Program Mode) — RESGEN-1 slice 5a (#352) - #368

Merged
leo-aa88 merged 3 commits into
mainfrom
resgen-5-generate
Sep 16, 2026
Merged

leo-aa88 merged 3 commits into
mainfrom
resgen-5-generate

Conversation

@leo-aa88

@leo-aa88 leo-aa88 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds gombit generate — regenerate a gombit app's model-first resource files (*.gen.go) from its real compiled GORM models, with --check verifying 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 both gombit generate (now) and make resource (slice 5b) will call, so a resource's files are only ever derived one way. Returns []GeneratedArtifact{Path, Content, Ownership}: DTOs + handler are GeneratorOwned (*.gen.go), the hooks file is SeedOnce (human-owned).
  • generate package — Program Mode, mirroring migrations (ADR-012): discover the app's model-first resources by an explicit marker (resourcegen.ResourceMarkerFile = .gombit-resource, written by make resource in 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 loader main into the app module and go runs 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.
  • cli: gombit generate [--check] [--dry-run].

Related #352

Ownership policy (the three constraints from review direction)

  • --check compares only generator-owned .gen.go. The hooks file is SeedOnce: written only when absent, never overwritten, and never drift-checked — it's the human-owned customization surface.
  • Fail closed on the legacy layout. A resource that still has the human-owned handler.go is refused before the loader runs or anything is written, so the old and new layouts never coexist half-migrated.
  • One authoritative derivation. generate and (later) make resource both go through RenderResource; the loader answers "what should exist?", the parent decides how the tree changes.

Acceptance criteria (epic #352)

  • Drift becomes staleness checked by regeneration (generate --check), not AST inspection.
  • Changing a model + policy needs no manual cross-artifact sync — generate re-derives DTOs/mappers/handler from the model.
  • Editing generated plumbing is unsupported (.gen.go overwritten); customization is the seed-once hooks file (never clobbered).

Scope notes

  • Slice 5b (next): rewire make resource to bootstrap this layout (model + seed-once hooks + register) and invoke RenderResource, dropping the legacy human-owned handler.go, updating goldentest/frontend/routes. The CI generate --check job 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.
  • Slice 6: remove resourcecheck + the breaking-change migration guide.

Validation

  • go test ./generate/ ./resourcegen/ ./cli/ — pass, incl. a real Program-Mode end-to-end test (go run a loader in a temp app: --check clean 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 two createsuperuser.go gosec G115 are pre-existing and unflagged by CI)

Working agreement checklist

  • New behavior has tests — fake-runner unit tests for all parent policy paths + a real go run integration test; the executed DB-independent (schema parsing only)
  • Stable features ship docs and appear in an example app — N/A until make resource emits the layout (5b); docs + migration guide in slice 6
  • Extraction preserves contracts — additive; the legacy make-resource path is untouched, and generate refuses to run against it
  • Generators are idempotent/additive, AST-safe, never overwrite user-owned files — .gen.go regenerable, hooks seed-once/never-clobbered; deterministic output
  • API changes regenerate OpenAPI + TS client — N/A (no API change)
  • No secrets in generated frontend source — N/A
  • Scope stays inside its milestone — post-v0.1 epic [RESGEN-1] Model-derived, generator-owned resource contracts (supersedes contract-drift in #218) #352
  • This PR links its issue and states which acceptance criteria it satisfies

…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 leo-aa88 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: QUESTIONappResourceModels 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.

Comment thread generate/generate.go
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))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.go with package book (from path.Base) alongside the existing book.go's package 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.

leo-aa88 and others added 2 commits September 16, 2026 16:15
…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 leo-aa88 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@leo-aa88
leo-aa88 merged commit 85f3908 into main Sep 16, 2026
18 checks passed
@leo-aa88
leo-aa88 deleted the resgen-5-generate branch September 16, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant