@metaobjectsdev/codegen-ts 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/generators/entity-file.d.ts.map +1 -1
  2. package/dist/generators/entity-file.js +7 -0
  3. package/dist/generators/entity-file.js.map +1 -1
  4. package/dist/generators/index.d.ts +1 -0
  5. package/dist/generators/index.d.ts.map +1 -1
  6. package/dist/generators/index.js +1 -0
  7. package/dist/generators/index.js.map +1 -1
  8. package/dist/generators/output-prompt-file.d.ts +9 -0
  9. package/dist/generators/output-prompt-file.d.ts.map +1 -0
  10. package/dist/generators/output-prompt-file.js +51 -0
  11. package/dist/generators/output-prompt-file.js.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/instance-artifacts.d.ts +29 -0
  17. package/dist/instance-artifacts.d.ts.map +1 -0
  18. package/dist/instance-artifacts.js +57 -0
  19. package/dist/instance-artifacts.js.map +1 -0
  20. package/dist/metaobjects-config.d.ts +10 -0
  21. package/dist/metaobjects-config.d.ts.map +1 -1
  22. package/dist/metaobjects-config.js +1 -0
  23. package/dist/metaobjects-config.js.map +1 -1
  24. package/dist/render-context.d.ts +4 -1
  25. package/dist/render-context.d.ts.map +1 -1
  26. package/dist/render-context.js +1 -0
  27. package/dist/render-context.js.map +1 -1
  28. package/dist/runner.d.ts.map +1 -1
  29. package/dist/runner.js +1 -0
  30. package/dist/runner.js.map +1 -1
  31. package/dist/templates/entity-file.d.ts.map +1 -1
  32. package/dist/templates/entity-file.js +12 -0
  33. package/dist/templates/entity-file.js.map +1 -1
  34. package/dist/templates/fr010-field-mapping.d.ts +28 -0
  35. package/dist/templates/fr010-field-mapping.d.ts.map +1 -0
  36. package/dist/templates/fr010-field-mapping.js +170 -0
  37. package/dist/templates/fr010-field-mapping.js.map +1 -0
  38. package/dist/templates/output-format-spec-emitter.d.ts +4 -0
  39. package/dist/templates/output-format-spec-emitter.d.ts.map +1 -0
  40. package/dist/templates/output-format-spec-emitter.js +60 -0
  41. package/dist/templates/output-format-spec-emitter.js.map +1 -0
  42. package/dist/templates/output-parser.d.ts.map +1 -1
  43. package/dist/templates/output-parser.js +69 -4
  44. package/dist/templates/output-parser.js.map +1 -1
  45. package/dist/templates/output-prompt.d.ts +10 -0
  46. package/dist/templates/output-prompt.d.ts.map +1 -0
  47. package/dist/templates/output-prompt.js +75 -0
  48. package/dist/templates/output-prompt.js.map +1 -0
  49. package/dist/templates/recover-schema-emitter.d.ts +8 -0
  50. package/dist/templates/recover-schema-emitter.d.ts.map +1 -0
  51. package/dist/templates/recover-schema-emitter.js +64 -0
  52. package/dist/templates/recover-schema-emitter.js.map +1 -0
  53. package/package.json +4 -4
  54. package/src/generators/entity-file.ts +7 -0
  55. package/src/generators/index.ts +1 -0
  56. package/src/generators/output-prompt-file.ts +66 -0
  57. package/src/index.ts +1 -0
  58. package/src/instance-artifacts.ts +61 -0
  59. package/src/metaobjects-config.ts +11 -0
  60. package/src/render-context.ts +5 -1
  61. package/src/runner.ts +1 -0
  62. package/src/templates/entity-file.ts +13 -0
  63. package/src/templates/fr010-field-mapping.ts +191 -0
  64. package/src/templates/output-format-spec-emitter.ts +97 -0
  65. package/src/templates/output-parser.ts +77 -2
  66. package/src/templates/output-prompt.ts +88 -0
  67. package/src/templates/recover-schema-emitter.ts +91 -0
