Skip to content

fix(codegen-ts): api reference follows routes generator filter - #462

Merged
dmealing merged 5 commits into
mainfrom
fm/mo-docs-respect-expose
Oct 25, 2026
Merged

dmealing merged 5 commits into
mainfrom
fm/mo-docs-respect-expose

Conversation

@dmealing

Copy link
Copy Markdown
Member

What Changed

  • The api reference (apiDocsFile()) now drops entities that a routes generator's filter rejects, and documents only the verbs the surviving routes mount. Filters and expose are unioned per surface (Fastify and Hono apart), resolved in ui-tier-gate.ts runRoutesExpose. Output with neither option is unchanged.
  • routesFile and routesFileHono (reference and generator copies) publish their filter through the mountsRoutes marker. The Generator.mountsRoutes type gains an optional filter. api-model.ts skips REST and Hono symbols for entities no surface mounts.
  • meta gen warns, by name, when an api reference is written and a routes generator carries no mountsRoutes marker (an owned copy ejected before the marker existed). Adding the marker or re-ejecting with meta eject routes --force silences it. Also updates the own-your-codegen doc and the CHANGELOG Fixed entry, and syncs the example and test-generator routes copies.
Validation

Risk Assessment

✅ Low: Docs-only generator change, bounded to the api reference model. The fix rounds are verified correct: read-only surfaces keep list+get regardless of expose and drop only when filter rejects them, and the added tests cover both cases.

Testing

Baseline ts-fast/ts-unit passed. Three live scenarios driven through the real meta gen CLI against disposable projects: a routes filter removes a projection's routes and its API docs; an expose that narrows a projection to nothing still documents GET list and GET by id, matching the mounted routes; per-entity expose narrowing still shows in docs. All pass. Full regression suite (bun test in server/typescript) passed: 12088 pass, 0 fail, recorded as a test run, not a live product check. Not driven live: the Hono routesFileHono filter path, the stale owned-copy warning, and the full-suite regression; the first two are covered only by unit tests in api-docs-expose.test.ts.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Routes filter removes a projection: its API page lists no REST routes and no routes file is emitted ✅ pass live live-proj: ProductSummary.routes.ts absent; docs/generated/api/ProductSummary.md has no REST section; README shows ProductSummary with 2 data access and no REST count
Writable entities unaffected by a projection filter: Product and AuditEntry still document 6 REST routes ✅ pass live live-proj README: Product and AuditEntry show 6 REST
Expose that narrows a projection to nothing: docs still match the read-only mount (GET list and GET by id) ✅ pass live live-proj-b: ProductSummary.routes.ts emitted; ProductSummary.md documents GET /api/product_summaries and GET /api/product_summaries/:id
Per-entity expose narrows docs to the mounted verbs for a writable entity ✅ pass live live-proj-c: Product.md lists 2 verbs (GET list, GET by id), matching mount expose ["list","get"] in Product.routes.ts; AuditEntry.md lists all 6
Hono surface (routesFileHono) filter removes a projection from the API docs ⏸️ untested no Not driven live: would need a second scratch project with includeHonoRoutes and routesFileHono wired in. Covered only by the unit test in this run.
Stale owned routes copy (no mountsRoutes marker) produces a meta gen warning when an API reference is written ⏸️ untested no Not driven live: would need an ejected routes copy without the marker in a scratch project. Warning path covered by unit tests only.
Full repository TypeScript regression suite passes with the change ⏸️ untested no Not driven live: the full bun test regression is a test-runner pass, not a run against the live product. The prior payload recorded it as live=false with no live result, so it is downgraded to unteste…
Evidence: Live scenario summary
Live scenarios (disposable projects, real `meta gen` via bun on worktree cli source)
A: live-proj: routesFile({filter: e => e.name !== "ProductSummary"}) + apiDocsFile()
   -> no ProductSummary.routes.ts; docs README lists ProductSummary "2 data access" (no REST); ProductSummary.md has no REST section; Product/AuditEntry docs unchanged (6 REST)
B: live-proj-b: routesFile({expose: () => []}) over projection
   -> ProductSummary.routes.ts emitted; ProductSummary.md documents GET list + GET /:id (matches mounts)
