diff --git a/.claude/skills/metaobjects-audit/SKILL.md b/.claude/skills/metaobjects-audit/SKILL.md index 64094b5..b388085 100644 --- a/.claude/skills/metaobjects-audit/SKILL.md +++ b/.claude/skills/metaobjects-audit/SKILL.md @@ -23,8 +23,12 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid `com.metaobjects:*` / `metaobjects` / `MetaObjects.*` deps). - [ ] Count metadata source lines + all `@generated` / `DO NOT EDIT` files repo-wide. - [ ] **Owned-generators check:** does the project own generators at `codegen/generators/*` - (scaffold-and-own via `meta init`), or still import the **deprecated** package export - (`@metaobjectsdev/codegen-ts/generators`)? Not owning is itself a finding. + (scaffold-and-own via `meta init`), or still import `entityFile` / `queriesFile` / + `routesFile` / `barrel` from `@metaobjectsdev/codegen-ts/generators`? Those four were + REMOVED at 1.0, so an import of them is an upgrade blocker, not just a style finding. + **Match the four NAMES, never the subpath alone** — that subpath is the supported home of + the non-ownable generators (`promptRender`, `outputParser`, `routesFileHono`, …), so + flagging a hit on the path convicts a correct project. - [ ] **Cross-language version consistency (silent-drift check).** If the project uses MetaObjects in more than one language (e.g. a TS web client + a Java/Python/C# backend), enumerate EVERY MetaObjects package across ALL ecosystems (npm `@metaobjectsdev/*`, Maven `com.metaobjects:*`, PyPI `metaobjects`, NuGet `MetaObjects.*`) and record each version. **The version-number LINES differ by ecosystem (npm/PyPI/NuGet `0.x`/`1.x` vs Maven `7.x`/`8.x`), so you CANNOT eyeball drift** — a `0.12` next to a `7.7` looks fine but can be badly out of sync. Compare the **`metamodelVersion`** each port reports (the shared spec version on the registry manifest): a mismatch is real cross-language drift and a **finding** — the ports disagree on vocabulary/wire behavior. Also flag any port not on the latest release for its ecosystem. (This is a known real-world failure mode: newest backend, stale client, invisible because the numbers differ.) - [ ] Classify: **Greenfield** (none/minimal) · **Partial** · **Deep** → choose path below. @@ -118,19 +122,25 @@ code behind a grep hit; a "duplicate" validator's *divergence* is the finding. references repo-wide (grep the emitted symbol / module path for importers): delete it and stop generating it. - **Artifacts an entity doesn't need** — REST routes / TanStack grids / forms / - hooks emitted for an entity that has no such surface. The per-entity opt-outs - exist for exactly this (`@emitRoutes: false`, `@emitTanstack: false`, a - `layout.dataGrid`-gated grid): flag the opt-out NOT used where the artifact is - unused. + hooks emitted for an entity that has no such surface. The remedy is the + generator's own `filter` option (`routesFile({ filter: (e) => e.name !== "Audit" })`, + `tanstackQuery({ filter })`), which is ANDed with the generator's built-in gates + and so can only NARROW what emits; a grid additionally needs a `layout.dataGrid` + on the entity at all. Flag the unused artifact and name the filter that would + suppress it. **Never recommend an `@emit*` attribute** — `@emitRoutes`, + `@emitTanstack`, `@emitForm`, `@emitGrid` and `@emitAngular` were never + registered vocabulary, so they passed `meta gen` (open load) and FAILED + `meta verify` (strict). A project carrying one is a **finding**, not an opt-out: + report it and route to `meta upgrade --apply` plus the generator config above. - **Generators wired but unconsumed** — a generator in `metaobjects.config.ts` `generators: [...]` (or the per-port equivalent) whose whole output class no code imports: drop the generator rather than generate into the void. - **Wrong target / duplicate output** — the same logical artifact emitted to two places (a mis-set per-target `outDir`), one of which is orphaned. - Recommend generating ONLY what is consumed — narrow the generator set + use the - per-entity opt-outs. A smaller, fully-consumed generated surface beats a large one - with dead files (which also make the leverage ratio lie; discount them from the - census). + Recommend generating ONLY what is consumed — decide per generator: drop the ones + whose whole output class nothing imports, and narrow the ones that over-emit with + their `filter`. A smaller, fully-consumed generated surface beats a large one with + dead files (which also make the leverage ratio lie; discount them from the census). - [ ] **G. Runtime-contract anti-patterns.** Module-global `db` vs context-as-parameter (ADR-0008); wire-canonicalization in the query path vs native in-process return types (ADR-0019); runtime reflection to resolve a type from FQN vs generated static imports / @@ -331,13 +341,17 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend - **Not expressible → carry it in `@sql` or `@unmanaged`, never a hand-edited migration (#208, ADR-0043).** When a NAMED irreducible construct blocks origin authoring — recursive CTE, window function / `OVER`, `UNION` / `INTERSECT` / `EXCEPT`, lateral join — the body still belongs in the metadata: carry the hand-written SQL in the `source.rdb` **`@sql`** escape — a read-only-`@kind` body the tool REGISTERS, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`); `@sql` forbids `origin.*` children (two sources of truth). A DB object whose DDL is owned **entirely elsewhere** (Flyway / a hand-migration) → mark its source **`@unmanaged: true`** (legal on any `@kind` incl. `table`); `meta migrate` never creates/drops/drift-checks it and `verify --db` reports it as external. `@sql` and `@unmanaged` are mutually exclusive. **Only a view left *undeclared*** — neither modeled, nor `@sql`, nor `@unmanaged` — is truly *unmanaged*, invisible to `meta verify --db`, so this audit is the only gate that sees it. "It's an aggregation" is NOT an irreducibility justification (plain count/sum/avg/min/max rollups are `origin.aggregate`); nor is a `DISTINCT ON` pick-one-row (`origin.first`) or a non-aggregate expression column (`origin.computed`). 9. **A closed variant-set hand-modeled per instance** — N sibling modules / classes / config blocks, one per channel / provider / target, sharing a payload + config shape and diverging only by transport. Grep for sibling-file families and switch-on-a-string dispatch; verify the set is closed and recurring (never a one-off). → axis I "New-vocabulary OPPORTUNITY" (VOCAB CANDIDATE, advisory). 10. **N declarations of one FIELD across objects (same-name-field census)** — the field-level sibling of signature 6. Census field names across `object.*` nodes (`grep -rn 'name: '` the metadata dir); a name recurring in ≥2 objects where a canonical owner exists — one whose name the field embeds (``: `wizardId` → `Wizard.id`, `orderTotal` → `Order.total`) or whose type+constraints it matches — is provenance loss → `extends: Owner.field` (dotted child targets, ADR-0029). **VERIFY by diffing the copies' attrs: a `@maxLength` / `@required` / validator divergence across them is drift already shipping — cite it.** Evidence multiplier: `extends` already used elsewhere in the repo raises confidence. Do NOT flag: generic names on unrelated concepts (`id` / `name` / `status` with no owner-embedding name and no matching constraints); required per-node attrs a loader forces (e.g. `payloadRef` / `format` on sibling `template.prompt` nodes — a product constraint, not a copy). +11. **A physical table / column / schema name spelled as a literal outside its metadata declaration** — in a hand-written repository, raw SQL, a migration script, a log line, or a hand-maintained body-to-column map. Every port emits a per-object names artifact (`Names` / `_names.py`) from the declaration, so a literal is a second spelling of the same fact; and because the physical column is free-form, deriving it from the field name is a guess that fails silently. **No verify subverb sees this** — `--codegen` diffs generated files, `--db` compares schema to metadata — so this audit is the only gate. Remedy: reference the constant — but **check the artifact is emitted at all before scoring the literals, because on THREE of five ports an existing project emits none**. C# and Python have a real default suite and get it by upgrading; TypeScript's `generators: [...]` and the JVM's `` are each the COMPLETE list, so `meta init` scaffolding `namesFile()` covers only a project initialized at 1.0 and upgrading the package never edits a config written earlier. Where no artifact exists, the un-wired generator is the FIRST finding and the first remedy (`namesFile()` on TS after `meta eject names`; `SpringNamesGenerator` / `KotlinNamesGenerator` in the pom) — score the literals under it rather than as N independent findings, since one config line fixes the cause and generated code stops embedding the names too. **Do NOT flag:** a typed ORM handle in its place (a Drizzle column object, an Exposed `Column`, an EF property — replacing one with a string is a regression); a physical name inside a `source.rdb` hand-written SQL body (a metadata document cannot import a constant, which is why that body is fingerprinted); a flattened value-object composite column (it belongs to no single field of either object); a write-through entity's replica view name (the artifact holds the PRIMARY source's); a relationship-synthesized foreign-key column (derived, never declared). A hand-written repository that imports `Names` is the gold standard — call it out as good. --- ## Owned-codegen & scaffold-and-own assessment -- If config imports deprecated `@metaobjectsdev/codegen-ts/generators` instead of - owned `codegen/generators/*`, recommend the scaffold-and-own migration (`meta init`). +- If config imports `entityFile` / `queriesFile` / `routesFile` / `barrel` from + `@metaobjectsdev/codegen-ts/generators` instead of owned `codegen/generators/*`, + recommend the scaffold-and-own migration (`meta init`, or `meta eject `) — on 1.0+ + those four no longer resolve. Importing the non-ownable generators from that subpath is + correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as diff --git a/.claude/skills/metaobjects-audit/references/capability-checklist.md b/.claude/skills/metaobjects-audit/references/capability-checklist.md index 78ef522..0e844ad 100644 --- a/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -66,7 +66,15 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove ## Source — `source.rdb` - **`source.rdb`** (`@table`, `@schema`) — hunt hard-coded physical table/schema names that - diverge from the default naming the source models. + diverge from the default naming the source models, AND any physical table/column/schema + name spelled as a literal outside its declaration — raw SQL, a hand-written repository, a + migration script, a body-to-column map (drift signature 11). Every port emits a per-object + names artifact from the declaration, so a literal is a second source of truth even when it + agrees with the naming strategy today. A typed ORM handle in its place is correct. **Check + the artifact is emitted at all before scoring the literals: on TypeScript and the JVM the + generator list in the config IS the complete list, so an existing project emits none and the + un-wired generator is the finding FIRST** (C# and Python have a real default suite and get it + by upgrading). - **`@kind` = `view` / `materializedView`** — hunt hand-written SQL views where an authored read-only source belongs. Apply the **view-necessity test** (SKILL.md, drift signature 8): a hand-written `CREATE VIEW` (or read-only SQL mirroring a read model) is a CODEGEN CANDIDATE when diff --git a/.claude/skills/metaobjects-audit/references/typescript.md b/.claude/skills/metaobjects-audit/references/typescript.md index 12f82ed..2185ec0 100644 --- a/.claude/skills/metaobjects-audit/references/typescript.md +++ b/.claude/skills/metaobjects-audit/references/typescript.md @@ -14,8 +14,12 @@ Generated files carry the header: // @generated by @metaobjectsdev/codegen-ts ``` -The runner **only overwrites files bearing this header** — hand-edited sibling -files (`.extra.ts`) are never touched. Locate generated output at the +The header is **informational on this port** — the write decision never reads it. What +the runner overwrites is decided by `.metaobjects/.gen-state/`: the snapshot body if the +machine has one (three-way merge), otherwise the committed `.hashes.json` (byte-for-byte +what it wrote ⇒ overwrite; anything else ⇒ refused, exit 1). A sibling file you created +(`.extra.ts` by convention) is never touched because it is not a path the runner +records — not because of its name, and not because of the header. Locate generated output at the `outDir` (and per-target `outDir` values) declared in `metaobjects.config.ts`. With the default single-target config, generated files land at the one configured `outDir` (e.g. `src/generated/`). @@ -50,6 +54,7 @@ value-sets against the constructs that replace them. | `// keep in sync with` / `// mirrors the` | second-source-of-truth comment — always a finding | | camelCase↔snake_case mapping tables | generated views handle this; diff field by field | | `@generated` file with hand edits | three-way merge preserves them; review at codegen; never delete | +| a table/column string in a `sql` fragment, a Kysely identifier, or a migration/seed script outside the generated dir | second spelling of a declared physical name — reference `.names.ts` (`ProgramNames.fields..column`): `generators: [...]` is the COMPLETE list and there is no default suite, so a project scaffolded before 1.0 emits NO names artifact and the missing `namesFile()` is the finding first; a Drizzle column object in its place is correct, not a finding | --- @@ -64,22 +69,34 @@ import { entityFile } from "./codegen/generators/entity"; import { queriesFile } from "./codegen/generators/queries"; ``` -**Deprecated import path** (still works, removal planned): +**Removed import path.** `entityFile` / `queriesFile` / `routesFile` / `barrel` were +exported from `@metaobjectsdev/codegen-ts/generators` and deprecated; **1.0 removed them** +(ADR-0035 A3). On 1.0+ this no longer resolves: ```ts import { entityFile, queriesFile, routesFile, barrel } from "@metaobjectsdev/codegen-ts/generators"; ``` -**Finding this in a project:** grep for the deprecated path: +**Do NOT flag the subpath itself.** `@metaobjectsdev/codegen-ts/generators` is the supported, +non-deprecated home of the generators that have no ownable copy — the prompt/output tier +(`promptRender`, `outputParser`, `outputPrompt`, `extractor`, `renderHelper`, +`traceHelperFile`) plus `routesFileHono`, `namesFile` and `callableFile`. The CLI's own +prompt-gate warning tells adopters to import `promptRender` from exactly this path. A +project importing those from it is CORRECT, and reporting it as un-adopted scaffold-and-own +is a false finding. + +**Finding this in a project:** grep for the four removed NAMES on that path, never the path +alone: ```bash -grep -r '@metaobjectsdev/codegen-ts/generators' . +grep -rn '@metaobjectsdev/codegen-ts/generators' . \ + | grep -E 'entityFile|queriesFile|routesFile|barrel' ``` -A hit means the project has not adopted scaffold-and-own — recommend `meta init` -to scaffold the owned copies. The owned generator files themselves (`codegen/generators/*.ts`) -are the second thing to audit: are they stale relative to the reference templates, -or intentionally customized? +A hit is a 1.0 upgrade blocker (the import fails to resolve) — recommend `meta init` (or +`meta eject `) to scaffold the owned copies, then import those locally. The owned +generator files themselves (`codegen/generators/*.ts`) are the second thing to audit: are +they stale relative to the reference templates, or intentionally customized? --- diff --git a/.claude/skills/metaobjects-authoring/SKILL.md b/.claude/skills/metaobjects-authoring/SKILL.md index 0b1d829..96680a6 100644 --- a/.claude/skills/metaobjects-authoring/SKILL.md +++ b/.claude/skills/metaobjects-authoring/SKILL.md @@ -49,10 +49,19 @@ aggregate — that is almost always **metadata you have not declared yet.** In o 1. **Search the vocabulary** — `meta types `, or `meta types --all ` to search by behavior. There are field subtypes, relationships, projections, origins, identities, sources, and attributes you may not know exist. - Find the construct that models it. + Find the construct that models it. Add `--detail` for one construct's valid + `@attrs`, or `--format json` for the same answer as one machine-readable document + — that form carries every match (`--limit` never truncates it) with each attr's + `allowedValues`, so you read the accepted values rather than guessing them. 2. **Declare it and generate** — then *consume* the generated query/type/route; never reimplement it alongside. -3. **Only if no construct can express it** — and you have actually looked — +3. **If the model is right but the generated OUTPUT is wrong, change your generator.** + Naming, file layout, imports, framework, signatures are generator concerns, not + reasons to hand-write. The generators are in *your* repo and are yours to edit — a + standing rule not to change the MetaObjects repo does not reach them; they are a + different repository. Editing one is ordinary work, not an escalation. (See + `metaobjects-codegen` → "Your generators are yours".) +4. **Only if no construct can express it** — and you have actually looked — hand-write it, wired to generated types. Business algorithms, external integrations, and bespoke interactions are legitimately hand-written; CRUD, validation, finders, relationships, and derived/aggregate data are not. @@ -86,10 +95,20 @@ Read the existing code and schema *first*, then model to reproduce them: **Customize the CODEGEN to match the existing code before you change the existing code.** If generated output doesn't match the code's shape (naming, file layout, imports, -signatures), **tune the generator/template/config to reproduce it** — that is the -intended adoption path (owned generators, `outputPattern`, naming strategy — see the -`metaobjects-codegen` skill), **not a hack**. Reshaping working call sites to satisfy -the generator's defaults is the *last* resort, not the first. +signatures), **tune the generator/template/config to reproduce it** — naming strategy +first, then the generator or the template. **Which lever you have depends on the port, +so establish that before planning:** on TypeScript and the JVM you can own a generator +outright (TS scaffolds copies into your repo; the JVM loads your class from the project +classpath), while on C# and Python the generator registry is closed and a **Mustache +template** is the customization path — a real one, with `scope` and `outputPattern` +doing the walk and the file naming. The `metaobjects-codegen` skill has the per-port +matrix and defines `outputPattern`; read it before concluding a shape is unreachable. +**Whichever lever you get, it is yours to edit.** A standing instruction not to +change the MetaObjects repo says nothing about your own generators or templates — +reading it as though it did is how an adoption ends up hand-written, and it is the most +common way this step is skipped. Editing a generator or a template here is ordinary +adoption work: not an escalation, not a hack, and nothing to ask permission for. Reshaping working call sites to satisfy the +generator's defaults is the *last* resort, not the first. **Minimize churn to code the generator is not replacing.** The ONLY existing code that should change is the hand-written layer codegen now **owns** (the hand-rolled @@ -743,6 +762,16 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one { "source.rdb": { "@kind": "view", "@view": "v_author", "@schema": "blog" } } ``` +**This declaration is the only place a physical name is ever spelled.** Codegen emits a +per-object names artifact from it (`Names` — `ProgramNames.fields.createdAt.column`, +`ProgramNames.CreatedAtColumn`, `PROGRAM_CREATED_AT_COLUMN`, per port), the generated table +binding reads that artifact, and hand-written SQL, repositories and migration scripts +reference it — so a consumer never restates `v_author` or `created_at`, and a rename here +propagates. Declare `@column` explicitly whenever the physical name is not the naming +strategy's answer (`callPurpose` → `purpose_code`): nothing downstream can recover that +mapping by derivation, and the constant is what carries it. (See `metaobjects-codegen` → +"Never hand-write a physical name".) + **An entity's PRIMARY source must be writable** (`table`) — read-only kinds are legal only in non-primary roles. diff --git a/.claude/skills/metaobjects-codegen/SKILL.md b/.claude/skills/metaobjects-codegen/SKILL.md index 0948678..ce26d44 100644 --- a/.claude/skills/metaobjects-codegen/SKILL.md +++ b/.claude/skills/metaobjects-codegen/SKILL.md @@ -24,8 +24,38 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Refuses to overwrite any file that does NOT carry the `@generated` header; - overwrites the ones that do. +4. Decides whether it may overwrite a file — and **the rule differs by port**: + - **TypeScript, C#, Python** — by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the + generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. + - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment + (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** + `@generated`: the matcher allows only whitespace between the comment punctuation + and the token, so an `@`-prefixed tag does not match it. Remove that token and + regeneration never touches the file again. + + **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and + C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen + refused to write). The one-time fix for a project that predates the committed manifest + is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes + nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the + regeneration lands as its own diff. Adopting DECLARES those files to be generated + output, so an edit already inside one is part of the baseline and that regeneration + replaces it; commit before you run it. (TypeScript additionally has + `--baseline=fresh`: write fresh output now and discard the edits.) + + **The two rules protect a hand edit in opposite ways, so do not carry a habit across + ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — + that is what breaks the hash — and deleting the header changes nothing except your + ability to tell what generated the file. On Java and Kotlin the reverse holds: editing + a file protects it not at all, because the `GENERATED` token is still there and the + next run overwrites the edit; only removing that token does. + + (The header text differs per port and none of it is the write decision on the + hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits + ``, Java and Kotlin emit the `GENERATED` token above.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. @@ -35,8 +65,11 @@ like any hand-written module. Every emitted file carries a `@generated` header. This is load-bearing: - **Never hand-edit a file with a `@generated` header for a change you want to - keep.** The next `gen` run overwrites it. If you need different output, change - the metadata (or the template), not the generated file. + keep.** The next `gen` run overwrites it. If you need different output, change the + metadata, or change the generator that emits it. +- **This rule is about emitted output — it is not a rule about your generators.** A + generator you own carries no `@generated` header and is edited like any other source + file in your repo. See the next section before you conclude a shape is unreachable. - **Hand-written regions are preserved by three-way merge.** Where the codegen supports designated hand-editable regions, regeneration runs a three-way merge (base → yours → newly-generated) so your edits survive a regen. Code review is @@ -54,6 +87,43 @@ for a shape origins can express is drift the drift gate can't even see. A genuin loose either — it goes in the `source.rdb` **`@sql`** escape (#208, ADR-0043) so the tool still registers, fingerprints, and drift-checks it (see the projection bullet). +## Your generators are yours — editing one needs no permission + +A generator in your repo is your code, not a vendor artifact. ADR-0034 is +**scaffold-and-own**: the generators the scaffolded config wires are copied into your +repo at init, and every reference template's header says so in its own first line +("copy this into your repo … and own it", "now YOURS to change"). None carries a +`@generated` header. Editing one is ordinary work. + +**A standing rule not to change the MetaObjects repo is not a rule about your +generators.** They are different repositories, and you own yours outright. That +generalisation is the observed failure mode, not a hypothetical: an agent told not to +touch upstream quietly drops "edit the generator" from the moves available to it and +hand-writes the layer instead — the single outcome the rest of this skill exists to +prevent. If you are about to hand-write something data-shaped because the generated +shape is wrong, the generator is the file to open, and you do not need to ask first. + +**The order when generated output does not fit:** + +1. **The metadata**, if the model is wrong — wrong column type, missing relationship, + a join that should be a projection. Fix the spine first; it fixes every port at once. +2. **Your own generator**, if the model is right and the *emit* is wrong — naming, file + layout, imports, framework, signatures. +3. **Hand-write**, only for what metadata genuinely cannot express — and wire it to the + generated types. + +Hand-writing something the metadata already describes is step 3 used as step 1. + +**The converse, so ownership does not become sprawl:** wire a generator only for output +you will actually consume. Decide per generator, narrow one with its own `filter`, and +own the ones you keep — an emitted file nobody imports still reads as an invitation to +adopt the surface you decided against. + +How you get a generator's source differs per port — a copy command on TypeScript, +implementing the port's generator interface elsewhere. Your language reference has the +mechanism; see also "The commands and config keys that implement the steps above differ +per port" below. + ## Selecting generators by stable name Codegen is a set of named generators you opt into. Each generator has a **stable @@ -150,12 +220,13 @@ don't silently churn the existing code. ## Write your own generators — the built-ins rarely fit an app exactly -The built-in generators (entity, queries, routes, form, grid, barrel) cover the -common shape, but **real apps routinely need output the built-ins don't emit as-is** -— a bespoke REST contract, custom DTO/response shapes, an app-specific service or -repository layer, a UI the defaults don't produce. When that happens the model-first -move is **not** to abandon metadata and hand-write the layer. Write a **custom -generator** that reads the same metadata and emits *your* app's shape. +The built-in generators (entity, queries, routes, routes-hono, barrel, form, hooks, +grid, grid-hook) cover the common shape, but **real apps routinely need output the +built-ins don't emit as-is** — a bespoke REST contract, custom DTO/response shapes, +an app-specific service or repository layer, a UI the defaults don't produce. When +that happens the model-first move is **not** to abandon metadata and hand-write the +layer. Write a **custom generator** that reads the same metadata and emits *your* +app's shape. Treat this as a first-class, expected activity — not an escape hatch. A custom generator is still model-first: it derives from the metadata spine, so it @@ -163,6 +234,12 @@ regenerates on change and stays consistent across every entity — the leverage forfeit by hand-writing. Hand-rolling *away from* metadata is the anti-pattern; generating *your own shape from* metadata is the point. +This is for when the *shape* itself needs to change. If a built-in's shape is +already right and only the *target* is wrong — a different framework than the +shipped reference emits for — take ownership of that generator instead of writing +one from scratch; see "Your framework isn't the default" below, and your language +reference for the command that does it. + The plugin interface is small (`@metaobjectsdev/codegen-ts`): a `Generator` is `{ name, filter?, generate }`, where `generate(ctx)` returns `EmittedFile[]` (`{ path, content }`). `perEntity` / `oncePerRun` wrap the common cases: @@ -190,6 +267,31 @@ the `generators` array in `metaobjects.config.ts` next to the built-ins — it r the same pass, writes under the same target rules, and carries the `@generated` header so it round-trips like any other. +## Your framework isn't the default — the retargeting procedure + +If the shipped templates do not emit for your stack, retargeting is the **normal first +move** — not a workaround and not a sign of a bug. Owning a generator is the supported +path to any framework; MetaObjects does not ship a codegen package per framework and is +not waiting to. + +The doctrine, in order of what to try: + +1. **Check config first.** Several apparent codegen failures are one config value + (module-specifier style, output directory, dialect, API prefix). Change it and retest + before writing any code. +2. **Own the generator, not the renderer.** Take a copy of the reference template for the + artifact that is wrong and edit the one step your framework disagrees about. Each + template's header names what its emit is coupled to and which call to swap. +3. **Compose, do not fork.** Call the exported render function and wrap its result where + you can, so you keep receiving upstream fixes. Forking a whole renderer is the thing + to avoid — not owning the generator. +4. **Server-tier output is usually already portable.** The entity module and the query + helpers carry no HTTP-framework coupling; retargeting is usually only needed at the + routes and UI tiers. + +Hand-rolling *away from* metadata is the anti-pattern. Generating *your own shape from* +metadata is the point. + ### Never read metadata through an `own*()` accessor (ADR-0039) — top bug source When writing OR reviewing a generator, **read every field/node property and iterate @@ -239,6 +341,176 @@ output/template · doesn't fit → write a generator that emits your shape *from metadata* · only the genuinely un-modelable (business algorithms, external calls) is hand-written outside codegen — and it still imports the generated types. +## Two ways to author a generator — pick deliberately + +A generator can be **programmatic** (code that builds the output) or **declarative** (a +Mustache template plus a scope). Both are first-class, both ship in every port, and they +are good at different things. + +| | Programmatic | Declarative template | +|---|---|---| +| What you write | a `Generator` in the port's language, using its AST builder (ts-poet, KotlinPoet, …) | a `.mustache` file + `{ template, scope, outputPattern, format? }` | +| Output shape | expressed in code | **is the file you are editing** | +| Cross-language | per-port by construction | one template emits for any language — it renders against the neutral, byte-gated data dict | +| Logic | any | what a template can express: sections, iteration, presence flags | + +**The rule:** reach for **programmatic** when the logic is gnarly or the run is hot; reach +for a **template** when the *shape* is what you are iterating on, or when you want the same +output across languages. `scope` is `perEntity` / `perPackage` / `perModel` — the walk you +would otherwise hand-write — and `outputPattern` is the output path per item, with +`{name}` / `{Name}` / `{package}` placeholders (e.g. `"{package}/{Name}Service.java"`). +Full tradeoff table and the data dict: `docs/features/codegen-concepts.md` §3 and §10. +**Asking whether a base/extension split or a write-if-absent file exists? That's §5-§7, not +here.** §5 (*Preserving hand edits*) states MetaObjects ships exactly one hand-edit strategy — +no shipped generator on any port emits a generated-base + hand-owned-concrete pair, or a +write-if-absent file; §6 names the `skip-existing` merge strategy `runGen` accepts for +building that pair yourself, reachable only from a programmatic caller (no CLI flag selects +it); §7 (*Safety*) is the per-port write-decision mechanism behind "What codegen does" step 4 +above. + +**A template is not limited to documents.** It emits source as readily as docs — that is +what the neutral data dict is for. + +### Which is available to you depends on the port — check before you plan + +**TypeScript** has both, and the whole programmatic procedure is documented: `meta eject`, +the `metaobjects.config.ts` keys, the exported `render*` functions — see this skill's +`references/typescript.md`. The declarative path is declared in the SAME config: call +`templateGenerator()` in `generators`, or spread a parsed JSON spec with +`templateSpecToGenerators(parseTemplateSpec(...))` to reuse one written for C#/Python. +**There is no `--template-spec` flag on `meta gen` and its absence is not a gap** — the +config takes generator values, and keeping the declaration there is what keeps +`meta verify --codegen` regenerating with it. + +**Java / Kotlin** have both. **No eject command** — a programmatic generator means +implementing `com.metaobjects.generator.Generator` and naming your class in the Maven +`` element, which the plugin loads from the project classpath. The declarative +path is `TemplateScopeGenerator`, wired the same way with `