@@ -0,0 +1,66 @@
1
+ // server/typescript/packages/codegen-ts/src/generators/output-prompt-file.ts
2
+ //
3
+ // FR-010 stock generator that emits one <TemplateName>.prompt.ts file per json/xml
4
+ // template.output node — the output-format prompt fragment ("produce your answer
5
+ // like this"). Wraps renderOutputPrompt() from templates/output-prompt.ts. Skips
6
+ // text-format outputs and outputs whose @payloadRef doesn't resolve to a value-object
7
+ // (same contract as the output-parser generator).
8
+ //
9
+ // Consumer wiring (metaobjects.config.ts):
10
+ // generators: [..., promptRender(), outputParser(), outputPrompt()]
11
+ //
12
+ // Custom output directory:
13
+ // generators: [..., outputPrompt({ outDir: "src/generated/outputs" })]
14
+
15
+ import {
16
+ TYPE_OBJECT,
17
+ TYPE_TEMPLATE,
18
+ TEMPLATE_SUBTYPE_OUTPUT,
19
+ TEMPLATE_ATTR_PAYLOAD_REF,
20
+ } from "@metaobjectsdev/metadata";
21
+ import {
22
+ type EmittedFile,
23
+ type Generator,
24
+ type GeneratorFactory,
25
+ oncePerRun,
26
+ } from "../generator.js";
27
+ import { renderOutputPrompt, templateSupportsPrompt } from "../templates/output-prompt.js";
28
+
29
+ export interface OutputPromptOpts {
30
+ /** Output directory prefix relative to the target's outDir. Default: "" (root). */
31
+ outDir?: string;
32
+ /** Optional named output target (registry key). Defaults to "default". */
33
+ target?: string;
34
+ }
35
+
36
+ export const outputPrompt = function outputPrompt(opts?: OutputPromptOpts): Generator {
37
+ const dirPrefix = opts?.outDir ? `${opts.outDir.replace(/\/$/, "")}/` : "";
38
+ const generator: Generator = {
39
+ name: "output-prompt",
40
+ generate: oncePerRun((_entities, ctx) => {
41
+ const root = ctx.loadedRoot;
42
+ const outputs = root
43
+ .ownChildren()
44
+ .filter((c) => c.type === TYPE_TEMPLATE && c.subType === TEMPLATE_SUBTYPE_OUTPUT);
45
+ const files: EmittedFile[] = [];
46
+ for (const t of outputs) {
47
+ // Only json/xml outputs get a renderable prompt fragment.
48
+ if (!templateSupportsPrompt(t)) continue;
49
+ // @payloadRef must resolve to a value-object (same contract as the parser).
50
+ const payloadRef = t.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
51
+ if (typeof payloadRef !== "string") continue;
52
+ const vo = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === payloadRef);
53
+ if (!vo) continue;
54
+ files.push({
55
+ path: `${dirPrefix}${t.name}.prompt.ts`,
56
+ content: renderOutputPrompt(root, t.name),
57
+ });
58
+ }
59
+ return files;
60
+ }),
61
+ };
62
+ if (opts?.target) {
63
+ generator.target = opts.target;
64
+ }
65
+ return generator;
66
+ } as GeneratorFactory<OutputPromptOpts>;
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { packageToPath, entityOutputPath, crossEntitySpecifier, barrelEntrySpeci
44
44
  export type { OutputLayout, ResolvedTarget } from "./import-path.js";
45
45
 
46
46
  export { isProjection, isWriteThrough } from "./projection/projection-detector.js";
47
+ export { isAbstract, emitsInstanceArtifacts, emitsWriteArtifacts } from "./instance-artifacts.js";
47
48
  export { extractViewSpec } from "./projection/extract-view-spec.js";
48
49
  export type { ExtractContext } from "./projection/extract-view-spec.js";
49
50
  export { emitViewDdl } from "./projection/view-ddl-emit.js";
@@ -0,0 +1,61 @@
1
+ // Framework-level guard shared by every generator that emits INSTANCE / WRITE
2
+ // artifacts (write forms, CRUD hooks, grid columns, grid hooks, …).
3
+ //
4
+ // Two MetaData concepts make an entity *not* a source of instance artifacts:
5
+ //
6
+ // 1. Abstract (`@isAbstract: true`) — a fundamental MetaData concept: an
7
+ // abstract type contributes shape via inheritance ONLY. It never has an
8
+ // instantiable representation, so it must never produce a write form,
9
+ // CRUD hooks, columns, a grid, or any instance artifact. It still gets a
10
+ // type-only interface from the entity-file generator (so subclasses and
11
+ // consumers can reference its shape), but nothing that points at an
12
+ // `<E>Insert` schema / `$table` / mutation hook it does not have.
13
+ //
14
+ // 2. Projection (read-only view; see `isProjection`) — instantiable for READ
15
+ // but never for WRITE. It legitimately gets read models + read-only hooks
16
+ // + grids, but NOT a write form. WRITE-only generators (forms) reuse
17
+ // `emitsWriteArtifacts` to skip these; read-capable generators (tanstack
18
+ // hooks/grids) instead branch internally on `isProjection`.
19
+ //
20
+ // Centralizing these as `emitsInstanceArtifacts` / `emitsWriteArtifacts` keeps
21
+ // the rule in one place: a generator composes the matching guard into its
22
+ // `filter` rather than re-deriving "is this abstract / read-only" ad hoc.
23
+
24
+ import type { MetaData } from "@metaobjectsdev/metadata";
25
+ import { isProjection } from "./projection/projection-detector.js";
26
+
27
+ /**
28
+ * True when `entity` is abstract (`@isAbstract: true`).
29
+ *
30
+ * Thin, framework-level accessor so generators in sibling codegen packages
31
+ * have a single import surface for the abstract concept and don't reach into
32
+ * metadata internals (mirrors how `isProjection` is the shared read-only
33
+ * discriminator). Abstract types contribute shape via inheritance only.
34
+ */
35
+ export function isAbstract(entity: MetaData): boolean {
36
+ return entity.isAbstract === true;
37
+ }
38
+
39
+ /**
40
+ * True when `entity` should produce INSTANCE artifacts of ANY kind (read OR
41
+ * write): CRUD/read hooks, grid columns, grid hooks. Abstract types are
42
+ * excluded — they have no instantiable representation.
43
+ *
44
+ * Read-capable generators (tanstack hooks/grids) compose this so they skip
45
+ * abstract bases while still serving projections via their own
46
+ * `isProjection` read-only branch.
47
+ */
48
+ export function emitsInstanceArtifacts(entity: MetaData): boolean {
49
+ return !isAbstract(entity);
50
+ }
51
+
52
+ /**
53
+ * True when `entity` should produce WRITE artifacts (write forms, mutation
54
+ * surfaces). Excludes both abstract types (no instance at all) and projections
55
+ * (read-only views — instantiable for read, never for write).
56
+ *
57
+ * WRITE-only generators (e.g. the React form generator) compose this.
58
+ */
59
+ export function emitsWriteArtifacts(entity: MetaData): boolean {
60
+ return !isAbstract(entity) && !isProjection(entity);
61
+ }
@@ -37,6 +37,15 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
37
37
  columnNamingStrategy?: ColumnNamingStrategy;
38
38
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
39
39
  apiPrefix?: string;
40
+ /**
41
+ * Whether abstract entities (`@isAbstract: true`) emit their shape artifact
42
+ * (the type-only interface / value-object file from the entity-file
43
+ * generator). Defaults to `true`. Instance/write artifacts (forms, CRUD/read
44
+ * hooks, grids) are NEVER emitted for abstract entities regardless of this
45
+ * flag — that invariant lives in `instance-artifacts.ts`. This knob only
46
+ * governs the shape, mirroring the cross-port `emitAbstractShapes` option.
47
+ */
48
+ emitAbstractShapes?: boolean;
40
49
  /** Named output destinations. Generators reference one via `target`. */
41
50
  targets?: Record<string, TargetConfig>;
42
51
  /** importBase for the default target (top-level outDir). */
@@ -57,6 +66,7 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
57
66
  export interface NormalizedMetaobjectsGenConfig extends Omit<MetaobjectsGenConfig, "targets"> {
58
67
  columnNamingStrategy: ColumnNamingStrategy;
59
68
  apiPrefix: string;
69
+ emitAbstractShapes: boolean;
60
70
  outputLayout: OutputLayout;
61
71
  targets: Record<string, ResolvedTarget>;
62
72
  }
@@ -98,6 +108,7 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
98
108
  ...config,
99
109
  columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
100
110
  apiPrefix: config.apiPrefix ?? "",
111
+ emitAbstractShapes: config.emitAbstractShapes ?? true,
101
112
  outputLayout: config.outputLayout ?? "flat",
102
113
  targets: resolveTargets(config),
103
114
  };