C: live-proj-c: routesFile({expose: e => e.name==="Product" ? ["list","get"] : undefined})
   -> Product docs 2 REST verbs; Product.routes.ts mount expose ["list","get"]; AuditEntry docs 6 verbs
Evidence: Full regression suite output
bun test v1.3.14 (0d9b296a)

packages/metadata/test/registry-coverage.test.ts:

[registry-coverage] subtypes: 53/65 exercised (81.5%), 12 UNTESTED
[registry-coverage] untested subtypes:
  attr.boolean
  attr.class
  attr.double
  attr.expression
  attr.filter
  attr.int
  attr.intMap
  attr.long
  validator.atLeastOne
  validator.comparison
  validator.presentIff
  validator.requiredWhen
[registry-coverage] exercised subtypes with untested attrs: 27

packages/codegen-ts/test/format.test.ts:
[codegen-ts] Biome formatter: 2 diagnostics; first: expected `,` but instead found `'x'`

packages/cli/test/migrate-drop-unmanaged.test.ts:
meta: migrate: refusing to drop public.theirs — absent from the committed schema snapshot, so this toolchain never managed it and the migration could not replay against a database where it never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.
meta migrate — sqlite, file:/tmp/drop-unmanaged-5ChsCk/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-5ChsCk/.metaobjects/migrations/20261025210536-x/up.sql
    /tmp/drop-unmanaged-5ChsCk/.metaobjects/migrations/20261025210536-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-VUSiQS/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-VUSiQS/.metaobjects/migrations/20261025210536-x/up.sql
    /tmp/drop-unmanaged-VUSiQS/.metaobjects/migrations/20261025210536-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-IJlXJI/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-IJlXJI/.metaobjects/migrations/20261025210536-x/up.sql
    /tmp/drop-unmanaged-IJlXJI/.metaobjects/migrations/20261025210536-x/down.sql

meta: migrate: refusing to drop public.orders.orders_job_id_fk — absent from the committed schema snapshot, so this toolchain never managed it and the migration could not replay against a database where it never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.
meta migrate — sqlite, file:/tmp/drop-unmanaged-fk-094pq4/t.db

  Changes: 1 drop-fk

  Written:
    /tmp/drop-unmanaged-fk-094pq4/.metaobjects/migrations/20261025210536-x/up.sql
    /tmp/drop-unmanaged-fk-094pq4/.metaobjects/migrations/20261025210536-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-fk-Zx20KG/t.db

  Changes: 1 drop-fk

  Written:
    /tmp/drop-unmanaged-fk-Zx20KG/.metaobjects/migrations/20261025210536-x/up.sql
    /tmp/drop-unmanaged-fk-Zx20KG/.metaobjects/migrations/20261025210536-x/down.sql


packages/cli/test/help-and-exit.test.ts:
meta gen — codegen TS targets from your declared metadata

USAGE:
  meta gen [<entity>...] [flags]

FLAGS:
  --dry-run             Compute and print, don't write
  --list                Print the generator CATALOG (name, layer, tier, what it emits,
                        what to install) and exit. Codegen is opt-in: nothing runs until
                        you wire it, and this is where you choose. No config or metadata
                        required — add --format json for the same catalog, machine-readable.
  --probe               With --list: construct every catalog generator and dry-run it
                        against YOUR model, reporting how many files each would emit.
                        A count per generator beats any category label — and it cannot go
                        stale, because it runs the generators. Needs a project.
  --baseline <default|adopt|fresh>
                        First-time-on-existing-file behavior. Default: refuse a file that
                        cannot be proved to be generated output. "adopt" records the files
                        you have as the merge base and writes NOTHING — the first run a
                        project with no committed .hashes.json can perform. "fresh"
                        overwrites them with fresh output and re-baselines (discards edits).
  --no-antipatterns     Suppress the advisory "hand-rolled what MetaObjects can model" pass
  --limit <n|all>       Advisory lines TEXT output prints before truncating (default 20)
  --generators <names>  Narrow the run to these DECLARED generators, by stable name, comma-
                        separated (e.g. docs,api-docs to write only the documentation). A
                        name metaobjects.config.ts does not declare is refused: it never adds one.
  --format <toon|json|text>   Output format (global flag; default toon off-TTY)
  <entity> [<entity>]   Positional filter on entity names
  --help, -h            Print this help

