Skip to content

Commit 73c76f9

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): api-docs relative import specifiers (copy-paste compiles) + document Hono when generated
Fix 1: render `./`-prefixed relative import specifiers in the api-docs code blocks (human page imports, agent group headers, and example import blocks) so a verbatim copy-paste consumer resolves against the co-located generated code (no TS2307). The ApiSymbol.importPath data stays the bare module path (the accuracy gate verifies it against the emitted file); only the rendered `from "..."` gains the `./` prefix. Fix 2: auto-detect the opt-in Hono routes generator. routesFileHono carries a new `emitsHonoRoutes` marker; the runner aggregates it across the active suite into ctx.config.includeHonoRoutes; api-docs reads it and documents the Hono CRUD registrars exactly when routesFileHono() is wired (Fastify-only otherwise). New gate api-docs-relative-imports.test.ts: asserts the rendered import blocks are `./`-prefixed (flat + package layout), tsc-compiles a CRUD consumer written from AGENT-API.md against the REAL generated entity+queries code (proves no TS2307 + the CRUD call type-checks), and proves Hono auto-detection end-to-end through runGen. Render goldens updated; other generators' goldens byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 972c956 commit 73c76f9

8 files changed

Lines changed: 511 additions & 30 deletions

File tree

server/typescript/packages/codegen-ts/src/generator.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export interface Generator {
4747
/** Marks the generator that produces entity modules — the runner uses its
4848
* target as the entity-module target for cross-target import resolution. */
4949
emitsEntityModule?: boolean;
50+
/** Marks the OPT-IN Hono routes generator (routesFileHono). The runner
51+
* aggregates this across the active suite into `ctx.config.includeHonoRoutes`,
52+
* so a generator that documents the API surface (api-docs) can AUTO-DETECT
53+
* that Hono routes are actually being emitted and document them — rather than
54+
* silently omitting the Hono CRUD registrars whenever the variant is wired. */
55+
emitsHonoRoutes?: boolean;
5056
}
5157

5258
export type GeneratorFactory<TOpts = void> = TOpts extends void

server/typescript/packages/codegen-ts/src/generators/api-doc-render.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,40 @@ const AGENT_REF = "api/agent-api.md";
2929

3030
const GENERATED_MARKER = `<!-- ${GENERATED_HEADER} — DO NOT EDIT. -->`;
3131

