Skip to content

Commit 477b159

Browse files
dmealingclaude
andcommitted
Merge: api-docs real-output fixes — copy-paste-compilable imports, Hono coverage, + a generator bugfix
From a second staff agentic-AI review that ran the REAL generation and tsc-compiled a consumer from AGENT-API.md (value jumped ~3->7; the prior blockers are real-closed): - api-docs renders ./-prefixed import specifiers so a consumer copied from the doc COMPILES (was bare specifiers -> TS2307); a new gate tsc-compiles a CRUD consumer from AGENT-API.md against the real generated code. - api-docs auto-documents Hono routes when routesFileHono() is in the suite (new emitsHonoRoutes flag aggregated by the runner). - Generator bugfix: extractor/render-helper imported the payload VO from a non-existent ./payloads.js -> now import each VO from its real entity module; a new gate tsc-compiles the generated extractor/render files. This uncovered + fixed a second latent bug (the mapper emitted ?? null against a T|undefined interface -> TS2322; now ?? undefined). Engine-layer extract-conformance unaffected (the change is in generated TS glue below the cross-port contract; corpus 22/22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 972c956 + d6aaa71 commit 477b159

13 files changed

Lines changed: 890 additions & 84 deletions

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/extractor-file.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
//
66
// The emitted extractor sits over the output-parser's nested-capable extract and turns dirty LLM
77
// text into the strict typed payload graph. It imports from the sibling <Name>.output.ts (the
8-
// output-parser) and ./payloads.ts, so run it alongside outputParser() + a payload generator.
8+
// output-parser) and from each payload value-object's own entity module (<VO>.ts, emitted by
9+
// entityFile), so run it alongside outputParser() + entityFile().
910
//
1011
// Consumer wiring (metaobjects.config.ts):
1112
// generators: [..., outputParser(), extractor()]

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/src/templates/extractor.ts

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ function isObjectField(field: MetaData): boolean {
6464

6565
/**
6666
* The union-alias type name for a `field.enum` with effective `@values`, or undefined when the
67-
* field is not a value-constrained enum. Reuses `enumUnionAliasName` — the SAME naming the payload
68-
* emitter (`payload-codegen.ts`) types the field as — so the cast target resolves to the exact
69-
* alias exported from `payloads.ts`. `ownerName` is the owning value-object's interface name.
67+
* field is not a value-constrained enum. Reuses `enumUnionAliasName` — the SAME naming the entity
68+
* inferred-types emitter types the field as — so the cast target resolves to the exact alias
69+
* exported from the owning VO's entity module. `ownerName` is the owning value-object's interface name.
7070
*/
7171
function enumAlias(field: MetaData, ownerName: string): string | undefined {
7272
if (field.subType !== FIELD_SUBTYPE_ENUM) return undefined;
@@ -76,11 +76,12 @@ function enumAlias(field: MetaData, ownerName: string): string | undefined {
7676
}
7777

7878
/**
79-
* True iff the field is required IN THE STRICT PAYLOAD TYPE. This MUST match
80-
* payload-codegen.ts's `isFieldRequired` predicate EXACTLY (boolean `true` only) — the payload
81-
* interface decides `T` vs `T | null` by that predicate, and the mapper's optionality assumption
82-
* (`m.f!` vs `m.f ?? null`) has to agree with the type it is constructing. A `@required:"true"`
83-
* string field is therefore `T | null` in the payload AND optional here (no skew).
79+
* True iff the field is required IN THE STRICT PAYLOAD TYPE. The strict payload IS the VO's own
80+
* generated entity-module interface (`renderValueObjectInterface`), which types a required field
81+
* `f: T` and an optional one `f?: T` (i.e. `T | undefined` — NOT `T | null`). So the mapper's
82+
* optionality assumption (`m.f!` vs `m.f ?? undefined`) has to agree with THAT interface, and an
83+
* absent optional maps to `undefined`, never `null`. This predicate matches the interface's
84+
* required test (boolean `true` only) so the two never skew.
8485
*/
8586
function isFieldRequired(field: MetaData): boolean {
8687
return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
@@ -93,8 +94,10 @@ function mapperName(vo: MetaData): string {
9394

9495
/**
9596
* The mapper-body initializer expression for one field, reading mirror member `m.<name>` and
96-
* mapping it onto the strict payload's exact optionality (required → `m.f!`; optional → `m.f ?? null`).
97-
* Nested single/array objects recurse into their toStrict<Type> mapper, guarding null when optional.
97+
* mapping it onto the strict payload's exact optionality (required → `m.f!`; optional → `m.f ?? undefined`).
98+
* The strict payload is the VO's generated entity-module interface, whose optional fields are
99+
* `f?: T` (= `T | undefined`, never `T | null`), so an absent optional maps to `undefined`.
100+
* Nested single/array objects recurse into their toStrict<Type> mapper, guarding when optional.
98101
*/
99102
function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
100103
const name = field.name;
@@ -104,25 +107,25 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
104107
const target = refVo(field, root);
105108
if (target === undefined) {
106109
// Unresolved @objectRef — the payload type would be `unknown`; pass through as-is.
107-
return required ? `m.${name}!` : `m.${name} ?? null`;
110+
return required ? `m.${name}!` : `m.${name} ?? undefined`;
108111
}
109112
const fn = mapperName(target);
110113
if (isArray(field)) {
111114
// Required array-of-objects: each element mapped; element nulls dropped at the type level
112115
// via the non-null assertion (extract never yields null elements for a present array).
113116
if (required) return `m.${name}!.map((e) => ${fn}(e!))`;
114-
return `m.${name} ? m.${name}!.map((e) => ${fn}(e!)) : null`;
117+
return `m.${name} ? m.${name}!.map((e) => ${fn}(e!)) : undefined`;
115118
}
116119
// Single nested object.
117120
if (required) return `${fn}(m.${name}!)`;
118-
return `m.${name} ? ${fn}(m.${name}) : null`;
121+
return `m.${name} ? ${fn}(m.${name}) : undefined`;
119122
}
120123

121124
// Scalar ARRAY (e.g. `field.string` with isArray): the mirror types it `(T | null)[] | null`
122-
// but the strict payload types it `T[]` (required) / `T[] | null` (optional). A bare `m.f!`
125+
// but the strict payload types it `T[]` (required) / `T[]?` (optional). A bare `m.f!`
123126
// would leave the element type `T | null`, a `tsc --strict` TS2322 error. Filter out null
124127
// elements so the element type narrows to non-null (consistent with the lost-element DROP policy
125-
// already used for required arrays-of-objects above).
128+
// already used for required arrays-of-objects above). An absent optional array maps to `undefined`.
126129
//
127130
// ENUM arrays: the mirror element is a plain `string`, but the strict payload types it as the
128131
// closed `<Alias>[]` union. The null-filter alone narrows to `string[]`, not `<Alias>[]` — a
@@ -136,23 +139,23 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
136139
return alias !== undefined ? `(${filtered}) as ${alias}[]` : filtered;
137140
}
138141
const filtered = `m.${name}.filter((x): x is NonNullable<typeof x> => x != null)`;
139-
const guarded = `m.${name} == null ? null : ${filtered}`;
142+
const guarded = `m.${name} == null ? undefined : ${filtered}`;
140143
return alias !== undefined
141-
? `m.${name} == null ? null : (${filtered}) as ${alias}[]`
144+
? `m.${name} == null ? undefined : (${filtered}) as ${alias}[]`
142145
: guarded;
143146
}
144147

145148
// Scalar / enum (single): the strict payload's optionality decides the shape.
146-
// Required → non-null assertion; optional → `?? null` (matches the payload's `f?: T | null`).
149+
// Required → non-null assertion; optional → `?? undefined` (matches the entity-module `f?: T`).
147150
//
148151
// ENUM scalar: the mirror member is a plain `string`, but the strict payload types it as the
149152
// closed `<Alias>` union — assigning `string` into `<Alias>` is a `tsc --strict` TS2322 error.
150153
// So the value is CAST to `<Alias>`. Sound for the same reason as enum arrays above: the engine
151154
// already validated membership (or extract throws on a lost required field).
152155
if (alias !== undefined) {
153-
return required ? `m.${name}! as ${alias}` : `(m.${name} ?? null) as ${alias} | null`;
156+
return required ? `m.${name}! as ${alias}` : `(m.${name} ?? undefined) as ${alias} | undefined`;
154157
}
155-
return required ? `m.${name}!` : `m.${name} ?? null`;
158+
return required ? `m.${name}!` : `m.${name} ?? undefined`;
156159
}
157160

158161
/**
@@ -202,32 +205,52 @@ function emitMapper(
202205
}
203206

204207
/**
205-
* Collect the strict payload-interface names reachable from `vo` (for the type-only import),
206-
* PLUS every enum union-alias reachable from those VOs. Both are exported from `payloads.ts`
207-
* (the alias is hoisted above the interface there), so the extractor's `as <Alias>` casts need
208-
* the alias names imported alongside the interface names. Deduped, in discovery order.
208+
* One payload-type import group: the strict types (VO interface + its own enum
209+
* union-aliases) imported from a single VO entity module.
209210
*/
210-
function reachablePayloadTypes(vo: MetaData, root: MetaData): string[] {
211-
const order: string[] = [];
212-
const seen = new Set<string>();
211+
interface PayloadImportGroup {
212+
/** The VO whose entity module exports these types (`./<module>.js`). */
213+
module: string;
214+
/** The strict type names exported by that module, in discovery order. */
215+
types: string[];
216+
}
217+
218+
/**
219+
* Group the strict payload types reachable from `vo` BY THE ENTITY MODULE THAT EXPORTS THEM.
220+
*
221+
* Each value-object gets its own generated entity module (`entityFile()` emits `<VO>.ts` exporting
222+
* `export interface <VO>`), and `renderEnumTypeAliases` hoists every `field.enum` union-alias INTO
223+
* the OWNING VO's module (`export type <Owner><Field> = ...` co-located with the interface). So a
224+
* VO's interface AND the aliases for its own enum fields are imported from `./<VO>.js` — NOT from a
225+
* single `payloads.ts` (which no generator emits). Deduped, in discovery order, one group per VO.
226+
*/
227+
function reachablePayloadGroups(vo: MetaData, root: MetaData): PayloadImportGroup[] {
228+
const groups: PayloadImportGroup[] = [];
229+
const seenVo = new Set<string>();
230+
const seenAlias = new Set<string>();
213231
const visit = (cur: MetaData) => {
214-
if (seen.has(cur.name)) return;
215-
seen.add(cur.name);
216-
order.push(cur.name);
232+
if (seenVo.has(cur.name)) return;
233+
seenVo.add(cur.name);
234+
// The VO interface + its OWN enum aliases share the VO's entity module.
235+
const types: string[] = [cur.name];
217236
for (const f of fields(cur)) {
218237
const alias = enumAlias(f, cur.name);
219-
if (alias !== undefined && !seen.has(alias)) {
220-
seen.add(alias);
221-
order.push(alias);
238+
if (alias !== undefined && !seenAlias.has(alias)) {
239+
seenAlias.add(alias);
240+
types.push(alias);
222241
}
242+
}
243+
groups.push({ module: cur.name, types });
244+
// Recurse into nested object refs (their interfaces live in their own modules).
245+
for (const f of fields(cur)) {
223246
if (isObjectField(f)) {
224247
const target = refVo(f, root);
225248
if (target !== undefined) visit(target);
226249
}
227250
}
228251
};
229252
visit(vo);
230-
return order;
253+
return groups;
231254
}
232255

233256
/** Collect the mirror-interface names reachable from `vo` (root mirror + nested VO mirrors). */
@@ -286,10 +309,16 @@ export function renderExtractor(root: MetaData, templateName: string): string {
286309
const extractName = `extract${templateName}`;
287310
const rootMapper = mapperName(vo);
288311

289-
const payloadTypes = reachablePayloadTypes(vo, root);
312+
const payloadGroups = reachablePayloadGroups(vo, root);
290313
const mirrorTypes = reachableMirrorTypes(vo, root, rootMirror);
291314
const mappers = emitMappers(vo, root, rootMirror);
292315

316+
// One type-only import per VO entity module (the VO interface + its own enum
317+
// union-aliases co-located there). NOT a single non-existent `./payloads.js`.
318+
const payloadImports = payloadGroups
319+
.map((g) => `import type { ${g.types.join(", ")} } from "./${g.module}.js";`)
320+
.join("\n");
321+
293322
const lostMsg =
294323
`${extractName}: lost required field(s): `;
295324

@@ -301,7 +330,7 @@ export function renderExtractor(root: MetaData, templateName: string): string {
301330
`// mapping the all-nullable mirror onto the strict payload. No registry / binding / factory.\n` +
302331
`\n` +
303332
`import {\n ${extractLenientWithName},\n type ${mirrorTypes.join(",\n type ")},\n} from "./${templateName}.output.js";\n` +
304-
`import type { ${payloadTypes.join(", ")} } from "./payloads.js";\n` +
333+
`${payloadImports}\n` +
305334
`import type { MetaRoot } from "@metaobjectsdev/metadata";\n` +
306335
`import type { ExtractionResult } from "@metaobjectsdev/render";\n` +
307336
`\n` +

server/typescript/packages/codegen-ts/src/templates/render-helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export function renderRenderHelper(
192192

193193
return `import { render } from "@metaobjectsdev/render";
194194
import type { Provider, EmailDocument } from "@metaobjectsdev/render";
195-
import type { ${payloadRef} } from "./payloads.js";
195+
import type { ${payloadRef} } from "./${payloadRef}.js";
196196
197197
/**
198198
* Render the ${templateName} email (subject + html body${typeof textBodyRef === "string" ? " + text body" : ""}) from a
@@ -230,7 +230,7 @@ export function ${fnName}(payload: ${payloadRef}, provider: Provider): EmailDocu
230230

231231
return `import { render } from "@metaobjectsdev/render";
232232
import type { Provider } from "@metaobjectsdev/render";
233-
import type { ${payloadRef} } from "./payloads.js";
233+
import type { ${payloadRef} } from "./${payloadRef}.js";
234234
235235
/**
236236
* Render the ${templateName} document from a typed ${payloadRef} payload. Wraps the

0 commit comments

Comments
 (0)