A real write run (not --dry-run) also runs an ADVISORY anti-pattern pass: it scans
your authored source for hand-rolled aggregates, money-as-float, and CHECK-IN enums
and points you at the construct that models them (origin.aggregate / field.currency /
field.enum). Warnings only — it never fails the build. Opt out with --no-antipatterns
or META_NO_ANTIPATTERNS=1.

Text output caps that list at --limit lines (default 20) and says how many it held
back. --format toon / --format json carry EVERY finding in the payload's
antiPatterns block, uncapped — --limit is a display cap for a terminal, never a
limit on the machine-readable result.

NOTE: outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts

meta migrate — diff metadata vs live DB; emit migration SQL files

USAGE:
  meta migrate [baseline|apply-pending] [flags]

SUBCOMMANDS:
  baseline             Snapshot an EXISTING database's schema as the reference point
                       (use with --from-db). NOTE: for a brand-new/empty database use
                       the greenfield example below, NOT baseline — an offline baseline
                       records your metadata as already-applied and emits no CREATE TABLE.
  apply-pending        Replay committed migration files against --db (no diff).
                       Provisions a fresh/CI database when the chain BUILDS the
                       schema — 'meta verify --replay' proves that it does. A
                       database adopted via 'baseline --from-db' has no such
                       chain. postgres/sqlite only.

MIGRATE FLAGS:
  --db <url>           DB connection URL (required for live-introspect / --apply / --rollback)
                       Supports: file:, libsql:, postgres:, postgresql:
  --dialect sqlite|postgres|d1
                       Optional dialect override (auto-detected from URL scheme)
  --migration-format default|flyway
                       Migration file layout (default: default). 'flyway' emits
                       V<N>__<slug>.sql + U<N>__<slug>.sql for a Flyway runner;
                       --apply/apply-pending/--rollback are refused under it
                       because Flyway owns apply and flyway_schema_history.
                       Also settable once as migrate.format in
                       .metaobjects/config.json.
  --out-dir <path>     Migration directory (default: ./.metaobjects/migrations;
                       flyway: src/main/resources/db/migration)
  --slug <name>        Required to WRITE a migration (not for --dry-run) (e.g., --slug add-user-shipping)
  --allow <csv>        Comma-separated destructive-change permissions:
                       drop-column,drop-table,type-change,drop-index,drop-fk,
                       drop-check,drop-view,drop-view-cascade,
                       adopt-view,nullable-to-not-null,drop-identity-default,
                       drop-unmanaged
                       drop-column DELETES the column and every value in it. If
                       the column was RENAMED, use --rename-column instead: it
                       keeps the data.
                       drop-unmanaged permits dropping a table/view the committed
                       snapshot never contained — one this toolchain never managed.
                       Without it that drop is refused, because the migration it
                       writes cannot replay against a database where the object
                       never existed.
  --on-ambiguous abort|rename|drop-add
                       How to handle ambiguous renames (default: abort)
  --rename-table [schema.]old=new
                       Declar

... [246232 bytes truncated] ...

ile": "out--site/site/acme/shop/StoreTotals.html",
      "status": "new"
    },
    {
      "file": "out--site/site/acme/shop/WorkoutEvent.html",
      "status": "new"
    },
    {
      "file": "out--site/site/coverage.html",
      "status": "new"
    },
    {
      "file": "out--site/site/assets/site.css",
      "status": "new"
    },
    {
      "file": "out--site/site/assets/site.js",
      "status": "new"
    },
    {
      "file": "out--site/site/assets/search-index.json",
      "status": "new"
    }
  ],
  "summary": "22 written",
  "help": [
    "typecheck the generated code with `npx tsc`",
    "create your database tables with `meta migrate --from-db --db <url> --dialect postgres --slug init --apply`"
  ],
  "antiPatterns": {
    "status": "ran",
    "total": 0,
    "rows": []
  },
  "warnings": [
    "1 wired generator(s) matched nothing and wrote no file: requirements-file. That is often correct — a generator whose metadata this model does not declare has nothing to emit. It is reported because a run that emitted nothing and a run that did the work otherwise look identical."
  ]
}

