@metaobjectsdev/codegen-ts 0.23.2 → 0.24.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 (153) hide show
  1. package/dist/generator.d.ts +9 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js.map +1 -1
  4. package/dist/generators/api-field-shape.js +1 -1
  5. package/dist/generators/api-field-shape.js.map +1 -1
  6. package/dist/generators/api-model.d.ts.map +1 -1
  7. package/dist/generators/api-model.js +71 -42
  8. package/dist/generators/api-model.js.map +1 -1
  9. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  10. package/dist/generators/docs-data-builder.js +36 -1
  11. package/dist/generators/docs-data-builder.js.map +1 -1
  12. package/dist/generators/docs-data.d.ts +14 -0
  13. package/dist/generators/docs-data.d.ts.map +1 -1
  14. package/dist/generators/docs-file.d.ts.map +1 -1
  15. package/dist/generators/docs-file.js +13 -4
  16. package/dist/generators/docs-file.js.map +1 -1
  17. package/dist/generators/extractor-file.d.ts.map +1 -1
  18. package/dist/generators/extractor-file.js +7 -11
  19. package/dist/generators/extractor-file.js.map +1 -1
  20. package/dist/generators/index.d.ts +4 -0
  21. package/dist/generators/index.d.ts.map +1 -1
  22. package/dist/generators/index.js +5 -0
  23. package/dist/generators/index.js.map +1 -1
  24. package/dist/generators/output-parser-file.d.ts.map +1 -1
  25. package/dist/generators/output-parser-file.js +12 -7
  26. package/dist/generators/output-parser-file.js.map +1 -1
  27. package/dist/generators/output-prompt-file.d.ts.map +1 -1
  28. package/dist/generators/output-prompt-file.js +14 -24
  29. package/dist/generators/output-prompt-file.js.map +1 -1
  30. package/dist/generators/requirement-tests.d.ts +44 -0
  31. package/dist/generators/requirement-tests.d.ts.map +1 -0
  32. package/dist/generators/requirement-tests.js +127 -0
  33. package/dist/generators/requirement-tests.js.map +1 -0
  34. package/dist/generators/requirements-file.d.ts +9 -0
  35. package/dist/generators/requirements-file.d.ts.map +1 -0
  36. package/dist/generators/requirements-file.js +53 -0
  37. package/dist/generators/requirements-file.js.map +1 -0
  38. package/dist/generators/requirements-markdown.d.ts +10 -0
  39. package/dist/generators/requirements-markdown.d.ts.map +1 -0
  40. package/dist/generators/requirements-markdown.js +71 -0
  41. package/dist/generators/requirements-markdown.js.map +1 -0
  42. package/dist/generators/requirements-toon.d.ts +3 -0
  43. package/dist/generators/requirements-toon.d.ts.map +1 -0
  44. package/dist/generators/requirements-toon.js +47 -0
  45. package/dist/generators/requirements-toon.js.map +1 -0
  46. package/dist/generators/requirements-view.d.ts +32 -0
  47. package/dist/generators/requirements-view.d.ts.map +1 -0
  48. package/dist/generators/requirements-view.js +64 -0
  49. package/dist/generators/requirements-view.js.map +1 -0
  50. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  51. package/dist/generators/trace-helper-file.js +19 -11
  52. package/dist/generators/trace-helper-file.js.map +1 -1
  53. package/dist/index.d.ts +13 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +18 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/metaobjects-config.d.ts +12 -20
  58. package/dist/metaobjects-config.d.ts.map +1 -1
  59. package/dist/metaobjects-config.js +5 -1
  60. package/dist/metaobjects-config.js.map +1 -1
  61. package/dist/orphan-sweep.d.ts +38 -0
  62. package/dist/orphan-sweep.d.ts.map +1 -0
  63. package/dist/orphan-sweep.js +129 -0
  64. package/dist/orphan-sweep.js.map +1 -0
  65. package/dist/overwrite-policy.d.ts +97 -3
  66. package/dist/overwrite-policy.d.ts.map +1 -1
  67. package/dist/overwrite-policy.js +293 -81
  68. package/dist/overwrite-policy.js.map +1 -1
  69. package/dist/projection/build-projection-views.d.ts +8 -0
  70. package/dist/projection/build-projection-views.d.ts.map +1 -1
  71. package/dist/projection/build-projection-views.js +2 -0
  72. package/dist/projection/build-projection-views.js.map +1 -1
  73. package/dist/reconcile-orphans.d.ts +80 -0
  74. package/dist/reconcile-orphans.d.ts.map +1 -0
  75. package/dist/reconcile-orphans.js +61 -0
  76. package/dist/reconcile-orphans.js.map +1 -0
  77. package/dist/render-engine/embedded-templates.generated.js +1 -1
  78. package/dist/render-engine/embedded-templates.generated.js.map +1 -1
  79. package/dist/requirement-walk.d.ts +57 -0
  80. package/dist/requirement-walk.d.ts.map +1 -0
  81. package/dist/requirement-walk.js +94 -0
  82. package/dist/requirement-walk.js.map +1 -0
  83. package/dist/runner.d.ts +35 -0
  84. package/dist/runner.d.ts.map +1 -1
  85. package/dist/runner.js +217 -21
  86. package/dist/runner.js.map +1 -1
  87. package/dist/templates/extractor.d.ts +4 -4
  88. package/dist/templates/extractor.d.ts.map +1 -1
  89. package/dist/templates/extractor.js +18 -22
  90. package/dist/templates/extractor.js.map +1 -1
  91. package/dist/templates/find-inbound.d.ts +44 -0
  92. package/dist/templates/find-inbound.d.ts.map +1 -0
  93. package/dist/templates/find-inbound.js +69 -0
  94. package/dist/templates/find-inbound.js.map +1 -0
  95. package/dist/templates/output-format-spec-emitter.d.ts.map +1 -1
  96. package/dist/templates/output-format-spec-emitter.js +7 -4
  97. package/dist/templates/output-format-spec-emitter.js.map +1 -1
  98. package/dist/templates/output-parser.d.ts +8 -3
  99. package/dist/templates/output-parser.d.ts.map +1 -1
  100. package/dist/templates/output-parser.js +71 -31
  101. package/dist/templates/output-parser.js.map +1 -1
  102. package/dist/templates/output-prompt.d.ts +6 -5
  103. package/dist/templates/output-prompt.d.ts.map +1 -1
  104. package/dist/templates/output-prompt.js +26 -37
  105. package/dist/templates/output-prompt.js.map +1 -1
  106. package/dist/templates/queries.js +1 -1
  107. package/dist/templates/queries.js.map +1 -1
  108. package/dist/templates/requirement-test.d.ts +12 -0
  109. package/dist/templates/requirement-test.d.ts.map +1 -0
  110. package/dist/templates/requirement-test.js +120 -0
  111. package/dist/templates/requirement-test.js.map +1 -0
  112. package/dist/templates/zod-validators.d.ts.map +1 -1
  113. package/dist/templates/zod-validators.js +32 -15
  114. package/dist/templates/zod-validators.js.map +1 -1
  115. package/package.json +7 -6
  116. package/src/generator.ts +9 -0
  117. package/src/generators/api-field-shape.ts +1 -1
  118. package/src/generators/api-model.ts +69 -44
  119. package/src/generators/docs-data-builder.ts +37 -0
  120. package/src/generators/docs-data.ts +15 -0
  121. package/src/generators/docs-file.ts +13 -4
  122. package/src/generators/extractor-file.ts +7 -11
  123. package/src/generators/index.ts +11 -0
  124. package/src/generators/output-parser-file.ts +12 -7
  125. package/src/generators/output-prompt-file.ts +14 -27
  126. package/src/generators/requirement-tests.ts +203 -0
  127. package/src/generators/requirements-file.ts +71 -0
  128. package/src/generators/requirements-markdown.ts +72 -0
  129. package/src/generators/requirements-toon.ts +64 -0
  130. package/src/generators/requirements-view.ts +93 -0
  131. package/src/generators/trace-helper-file.ts +20 -10
  132. package/src/index.ts +47 -1
  133. package/src/metaobjects-config.ts +17 -22
  134. package/src/orphan-sweep.ts +178 -0
  135. package/src/overwrite-policy.ts +362 -89
  136. package/src/projection/build-projection-views.ts +10 -0
  137. package/src/reconcile-orphans.ts +136 -0
  138. package/src/reference/barrel.ts +4 -0
  139. package/src/reference/entity.ts +4 -0
  140. package/src/reference/queries.ts +4 -0
  141. package/src/reference/routes.ts +4 -0
  142. package/src/render-engine/embedded-templates.generated.ts +1 -1
  143. package/src/requirement-walk.ts +124 -0
  144. package/src/runner.ts +266 -27
  145. package/src/templates/extractor.ts +19 -24
  146. package/src/templates/find-inbound.ts +96 -0
  147. package/src/templates/output-format-spec-emitter.ts +7 -4
  148. package/src/templates/output-parser.ts +76 -34
  149. package/src/templates/output-prompt.ts +29 -42
  150. package/src/templates/queries.ts +1 -1
  151. package/src/templates/requirement-test.ts +140 -0
  152. package/src/templates/zod-validators.ts +32 -15
  153. package/templates/docs/entity-page.md.mustache +8 -0
