@metaobjectsdev/codegen-ts 0.14.1 → 0.15.0-rc.1

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 (154) hide show
  1. package/dist/column-mapper.d.ts.map +1 -1
  2. package/dist/column-mapper.js +36 -16
  3. package/dist/column-mapper.js.map +1 -1
  4. package/dist/generators/api-model.js +21 -13
  5. package/dist/generators/api-model.js.map +1 -1
  6. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  7. package/dist/generators/docs-data-builder.js +27 -22
  8. package/dist/generators/docs-data-builder.js.map +1 -1
  9. package/dist/generators/docs-file.d.ts.map +1 -1
  10. package/dist/generators/docs-file.js +2 -1
  11. package/dist/generators/docs-file.js.map +1 -1
  12. package/dist/generators/extractor-file.d.ts.map +1 -1
  13. package/dist/generators/extractor-file.js +4 -2
  14. package/dist/generators/extractor-file.js.map +1 -1
  15. package/dist/generators/output-prompt-file.d.ts.map +1 -1
  16. package/dist/generators/output-prompt-file.js +5 -3
  17. package/dist/generators/output-prompt-file.js.map +1 -1
  18. package/dist/generators/render-helper-file.d.ts.map +1 -1
  19. package/dist/generators/render-helper-file.js +5 -3
  20. package/dist/generators/render-helper-file.js.map +1 -1
  21. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  22. package/dist/generators/routes-file-hono.js +2 -1
  23. package/dist/generators/routes-file-hono.js.map +1 -1
  24. package/dist/generators/routes-file.d.ts.map +1 -1
  25. package/dist/generators/routes-file.js +3 -1
  26. package/dist/generators/routes-file.js.map +1 -1
  27. package/dist/generators/template-doc-builder.d.ts.map +1 -1
  28. package/dist/generators/template-doc-builder.js +14 -11
  29. package/dist/generators/template-doc-builder.js.map +1 -1
  30. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  31. package/dist/generators/trace-helper-file.js +11 -5
  32. package/dist/generators/trace-helper-file.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/naming.d.ts +10 -0
  38. package/dist/naming.d.ts.map +1 -1
  39. package/dist/naming.js +47 -0
  40. package/dist/naming.js.map +1 -1
  41. package/dist/payload-codegen.d.ts.map +1 -1
  42. package/dist/payload-codegen.js +12 -9
  43. package/dist/payload-codegen.js.map +1 -1
  44. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  45. package/dist/projection/extract-view-spec.js +26 -3
  46. package/dist/projection/extract-view-spec.js.map +1 -1
  47. package/dist/projection/projection-detector.d.ts.map +1 -1
  48. package/dist/projection/projection-detector.js +4 -0
  49. package/dist/projection/projection-detector.js.map +1 -1
  50. package/dist/relation-resolver.d.ts.map +1 -1
  51. package/dist/relation-resolver.js +20 -12
  52. package/dist/relation-resolver.js.map +1 -1
  53. package/dist/source-detect.d.ts.map +1 -1
  54. package/dist/source-detect.js +4 -1
  55. package/dist/source-detect.js.map +1 -1
  56. package/dist/template-codegen/template-data.js +1 -1
  57. package/dist/template-codegen/template-data.js.map +1 -1
  58. package/dist/templates/callable-file.d.ts.map +1 -1
  59. package/dist/templates/callable-file.js +8 -4
  60. package/dist/templates/callable-file.js.map +1 -1
  61. package/dist/templates/entity-constants.d.ts.map +1 -1
  62. package/dist/templates/entity-constants.js +15 -11
  63. package/dist/templates/entity-constants.js.map +1 -1
  64. package/dist/templates/entity-file.d.ts.map +1 -1
  65. package/dist/templates/entity-file.js +7 -2
  66. package/dist/templates/entity-file.js.map +1 -1
  67. package/dist/templates/extract-delegate-emitter.d.ts.map +1 -1
  68. package/dist/templates/extract-delegate-emitter.js +2 -1
  69. package/dist/templates/extract-delegate-emitter.js.map +1 -1
  70. package/dist/templates/extractor.d.ts.map +1 -1
  71. package/dist/templates/extractor.js +8 -4
  72. package/dist/templates/extractor.js.map +1 -1
  73. package/dist/templates/field-meta.d.ts.map +1 -1
  74. package/dist/templates/field-meta.js +22 -4
  75. package/dist/templates/field-meta.js.map +1 -1
  76. package/dist/templates/find-templates.d.ts.map +1 -1
  77. package/dist/templates/find-templates.js +3 -0
  78. package/dist/templates/find-templates.js.map +1 -1
  79. package/dist/templates/fr010-field-mapping.d.ts.map +1 -1
  80. package/dist/templates/fr010-field-mapping.js +5 -2
  81. package/dist/templates/fr010-field-mapping.js.map +1 -1
  82. package/dist/templates/inferred-types.d.ts.map +1 -1
  83. package/dist/templates/inferred-types.js +18 -14
  84. package/dist/templates/inferred-types.js.map +1 -1
  85. package/dist/templates/output-format-spec-emitter.js +7 -3
  86. package/dist/templates/output-format-spec-emitter.js.map +1 -1
  87. package/dist/templates/output-parser.d.ts.map +1 -1
  88. package/dist/templates/output-parser.js +9 -5
  89. package/dist/templates/output-parser.js.map +1 -1
  90. package/dist/templates/output-prompt.d.ts.map +1 -1
  91. package/dist/templates/output-prompt.js +8 -4
  92. package/dist/templates/output-prompt.js.map +1 -1
  93. package/dist/templates/projection-decl.d.ts.map +1 -1
  94. package/dist/templates/projection-decl.js +4 -1
  95. package/dist/templates/projection-decl.js.map +1 -1
  96. package/dist/templates/queries-file.d.ts.map +1 -1
  97. package/dist/templates/queries-file.js +12 -1
  98. package/dist/templates/queries-file.js.map +1 -1
  99. package/dist/templates/queries.d.ts +19 -1
  100. package/dist/templates/queries.d.ts.map +1 -1
  101. package/dist/templates/queries.js +49 -7
  102. package/dist/templates/queries.js.map +1 -1
  103. package/dist/templates/render-helper.d.ts.map +1 -1
  104. package/dist/templates/render-helper.js +17 -10
  105. package/dist/templates/render-helper.js.map +1 -1
  106. package/dist/templates/routes-file.js +2 -1
  107. package/dist/templates/routes-file.js.map +1 -1
  108. package/dist/templates/tph-discriminator.d.ts.map +1 -1
  109. package/dist/templates/tph-discriminator.js +7 -0
  110. package/dist/templates/tph-discriminator.js.map +1 -1
  111. package/dist/templates/zod-validators.d.ts.map +1 -1
  112. package/dist/templates/zod-validators.js +62 -16
  113. package/dist/templates/zod-validators.js.map +1 -1
  114. package/package.json +6 -6
  115. package/src/column-mapper.ts +37 -16
  116. package/src/generators/api-model.ts +21 -13
  117. package/src/generators/docs-data-builder.ts +27 -22
  118. package/src/generators/docs-file.ts +2 -1
  119. package/src/generators/extractor-file.ts +4 -2
  120. package/src/generators/output-prompt-file.ts +5 -3
  121. package/src/generators/render-helper-file.ts +5 -3
  122. package/src/generators/routes-file-hono.ts +2 -1
  123. package/src/generators/routes-file.ts +2 -1
  124. package/src/generators/template-doc-builder.ts +14 -11
  125. package/src/generators/trace-helper-file.ts +11 -5
  126. package/src/index.ts +2 -0
  127. package/src/naming.ts +51 -0
  128. package/src/payload-codegen.ts +12 -9
  129. package/src/projection/extract-view-spec.ts +26 -3
  130. package/src/projection/projection-detector.ts +4 -0
  131. package/src/reference/queries.ts +9 -0
  132. package/src/reference/routes.ts +2 -1
  133. package/src/relation-resolver.ts +20 -12
  134. package/src/source-detect.ts +4 -1
  135. package/src/template-codegen/template-data.ts +1 -1
  136. package/src/templates/callable-file.ts +8 -4
  137. package/src/templates/entity-constants.ts +15 -11
  138. package/src/templates/entity-file.ts +7 -2
  139. package/src/templates/extract-delegate-emitter.ts +2 -1
  140. package/src/templates/extractor.ts +8 -4
  141. package/src/templates/field-meta.ts +25 -3
  142. package/src/templates/find-templates.ts +3 -0
  143. package/src/templates/fr010-field-mapping.ts +6 -1
  144. package/src/templates/inferred-types.ts +19 -13
  145. package/src/templates/output-format-spec-emitter.ts +7 -3
  146. package/src/templates/output-parser.ts +9 -5
  147. package/src/templates/output-prompt.ts +8 -4
  148. package/src/templates/projection-decl.ts +4 -1
  149. package/src/templates/queries-file.ts +14 -0
  150. package/src/templates/queries.ts +68 -8
  151. package/src/templates/render-helper.ts +17 -10
  152. package/src/templates/routes-file.ts +2 -1
  153. package/src/templates/tph-discriminator.ts +7 -0
  154. package/src/templates/zod-validators.ts +64 -15