packages/cli/test/unit/migrate-d1.test.ts:
migrate: wrote /tmp/migrate-d1-cmd-o1VSsR/migrations/0001_init.sql
migrate: wrote /tmp/migrate-d1-cmd-o1VSsR/migrations/.down/0001_init.sql
  create-table: 1
migrate: wrote /tmp/migrate-d1-cmd-nA6CSd/migrations/0001_init-programs.sql
migrate: wrote /tmp/migrate-d1-cmd-nA6CSd/migrations/.down/0001_init-programs.sql
  create-table: 2
  add-fk: 1
  create-view: 1
meta: migrate: no wrangler.toml found in /tmp/migrate-d1-cmd-Yto5Xw or parents; pass --d1 <binding> to bypass
meta: migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection

packages/cli/test/unit/verify-strict.test.ts:
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: failed to load metadata: ERR_UNKNOWN_ATTR: Unknown attribute '@madeUpAttr' on field.string 'email' — not declared by any registered provider for field.string
  in meta.users.json at $['metadata.root'].children[0]['object.entity'].children[1]['field.string']
meta: meta verify is strict (ADR-0023): every authored @attr must be declared. Fix: register the attr on a metadata provider, OR move arbitrary author-supplied properties into an `attr.properties` bag, OR re-run with `meta verify --lax` to keep the legacy open-attr load.
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — no template.* nodes found; the template gate had nothing to check.
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — no template.* nodes found; the template gate had nothing to check.
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it

packages/cli/test/unit/verify-output.test.ts:
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: failed to load metadata: ERR_INVALID_TEMPLATE: template "Broken" @payloadRef "DoesNotExist" does not resolve to an object.value or sourceless object.projection at root
  in meta.ai.json at $['metadata.root'].children[0]['template.output']
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: [Welcome] (email/html) ERR_VAR_NOT_ON_PAYLOAD: nonExistentField (prompts/e/html:1)
meta: meta verify — 1 drift error(s) across 1 template(s).
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (drift of everything `generators` declares, documentation included), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: [Doc] (document) ERR_VAR_NOT_ON_PAYLOAD: missingField (prompts/o/x:1)
meta: meta verify — 1 drift error(s) across 1 template(s).
meta verify — not run: schema (--db <url>), codegen (--codegen), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it

 12088 pass
 50 skip
 0 fail
 4 snapshots, 41101 expect() calls
Ran 12138 tests across 1076 files. [1289.61s]
EXIT 0
Evidence: Scenario A generated docs (filtered projection)
<!-- @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT. -->

# ProductSummary API

> Import paths are relative to your generated-output directory.

## Model

\### `interface ProductSummary`

The typed shape of a ProductSummary row, generated from its metadata.

`` `ts
import { ProductSummary } from "./ProductSummary"
`` `

Fields:

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | `number` | yes |  |
| `title` | `string` |  |  |

## Data access

\### `findProductSummaryById(db: Db, id: number): Promise<ProductSummary | null>`

Fetch a single ProductSummary by its primary key; null when not found.

`` `ts
import { findProductSummaryById } from "./ProductSummary.queries"
`` `

\### `listProductSummaries(db: Db, opts?: { limit?: number; offset?: number }): Promise<ProductSummary[]>`

List ProductSummary rows with optional limit/offset paging.

`` `ts
import { listProductSummaries } from "./ProductSummary.queries"
`` `
Evidence: Scenario B generated docs (expose none over projection)
<!-- @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT. -->

# ProductSummary API

> Import paths are relative to your generated-output directory.

## Model

\### `interface ProductSummary`

The typed shape of a ProductSummary row, generated from its metadata.

`` `ts
import { ProductSummary } from "./ProductSummary"
`` `

Fields:

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | `number` | yes |  |
| `title` | `string` |  |  |