@@ -13,13 +13,15 @@ import {
13
13
  type MetaData,
14
14
  TYPE_FIELD,
15
15
  TYPE_TEMPLATE,
16
- TEMPLATE_SUBTYPE_OUTPUT,
16
+ TEMPLATE_SUBTYPE_PROMPT,
17
17
  FIELD_SUBTYPE_OBJECT,
18
18
  FIELD_ATTR_OBJECT_REF,
19
- TEMPLATE_ATTR_PAYLOAD_REF,
20
- TEMPLATE_ATTR_FORMAT,
19
+ TEMPLATE_ATTR_RESPONSE_REF,
20
+ RESPONSE_FORMAT_XML,
21
21
  resolveObjectRef,
22
22
  } from "@metaobjectsdev/metadata";
23
+ import { responseShape } from "./find-inbound.js";
24
+ import { isRequired } from "./fr010-field-mapping.js";
23
25
  import {
24
26
  nestedMirrorInterfaces,
25
27
  nestedMappers,
@@ -53,7 +55,19 @@ function findTemplate(root: MetaData, name: string): MetaData | undefined {
53
55
  return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
54
56
  }
55
57
 
56
- /** Render the Zod expression for a single field; recurses on @objectRef. */
58
+ /** Render the Zod expression for a single field; recurses on @objectRef.
59
+ *
60
+ * Optionality comes from `@required`, via the SAME `isRequired` predicate the
61
+ * tolerant tier uses — so the two tiers in this file cannot disagree about what
62
+ * the contract is. They used to: this schema emitted every field as mandatory
63
+ * and never read `@required`, so `parse<Name>` threw on a reply that correctly
64
+ * omitted a declared-optional field, while the tolerant extract accepted it.
65
+ *
66
+ * Every other port reuses the payload VO, which #309 made `@required`-correct.
67
+ * TypeScript re-derives its schema inline here, which is how it drifted — the
68
+ * same "payload tier disagrees with the rest of the toolchain" shape ADR-0052
69
+ * came out of.
70
+ */
57
71
  function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet<string>, depth: number): string {
58
72
  // isArray is a native (reserved) property on MetaData, not an attr.
59
73
  const isArray = field.resolvedIsArray();
@@ -73,7 +87,10 @@ function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet<string>, de
73
87
  } else {
74
88
  base = SCALAR_ZOD[field.subType] ?? "z.unknown()";
75
89
  }