package/src/naming.ts CHANGED
@@ -121,6 +121,57 @@ export function deleteByIdFnName(entityName: string): string {
121
121
  return `delete${entityName}ById`;
122
122
  }
123
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // ADR-0038 — reverse-relationship navigation as explicit FK finders.
126
+ //
127
+ // For each FK relationship (entity `E` references entity `T` via an
128
+ // `identity.reference` FK field), `E`'s query surface gains a finder returning
129
+ // the `E` rows matching a given `T` id. Two variants: a single-value finder and
130
+ // a batched (anti-N+1) `…In` finder.
131
+ //
132
+ // CANONICAL NAMING CONVENTION (the cross-port contract — every port replicates
133
+ // this spelling exactly):
134
+ //
135
+ // find<EPlural>By<FkField>(value) → SELECT … FROM E WHERE <fkColumn> = ?
136
+ // find<EPlural>By<FkField>In(values) → SELECT … FROM E WHERE <fkColumn> IN (…)
137
+ //
138
+ // where:
139
+ // - <EPlural> is the source entity name pluralized, PascalCase
140
+ // (`GameSession` → `GameSessions`) — the same spelling `list<Plural>` uses.
141
+ // - <FkField> is the FK FIELD name (NOT the relationship/navigation name and
142
+ // NOT the raw column), PascalCased, with a single trailing `Id` dropped if
143
+ // present. The FK field name is unique within an entity, so the finder name
144
+ // is unique by construction — this is what dissolves the same-pair collision
145
+ // and removes any need for a naming attribute.
146
+ //
147
+ // SAME-PAIR EXAMPLE (GameSession has THREE FKs to Scene):
148
+ // FK field `currentSceneId` → findGameSessionsByCurrentScene
149
+ // FK field `lastOpeningNarrativeSceneId` → findGameSessionsByLastOpeningNarrativeScene
150
+ // FK field `transitioningFromSceneId` → findGameSessionsByTransitioningFromScene
151
+ // Three distinct finders — no collision.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Lower an FK field name to the `<FkField>` segment of a reverse finder name:
156
+ * PascalCase the field, then drop a single trailing `Id` if present.
157
+ * E.g. `currentSceneId` → `CurrentScene`, `authorId` → `Author`, `scene` → `Scene`.
158
+ */
159
+ export function reverseFinderFkSegment(fkFieldName: string): string {
160
+ const pascal = toPascalCase(toCamelCase(fkFieldName));
161
+ // Drop a single trailing "Id" (but not a bare "Id" — that would yield "").
162
+ return pascal.length > 2 && pascal.endsWith("Id") ? pascal.slice(0, -2) : pascal;
163
+ }
164
+
165
+ /** Generated reverse single-value finder name: `find<EPlural>By<FkField>`. */
166
+ export function reverseFinderFnName(sourceEntityName: string, fkFieldName: string): string {
167
+ return `find${pluralize(sourceEntityName)}By${reverseFinderFkSegment(fkFieldName)}`;
168
+ }
169
+
170
+ /** Generated reverse batched finder name: `find<EPlural>By<FkField>In`. */
171
+ export function reverseFinderInFnName(sourceEntityName: string, fkFieldName: string): string {
172
+ return `${reverseFinderFnName(sourceEntityName, fkFieldName)}In`;
173
+ }
174
+
124
175
  /**
125
176
  * Generated Fastify route-registrar name: camelCase `<entity>Routes`. The routes
126
177
  * generator (templates/routes-file.ts) emits a single exported
@@ -46,10 +46,11 @@ const SCALAR_TS: Record<string, string> = {
46
46
  timestamp: "string",
47
47
  };
48
48
 
49
+ // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
49
50
  function findObject(root: MetaData, name: string): MetaData | undefined {
50
51
  // FR-032 — @payloadRef/@responseRef are FQN after the desugar/sweep; match on
51
52
  // the effective FQN resolution key (with bare back-compat).
52
- return root.ownChildren().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
53
+ return root.children().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
53
54
  }
54
55
 
55
56
  /**
@@ -68,7 +69,7 @@ function fieldTsType(
68
69
  const ref = field.attr(FIELD_ATTR_OBJECT_REF);
69
70
  const refName = typeof ref === "string" ? ref : "unknown";
70
71
  // isArray is a structural property on MetaData, not an attr.
71
- const isArray = field.isArray;
72
+ const isArray = field.resolvedIsArray();
72
73
  const result: { type: string; refVo?: string } = {
73
74
  type: isArray ? `${refName}[]` : refName,
74
75
  };
@@ -83,15 +84,15 @@ function fieldTsType(
83
84
  if (values !== undefined) {
84
85
  const aliasName = enumUnionAliasName(ownerName, field as MetaField);
85
86
  return {
86
- type: field.isArray ? `${aliasName}[]` : aliasName,
87
+ type: field.resolvedIsArray() ? `${aliasName}[]` : aliasName,
87
88
  enumAlias: { name: aliasName, decl: `export type ${aliasName} = ${enumUnionString(values)};` },
88
89
  };
89
90
  }
90
91
  // No @values → fall through to the raw-string representation.
91
- return { type: field.isArray ? "string[]" : "string" };
92
+ return { type: field.resolvedIsArray() ? "string[]" : "string" };
92
93
  }
93
94
  const scalar = SCALAR_TS[field.subType] ?? "unknown";
94
- return { type: field.isArray ? `${scalar}[]` : scalar };
95
+ return { type: field.resolvedIsArray() ? `${scalar}[]` : scalar };
95
96
  }
96
97
 
97
98
  /** True iff the field's @required is explicitly set to true. */