## Data access

\### `findProductSummaryById(db: Db, id: number): Promise<ProductSummary | null>`

Fetch a single ProductSummary by its primary key; null when not found.

`` `ts
import { findProductSummaryById } from "./ProductSummary.queries"
`` `

\### `listProductSummaries(db: Db, opts?: { limit?: number; offset?: number }): Promise<ProductSummary[]>`

List ProductSummary rows with optional limit/offset paging.

`` `ts
import { listProductSummaries } from "./ProductSummary.queries"
`` `

## REST

\### `GET /api/product_summaries`

List ProductSummary (supports filter/sort/paging query params).

`` `ts
import { productSummaryRoutes } from "./ProductSummary.routes"
`` `

Response body:

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | `number` | yes |  |
| `title` | `string` |  |  |

Mount: `await productSummaryRoutes(fastify)`

\### `GET /api/product_summaries/:id`

Fetch a single ProductSummary by id (404 when not found).

`` `ts
import { productSummaryRoutes } from "./ProductSummary.routes"
`` `

Response body:

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | `number` | yes |  |
| `title` | `string` |  |  |

Mount: `await productSummaryRoutes(fastify)`
Evidence: Scenario C generated API index (per-entity expose)
<!-- @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT. -->

# API Reference

Generated public API surface, one page per entity and output template.

## Entities