32+
// ---------------------------------------------------------------------------
33+
// Relative-import RENDER prefix.
34+
//
35+
// An `ApiSymbol.importPath` is the generated module's path RELATIVE TO the
36+
// codegen output dir (flat → `Product.queries`; package → the package-folded
37+
// `acme/shop/Product.queries`). It is the DATA the accuracy gate verifies
38+
// against the emitted file, so it stays a bare module path.
39+
//
40+
// The generated files import each OTHER with `./`-prefixed RELATIVE specifiers
41+
// (`from "./Product"`), so a copy-paste consumer co-located in the generated
42+
// output dir must do the same — a BARE specifier (`from "Product.queries"`)
43+
// only resolves with a non-default `baseUrl` and otherwise fails TS2307. The
44+
// renderers therefore `./`-prefix the importPath in every rendered `from "…"`
45+
// (the human page, the agent group headers, AND the example import blocks),
46+
// without touching the underlying importPath data. A consumer importing from
47+
// elsewhere adjusts the prefix (stated once in the page's import note).
48+
// ---------------------------------------------------------------------------
49+
50+
/** The `./`-prefixed RELATIVE specifier for a documented module path (the form a
51+
* co-located consumer copy-pastes). Already-relative paths pass through. */
52+
function relSpecifier(importPath: string): string {
53+
return importPath.startsWith("./") || importPath.startsWith("../")
54+
? importPath
55+
: `./${importPath}`;
56+
}
57+
58+
/** Rewrite the `from "<module>"` tail of an example `import { … } from "<module>";`
59+
* line to the `./`-prefixed relative specifier — the example imports are
60+
* pre-composed in the ApiModel (reusing each symbol's bare importPath), so the
61+
* `./` prefix is applied at render time alongside the section/header imports. */
62+
function relImportLine(line: string): string {
63+
return line.replace(/(\bfrom\s+")([^"]+)(")/, (_m, pre, mod, post) => `${pre}${relSpecifier(mod)}${post}`);
64+
}
65+
3266
// The human-facing section ORDER + HEADING per ApiSymbolKind. A unit's page
3367
// renders only the kinds it actually carries, always in this canonical order
3468
// (so two runs over the same model are byte-stable regardless of symbol order).
@@ -221,7 +255,7 @@ function importLineFor(s: ApiSymbol): string {
221255
// registrar named on the symbol instead of the "METHOD /path" name.
222256
const isRouteKind = s.kind === "rest" || s.kind === "rest-hono";
223257
const imported = isRouteKind ? s.registrar ?? s.name : s.name;
224-
return `import { ${imported} } from "${s.importPath}"`;
258+
return `import { ${imported} } from "${relSpecifier(s.importPath)}"`;
225259
}
226260

227261
/** Group a unit's symbols into ordered sections (one per present kind), each a
@@ -253,7 +287,9 @@ function entityPageVM(unit: ApiUnitDoc): EntityPageVM {
253287
* string. Undefined when the unit has no runnable example. */
254288
function unitExampleBlock(example: UnitExample | undefined): string | undefined {
255289
if (example === undefined) return undefined;
256-
const lines = [...example.imports];
290+
// The example imports are pre-composed in the ApiModel from the symbols' bare
291+
// importPaths; `./`-prefix each at render time so the copy-paste block resolves.
292+
const lines = example.imports.map(relImportLine);
257293
if (example.imports.length > 0 && example.body.length > 0) lines.push("");
258294
lines.push(...example.body);
259295
return lines.join("\n");
@@ -469,7 +505,7 @@ function agentGroups(unit: ApiUnitDoc): AgentGroupVM[] {
469505
return order.map((mod) => {
470506
const g = byModule.get(mod)!;
471507
return {
472-
importHeader: `import { ${g.names.join(", ")} } from "${mod}"`,
508+
importHeader: `import { ${g.names.join(", ")} } from "${relSpecifier(mod)}"`,
473509
symbols: g.symbols,
474510
};
475511
});

server/typescript/packages/codegen-ts/src/generators/api-docs-file.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,15 @@ export const apiDocsFile = function apiDocsFile(opts?: ApiDocsFileOpts): Generat
6464
// ONE ApiModel feeds every form (Task-1 builder; Task-2 renderers). The
6565
// pkMap is reused from the run's renderContext when present (the real gen
6666
// run always provides it) and derived otherwise.
67+
// Auto-detect: document the OPT-IN Hono CRUD surface iff the Hono routes
68+
// generator is actually in the run. The runner aggregates each generator's
69+
// `emitsHonoRoutes` marker into ctx.config.includeHonoRoutes, so api-docs
70+
// "just works" — it documents Hono exactly when `routesFileHono` is wired,
71+
// and omits it (Fastify-only) otherwise. No explicit opt needed.
6772
const model = buildApiModel(ctx.loadedRoot, {
6873
loadedRoot: ctx.loadedRoot,
6974
outputLayout: layout,
75+
includeHonoRoutes: ctx.config.includeHonoRoutes ?? false,
7076
...(ctx.renderContext?.pkMap !== undefined && { pkMap: ctx.renderContext.pkMap }),
7177
});
7278

server/typescript/packages/codegen-ts/src/generators/routes-file-hono.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
2727
const userFilter = opts?.filter ?? (() => true);
2828
const generator: Generator = {
2929
name: "routes-file-hono",
30+
// Marks this as the Hono routes generator so the runner can aggregate
31+
// `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface.
32+
emitsHonoRoutes: true,
3033
filter: (e: MetaObject) => e.ownAttr(CODEGEN_ATTR_EMIT_ROUTES) !== false && userFilter(e),
3134
generate: perEntity(async (entity, ctx) => {
3235
if (!ctx.renderContext) {

server/typescript/packages/codegen-ts/src/metaobjects-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export interface ResolvedGenConfig {
4242
dialect: Dialect;
4343
/** "flat" (default) — all files in outDir; "package" — files placed in a sub-path derived from each entity's metadata package. */
4444
outputLayout?: OutputLayout;
45+
/** Whether the OPT-IN Hono routes generator (routesFileHono) is active in the
46+
* run — aggregated by the runner from the suite's `emitsHonoRoutes` markers.
47+
* api-docs reads this to AUTO-DETECT whether to document the Hono CRUD surface
48+
* (it otherwise mirrors the default Fastify-only suite). Undefined ⇒ false. */
49+
includeHonoRoutes?: boolean;
4550
}
4651

4752
export interface MetaobjectsGenConfig extends ResolvedGenConfig {

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
143143
root.objects().map((o) => [o.name, o.package]),
144144
);
145145

146+
// Auto-detect: is the OPT-IN Hono routes generator in the active suite? If so,
147+
// surface it on every generator's ctx.config so api-docs documents the Hono
148+
// CRUD surface it actually emits (rather than silently omitting it).
149+
const includeHonoRoutes = config.generators.some((g) => g.emitsHonoRoutes === true);
150+
146151
// 4. Run each generator with a per-target render context; collect with full path.
147152
const emitted: { fullPath: string; content: string; generatedBy: string }[] = [];
148153
for (const generator of config.generators) {
@@ -173,6 +178,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
173178
dbImport: selfTarget.dbImport,
174179
dialect: config.dialect,
175180
outputLayout: selfTarget.outputLayout,
181+
includeHonoRoutes,
176182
},
177183
renderContext,
178184
...(projectRoot !== undefined && { projectRoot }),

server/typescript/packages/codegen-ts/test/golden/api-doc-render.test.ts

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
9696
## Example
9797
9898
\`\`\`ts
99-
import { createProduct, findProductById, updateProduct, deleteProductById } from "Product.queries";
99+
import { createProduct, findProductById, updateProduct, deleteProductById } from "./Product.queries";
100100
101101
const created = await createProduct(db, { name: "…" });
102102
const found = await findProductById(db, created.id);
@@ -111,7 +111,7 @@ const removed = await deleteProductById(db, created.id);
111111
The typed shape of a Product row, generated from its metadata.
112112
113113
\`\`\`ts
114-
import { Product } from "Product"
114+
import { Product } from "./Product"
115115
\`\`\`
116116
117117
Fields:
@@ -128,23 +128,23 @@ Fields:
128128
Fetch a single Product by its primary key; null when not found.
129129
130130
\`\`\`ts
131-
import { findProductById } from "Product.queries"
131+
import { findProductById } from "./Product.queries"
132132
\`\`\`
133133
134134
### \`listProducts(db: Db, opts?: { limit?: number; offset?: number }): Promise<Product[]>\`
135135
136136
List Product rows with optional limit/offset paging.
137137
138138
\`\`\`ts
139-
import { listProducts } from "Product.queries"
139+
import { listProducts } from "./Product.queries"
140140
\`\`\`
141141
142142
### \`createProduct(db: Db, data: unknown): Promise<Product>\`
143143
144144
Validate (via ProductInsertSchema) and insert a new Product.
145145
146146
\`\`\`ts
147-
import { createProduct } from "Product.queries"
147+
import { createProduct } from "./Product.queries"
148148
\`\`\`
149149
150150
Request body (data):
@@ -160,7 +160,7 @@ Throws: ZodError when data fails ProductInsertSchema validation.
160160
Partially update an existing Product by primary key; null when not found.
161161
162162
\`\`\`ts
163-
import { updateProduct } from "Product.queries"
163+
import { updateProduct } from "./Product.queries"
164164
\`\`\`
165165
166166
Request body (data):
@@ -176,7 +176,7 @@ Throws: ZodError when data fails the partial ProductInsertSchema validation.
176176
Delete a Product by primary key; true when a row was removed.
177177
178178
\`\`\`ts
179-
import { deleteProductById } from "Product.queries"
179+
import { deleteProductById } from "./Product.queries"
180180
\`\`\`
181181
182182
## REST
@@ -186,7 +186,7 @@ import { deleteProductById } from "Product.queries"
186186
List Product (supports filter/sort/paging query params).
187187
188188
\`\`\`ts
189-
import { productRoutes } from "Product.routes"
189+
import { productRoutes } from "./Product.routes"
190190
\`\`\`
191191
192192
Response body:
@@ -203,7 +203,7 @@ Mount: \`await productRoutes(fastify)\`
203203
Fetch a single Product by id (404 when not found).
204204
205205
\`\`\`ts
206-
import { productRoutes } from "Product.routes"
206+
import { productRoutes } from "./Product.routes"
207207
\`\`\`
208208
209209
Response body:
@@ -220,7 +220,7 @@ Mount: \`await productRoutes(fastify)\`
220220
Create a Product (body validated by ProductInsertSchema).
221221
222222
\`\`\`ts
223-
import { productRoutes } from "Product.routes"
223+
import { productRoutes } from "./Product.routes"
224224
\`\`\`
225225
226226
Request body:
@@ -236,7 +236,7 @@ Mount: \`await productRoutes(fastify)\`
236236
Partially update a Product by id (body validated by ProductUpdateSchema).
237237
238238
\`\`\`ts
239-
import { productRoutes } from "Product.routes"
239+
import { productRoutes } from "./Product.routes"
240240
\`\`\`
241241
242242
Request body:
@@ -252,7 +252,7 @@ Mount: \`await productRoutes(fastify)\`
252252
Delete a Product by id.
253253
254254
\`\`\`ts
255-
import { productRoutes } from "Product.routes"
255+
import { productRoutes } from "./Product.routes"
256256
\`\`\`
257257
258258
Mount: \`await productRoutes(fastify)\`
@@ -264,7 +264,7 @@ Mount: \`await productRoutes(fastify)\`
264264
Zod schema validating the body of a create<Product> / POST request (auto-generated PKs excluded).
265265
266266
\`\`\`ts
267-
import { ProductInsertSchema } from "Product"
267+
import { ProductInsertSchema } from "./Product"
268268
\`\`\`
269269
270270
Accepted fields:
@@ -278,7 +278,7 @@ Accepted fields:
278278
Zod schema validating the body of an update / PATCH request (all fields optional).
279279
280280
\`\`\`ts
281-
import { ProductUpdateSchema } from "Product"
281+
import { ProductUpdateSchema } from "./Product"
282282
\`\`\`
283283
284284
Accepted fields:
@@ -299,11 +299,11 @@ describe("renderEntityApiPage — human per-unit page", () => {
299299
const { model } = await loadModel();
300300
const out = renderEntityApiPage(unit(model, "Product"), provider);
301301
// Import per symbol — the #1 actionability fix.
302-
expect(out).toContain(`import { findProductById } from "Product.queries"`);
303-
expect(out).toContain(`import { Product } from "Product"`);
304-
expect(out).toContain(`import { ProductInsertSchema } from "Product"`);
302+
expect(out).toContain(`import { findProductById } from "./Product.queries"`);
303+
expect(out).toContain(`import { Product } from "./Product"`);
304+
expect(out).toContain(`import { ProductInsertSchema } from "./Product"`);
305305
// REST wires through the route registrar (not an importable endpoint fn).
306-
expect(out).toContain(`import { productRoutes } from "Product.routes"`);
306+
expect(out).toContain(`import { productRoutes } from "./Product.routes"`);
307307
expect(out).toContain("Mount: `await productRoutes(fastify)`");
308308
// Computed-but-previously-dropped throws now surfaces on the human page.
309309
expect(out).toContain("Throws: ZodError when data fails ProductInsertSchema validation.");
@@ -351,7 +351,7 @@ describe("renderEntityApiPage — human per-unit page", () => {
351351
Build one.
352352
353353
\`\`\`ts
354-
import { makeWidget } from "Widget.queries"
354+
import { makeWidget } from "./Widget.queries"
355355
\`\`\`
356356
357357
\`\`\`ts
@@ -433,19 +433,19 @@ Generated API reference for this project; call these exactly as written. Imports
433433
434434
## Product
435435
436-
\`import { Product, ProductInsertSchema, ProductUpdateSchema } from "Product"\`
436+
\`import { Product, ProductInsertSchema, ProductUpdateSchema } from "./Product"\`
437437
- \`interface Product { id: number; name?: string }\` — The typed shape of a Product row, generated from its metadata.
438438
- \`ProductInsertSchema: ZodType<{ name?: string }>\` — Zod schema validating the body of a create<Product> / POST request (auto-generated PKs excluded).
439439
- \`ProductUpdateSchema: ZodType<{ name?: string }>\` — Zod schema validating the body of an update / PATCH request (all fields optional).
440440
441-
\`import { findProductById, listProducts, createProduct, updateProduct, deleteProductById } from "Product.queries"\`
441+
\`import { findProductById, listProducts, createProduct, updateProduct, deleteProductById } from "./Product.queries"\`
442442
- \`findProductById(db: Db, id: number): Promise<Product | null>\` — Fetch a single Product by its primary key; null when not found.
443443
- \`listProducts(db: Db, opts?: { limit?: number; offset?: number }): Promise<Product[]>\` — List Product rows with optional limit/offset paging.
444444
- \`createProduct(db: Db, data: { name?: string }): Promise<Product>\` — Validate (via ProductInsertSchema) and insert a new Product. [throws: ZodError when data fails ProductInsertSchema validation.]
445445
- \`updateProduct(db: Db, id: number, data: { name?: string }): Promise<Product | null>\` — Partially update an existing Product by primary key; null when not found. [throws: ZodError when data fails the partial ProductInsertSchema validation.]
446446
- \`deleteProductById(db: Db, id: number): Promise<boolean>\` — Delete a Product by primary key; true when a row was removed.
447447
448-
\`import { productRoutes } from "Product.routes"\`
448+
\`import { productRoutes } from "./Product.routes"\`
449449
- \`GET /products -> { id: number; name?: string }\` — List Product (supports filter/sort/paging query params).
450450
- \`GET /products/:id -> { id: number; name?: string }\` — Fetch a single Product by id (404 when not found).
451451
- \`POST /products body: { name?: string }\` — Create a Product (body validated by ProductInsertSchema).
@@ -462,16 +462,16 @@ const removed = await deleteProductById(db, created.id);
462462
463463
## SummaryVO
464464
465-
\`import { SummaryVO } from "SummaryVO"\`
465+
\`import { SummaryVO } from "./SummaryVO"\`
466466
- \`interface SummaryVO { headline: string }\` — The typed shape of a SummaryVO row, generated from its metadata.
467467
468468
## ProductSummary
469469
470-
\`import { extractProductSummary, extractLenientProductSummary } from "ProductSummary.extractor"\`
470+
\`import { extractProductSummary, extractLenientProductSummary } from "./ProductSummary.extractor"\`
471471
- \`extractProductSummary(root: MetaRoot, text: string): SummaryVO // SummaryVO: { headline: string }\` — Parse dirty LLM json text into a strict, fully-typed SummaryVO graph. [throws: Error when a @required field is lost (the strict opt-in gate).]
472472
- \`extractLenientProductSummary(root: MetaRoot, text: string): ExtractionResult<ProductSummaryExtracted>\` — Never-throwing extract; inspect report for lost/defaulted fields.
473473
474-
\`import { renderProductSummary } from "ProductSummary.render"\`
474+
\`import { renderProductSummary } from "./ProductSummary.render"\`
475475
- \`renderProductSummary(payload: SummaryVO, provider: Provider): string\` — Render the ProductSummary document from a typed SummaryVO payload.
476476
477477
Example:
@@ -543,12 +543,12 @@ describe("renderAgentApi — condensed agent/LLM form", () => {
543543
// The import header names the module AND the importable identifier; the
544544
// bullet names the callable signature. Together an agent has the exact import
545545
// + the call shape for createProduct.
546-
expect(out).toContain(`import { findProductById, listProducts, createProduct, updateProduct, deleteProductById } from "Product.queries"`);
546+
expect(out).toContain(`import { findProductById, listProducts, createProduct, updateProduct, deleteProductById } from "./Product.queries"`);
547547
// T2: the agent form now inlines the create payload SHAPE in place of the
548548
// opaque `data: unknown`, so an agent knows what fields to pass.
549549
expect(out).toContain("`createProduct(db: Db, data: { name?: string }): Promise<Product>`");
550550
// REST endpoints are wired through the entity's route registrar import.
551-
expect(out).toContain(`import { productRoutes } from "Product.routes"`);
551+
expect(out).toContain(`import { productRoutes } from "./Product.routes"`);
552552
});
553553

554554
test("throws is surfaced as a compact [throws: …] marker", async () => {

0 commit comments

Comments
 (0)