@@ -169,9 +170,10 @@ function pascal(s: string): string {
169
170
 
170
171
  /** Emit a typed render handle binding a template's @textRef + @format and typing its payload. */
171
172
  export function generateRenderHandle(root: MetaData, templateName: string): string {
172
- const tmpl = root.ownChildren().find((c) => c.type === TYPE_TEMPLATE && c.name === templateName);
173
+ const tmpl = root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === templateName);
173
174
  if (!tmpl) throw new Error(`template "${templateName}" not found`);
174
- const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
175
+ // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
176
+ const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF);
175
177
  // FR-032 — @payloadRef is an FQN after the desugar/sweep; the generated TS
176
178
  // TYPE NAME is the resolved value-object's bare name (an FQN like
177
179
  // `acme::ai::Payload` is not a valid TS identifier). Fall back to the last
@@ -179,8 +181,9 @@ export function generateRenderHandle(root: MetaData, templateName: string): stri
179
181
  const payloadType =
180
182
  (typeof payloadRef === "string" ? findObject(root, payloadRef)?.name : undefined) ??
181
183
  (typeof payloadRef === "string" ? stripPackage(payloadRef) : String(payloadRef));
182
- const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
183
- const format = (tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text";
184
+ // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
185
+ const textRef = tmpl.attr(TEMPLATE_ATTR_TEXT_REF);
186
+ const format = (tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text";
184
187
  const fn = `render${pascal(templateName)}`;
185
188
  return [
186
189
  `import { render, type Provider } from "@metaobjectsdev/render";`,
@@ -100,7 +100,9 @@ export interface ExtractContext {
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
102
  function findRelationship(obj: MetaData, name: string): MetaData | undefined {
103
- return obj.ownChildren().find(
103
+ // ADR-0039: resolving — a @via join hop may traverse a relationship the entity
104
+ // inherits via extends; own-only would silently drop the join.
105
+ return obj.children().find(
104
106
  (c) => c.type === TYPE_RELATIONSHIP && c.name === name,
105
107
  );
106
108
  }
@@ -110,6 +112,9 @@ function viewName(projection: MetaObject, ctx: ExtractContext): string {
110
112
  // implements the four-step rule (kind-matching alias → legacy @table →
111
113
  // source.name → entity-name fallback), so the call below correctly resolves
112
114
  // @view / @materializedView / legacy @table for projection sources.
115
+ // ADR-0039: own — projection source classification (mirrors C# projection
116
+ // OwnSources / IsReadOnlyProjection): the view name comes from the projection's
117
+ // OWN read-only source, not one inherited via extends.
113
118
  const viewSource = projection.ownChildren().find(
114
119
  (c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
115
120
  );
@@ -171,6 +176,10 @@ function baseEntityFor(
171
176
  // ERR_PROJECTION_IDENTITY_NOT_EXTENDED) rejects any projection whose identity
172
177
  // is not dotted-extends-bound before codegen runs. If that loader gate is
173
178
  // ever loosened, this function must grow the same fallback.
179
+ // ADR-0039: own — the base anchor is derived from the projection's OWN
180
+ // extends-bound declarations (refNamedOwner reads each node's own superRef,
181
+ // which only exists on locally-declared nodes); an inherited node carries no
182
+ // projection-local superRef, so own-iteration is correct here.
174
183
  for (const identity of projection
175
184
  .ownChildren()
176
185
  .filter((c) => c.type === TYPE_IDENTITY)) {
@@ -178,6 +187,7 @@ function baseEntityFor(
178
187
  if (named !== undefined) return named;
179
188
  }
180
189
  const targets = new Set<MetaObject>();
190
+ // ADR-0039: own — see above; base-entity derivation reads own extends-bound fields.
181
191
  for (const f of projection.ownChildren().filter((c) => c.type === TYPE_FIELD)) {
182
192
  const named = refNamedOwner(f, root);
183
193
  if (named !== undefined && named !== projection) targets.add(named);
@@ -253,10 +263,15 @@ function buildJoinTree(
253
263
  ): JoinTree {
254
264
  const allPaths: Path[] = [];
255
265
 
266
+ // ADR-0039: own — a projection's DECLARED field set IS the exposure (FR-024/
267
+ // ADR-0028, inclusive + fail-closed); iterate the projection's own fields and
268
+ // each field's own origin. origin.* NEVER inherits (ADR-0029), so origin reads
269
+ // below are own by policy (category 4).
256
270
  for (const field of projection.ownChildren()) {
257
271
  if (field.type !== TYPE_FIELD) continue;
258
272
  for (const origin of field.ownChildren()) {
259
273
  if (origin.type !== TYPE_ORIGIN) continue;
274
+ // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
260
275
  const viaAttr = origin.subType === ORIGIN_SUBTYPE_AGGREGATE
261
276
  ? (origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined)
262
277
  : (origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA) as string | undefined);
@@ -276,7 +291,8 @@ function buildJoinTree(
276
291
  for (const relName of relSegments) {
277
292
  const rel = findRelationship(currentObj, relName);
278
293
  if (!rel) break;
279
- const targetName = rel.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
294
+ // ADR-0039: resolving a traversed relationship may inherit @objectRef via extends.
295
+ const targetName = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
280
296
  // @objectRef may be package-qualified ("pkg::Entity") — the directory loader
281
297
  // qualifies a same-package objectRef even when authored bare — but findObject
282
298
  // keys on the BARE name (like the @via/@of/@from paths above). Strip first, or
@@ -284,7 +300,8 @@ function buildJoinTree(
284
300
  const target = targetName ? root.findObject(stripPackage(targetName)) : undefined;
285
301
  if (!target || !targetName) break;
286
302
 
287
- const cardAttr = rel.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
303
+ // ADR-0039: resolving a traversed relationship may inherit @cardinality via extends.
304
+ const cardAttr = rel.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
288
305
  const cardinality: "one" | "many" = cardAttr === CARDINALITY_ONE ? "one" : "many";
289
306
 
290
307
  const ref = findReferenceBetween(currentObj as MetaObject, target);
@@ -391,6 +408,9 @@ function buildSelectSpec(
391
408
  // extends-bound fields (`{ field.int: { name: id, extends: "Program.id" } }`).
392
409
 
393
410
  // Fields explicitly declared on the projection.
411
+ // ADR-0039: own — the projection's DECLARED field set IS the exposure
412
+ // (FR-024/ADR-0028); iterate own fields + each field's own origin. origin.*
413
+ // NEVER inherits (ADR-0029), so the origin attr reads below are own (category 4).
394
414
  for (const field of projection.ownChildren()) {
395
415
  if (field.type !== TYPE_FIELD) continue;
396
416
  const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
@@ -409,6 +429,7 @@ function buildSelectSpec(
409
429
  }
410
430
 
411
431
  if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
432
+ // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
412
433
  const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM) as string;
413
434
  const dotIdx = from.indexOf(".");
414
435
  if (dotIdx < 1) continue;
@@ -432,6 +453,7 @@ function buildSelectSpec(
432
453
  sourceColumn: sourceColumnNameFor(targetField, ctx),
433
454
  });
434
455
  } else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
456
+ // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
435
457
  const agg = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_AGG) as AggregateFunction;
436
458
  const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF) as string;
437
459
  if (!agg || !of_) continue;
@@ -446,6 +468,7 @@ function buildSelectSpec(
446
468
  if (!targetField) continue;
447
469
  // Optional scoping filter — resolved against the aggregated entity (the @of
448
470
  // entity, reached at `sourceAlias`), e.g. max(version) over only active rows.
471
+ // ADR-0039: own (category 4) — origin.* never inherits (ADR-0029).
449
472
  const filterAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_FILTER);
450
473
  const filter = filterAttr !== undefined
451
474
  ? resolveAggregateFilter(filterAttr, targetEntity, sourceAlias, ctx)
@@ -2,12 +2,16 @@ import { MetaSource } from "@metaobjectsdev/metadata";
2
2
  import type { MetaData } from "@metaobjectsdev/metadata";
3
3
 
4
4
  function hasReadOnlyKindSource(entity: MetaData): boolean {
5
+ // ADR-0039: own — projection source-kind classification. Mirrors C#
6
+ // IsReadOnlyProjection()/projection OwnSources: an entity's projection-ness is
7
+ // determined by its OWN declared source @kind, not one inherited via extends.
5
8
  return entity.ownChildren().some(
6
9
  (c) => c instanceof MetaSource && c.isReadOnly(),
7
10
  );
8
11
  }
9
12
 
10
13
  function hasWritableKindSource(entity: MetaData): boolean {
14
+ // ADR-0039: own — projection source-kind classification (see hasReadOnlyKindSource).
11
15
  return entity.ownChildren().some(
12
16
  (c) => c instanceof MetaSource && c.isWritable(),
13
17
  );
@@ -27,6 +27,8 @@ import {
27
27
  renderCreateFn,
28
28
  renderUpdateFn,
29
29
  renderDeleteByIdFn,
30
+ renderReverseFinderFns,
31
+ reverseFksFor,
30
32
  isTphDiscriminatorBase,
31
33
  isProjection,
32
34
  isTphSubtype,
@@ -80,6 +82,13 @@ import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON
80
82
  renderDeleteByIdFn(obj, ctx),
81
83
  ];
82
84
 
85
+ // ADR-0038 — reverse-relationship navigation as explicit FK finders. One
86
+ // find<Plural>By<FkField> (+ batched …In) per FK this entity holds. OWNED:
87
+ // drop this loop if you don't want reverse finders.
88
+ for (const fk of reverseFksFor(obj)) {
89
+ sections.push(renderReverseFinderFns(obj, fk, ctx));
90
+ }
91
+
83
92
  const body = joinCode(sections, { on: "\n" }).toString();
84
93
  const header =
85
94
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
@@ -40,7 +40,8 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
40
40
  // per-entity opt-out via `@emitRoutes: false`; TPH subtypes get no standalone routes
41
41
  // file (their routes live in the discriminator base's); AND-composed with your filter.
42
42
  filter: (e: MetaObject) =>
43
- e.ownAttr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
43
+ // ADR-0039: resolving a concrete entity may inherit its @emit* opt-out flag via extends.
44
+ e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
44
45
  generate: perEntity(async (entity, ctx) => {
45
46
  if (!ctx.renderContext) {
46
47
  throw new Error("routes-file: renderContext is required (provided by runGen)");
@@ -69,12 +69,14 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
69
69
  if (isProjection(obj)) continue;
70
70
 
71
71
  for (const child of obj.relationships()) {
72
- const cardinality = child.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
72
+ // ADR-0039: resolving a relationship may inherit @cardinality via extends.
73
+ const cardinality = child.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
73
74
 
74
75
  // FR-018 M:N: `@cardinality: "many"` + `@through` — derive the junction FK
75
76
  // columns from the junction's identity.reference children and register a
76
77
  // many(junction) navigation on the source.
77
- if (cardinality === CARDINALITY_MANY && child.ownAttr(RELATIONSHIP_ATTR_THROUGH) !== undefined) {
78
+ // ADR-0039: resolving @through may be inherited via extends.
79
+ if (cardinality === CARDINALITY_MANY && child.attr(RELATIONSHIP_ATTR_THROUGH) !== undefined) {
78
80
  const m2m = buildM2mEntry(obj, child as MetaRelationship, root);
79
81
  if (m2m) ensure(obj.name).push(m2m);
80
82
  continue;
@@ -82,7 +84,8 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
82
84
 
83
85
  if (cardinality !== CARDINALITY_ONE) continue;
84
86
 
85
- const targetEntityRaw = child.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
87
+ // ADR-0039: resolving @objectRef may be inherited via extends.
88
+ const targetEntityRaw = child.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
86
89
  if (!targetEntityRaw) continue;
87
90
  const targetEntity = stripPackage(targetEntityRaw);
88
91
 
@@ -104,11 +107,14 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
104
107
  fkField,
105
108
  targetPkField: "id",
106
109
  });
107
- ensure(targetEntity).push({
108
- name: variableNameFromEntity(obj.name),
109
- cardinality: "many",
110
- targetEntity: obj.name,
111
- });
110
+ // ADR-0038: do NOT register a reverse lazy `many()` on the target. Those
111
+ // entries were named after the SOURCE entity (`variableNameFromEntity(obj)`),
112
+ // so two relationships from the same source to the same target produced
113
+ // duplicate object-literal keys and silently overwrote each other (the
114
+ // same-pair collision). Reverse 1:N navigation is now provided as explicit,
115
+ // unique-by-FK-field finders in the source's queries module
116
+ // (find<SourcePlural>By<FkField>), which are framework-free and non-N+1.
117
+ // The forward one() relation above stays.
112
118
  }
113
119
  }
114
120
 
@@ -149,8 +155,9 @@ function collectJunctionNames(root: MetaRoot): Set<string> {
149
155
  const names = new Set<string>();
150
156
  for (const obj of root.objects()) {
151
157
  for (const rel of obj.relationships()) {
152
- if (rel.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
153
- const through = rel.ownAttr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
158
+ // ADR-0039: resolving @cardinality/@through may be inherited via extends.
159
+ if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
160
+ const through = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
154
161
  if (through) names.add(stripPackage(through));
155
162
  }
156
163
  }
@@ -168,8 +175,9 @@ function buildM2mEntry(
168
175
  rel: MetaRelationship,
169
176
  root: MetaRoot,
170
177
  ): RelationEntry | null {
171
- const targetRaw = rel.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
172
- const throughRaw = rel.ownAttr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
178
+ // ADR-0039: resolving @objectRef/@through may be inherited via extends.
179
+ const targetRaw = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
180
+ const throughRaw = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
173
181
  if (!targetRaw || !throughRaw) return null;
174
182
  let fields;
175
183
  try {
@@ -18,7 +18,10 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
18
18
  * transit data — no migration, no ORM table to point at.
19
19
  */
20
20
  export function hasWritableRdbSource(entity: MetaObject): boolean {
21
- for (const child of entity.ownChildren()) {
21
+ // ADR-0039: resolving an entity may inherit its writable source.rdb via extends
22
+ // (the JVM port's "entity inheriting its source emitted NOTHING" bug); own-only
23
+ // would suppress the Drizzle table for such an entity.
24
+ for (const child of entity.children()) {
22
25
  if (child.type !== TYPE_SOURCE) continue;
23
26
  if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
24
27
  if (!(child instanceof MetaSource)) continue;
@@ -41,7 +41,7 @@ function fieldData(field: MetaField): FieldTemplateData {
41
41
  name: field.name,
42
42
  type: field.subType,
43
43
  required: field.isRequired,
44
- isArray: field.isArray === true,
44
+ isArray: field.resolvedIsArray(),
45
45
  };
46
46
  if (typeof field.maxLength === "number") d.maxLength = field.maxLength;
47
47
  if (field.subType === FIELD_SUBTYPE_ENUM) {
@@ -44,7 +44,8 @@ export function isCallableEntity(entity: MetaObject): boolean {
44
44
  }
45
45
 
46
46
  function callableSource(entity: MetaObject): MetaSource | undefined {
47
- for (const child of entity.ownChildren()) {
47
+ // ADR-0039: resolving an entity may inherit its callable source.rdb via extends.
48
+ for (const child of entity.children()) {
48
49
  if (child.type !== TYPE_SOURCE) continue;
49
50
  if (!(child instanceof MetaSource)) continue;
50
51
  if (CALLABLE_KINDS.has(child.effectiveKind)) return child;
@@ -63,14 +64,16 @@ export function renderCallableFile(entity: MetaObject): string {
63
64
  }
64
65
 
65
66
  const procName = source.physicalName;
66
- const argsRef = source.ownAttr(SOURCE_ATTR_PARAMETER_REF);
67
+ // ADR-0039: resolving — a source may inherit @parameterRef via extends.
68
+ const argsRef = source.attr(SOURCE_ATTR_PARAMETER_REF);
67
69
  const argsObjectName = typeof argsRef === "string" && argsRef !== "" ? argsRef : undefined;
68
70
 
69
71
  // Resolve the parameter value-object (when set) — same root as the entity.
70
72
  const root = entity.root();
73
+ // ADR-0039: resolving — root has no super (children()==ownChildren()).
71
74
  const argsObject =
72
75
  argsObjectName !== undefined
73
- ? (root.ownChildren().find(
76
+ ? (root.children().find(
74
77
  (c) =>
75
78
  c.subType === OBJECT_SUBTYPE_VALUE && c.name === argsObjectName,
76
79
  ) as MetaObject | undefined)
@@ -78,8 +81,9 @@ export function renderCallableFile(entity: MetaObject): string {
78
81
 
79
82
  // Build the parameter list for the SQL call site. Declaration order = arg
80
83
  // order. Empty when argsObject is undefined (zero-arg proc).
84
+ // ADR-0039: resolving — a value object may inherit fields via extends (shape reuse).
81
85
  const paramFieldNames: string[] = argsObject
82
- ? argsObject.ownChildren()
86
+ ? argsObject.children()
83
87
  .filter((c) => c.type === TYPE_FIELD)
84
88
  .map((f) => f.name)
85
89
  : [];
@@ -68,7 +68,8 @@ function humanize(s: string): string {
68
68
  * "WorkoutEvent" → "/workout_events"
69
69
  */
70
70
  export function resourcePath(entity: MetaData): string {
71
- const overrideAttr = entity.ownAttr("routePath");
71
+ // ADR-0039: resolving — a concrete entity may inherit @routePath via extends.
72
+ const overrideAttr = entity.attr("routePath");
72
73
  if (typeof overrideAttr === "string" && overrideAttr.length > 0) {
73
74
  return overrideAttr.startsWith("/") ? overrideAttr : `/${overrideAttr}`;
74
75
  }
@@ -130,27 +131,29 @@ function renderFieldRules(field: MetaField): string | undefined {
130
131
  let hasRequired = false;
131
132
  let hasMaxLength = false;
132
133
 
134
+ // ADR-0039: resolving — a validator may inherit its config attrs (@min/@max/
135
+ // @pattern/@message/…) via extends.
133
136
  for (const child of field.validators()) {
134
137
  if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) {
135
- const msg = (child.ownAttr("message") as string | undefined) ?? `${humanize(field.name)} is required`;
138
+ const msg = (child.attr("message") as string | undefined) ?? `${humanize(field.name)} is required`;
136
139
  ruleParts.push(`required: ${JSON.stringify(msg)}`);
137
140
  hasRequired = true;
138
141
  } else if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
139
- const min = child.ownAttr(VALIDATOR_ATTR_MIN);
140
- const max = child.ownAttr(VALIDATOR_ATTR_MAX);
142
+ const min = child.attr(VALIDATOR_ATTR_MIN);
143
+ const max = child.attr(VALIDATOR_ATTR_MAX);
141
144
  if (typeof min === "number") {
142
- const msg = (child.ownAttr("minMessage") as string | undefined) ?? `Must be at least ${min} characters`;
145
+ const msg = (child.attr("minMessage") as string | undefined) ?? `Must be at least ${min} characters`;
143
146
  ruleParts.push(`minLength: { value: ${min}, message: ${JSON.stringify(msg)} }`);
144
147
  }
145
148
  if (typeof max === "number") {
146
- const msg = (child.ownAttr("maxMessage") as string | undefined) ?? `Must be ${max} characters or fewer`;
149
+ const msg = (child.attr("maxMessage") as string | undefined) ?? `Must be ${max} characters or fewer`;
147
150
  ruleParts.push(`maxLength: { value: ${max}, message: ${JSON.stringify(msg)} }`);
148
151
  hasMaxLength = true;
149
152
  }
150
153
  } else if (child.subType === VALIDATOR_SUBTYPE_REGEX) {
151
- const pattern = child.ownAttr(VALIDATOR_ATTR_PATTERN);
154
+ const pattern = child.attr(VALIDATOR_ATTR_PATTERN);
152
155
  if (typeof pattern === "string") {
153
- const msg = (child.ownAttr("message") as string | undefined) ?? "Invalid format";
156
+ const msg = (child.attr("message") as string | undefined) ?? "Invalid format";
154
157
  // Emit as RegExp literal /.../ — `as const` preserves the value-ref.
155
158
  // Forward-slash inside the pattern is escaped so the literal closes correctly.
156
159
  const safe = pattern.replace(/\\/g, "\\\\").replace(/\//g, "\\/");
@@ -180,9 +183,10 @@ function renderFieldRules(field: MetaField): string | undefined {
180
183
  function renderFieldEntry(field: MetaField): string {
181
184
  const { view, viewNode } = resolveView(field);
182
185
  const label = labelFor(field);
183
- const placeholder = viewNode?.ownAttr("placeholder") as string | undefined;
184
- const helpText = viewNode?.ownAttr("helpText") as string | undefined;
185
- const htmlType = htmlTypeFromView(view, viewNode?.ownAttr("htmlType") as string | undefined);
186
+ // ADR-0039: resolving a view node may inherit @placeholder/@helpText/@htmlType via extends.
187
+ const placeholder = viewNode?.attr("placeholder") as string | undefined;
188
+ const helpText = viewNode?.attr("helpText") as string | undefined;
189
+ const htmlType = htmlTypeFromView(view, viewNode?.attr("htmlType") as string | undefined);
186
190
  const rules = renderFieldRules(field);
187
191
 
188
192
  const entries: string[] = [
@@ -11,7 +11,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
11
11
  import type { RenderContext } from "../render-context.js";
12
12
  import { renderDrizzleSchema } from "./drizzle-schema.js";
13
13
  import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js";
14
- import { renderZodValidators } from "./zod-validators.js";
14
+ import { renderZodValidators, isTphSubtype } from "./zod-validators.js";
15
15
  import { renderEntityConstants } from "./entity-constants.js";
16
16
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
17
17
  import { renderFilterType } from "./filter-type.js";
@@ -88,7 +88,12 @@ export function renderEntityFile(
88
88
  // shape) OR the target is contract-only (a UI/wire package gets the read shape,
89
89
  // not a DB table). Either way: interface + Zod, no migration footprint, no
90
90
  // drizzle-orm. Consumers validate via the Zod schema and type via the interface.
91
- if (!runtime || !hasWritableRdbSource(entity)) {
91
+ //
92
+ // A TPH subtype (FR-017) also routes here: it inherits the base's writable
93
+ // source.rdb via extends (so hasWritableRdbSource is now true under the ADR-0039
94
+ // resolving read), but the base owns the single shared table — the subtype must
95
+ // emit its per-subtype read schema (<Sub>Schema), never its own pgTable.
96
+ if (!runtime || !hasWritableRdbSource(entity) || isTphSubtype(entity)) {
92
97
  return renderValueObjectFile(entity, ctx.apiPrefix, ctx);
93
98
  }
94
99
 
@@ -31,8 +31,9 @@ import {
31
31
  } from "@metaobjectsdev/metadata";
32
32
  import { fields, isArray, scalarKind, jsonStringLiteral } from "./fr010-field-mapping.js";
33
33
 
34
+ // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
34
35
  function findObject(root: MetaData, name: string): MetaData | undefined {
35
- return root.ownChildren().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
36
+ return root.children().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
36
37
  }
37
38
 
38
39
  /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
@@ -40,12 +40,14 @@ import { mirrorName } from "./extract-delegate-emitter.js";
40
40
  import { enumUnionAliasName } from "./inferred-types.js";
41
41
  import { enumValues } from "../enum-meta.js";
42
42
 
43
+ // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
43
44
  function findObject(root: MetaData, name: string): MetaData | undefined {
44
- return root.ownChildren().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
45
+ return root.children().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name));
45
46
  }
46
47
 
48
+ // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
47
49
  function findTemplate(root: MetaData, name: string): MetaData | undefined {
48
- return root.ownChildren().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
50
+ return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
49
51
  }
50
52
 
51
53
  /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
@@ -288,7 +290,8 @@ export function renderExtractor(root: MetaData, templateName: string): string {
288
290
  if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
289
291
  throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
290
292
  }
291
- const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
293
+ // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
294
+ const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF);
292
295
  if (typeof payloadRef !== "string") {
293
296
  throw new Error(`template "${templateName}" missing @payloadRef`);
294
297
  }
@@ -296,7 +299,8 @@ export function renderExtractor(root: MetaData, templateName: string): string {
296
299
  if (!vo) {
297
300
  throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
298
301
  }
299
- const format = ((tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
302
+ // ADR-0039: resolving a template may inherit its @* refs/format/kind via extends.
303
+ const format = ((tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
300
304
  if (format !== "json" && format !== "xml") {
301
305
  throw new Error(
302
306
  `template "${templateName}" @format "${format}" has no extract API to extract over (json/xml only)`,
@@ -18,6 +18,11 @@ import {
18
18
  FIELD_SUBTYPE_CURRENCY,
19
19
  FIELD_SUBTYPE_ENUM,
20
20
  FIELD_SUBTYPE_UUID,
21
+ FIELD_SUBTYPE_URI,
22
+ FIELD_SUBTYPE_INET,
23
+ FIELD_ATTR_STRING_FORMAT,
24
+ STRING_FORMAT_EMAIL,
25
+ STRING_FORMAT_HOSTNAME,
21
26
  VIEW_SUBTYPE_TEXT,
22
27
  VIEW_SUBTYPE_DATE,
23
28
  VIEW_SUBTYPE_NUMBER,
@@ -76,9 +81,24 @@ function defaultViewForSubType(subType: string): string {
76
81
  */
77
82
  export function zodTypeFor(field: MetaField): string {
78
83
  switch (field.subType) {
79
- case FIELD_SUBTYPE_STRING:
84
+ case FIELD_SUBTYPE_STRING: {
85
+ // ADR-0036/0037 Wave 3: @stringFormat narrows a plain string. Codegen owns
86
+ // the canonical matcher (mirrors zod-validators.ts).
87
+ const fmt = field.attr(FIELD_ATTR_STRING_FORMAT);
88
+ if (fmt === STRING_FORMAT_EMAIL) return "z.string().email()";
89
+ if (fmt === STRING_FORMAT_HOSTNAME) {
90
+ return "z.string().regex(/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/)";
91
+ }
92
+ return "z.string()";
93
+ }
80
94
  case FIELD_SUBTYPE_UUID:
81
95
  return "z.string()";
96
+ case FIELD_SUBTYPE_URI:
97
+ // ADR-0036/0037 Wave 3: a URI string — codegen owns the URL matcher.
98
+ return "z.string().url()";
99
+ case FIELD_SUBTYPE_INET:
100
+ // ADR-0036/0037 Wave 3: an IP string — codegen owns the IP matcher (v4+v6).
101
+ return "z.string().ip()";
82
102
  case FIELD_SUBTYPE_BOOLEAN:
83
103
  return "z.boolean()";
84
104
  case FIELD_SUBTYPE_DATE:
@@ -116,8 +136,9 @@ export function currencyMetaFor(field: MetaField): { currency: string; locale: s
116
136
  const currency =
117
137
  (field.attr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT;
118
138
  const viewChild = field.views().find((c) => c.subType === VIEW_SUBTYPE_CURRENCY);
139
+ // ADR-0039: resolving — a view may inherit @locale via extends.
119
140
  const locale =
120
- (viewChild?.ownAttr(VIEW_CURRENCY_ATTR_LOCALE) as string | undefined) ??
141
+ (viewChild?.attr(VIEW_CURRENCY_ATTR_LOCALE) as string | undefined) ??
121
142
  VIEW_CURRENCY_ATTR_LOCALE_DEFAULT;
122
143
  return { currency, locale };
123
144
  }
@@ -132,7 +153,8 @@ export function currencyMetaFor(field: MetaField): { currency: string; locale: s
132
153
  */
133
154
  export function labelFor(field: MetaField): string {
134
155
  for (const child of field.views()) {
135
- const label = child.ownAttr("label");
156
+ // ADR-0039: resolving — a view may inherit @label via extends.
157
+ const label = child.attr("label");
136
158
  if (typeof label === "string" && label.length > 0) return label;
137
159
  }
138
160
  return humanize(field.name);
@@ -5,6 +5,9 @@ import { TYPE_TEMPLATE } from "@metaobjectsdev/metadata";
5
5
  export function findTemplates(root: MetaData, subType: string): MetaData[] {
6
6
  const out: MetaData[] = [];
7
7
  const visit = (node: MetaData) => {
8
+ // ADR-0039: own — structural declaration walk. Enumerates every template node
9
+ // physically DECLARED in the tree exactly once (each → one emitted file);
10
+ // resolving would re-visit an inherited template on every subclass.
8
11
  for (const child of node.ownChildren()) {
9
12
  if (child.type === TYPE_TEMPLATE && child.subType === subType) out.push(child);
10
13
  visit(child);