- [AuditEntry](./AuditEntry.md) — model, 5 data access, 6 REST, 2 validation (14 symbols)
- [Product](./Product.md) — model, 5 data access, 2 REST, 2 validation (10 symbols)
- [ProductSummary](./ProductSummary.md) — model, 2 data access, 2 REST (5 symbols)
  • Outcome: 🔧 2 issues found → auto-fixed ✅ across 2 runs (1h55m58s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (2) ✅
  • ⚠️ server/typescript/packages/codegen-ts/src/generators/api-model.ts:738 - The new filter handling is bypassed for read-only surfaces. surfaceExpose now returns [] for an entity the routes generator's filter rejects, but mountedVerbs in api-model.ts returns [&#34;list&#34;,&#34;get&#34;] for any isReadOnlySurface object (projection, report, view-backed read model) before it reads expose. So routesFile({ filter: e =&gt; e.name !== &#34;ProductSummary&#34; }) mounts nothing for the projection, yet the API page still documents GET /product-summaries and GET /product-summaries/:id. This is the same lie the change set out to remove (docs list routes that are never mounted), and it is reachable in normal use because projections and reports are the entities adopters most often filter out of a write API. The CHANGELOG claim that pages document 'the routes the generators mount and nothing else' is false for these. The same short-circuit applies to the Hono surface (restHonoSymbols -> mountedVerbs, api-model.ts ~line 920). The new tests only filter a writable entity (AuditEntry), so this is not covered. Fix: in mountedVerbs, resolve expose first and let an empty result win over the read-only default (for example, if resolveExpose(obj, expose) is an empty array, return an empty set; otherwise keep the read-only set). Add a test that filters out a projection.
  • ⚠️ server/typescript/packages/codegen-ts/src/ui-tier-gate.ts:105 - The tphDiscriminatorBase(entity) ?? entity remap in surfaceExpose (and the tph.js import) is not needed by the intent. restSymbols and restHonoSymbols are only reached through isQueryable, which excludes TPH subtypes (servesReadApi &amp;&amp; !isTphSubtype). So the expose function is never called with a subtype, and the remap is dead code. It also adds an untested rule: a subtype would inherit its base's filter and expose. Smallest form: remove the remap and pass entity straight to m.filter and resolveExpose.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ server/typescript/packages/codegen-ts/src/generators/api-model.ts:738 - The new filter handling is bypassed for read-only surfaces. surfaceExpose now returns [] for an entity the routes generator's filter rejects, but mountedVerbs in api-model.ts returns [&#34;list&#34;,&#34;get&#34;] for any isReadOnlySurface object (projection, report, view-backed read model) before it reads expose. So routesFile({ filter: e =&gt; e.name !== &#34;ProductSummary&#34; }) mounts nothing for the projection, yet the API page still documents GET /product-summaries and GET /product-summaries/:id. This is the same lie the change set out to remove (docs list routes that are never mounted), and it is reachable in normal use because projections and reports are the entities adopters most often filter out of a write API. The CHANGELOG claim that pages document 'the routes the generators mount and nothing else' is false for these. The same short-circuit applies to the Hono surface (restHonoSymbols -> mountedVerbs, api-model.ts ~line 920). The new tests only filter a writable entity (AuditEntry), so this is not covered. Fix: in mountedVerbs, resolve expose first and let an empty result win over the read-only default (for example, if resolveExpose(obj, expose) is an empty array, return an empty set; otherwise keep the read-only set). Add a test that filters out a projection.
  • ⚠️ server/typescript/packages/codegen-ts/src/generators/api-model.ts:738 - Fix round 1 over-corrected mountedVerbs: a read-only surface (projection/report) now INTERSECTS the author's expose with list+get, but the routes generators ignore expose for these. reference/routes.ts ~line 166 and templates/routes-file.ts ~line 105 say 'A projection mount is read-only by construction, so expose cannot narrow it', and the projection branch never reads expose. The old code was right to return list+get regardless of expose; only a filter rejection should empty it. Concrete failure: routesFile({ expose: [&#34;list&#34;] }) (a read-only API) mounts GET list AND GET /:id for a projection ProductSummary, but the API page now documents only GET list. expose: (e) =&gt; e.name === &#34;Order&#34; ? [&#34;list&#34;] : [] or any expose that returns []/omits get for a projection has the same understatement. This is a docs-lies-in-the-other-direction regression. Cause: filter is folded into the same ExposeOption function as an empty array (ui-tier-gate.ts surfaceExpose), so mountedVerbs cannot tell 'filtered out' from 'expose narrowed to []'. Same on the Hono surface via restHonoSymbols -> mountedVerbs (api-model.ts ~919). The new projection test only uses filter, so it passes with this bug. Smallest honest fix: carry the filter verdict separately from expose (e.g. RoutesExpose gets a per-entity 'mounted' predicate, or surfaceExpose returns a distinguishable sentinel), have mountedVerbs return list+get for read-only surfaces unless the generators' filter rejects them, and add a test with routesFile({ expose: [&#34;list&#34;] }) over a projection expecting both routes. TPH base keeps intersect, matching renderTphRoutes.

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Test** - 2 issues found → auto-fixed ✅
  • 🚨 configured test command failed with exit code 1
  • ⚠️ examples/showcase/codegen/generators/routes.ts - The configured gate failed because the owned-copy sync gate (scripts/sync-owned-template-copies.ts --check) found examples/showcase/codegen/generators/routes.ts and examples/advanced-modeling/codegen/generators/routes.ts out of date with the reference routes.ts. The re-synced copies sit in the worktree as uncommitted modifications, and the command passes with them. They must be committed with the change or the gate fails again.
  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Default routesFile(): the API docs list all five CRUD verbs for writable entities and GET list + GET by id for the projection ✅ pass live matrix.txt, A_default
routesFile({expose:[list,get]}): the docs for Product and AuditEntry drop POST, PUT, PATCH and DELETE ✅ pass live matrix.txt, B_expose_list_get
routesFile({expose:[list]}): writable entities document only GET list, while the read-only projection still documents list+get, matching what the generated projection routes file mounts ✅ pass live matrix.txt, E_expose_list_only; the generated ProductSummary.routes.ts calls mountReadOnlyCrudRoutes, which has no expose
routesFile({filter}) rejecting a writable entity: AuditEntry gets no routes file and its docs page lists no routes ✅ pass live matrix.txt, C_filter_audit
routesFile({filter}) rejecting a read-only projection: ProductSummary gets no routes file and its docs page lists no routes (review-1 regression) ✅ pass live matrix.txt, D_filter_projection
Hono: routesFileHono({filter, expose:[list,get]}) limits the Hono docs section to Product's list and get, with none for the filtered AuditEntry and ProductSummary ✅ pass live matrix.txt, Hono section. The Fastify 'REST' section is still documented in a Hono-only project; this change did not touch that behaviour.
The owned-copy sync gate sees the example routes.ts copies as current with the reference ✅ pass live bun scripts/sync-owned-template-copies.ts --check reports 114 owned copies current. This holds only with the uncommitted example changes.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains (passes in the current worktree)
  • bun scripts/sync-owned-template-copies.ts --check (114 owned copies current with the uncommitted example copies)
  • bun test test/api-docs-expose.test.ts in codegen-ts (16 pass)
  • bun test in server/typescript/packages/codegen-ts (2770 pass)
  • bun test in server/typescript/packages/cli (1737 pass, 3 skip, 0 fail)
  • Live meta gen on a disposable project with Product, AuditEntry and a ProductSummary projection, over six configs: default routesFile(), expose: [list,get], expose: [list], filter dropping AuditEntry, filter dropping the projection, and routesFileHono({filter, expose}). I compared the emitted API docs with the routes files actually emitted and the expose passed to the mount.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Routes filter removes a projection: its API page lists no REST routes and no routes file is emitted ✅ pass live live-proj: ProductSummary.routes.ts absent; docs/generated/api/ProductSummary.md has no REST section; README shows ProductSummary with 2 data access and no REST count
Writable entities unaffected by a projection filter: Product and AuditEntry still document 6 REST routes ✅ pass live live-proj README: Product and AuditEntry show 6 REST
Expose that narrows a projection to nothing: docs still match the read-only mount (GET list and GET by id) ✅ pass live live-proj-b: ProductSummary.routes.ts emitted; ProductSummary.md documents GET /api/product_summaries and GET /api/product_summaries/:id
Per-entity expose narrows docs to the mounted verbs for a writable entity ✅ pass live live-proj-c: Product.md lists 2 verbs (GET list, GET by id), matching mount expose ["list","get"] in Product.routes.ts; AuditEntry.md lists all 6
Hono surface (routesFileHono) filter removes a projection from the API docs ⏸️ untested no Not driven live: would need a second scratch project with includeHonoRoutes and routesFileHono wired in. Covered only by the unit test in this run.
Stale owned routes copy (no mountsRoutes marker) produces a meta gen warning when an API reference is written ⏸️ untested no Not driven live: would need an ejected routes copy without the marker in a scratch project. Warning path covered by unit tests only.
Full repository TypeScript regression suite passes with the change ⏸️ untested no Not driven live: the full bun test regression is a test-runner pass, not a run against the live product. The prior payload recorded it as live=false with no live result, so it is downgraded to unteste…
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • Baseline (given): scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • cd server/typescript && bun test packages/codegen-ts/test/api-docs-expose.test.ts (16 pass, 0 fail)
  • Live scenario A: scratch project live-proj, bun <worktree>/server/typescript/packages/cli/bin/meta.ts gen with routesFile({filter: e => e.name !== "ProductSummary"}) + apiDocsFile()
  • Live scenario B: scratch project live-proj-b, routesFile({expose: () => []}) over projection; meta gen, then read ProductSummary.md and ProductSummary.routes.ts
  • Live scenario C: scratch project live-proj-c, routesFile({expose: e => e.name==="Product" ? ["list","get"] : undefined}); read README.md, per-entity docs, Product.routes.ts mount args
  • Full regression: cd server/typescript && bun test (12138 tests across 1076 files; 12088 pass, 0 fail; exit 0)
  • Scenario summary written to ~/.no-mistakes/evidence/01M5T7AXVVWZ7FRRWEVP74S05D/live-scenarios.txt
  • Worktree git status clean after run; scratch projects live outside worktree in evidence dir
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…arns on unmarked owned copies

Entities a routes generator's own filter rejects are documented with no
routes; a pre-mountsRoutes owned copy is named in a gen warning. Adds an
end-to-end regression for expose omitting create/update/delete.
…DocsFile now also honours routesFile filter. CHANGELOG Unreleased entry already covered the change. No other stale docs found.
@dmealing
dmealing merged commit c0a91e6 into main Oct 25, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-docs-respect-expose branch October 25, 2026 21:11
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