@@ -39,6 +39,8 @@ export interface RenderContext {
39
39
  columnNamingStrategy: ColumnNamingStrategy;
40
40
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
41
41
  apiPrefix: string;
42
+ /** Whether abstract entities emit their shape artifact (type-only interface / value-object file). Defaults to true. Instance/write artifacts are never emitted for abstract entities regardless. */
43
+ emitAbstractShapes: boolean;
42
44
  /** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
43
45
  outputLayout: OutputLayout;
44
46
  /** The target THIS generator emits to (drives path layout + same-target imports). */
@@ -53,11 +55,12 @@ export interface RenderContext {
53
55
  }
54
56
 
55
57
  /** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). */
56
- export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
58
+ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
57
59
  extStyle?: ExtStyle;
58
60
  omImport?: string;
59
61
  columnNamingStrategy?: ColumnNamingStrategy;
60
62
  apiPrefix?: string;
63
+ emitAbstractShapes?: boolean;
61
64
  outputLayout?: OutputLayout;
62
65
  packageOf?: Map<string, string | undefined>;
63
66
  selfTarget?: ResolvedTarget;
@@ -85,6 +88,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
85
88
  omImport: opts.omImport ?? "../index",
86
89
  columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
87
90
  apiPrefix: opts.apiPrefix ?? "",
91
+ emitAbstractShapes: opts.emitAbstractShapes ?? true,
88
92
  outputLayout,
89
93
  packageOf: opts.packageOf ?? new Map(),
90
94
  selfTarget: defaultTarget,
package/src/runner.ts CHANGED
@@ -155,6 +155,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
155
155
  extStyle: config.extStyle,
156
156
  columnNamingStrategy: config.columnNamingStrategy,
157
157
  apiPrefix: config.apiPrefix,
158
+ emitAbstractShapes: config.emitAbstractShapes,
158
159
  outputLayout: selfTarget.outputLayout,
159
160
  pkMap,
160
161
  relationMap,
@@ -20,6 +20,7 @@ import { isProjection } from "../projection/projection-detector.js";
20
20
  import { renderProjectionDecl } from "./projection-decl.js";
21
21
  import { hasWritableRdbSource } from "../source-detect.js";
22
22
  import { renderValueObjectFile } from "./value-object-file.js";
23
+ import { isAbstract } from "../instance-artifacts.js";
23
24
 
24
25
  /**
25
26
  * Render-time options for the entity-file composer.
@@ -43,6 +44,18 @@ export function renderEntityFile(
43
44
  ): string {
44
45
  const allowlists = opts?.allowlists ?? true;
45
46
 
47
+ // --- Abstract path (shape only) ---
48
+ // An abstract entity contributes shape via inheritance only — it must NEVER
49
+ // produce a Drizzle table / migration footprint / filter allowlist, even when
50
+ // it carries a source.rdb child. This is the cross-port invariant (abstract →
51
+ // no instance/write artifacts, including CREATE TABLE). It still emits its
52
+ // value-object shape (interface + Zod) so subclasses/consumers can reference
53
+ // it. The entity-file generator suppresses this entirely when
54
+ // emitAbstractShapes is off; here we only guarantee "shape, never table".
55
+ if (isAbstract(entity)) {
56
+ return renderValueObjectFile(entity);
57
+ }
58
+
46
59
  // --- Projection path (read-only: view-backed entity with no table source) ---
47
60
  // Projections intentionally get the z.enum() validator but NOT a named enum
48
61
  // type alias — emitting aliases here is a deliberate v1 scope decision.
@@ -0,0 +1,191 @@
1
+ // server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts
2
+ //
3
+ // Shared field-kind mapping for the FR-010 codegen emitters (recover-schema-emitter +
4
+ // output-format-spec-emitter). Maps a metadata field subtype onto the render engine's
5
+ // FieldKind member, the idiomatic nullable TS type used by the recover mirror interface,
6
+ // and the RecoverMap accessor that reads it from the forgiving outcome map.
7
+ //
8
+ // Mirrors the C# Fr010FieldMapping (adapted to TS syntax + the `| null` nullable mirror).
9
+ // Bounded scope (parity with Java/Kotlin/C#): scalar / enum / scalar-array. Nested object +
10
+ // array-of-enum are deferred.
11
+
12
+ import {
13
+ type MetaData,
14
+ TYPE_FIELD,
15
+ FIELD_SUBTYPE_STRING,
16
+ FIELD_SUBTYPE_CLASS,
17
+ FIELD_SUBTYPE_DATE,
18
+ FIELD_SUBTYPE_TIME,
19
+ FIELD_SUBTYPE_TIMESTAMP,
20
+ FIELD_SUBTYPE_INT,
21
+ FIELD_SUBTYPE_SHORT,
22
+ FIELD_SUBTYPE_BYTE,
23
+ FIELD_SUBTYPE_LONG,
24
+ FIELD_SUBTYPE_CURRENCY,
25
+ FIELD_SUBTYPE_DOUBLE,
26
+ FIELD_SUBTYPE_FLOAT,
27
+ FIELD_SUBTYPE_DECIMAL,
28
+ FIELD_SUBTYPE_BOOLEAN,
29
+ FIELD_SUBTYPE_ENUM,
30
+ FIELD_SUBTYPE_OBJECT,
31
+ FIELD_ATTR_REQUIRED,
32
+ FIELD_ATTR_VALUES,
33
+ } from "@metaobjectsdev/metadata";
34
+
35
+ /** The render-engine FieldKind member name for a scalar field subtype, or null if non-scalar. */
36
+ export function scalarKind(subType: string): string | null {
37
+ switch (subType) {
38
+ case FIELD_SUBTYPE_STRING:
39
+ case FIELD_SUBTYPE_CLASS:
40
+ case FIELD_SUBTYPE_DATE:
41
+ case FIELD_SUBTYPE_TIME:
42
+ case FIELD_SUBTYPE_TIMESTAMP:
43
+ return "STRING";
44
+ case FIELD_SUBTYPE_INT:
45
+ case FIELD_SUBTYPE_SHORT:
46
+ case FIELD_SUBTYPE_BYTE:
47
+ return "INT";
48
+ case FIELD_SUBTYPE_LONG:
49
+ case FIELD_SUBTYPE_CURRENCY:
50
+ return "LONG";
51
+ case FIELD_SUBTYPE_DOUBLE:
52
+ case FIELD_SUBTYPE_FLOAT:
53
+ case FIELD_SUBTYPE_DECIMAL:
54
+ return "DOUBLE";
55
+ case FIELD_SUBTYPE_BOOLEAN:
56
+ return "BOOLEAN";
57
+ default:
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** The field children of a payload value-object, in declaration order. */
63
+ export function fields(vo: MetaData): MetaData[] {
64
+ return vo.children().filter((c) => c.type === TYPE_FIELD);
65
+ }
66
+
67
+ /** isArray is a native (reserved) property on MetaData, not an attr. */
68
+ export function isArray(field: MetaData): boolean {
69
+ return field.isArray === true;
70
+ }
71
+
72
+ /** True iff the field's @required is explicitly true (or the string "true"). */
73
+ export function isRequired(field: MetaData): boolean {
74
+ const v = field.ownAttr(FIELD_ATTR_REQUIRED);
75
+ if (v === true) return true;
76
+ return typeof v === "string" && v.toLowerCase() === "true";
77
+ }
78
+
79
+ /** The string members of an enum field's @values attr (empty when absent). */
80
+ export function enumValues(field: MetaData): string[] {
81
+ const v = field.ownAttr(FIELD_ATTR_VALUES);
82
+ if (Array.isArray(v)) return v.map((e) => String(e));
83
+ return [];
84
+ }
85
+
86
+ /** The nullable TS type for a field in the recover mirror interface. */
87
+ export function mirrorType(field: MetaData): string {
88
+ // Matches asStringList's `(string | null)[] | null` return — a recovered array
89
+ // can contain null elements where individual items were lost.
90
+ if (isArray(field)) return "(string | null)[] | null";
91
+ if (field.subType === FIELD_SUBTYPE_OBJECT) return "unknown"; // nested deferred
92
+ if (field.subType === FIELD_SUBTYPE_ENUM) return "string | null"; // enum is string-backed
93
+ switch (scalarKind(field.subType)) {
94
+ case "INT":
95
+ case "LONG":
96
+ case "DOUBLE":
97
+ return "number | null";
98
+ case "BOOLEAN":
99
+ return "boolean | null";
100
+ default:
101
+ return "string | null";
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The RecoverMap.as* helper name that reads this field from the forgiving map, or null
107
+ * for a nested object (which emits a null literal, not a helper call). Single source of
108
+ * truth for the per-field dispatch — both recoverMapCall and recoverMapHelpersUsed use it.
109
+ */
110
+ function recoverMapHelper(field: MetaData): string | null {
111
+ if (isArray(field)) return "asStringList";
112
+ if (field.subType === FIELD_SUBTYPE_OBJECT) return null; // null literal, no helper
113
+ if (field.subType === FIELD_SUBTYPE_ENUM) return "asString";
114
+ switch (scalarKind(field.subType)) {
115
+ case "INT":
116
+ return "asInt";
117
+ case "LONG":
118
+ return "asLong";
119
+ case "DOUBLE":
120
+ return "asDouble";
121
+ case "BOOLEAN":
122
+ return "asBool";
123
+ default:
124
+ return "asString";
125
+ }
126
+ }
127
+
128
+ /** The RecoverMap.as* helper name + call that reads this field from the forgiving map `d`. */
129
+ export function recoverMapCall(field: MetaData): string {
130
+ const helper = recoverMapHelper(field);
131
+ if (helper === null) return "null /* FR-010: nested recover deferred */";
132
+ return `${helper}(d, ${jsonStringLiteral(field.name)})`;
133
+ }
134
+
135
+ /** Distinct RecoverMap helper names used across a value-object's fields (for the import). */
136
+ export function recoverMapHelpersUsed(vo: MetaData): string[] {
137
+ const used = new Set<string>();
138
+ for (const f of fields(vo)) {
139
+ const helper = recoverMapHelper(f);
140
+ if (helper !== null) used.add(helper);
141
+ }
142
+ return [...used].sort();
143
+ }
144
+
145
+ /** A TS double-quoted string literal for `value`, with the load-bearing chars escaped. */
146
+ export function jsonStringLiteral(value: string): string {
147
+ let out = '"';
148
+ for (const ch of value) {
149
+ switch (ch) {
150
+ case "\\":
151
+ out += "\\\\";
152
+ break;
153
+ case '"':
154
+ out += '\\"';
155
+ break;
156
+ case "\t":
157
+ out += "\\t";
158
+ break;
159
+ case "\n":
160
+ out += "\\n";
161
+ break;
162
+ case "\r":
163
+ out += "\\r";
164
+ break;
165
+ default:
166
+ out += ch;
167
+ }
168
+ }
169
+ return out + '"';
170
+ }
171
+
172
+ /** A TS array literal `["a", "b"]` for the given members. */
173
+ export function stringArrayLiteral(values: readonly string[]): string {
174
+ return "[" + values.map((v) => jsonStringLiteral(v)).join(", ") + "]";
175
+ }
176
+
177
+ /**
178
+ * A TS object literal `{ "k": "v", … }` for a properties-shaped attr (e.g. @enumAlias /
179
+ * @enumDoc), or "null" when absent/empty. Null values are dropped; keys are sorted
180
+ * ordinally for deterministic output (matches the canonical-serializer properties sort).
181
+ */
182
+ export function propertiesMapLiteral(attr: unknown): string {
183
+ if (attr == null || typeof attr !== "object" || Array.isArray(attr)) return "null";
184
+ const d = attr as Record<string, unknown>;
185
+ const entries = Object.keys(d)
186
+ .filter((k) => d[k] != null)
187
+ .sort()
188
+ .map((k) => `${jsonStringLiteral(k)}: ${jsonStringLiteral(String(d[k]))}`);
189
+ if (entries.length === 0) return "null";
190
+ return "{ " + entries.join(", ") + " }";
191
+ }
@@ -0,0 +1,97 @@
1
+ // server/typescript/packages/codegen-ts/src/templates/output-format-spec-emitter.ts
2
+ //
3
+ // Turns a payload value-object + its template.output node into a TS source literal for an
4
+ // OutputFormatSpec — the artifact-1 prompt descriptor used by the FR-010 output-prompt codegen.
5
+ //
6
+ // Emits an `{ format, rootName, style, fields: [ … ] } satisfies OutputFormatSpec` literal.
7
+ // Mirrors the C# OutputFormatSpecEmitter (adapted to TS syntax). Field-kind mapping is shared
8
+ // via fr010-field-mapping. Bounded scope: scalar / enum. Nested object → FieldKind.OBJECT placeholder.
9
+
10
+ import {
11
+ type MetaData,
12
+ FIELD_SUBTYPE_ENUM,
13
+ FIELD_SUBTYPE_OBJECT,
14
+ FIELD_ATTR_EXAMPLE,
15
+ FIELD_ATTR_INSTRUCTION,
16
+ FIELD_ATTR_ENUM_DOC,
17
+ TEMPLATE_ATTR_FORMAT,
18
+ TEMPLATE_ATTR_PROMPT_STYLE,
19
+ PROMPT_STYLE_INLINE,
20
+ PROMPT_STYLE_EXAMPLE_ONLY,
21
+ } from "@metaobjectsdev/metadata";
22
+ import {
23
+ fields,
24
+ isRequired,
25
+ isArray,
26
+ scalarKind,
27
+ enumValues,
28
+ jsonStringLiteral,
29
+ stringArrayLiteral,
30
+ propertiesMapLiteral,
31
+ } from "./fr010-field-mapping.js";
32
+
33
+ /** Emit `{ format, rootName, style, fields: [ … ] } satisfies OutputFormatSpec`. */
34
+ export function specLiteral(vo: MetaData, template: MetaData, rootName: string): string {
35
+ const formatEnum = resolveFormat(template);
36
+ const styleEnum = resolvePromptStyle(template);
37
+ const fieldLits = fields(vo).map(promptFieldLiteral);
38
+ return (
39
+ `{ format: ${formatEnum}, rootName: ${jsonStringLiteral(rootName)}, ` +
40
+ `style: ${styleEnum}, fields: [${fieldLits.join(", ")}] } satisfies OutputFormatSpec`
41
+ );
42
+ }
43
+
44
+ function resolveFormat(template: MetaData): string {
45
+ const f = template.ownAttr(TEMPLATE_ATTR_FORMAT);
46
+ return typeof f === "string" && f.toLowerCase() === "xml" ? "Format.XML" : "Format.JSON";
47
+ }
48
+
49
+ function resolvePromptStyle(template: MetaData): string {
50
+ switch (template.ownAttr(TEMPLATE_ATTR_PROMPT_STYLE)) {
51
+ case PROMPT_STYLE_INLINE:
52
+ return "PromptStyle.INLINE";
53
+ case PROMPT_STYLE_EXAMPLE_ONLY:
54
+ return "PromptStyle.EXAMPLE_ONLY";
55
+ default:
56
+ return "PromptStyle.GUIDE";
57
+ }
58
+ }
59
+
60
+ /** A PromptField object literal. Field order matches the PromptField interface. */
61
+ function promptFieldLiteral(field: MetaData): string {
62
+ const name = jsonStringLiteral(field.name);
63
+ const required = isRequired(field);
64
+ const array = isArray(field);
65
+
66
+ if (field.subType === FIELD_SUBTYPE_OBJECT) {
67
+ return (
68
+ `{ name: ${name}, kind: FieldKind.OBJECT, required: ${required}, array: ${array}, ` +
69
+ `enumValues: null, enumDoc: null, example: null, instruction: null, nested: null }` +
70
+ ` /* FR-010: nested prompt deferred */`
71
+ );
72
+ }
73
+
74
+ const example = optStringAttr(field, FIELD_ATTR_EXAMPLE);
75
+ const instruction = optStringAttr(field, FIELD_ATTR_INSTRUCTION);
76
+
77
+ if (field.subType === FIELD_SUBTYPE_ENUM) {
78
+ const valuesLit = stringArrayLiteral(enumValues(field));
79
+ const enumDocLit = propertiesMapLiteral(field.ownAttr(FIELD_ATTR_ENUM_DOC));
80
+ return (
81
+ `{ name: ${name}, kind: FieldKind.ENUM, required: ${required}, array: ${array}, ` +
82
+ `enumValues: ${valuesLit}, enumDoc: ${enumDocLit}, example: ${example}, ` +
83
+ `instruction: ${instruction}, nested: null }`
84
+ );
85
+ }
86
+
87
+ const kind = scalarKind(field.subType) ?? "STRING";
88
+ return (
89
+ `{ name: ${name}, kind: FieldKind.${kind}, required: ${required}, array: ${array}, ` +
90
+ `enumValues: null, enumDoc: null, example: ${example}, instruction: ${instruction}, nested: null }`
91
+ );
92
+ }
93
+
94
+ function optStringAttr(field: MetaData, attrName: string): string {
95
+ const v = field.ownAttr(attrName);
96
+ return typeof v === "string" ? jsonStringLiteral(v) : "null";
97
+ }
@@ -17,7 +17,14 @@ import {
17
17
  FIELD_SUBTYPE_OBJECT,
18
18
  FIELD_ATTR_OBJECT_REF,
19
19
  TEMPLATE_ATTR_PAYLOAD_REF,
20
+ TEMPLATE_ATTR_FORMAT,
20
21
  } from "@metaobjectsdev/metadata";
22
+ import {
23
+ schemaLiteral,
24
+ mirrorInterface,
25
+ mirrorInitializer,
26
+ } from "./recover-schema-emitter.js";
27
+ import { recoverMapHelpersUsed } from "./fr010-field-mapping.js";
21
28
 
22
29
  const SCALAR_ZOD: Record<string, string> = {
23
30
  string: "z.string()",
@@ -103,9 +110,14 @@ export function renderOutputParser(root: MetaData, templateName: string): string
103
110
  const parseName = `parse${templateName}`;
104
111
  const safeParseName = `safeParse${templateName}`;
105
112
 
106
- return `import { z } from "zod";
113
+ // FR-010: emit the tolerant recover() API alongside the strict Zod parser when the
114
+ // template targets json/xml. The @payloadRef already resolved to a value-object above,
115
+ // so a RecoverSchema can always be baked. text-format outputs get no recover.
116
+ const format = (tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text";
117
+ const lc = format.toLowerCase();
118
+ const emitRecover = lc === "json" || lc === "xml";
107
119
 
108
- const ${schemaName} = ${schema};
120
+ const strictBody = `const ${schemaName} = ${schema};
109
121
 
110
122
  export type ${dataName} = z.infer<typeof ${schemaName}>;
111
123
  export type ${errorName} = z.ZodError;
@@ -140,4 +152,67 @@ export function ${safeParseName}(
140
152
  : { success: false, error: result.error };
141
153
  }
142
154
  `;
155
+
156
+ if (!emitRecover) {
157
+ return `import { z } from "zod";\n\n${strictBody}`;
158
+ }
159
+
160
+ // ---- FR-010 tolerant recover block (json/xml only) ----
161
+ const recoveredName = `${templateName}Recovered`;
162
+ const recoverFnName = `recover${templateName}`;
163
+ const tryRecoverName = `tryRecover${templateName}`;
164
+ const schemaConstName = `${templateName}RecoverSchema`;
165
+ const schemaLit = schemaLiteral(vo, format, payloadRef);
166
+ const mirrorDecl = mirrorInterface(vo, recoveredName);
167
+ const initializer = mirrorInitializer(vo);
168
+ const mapHelpers = recoverMapHelpersUsed(vo);
169
+
170
+ // Render-package imports the recover block needs. Only pull in the names the emitted
171
+ // source actually references, so the file has no unused imports (tsc noUnusedLocals-safe).
172
+ const renderImports = ["recover", "recoverSchema", "Format"];
173
+ if (schemaLit.includes("scalar(")) renderImports.push("scalar");
174
+ if (schemaLit.includes("enumField(")) renderImports.push("enumField");
175
+ if (schemaLit.includes("FieldKind.")) renderImports.push("FieldKind");
176
+ renderImports.push("type RecoverSchema", "type RecoverOptions", "type RecoveryResult");
177
+ renderImports.push(...mapHelpers);
178
+
179
+ const recoverBody = `/** Baked recover descriptor for the ${templateName} output. */
180
+ const ${schemaConstName}: RecoverSchema = ${schemaLit};
181
+
182
+ ${mirrorDecl}
183
+
184
+ /**
185
+ * Tolerant best-effort recovery of a dirty LLM response; never throws. Returns a
186
+ * nullable mirror (\`${recoveredName}\`) with fields null where lost/malformed,
187
+ * plus the per-field recovery report.
188
+ */
189
+ export function ${recoverFnName}(
190
+ text: string,
191
+ opts?: RecoverOptions,
192
+ ): RecoveryResult<${recoveredName}> {
193
+ const outcome = recover(text, ${schemaConstName}, opts);
194
+ const d = outcome.data;
195
+ const data: ${recoveredName} = ${initializer};
196
+ return { data, report: outcome.report };
197
+ }
198
+
199
+ /**
200
+ * Recovery as a bool gate: \`true\` when the response was non-empty and no required
201
+ * field was lost. On success, \`result\` carries the recovered mirror + report.
202
+ */
203
+ export function ${tryRecoverName}(
204
+ text: string,
205
+ ): { ok: boolean; result: RecoveryResult<${recoveredName}> } {
206
+ const result = ${recoverFnName}(text);
207
+ const ok = !result.report.isEmpty() && !result.report.hasLostRequired();
208
+ return { ok, result };
209
+ }
210
+ `;
211
+
212
+ return (
213
+ `import { z } from "zod";\n` +
214
+ `import {\n ${renderImports.join(",\n ")},\n} from "@metaobjectsdev/render";\n\n` +
215
+ `${strictBody}\n` +
216
+ `${recoverBody}`
217
+ );
143
218
  }