76
- return isArray ? `z.array(${base})` : base;
90
+ const shaped = isArray ? `z.array(${base})` : base;
91
+ // `.optional()` wraps the ARRAY, not its element: an absent list and a list of
92
+ // absent things are different claims.
93
+ return isRequired(field) ? shaped : `${shaped}.optional()`;
77
94
  }
78
95
 
79
96
  /** Render a `z.object({ ... })` for an object.value node.
@@ -90,28 +107,34 @@ function renderObjectSchema(vo: MetaData, root: MetaData, seen: ReadonlySet<stri
90
107
  }
91
108
 
92
109
  /**
93
- * Render the full output-parser file for one `template.output` node.
94
- * Throws if the template isn't found, isn't a template.output, or its
95
- * @payloadRef doesn't resolve to an object.value.
110
+ * Render the full response-parser file for one responding `template.prompt`.
111
+ * Throws if the template isn't found, isn't a template.prompt, or its
112
+ * @responseRef is missing / doesn't resolve to an object.value.
113
+ *
114
+ * ADR-0052: the shape parsed INTO is `@responseRef`, not `@payloadRef` —
115
+ * `@payloadRef` types the REQUEST rendered outbound, which is the distinction the
116
+ * trace helper has always drawn ("@responseRef types the result; @payloadRef types
117
+ * the request") and the inbound tier used to ignore.
96
118
  */
97
119
  export function renderOutputParser(root: MetaData, templateName: string, ctx?: RenderContext): string {
98
120
  const tmpl = findTemplate(root, templateName);
99
121
  if (!tmpl) {
100
122
  throw new Error(`template "${templateName}" not found in metadata root`);
101
123
  }
102
- if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
103
- throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
104
- }
105
- // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
106
- const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF);
107
- if (typeof payloadRef !== "string") {
108
- throw new Error(`template "${templateName}" missing @payloadRef`);
124
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_PROMPT) {
125
+ throw new Error(`template "${templateName}" is not a template.prompt (got subtype "${tmpl.subType}")`);
109
126
  }
110
- // ADR-0042: a bare @payloadRef resolves in the template's package.
111
- const vo = findObject(root, payloadRef, tmpl.package ?? tmpl.fileDefaultPackage ?? "");
112
- if (!vo) {
113
- throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
127
+ const shape = responseShape(root, tmpl);
128
+ if (!shape) {
129
+ // ADR-0039: resolving — @responseRef may be inherited via extends.
130
+ const declared = tmpl.attr(TEMPLATE_ATTR_RESPONSE_REF);
131
+ throw new Error(
132
+ typeof declared === "string"
133
+ ? `template "${templateName}" @responseRef "${declared}" not found in metadata root`
134
+ : `template "${templateName}" missing @responseRef`,
135
+ );
114
136
  }
137
+ const { vo, ref: payloadRef } = shape;
115
138
 
116
139
  const schema = renderObjectSchema(vo, root, new Set([payloadRef]), 0);
117
140
  const schemaName = `${templateName}Schema`;
@@ -120,13 +143,31 @@ export function renderOutputParser(root: MetaData, templateName: string, ctx?: R
120
143
  const parseName = `parse${templateName}`;
121
144
  const safeParseName = `safeParse${templateName}`;
122
145
 
123
- // FR-010: emit the tolerant extract() API alongside the strict Zod parser when the
124
- // template targets json/xml. The @payloadRef already resolved to a value-object above,
125
- // so a ExtractSchema can always be baked. text-format outputs get no extract.
126
- // ADR-0039: resolving a template may inherit its @* refs/format/kind via extends.
127
- const format = (tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text";
128
- const lc = format.toLowerCase();
129
- const emitExtractLenient = lc === "json" || lc === "xml";
146
+ // FR-010: emit the tolerant extract() API alongside the strict Zod parser.
147
+ //
148
+ // ADR-0052/0053: a declared @responseRef ALWAYS gets the tolerant path, and the
149
+ // syntax comes from @responseFormat (json|xml, default json) never from
150
+ // @format, which is the syntax of the rendered prompt BODY. The old json/xml
151
+ // gate on @format is what made a text-bodied prompt with a JSON reply emit a
152
+ // strict parser and no extract at all.
153
+ const format = shape.format;
154
+
155
+ // The strict Zod tier is JSON-ONLY, by construction.
156
+ //
157
+ // Its body is `Schema.parse(JSON.parse(text))`, and there is no XML equivalent to
158
+ // generate. Not because no XML reader exists — `render/src/extract/
159
+ // xml-forgiving-reader.ts` ships one — but because that reader is FORGIVING by
160
+ // design, and strict all-or-nothing semantics layered over a repairing parser is
161
+ // incoherent: it would throw or accept based on how much repair happened, which
162
+ // is not a contract anyone can reason about. The JS runtime's `JSON.parse` is an
163
+ // exact parser, which is what makes the strict tier meaningful for JSON and only
164
+ // for JSON. Before this, an XML template got `JSON.parse` anyway — a generated
165
+ // function that could never work.
166
+ //
167
+ // So an XML reply gets the tolerant extract and nothing else. Its typed shape is
168
+ // `<Name>Extracted` — a nullable mirror, which is the honest type for a
169
+ // best-effort parse of model output.
170
+ const emitStrict = format !== RESPONSE_FORMAT_XML;
130
171
 
131
172
  const strictBody = `const ${schemaName} = ${schema};
132
173
 
@@ -164,15 +205,14 @@ export function ${safeParseName}(
164
205
  }
165
206
  `;
166
207
 
167
- if (!emitExtractLenient) {
168
- return `import { z } from "zod";\n\n${strictBody}`;
169
- }
170
-
171
- // ---- FR-010 tolerant extract block (json/xml only) ----
208
+ // ---- FR-010 tolerant extract block ----
209
+ // Unconditional since ADR-0052: a declared @responseRef IS the request for the
210
+ // tolerant path, and @responseFormat is a closed json|xml set, so there is no
211
+ // longer a third case to gate on.
172
212
  const extractedName = `${templateName}Extracted`;
173
213
  const extractLenientWithName = `extractLenient${templateName}WithLoader`;
174
214
  const payloadFqnConst = `${templateName.toUpperCase()}_PAYLOAD_NAME`;
175
- const formatEnum = format.toLowerCase() === "xml" ? "Format.XML" : "Format.JSON";
215
+ const formatEnum = format === RESPONSE_FORMAT_XML ? "Format.XML" : "Format.JSON";
176
216
 
177
217
  // The nullable mirror is the return shape of the delegating extract. Use the nested-aware
178
218
  // emitter so the payload mirror's nested-object / array-of-object components are typed (not
@@ -262,13 +302,15 @@ export function ${extractLenientWithName}(
262
302
  : `import type { MetaRoot } from "@metaobjectsdev/metadata";\n`;
263
303
  const runtimeImport = `import { extractObject } from "@metaobjectsdev/runtime-ts";\n`;
264
304
 
305
+ // `zod` is imported only when the strict tier is emitted — an XML reply's file
306
+ // would otherwise carry an unused import (tsc noUnusedLocals-unsafe).
265
307
  return (
266
- `import { z } from "zod";\n` +
308
+ (emitStrict ? `import { z } from "zod";\n` : "") +
267
309
  `import {\n ${renderImports.join(",\n ")},\n} from "@metaobjectsdev/render";\n` +
268
310
  metadataImport +
269
311
  runtimeImport +
270
312
  `\n` +
271
- `${strictBody}\n` +
313
+ (emitStrict ? `${strictBody}\n` : "") +
272
314
  `${delegating}`
273
315
  );
274
316
  }
@@ -1,11 +1,11 @@
1
1
  // server/typescript/packages/codegen-ts/src/templates/output-prompt.ts
2
2
  //
3
- // Per-template renderer for the FR-010 artifact-1 output-format prompt fragment
4
- // ("produce your answer like this"). For each json/xml `template.output` whose
5
- // @payloadRef resolves to a value-object, emits a `<TemplateName>.prompt.ts` file
6
- // exporting `render<TemplateName>Format(overrides?)` backed by the render engine's
7
- // renderOutputFormat(). The baked OutputFormatSpec's rootName is the payload name,
8
- // so the prompt fragment and the extract() codegen agree on the root name.
3
+ // Per-template renderer for the FR-010 artifact-1 response-format fragment
4
+ // ("produce your answer like this"). For each `template.prompt` whose @responseRef
5
+ // resolves to a value-object, emits a `<PromptName>.responseFormat.ts` file
6
+ // exporting `render<PromptName>Format(overrides?)` backed by the render engine's
7
+ // renderOutputFormat(). The baked OutputFormatSpec's rootName is the RESPONSE VO's
8
+ // name, so the fragment and the extract() codegen agree on the root name.
9
9
  //
10
10
  // Mirrors the C# OutputPromptGenerator + OutputFormatSpecEmitter (split into a
11
11
  // generator factory + this pure renderer, matching the TS output-parser shape).
@@ -13,59 +13,46 @@
13
13
  import {
14
14
  type MetaData,
15
15
  TYPE_TEMPLATE,
16
- TEMPLATE_SUBTYPE_OUTPUT,
17
- TEMPLATE_ATTR_PAYLOAD_REF,
18
- TEMPLATE_ATTR_FORMAT,
19
- resolveObjectRef,
16
+ TEMPLATE_SUBTYPE_PROMPT,
17
+ TEMPLATE_ATTR_RESPONSE_REF,
20
18
  } from "@metaobjectsdev/metadata";
21
19
  import { specLiteral } from "./output-format-spec-emitter.js";
22
-
23
- // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
24
- // ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise.
25
- function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined {
26
- return resolveObjectRef(root, name, referrerPkg).node;
27
- }
20
+ import { responseShape } from "./find-inbound.js";
28
21
 
29
22
  // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
30
23
  function findTemplate(root: MetaData, name: string): MetaData | undefined {
31
24
  return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
32
25
  }
33
26
 
34
- /** True iff the template.output's @format is json or xml (the renderable structured formats). */
35
- export function templateSupportsPrompt(tmpl: MetaData): boolean {
36
- // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
37
- const f = ((tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
38
- return f === "json" || f === "xml";
39
- }
40
-
41
27
  /**
42
- * Render the full output-prompt file for one json/xml `template.output` node.
43
- * Throws if the template isn't found, isn't a template.output, isn't json/xml,
44
- * or its @payloadRef doesn't resolve to an object.value.
28
+ * Render the full response-format fragment file for one responding
29
+ * `template.prompt`. Throws if the template isn't found, isn't a template.prompt,
30
+ * or its @responseRef is missing / doesn't resolve to an object.value.
31
+ *
32
+ * ADR-0052: the fragment describes the shape named by `@responseRef` — the reply —
33
+ * not `@payloadRef`, which is the request this prompt renders outbound.
45
34
  */
46
35
  export function renderOutputPrompt(root: MetaData, templateName: string): string {
47
36
  const tmpl = findTemplate(root, templateName);
48
37
  if (!tmpl) {
49
38
  throw new Error(`template "${templateName}" not found in metadata root`);
50
39
  }
51
- if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
52
- throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
53
- }
54
- if (!templateSupportsPrompt(tmpl)) {
55
- throw new Error(`template "${templateName}" @format is not json/xml — no prompt fragment`);
56
- }
57
- // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
58
- const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF);
59
- if (typeof payloadRef !== "string") {
60
- throw new Error(`template "${templateName}" missing @payloadRef`);
40
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_PROMPT) {
41
+ throw new Error(`template "${templateName}" is not a template.prompt (got subtype "${tmpl.subType}")`);
61
42
  }
62
- // ADR-0042: a bare @payloadRef resolves in the template's package.
63
- const vo = findObject(root, payloadRef, tmpl.package ?? tmpl.fileDefaultPackage ?? "");
64
- if (!vo) {
65
- throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
43
+ const shape = responseShape(root, tmpl);
44
+ if (!shape) {
45
+ // ADR-0039: resolving — @responseRef may be inherited via extends.
46
+ const declared = tmpl.attr(TEMPLATE_ATTR_RESPONSE_REF);
47
+ throw new Error(
48
+ typeof declared === "string"
49
+ ? `template "${templateName}" @responseRef "${declared}" not found in metadata root`
50
+ : `template "${templateName}" missing @responseRef`,
51
+ );
66
52
  }
53
+ const { vo, ref: payloadRef } = shape;
67
54
 
68
- // rootName == payload name so the prompt fragment and extract() agree.
55
+ // rootName == response VO name so the fragment and extract() agree.
69
56
  const spec = specLiteral(vo, tmpl, payloadRef);
70
57
  const specName = `${templateName}FormatSpec`;
71
58
  const fnName = `render${templateName}Format`;
@@ -82,7 +69,7 @@ export function renderOutputPrompt(root: MetaData, templateName: string): string
82
69
  const ${specName}: OutputFormatSpec = ${spec};
83
70
 
84
71
  /**
85
- * The output-format instruction fragment for the ${templateName} template.output
72
+ * The response-format instruction fragment for the ${templateName} template.prompt
86
73
  * ("produce your answer like this"). Comment-free — guidance lives in prose /
87
74
  * inline placeholders / a filled skeleton. Pass \`overrides\` to override the style
88
75
  * or per-field example/instruction at render time.
@@ -130,7 +130,7 @@ export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
130
130
  const fnName = updateFnName(entityName);
131
131
  const findByIdFn = findByIdFnName(entityName);
132
132
  // PATCH contract (FR-035): validate the caller's assignments against the
133
- // UPDATE schema (all-optional, PK/@readOnly excluded, no insert-time transforms
133
+ // UPDATE schema (all-optional; PK, @mutability readOnly AND writeOnce excluded; no insert-time transforms
134
134
  // like @autoSet-onCreate → now() or the InsertSchema's discriminator handling)
135
135
  // — NOT `InsertSchema.partial()`. The typed `<Entity>Patch` param makes a
136
136
  // renamed/dropped field a compile error at every call site (PATCH-1..4);
@@ -0,0 +1,140 @@
1
+ // FR-038 §4 — the DEFAULT requirement-test renderer.
2
+ //
3
+ // "Default" is the operative word: an application owns its testing style, so this
4
+ // is what it gets when it registers no renderer of its own. The library supplies
5
+ // DATA (statement, counterexample, status, claimed refs); a renderer supplies SYNTAX.
6
+ // That separation is what keeps bun / vitest / jest / pytest / JUnit / xUnit from
7
+ // each being an upstream change to this package.
8
+ //
9
+ // The load-bearing rule: an empty generated stub must NOT pass. A `live` entry
10
+ // claims the capability works, so an empty green test asserts the opposite of the
11
+ // claim — the original defect recreated in a new place.
12
+
13
+ import { GENERATED_HEADER } from "../constants.js";
14
+ import type { RequirementView, ResolvedClaim } from "../requirement-walk.js";
15
+
16
+ export interface RequirementTestArgs {
17
+ readonly view: RequirementView;
18
+ readonly concern: string;
19
+ readonly statement: string;
20
+ readonly counterexample: string;
21
+ readonly targets: readonly ResolvedClaim[];
22
+ readonly disposition?: string | undefined;
23
+ readonly trackedBy?: readonly string[] | undefined;
24
+ }
25
+
26
+ /**
27
+ * Statuses whose stub is SKIPPED rather than failing.
28
+ *
29
+ * The rule is "does this entry claim the capability works right now?" — only `live`
30
+ * and `partial` do. `planned` is intended-not-built; `abandoned` and `superseded`
31
+ * describe a capability deliberately retired, whose `@implementedBy` is SUPPOSED to
32
+ * dangle. Emitting a failing stub for any of the three reddens an application's suite
33
+ * forever for something nobody intends to build, which is the noise an app silences
34
+ * wholesale — taking the `live` stubs with it.
35
+ *
36
+ * (FR-038 §4 proposes retiring `abandoned`/`superseded` from the vocabulary entirely.
37
+ * Until that breaking cut lands they are legal `@status` values, so the renderer has
38
+ * to handle them.)
39
+ */
40
+ const SKIPPED_STATUSES: ReadonlySet<string> = new Set([
41
+ "planned",
42
+ "abandoned",
43
+ "superseded",
44
+ ]);
45
+
46
+ /**
47
+ * Escape an author-supplied value for a double-quoted TS string literal.
48
+ *
49
+ * Unescaped, a quote closes the literal and a newline breaks it — emitting a stub that
50
+ * does not parse, which `meta gen` still reports as written because nothing here
51
+ * compiles it.
52
+ *
53
+ * Applied to EVERY value that reaches a literal, not only the two obviously-prose ones.
54
+ * `@statement` and `@counterexample` are the fields that look dangerous, but the requirement
55
+ * path and the concern land in the same two literals, and escaping only what looks like
56
+ * prose leaves the identical hole open one line down.
57
+ */
58
+ function forStringLiteral(s: string): string {
59
+ return s
60
+ .replace(/\\/g, "\\\\")
61
+ .replace(/"/g, '\\"')
62
+ .replace(/\r?\n/g, "\\n")
63
+ .replace(/\r/g, "\\r");
64
+ }
65
+
66
+ /**
67
+ * Make an author-supplied value safe inside a JSDoc block, preserving what was written.
68
+ *
69
+ * A comment-terminator would close the block early and spill the remainder into code; a
70
+ * newline needs its own continuation marker or the block's shape breaks. The terminator
71
+ * is separated by a space rather than deleted, so the sentence still reads — and
72
+ * deliberately NOT by a zero-width space, which would keep the text pixel-identical at
73
+ * the cost of putting an invisible character into generated source that nobody can see
74
+ * when debugging it.
75
+ *
76
+ * Every value interpolated into the block goes through this, including the ones whose
77
+ * shape the loader constrains. `@trackedBy` is the one that makes the rule non-optional:
78
+ * it is registered free-form ON PURPOSE — `verify` never resolves it, because which
79
+ * sprint owns a gap belongs in the tracker — so its contract invites arbitrary text.
80
+ */
81
+ function forDocComment(s: string): string {
82
+ return s.replace(/\*\//g, "* /").replace(/\r?\n/g, "\n * ");
83
+ }
84
+
85
+ function claimLines(targets: readonly ResolvedClaim[]): string {
86
+ if (targets.length === 0) {
87
+ return " * (none — this requirement names no model nodes)";
88
+ }
89
+ return targets
90
+ .map((t) => ` * - ${forDocComment(t.ref)} (${forDocComment(t.concern)})`)
91
+ .join("\n");
92
+ }
93
+
94
+ function gapLine(a: RequirementTestArgs): string {
95
+ const tracked = a.trackedBy ?? [];
96
+ if (a.disposition === undefined && tracked.length === 0) return "";
97
+ const decided = forDocComment(a.disposition ?? "undecided");
98
+ const refs = tracked.length > 0 ? ` — ${tracked.map(forDocComment).join(", ")}` : "";
99
+ return `\n *\n * Known gap: ${decided}${refs}`;
100
+ }
101
+
102
+ export function renderRequirementTest(a: RequirementTestArgs): string {
103
+ const skipped = a.view.status !== undefined && SKIPPED_STATUSES.has(a.view.status);
104
+ const statement = forDocComment(a.statement);
105
+ const counterexample = forDocComment(a.counterexample);
106
+ const runner = skipped ? "test.skip" : "test";
107
+ // The test NAME is the link between the ledger entry and the assertion, so it is
108
+ // built from the same two values every time — but it is still a string literal, and
109
+ // an unescaped quote in either closes it.
110
+ const testName = `${forStringLiteral(a.view.path)} [${forStringLiteral(a.concern)}]`;
111
+
112
+ // A `live` or `partial` stub asserts FAILURE until someone writes the real
113
+ // assertion over it. `expect.unreachable` names the requirement in the failure
114
+ // message, so a red run says which claim is unproven rather than just "failed".
115
+ const body = skipped
116
+ ? ` // Intended, not built. Write the assertion when this becomes live.`
117
+ : ` expect.unreachable(\n` +
118
+ ` "unimplemented requirement stub: ${testName} — " +\n` +
119
+ ` "replace this with an assertion that fails when: ${forStringLiteral(a.counterexample)}",\n` +
120
+ ` );`;
121
+
122
+ return (
123
+ `// ${GENERATED_HEADER}.\n` +
124
+ `// The test IDENTITY is generated from the requirement; the BODY below is yours\n` +
125
+ `// and survives regeneration. Do not rename the test — the name is the link.\n` +
126
+ `import { test, expect } from "bun:test";\n` +
127
+ `\n` +
128
+ `/**\n` +
129
+ ` * ${statement}\n` +
130
+ ` *\n` +
131
+ ` * Counterexample: ${counterexample}${gapLine(a)}\n` +
132
+ ` *\n` +
133
+ ` * Claims:\n` +
134
+ `${claimLines(a.targets)}\n` +
135
+ ` */\n` +
136
+ `${runner}("${testName}", () => {\n` +
137
+ `${body}\n` +
138
+ `});\n`
139
+ );
140
+ }
@@ -22,7 +22,8 @@ import {
22
22
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
23
23
  FIELD_ATTR_STRING_FORMAT, FIELD_ATTR_LENIENT, STRING_FORMAT_EMAIL, STRING_FORMAT_HOSTNAME,
24
24
  FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT,
25
- FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_VALUE_TYPE, FIELD_ATTR_READ_ONLY,
25
+ FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_VALUE_TYPE,
26
+ isReadOnlyMutability, isWriteOnceMutability,
26
27
  FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB,
27
28
  AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
28
29
  VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN,
@@ -215,12 +216,13 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co
215
216
  const insertFieldLines: Code[] = [];
216
217
  for (const child of obj.fields()) {
217
218
  if (autoGenPkFields.has(child.name)) continue;
218
- // FR-013: @readOnly fields are populated by DB / replication / external
219
- // owner; the application has no path to write them. Exclude from the
220
- // create-shape schema entirely.
219
+ // FR-037 R1: an @mutability:"readOnly" field is populated by DB / replication /
220
+ // external owner; the application has no path to write it. Exclude from the
221
+ // create-shape schema entirely. ("writeOnce" is NOT excluded here — that is
222
+ // the whole point of the mode: settable on create, frozen after.)
221
223
  // #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
222
- // excluded from the Insert/Update/preserving schemas, exactly like @readOnly.
223
- if (child.attr(FIELD_ATTR_READ_ONLY) === true || child.isDerived()) continue;
224
+ // excluded from the Insert/Update/preserving schemas, exactly like readOnly.
225
+ if (isReadOnlyMutability(child) || child.isDerived()) continue;
224
226
 
225
227
  // FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...).
226
228
  if (tphPin !== undefined && child.name === tphPin.fieldName) {
@@ -300,8 +302,9 @@ export function insertSchemaFields(obj: MetaObject): SchemaFieldShape[] {
300
302
  for (const child of obj.fields()) {
301
303
  if (autoGenPkFields.has(child.name)) continue;
302
304
  // #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
303
- // excluded from the Insert/Update/preserving schemas, exactly like @readOnly.
304
- if (child.attr(FIELD_ATTR_READ_ONLY) === true || child.isDerived()) continue;
305
+ // excluded from the Insert/Update/preserving schemas, exactly like readOnly.
306
+ // "writeOnce" stays IN the create shape — it is settable exactly once.
307
+ if (isReadOnlyMutability(child) || child.isDerived()) continue;
305
308
  if (tphPin !== undefined && child.name === tphPin.fieldName) {
306
309
  out.push({ name: child.name, optional: false, pinnedLiteral: tphPin.value });
307
310
  continue;
@@ -335,8 +338,16 @@ export function updateSchemaFields(obj: MetaObject): SchemaFieldShape[] {
335
338
  for (const child of obj.fields()) {
336
339
  if (autoGenPkFields.has(child.name)) continue;
337
340
  // #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
338
- // excluded from the Insert/Update/preserving schemas, exactly like @readOnly.
339
- if (child.attr(FIELD_ATTR_READ_ONLY) === true || child.isDerived()) continue;
341
+ // excluded from the Insert/Update/preserving schemas, exactly like readOnly.
342
+ if (isReadOnlyMutability(child) || child.isDerived()) continue;
343
+ // FR-037 R1 — "writeOnce" leaves the UPDATE shape, and only the update shape.
344
+ // Omitted, not rejected: a key absent from the settable set is STRIPPED on this
345
+ // path today (D1), and the generated edit form submits EVERY registered field
346
+ // (0.19.2 switched the resolver to UpdateSchema on edit and does not
347
+ // diff-and-omit), so 400-on-present would fail every save on every generated
348
+ // edit form for an entity carrying one. Clearing counts as a write, so the
349
+ // FR-035 present-null arm never reaches it either.
350
+ if (isWriteOnceMutability(child)) continue;
340
351
  // TPH subtype discriminator: omitted from the update schema entirely.
341
352
  if (tphPin !== undefined && child.name === tphPin.fieldName) continue;
342
353
  const autoSet = child.attr(FIELD_ATTR_AUTO_SET);
@@ -370,13 +381,13 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
370
381
  const emitPreserving = hasAutoSetFields(obj);
371
382
  for (const child of obj.fields()) {
372
383
  if (autoGenPkFields.has(child.name)) continue;
373
- // FR-013: @readOnly fields appear in neither InsertSchema nor UpdateSchema.
374
- // The DB / trigger / replication owns the write path; the app must not
375
- // pass these values in POST/PATCH bodies (routesFile enforces the same
384
+ // FR-037 R1: an @mutability:"readOnly" field appears in neither InsertSchema
385
+ // nor UpdateSchema. The DB / trigger / replication owns the write path; the app
386
+ // must not pass these values in POST/PATCH bodies (routesFile enforces the same
376
387
  // contract at the boundary with a 400 response).
377
388
  // #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
378
- // excluded from the Insert/Update/preserving schemas, exactly like @readOnly.
379
- if (child.attr(FIELD_ATTR_READ_ONLY) === true || child.isDerived()) continue;
389
+ // excluded from the Insert/Update/preserving schemas, exactly like readOnly.
390
+ if (isReadOnlyMutability(child) || child.isDerived()) continue;
380
391
 
381
392
  // FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...).
382
393
  // The discriminator is implicit on subtype rows (controlled by URL / insert
@@ -413,6 +424,12 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
413
424
  preservingFieldLines.push(fieldLine);
414
425
  }
415
426
 
427
+ // FR-037 R1 — "writeOnce": present in Insert + preserving (both are CREATE
428
+ // shapes), absent from Update. Same mechanism that drops @autoSet onCreate
429
+ // below; a value presented on PATCH is stripped, not 400'd (see
430
+ // updateSchemaFields for the grounding).
431
+ if (isWriteOnceMutability(child)) continue;
432
+
416
433
  // Update schema: @autoSet onCreate → omit entirely; onUpdate → transform
417
434
  if (autoSet === AUTO_SET_ON_CREATE) {
418
435
  // Omit: creation timestamps cannot be changed after creation
@@ -64,3 +64,11 @@
64
64
  - {{{bullet}}}
65
65
  {{/usedBy}}
66
66
  {{/hasUsedBy}}
67
+ {{#hasClaimedBy}}
68
+
69
+ ## Required by
70
+
71
+ {{#claimedBy}}
72
+ - {{{bullet}}}
73
+ {{/claimedBy}}
74
+ {{/hasClaimedBy}}