@metaobjectsdev/codegen-ts 1.0.2 → 1.0.4-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 (94) hide show
  1. package/dist/catalog-gates.d.ts +52 -0
  2. package/dist/catalog-gates.d.ts.map +1 -0
  3. package/dist/catalog-gates.js +183 -0
  4. package/dist/catalog-gates.js.map +1 -0
  5. package/dist/constants.d.ts +24 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +26 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/enum-shared.d.ts +9 -2
  10. package/dist/enum-shared.d.ts.map +1 -1
  11. package/dist/enum-shared.js +12 -3
  12. package/dist/enum-shared.js.map +1 -1
  13. package/dist/generator-registry.d.ts +73 -0
  14. package/dist/generator-registry.d.ts.map +1 -1
  15. package/dist/generator-registry.js +182 -38
  16. package/dist/generator-registry.js.map +1 -1
  17. package/dist/generator.d.ts +48 -1
  18. package/dist/generator.d.ts.map +1 -1
  19. package/dist/generator.js.map +1 -1
  20. package/dist/generators/entity-file.d.ts.map +1 -1
  21. package/dist/generators/entity-file.js +4 -1
  22. package/dist/generators/entity-file.js.map +1 -1
  23. package/dist/generators/index.d.ts +1 -0
  24. package/dist/generators/index.d.ts.map +1 -1
  25. package/dist/generators/index.js +5 -0
  26. package/dist/generators/index.js.map +1 -1
  27. package/dist/generators/shared-model-file.d.ts +23 -0
  28. package/dist/generators/shared-model-file.d.ts.map +1 -0
  29. package/dist/generators/shared-model-file.js +235 -0
  30. package/dist/generators/shared-model-file.js.map +1 -0
  31. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  32. package/dist/generators/trace-helper-file.js +54 -11
  33. package/dist/generators/trace-helper-file.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/metaobjects-config.d.ts +0 -15
  39. package/dist/metaobjects-config.d.ts.map +1 -1
  40. package/dist/metaobjects-config.js.map +1 -1
  41. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  42. package/dist/projection/extract-view-spec.js +111 -4
  43. package/dist/projection/extract-view-spec.js.map +1 -1
  44. package/dist/relation-resolver.d.ts.map +1 -1
  45. package/dist/relation-resolver.js +11 -9
  46. package/dist/relation-resolver.js.map +1 -1
  47. package/dist/routes-expose.d.ts +30 -0
  48. package/dist/routes-expose.d.ts.map +1 -1
  49. package/dist/routes-expose.js +46 -0
  50. package/dist/routes-expose.js.map +1 -1
  51. package/dist/runner.d.ts +29 -0
  52. package/dist/runner.d.ts.map +1 -1
  53. package/dist/runner.js +38 -1
  54. package/dist/runner.js.map +1 -1
  55. package/dist/templates/entity-file.js +2 -2
  56. package/dist/templates/entity-file.js.map +1 -1
  57. package/dist/templates/enums-file.d.ts +11 -1
  58. package/dist/templates/enums-file.d.ts.map +1 -1
  59. package/dist/templates/enums-file.js +10 -2
  60. package/dist/templates/enums-file.js.map +1 -1
  61. package/dist/templates/queries-file.js +5 -5
  62. package/dist/templates/queries-file.js.map +1 -1
  63. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  64. package/dist/templates/routes-file-hono.js +10 -3
  65. package/dist/templates/routes-file-hono.js.map +1 -1
  66. package/dist/templates/routes-file.d.ts.map +1 -1
  67. package/dist/templates/routes-file.js +21 -4
  68. package/dist/templates/routes-file.js.map +1 -1
  69. package/dist/templates/value-object-file.js +2 -2
  70. package/dist/templates/value-object-file.js.map +1 -1
  71. package/package.json +6 -6
  72. package/src/catalog-gates.ts +222 -0
  73. package/src/constants.ts +27 -0
  74. package/src/enum-shared.ts +17 -3
  75. package/src/generator-registry.ts +253 -41
  76. package/src/generator.ts +48 -1
  77. package/src/generators/entity-file.ts +7 -1
  78. package/src/generators/index.ts +11 -0
  79. package/src/generators/shared-model-file.ts +292 -0
  80. package/src/generators/trace-helper-file.ts +55 -12
  81. package/src/index.ts +17 -3
  82. package/src/metaobjects-config.ts +0 -15
  83. package/src/projection/extract-view-spec.ts +126 -5
  84. package/src/reference/entity.ts +12 -3
  85. package/src/reference/queries.ts +2 -2
  86. package/src/relation-resolver.ts +14 -8
  87. package/src/routes-expose.ts +57 -0
  88. package/src/runner.ts +77 -1
  89. package/src/templates/entity-file.ts +2 -2
  90. package/src/templates/enums-file.ts +13 -2
  91. package/src/templates/queries-file.ts +5 -5
  92. package/src/templates/routes-file-hono.ts +10 -3
  93. package/src/templates/routes-file.ts +21 -4
  94. package/src/templates/value-object-file.ts +2 -2
@@ -0,0 +1,292 @@
1
+ // FR-023 §4.3 — sharedModelFile(): the publisher's flattened shared-model artifact.
2
+ //
3
+ // A publisher project wires this generator to select a subset of its own metadata
4
+ // (by the same include/exclude scope-pattern grammar `scope` already uses) and emit
5
+ // it as ONE canonical-JSON `metadata.root` document plus a manifest — the artifact a
6
+ // consumer's `meta deps sync` copies into its own repo (design §2.2, §3.2, §3.5).
7
+ //
8
+ // The algorithm (design §4.3), in order:
9
+ // 1. Standalone load of `files` (default: the run's own source files, `ctx.sourceFiles`)
10
+ // with `ctx.registry` (the run's composed registry — the SAME vocabulary the
11
+ // project's own metadata loaded with), strict.
12
+ // 2. Select top-level nodes: never `requirement.*` / `template.*` (a publisher's
13
+ // ledger and prompts are not a consumer's shapes), and matching the compiled
14
+ // include/exclude scope.
15
+ // 3. Closure check: every `extends` target and every REF_BEARING_ATTR_NAMES value
16
+ // of a selected node (and of its own descendants) must resolve to a SELECTED
17
+ // node, else the selection is not self-contained and the build fails naming the
18
+ // (referrer, target) pairs.
19
+ // 4. Serialize the selection with `serializeSharedDocument` — raw own-layer form,
20
+ // `extends` preserved, sorted by resolution key.
21
+ // 5. Re-load the emitted document standalone with CORE providers only, strict — a
22
+ // consumer loads it with ITS providers, and a Phase 1 export must not need the
23
+ // publisher's non-core vocabulary.
24
+ // 6. Emit `<name>.metaobjects.json` + `metaobjects.pkg.json` (design §3.2).
25
+ import { createHash } from "node:crypto";
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import {
29
+ type MetaData,
30
+ type MetaRoot,
31
+ MetaDataLoader,
32
+ InMemoryStringSource,
33
+ compileScope,
34
+ matchesScope,
35
+ REF_BEARING_ATTR_NAMES,
36
+ resolveObjectRef,
37
+ serializeSharedDocument,
38
+ packageOfResolutionKey,
39
+ PACKAGE_SEPARATOR,
40
+ CHILD_REF_SEPARATOR,
41
+ TYPE_REQUIREMENT,
42
+ TYPE_TEMPLATE,
43
+ METAMODEL_VERSION,
44
+ } from "@metaobjectsdev/metadata";
45
+ import { FileSource } from "@metaobjectsdev/metadata/core";
46
+ import { oncePerRun, type Generator, type GenContext, type EmittedFile } from "../generator.js";
47
+ import { effectivePackage } from "../docs-paths.js";
48
+
49
+ // The FR-023 manifest/artifact tool-file conventions (design §3.2, §4.3, "Hash
50
+ // format"). Their single canonical source is `sdk/src/dependencies.ts`
51
+ // (`MANIFEST_FILE`, `ARTIFACT_SUFFIX`, `INTEGRITY_PREFIX`) — codegen-ts cannot
52
+ // import sdk (the dependency runs the other way: `cli` depends on both, design
53
+ // §4.3's closing note), so these mirror those values locally. Keep in sync —
54
+ // exported (not merely local) so `cli`, which depends on both packages, can
55
+ // assert equality against `sdk`'s exports in a test; see
56
+ // `cli/test/shared-model-constants-parity.test.ts`.
57
+ export const MANIFEST_FILE = "metaobjects.pkg.json";
58
+ export const ARTIFACT_SUFFIX = ".metaobjects.json";
59
+ export const INTEGRITY_PREFIX = "sha256-";
60
+ /** design §3.2 — the manifest schema's own version, distinct from `metamodelVersion`. */
61
+ const MANIFEST_SCHEMA_VERSION = 1;
62
+
63
+ /**
64
+ * Separator for `checkClosure`'s dedupe key. A resolution key never contains a NUL,
65
+ * so this cannot collide with either half it joins — unlike a printable separator
66
+ * (`->`, `→`), which would need a separate argument for why no legal FQN could ever
67
+ * contain it. Written as the `\0` ESCAPE, never a raw control byte: a raw 0x00 in
68
+ * this source file previously made `git` treat it as binary, which (a) makes
69
+ * `git diff`/GitHub show no content for the file at all, and (b) more seriously,
70
+ * makes `.githooks/pre-commit`'s public-repo-hygiene grep skip its added lines
71
+ * entirely (a binary file's diff is never text-enumerated) — silently defeating that
72
+ * guard for any future edit to this file. The escape produces the byte-identical
73
+ * runtime string; only the SOURCE FILE's byte content changes (readable text instead
74
+ * of an embedded control byte).
75
+ */
76
+ const DEDUPE_KEY_SEPARATOR = "\0";
77
+
78
+ export interface SharedModelFileOpts {
79
+ /** The dependency key a consumer will `meta deps sync` under. Also the artifact's
80
+ * basename: emits `<name>.metaobjects.json`. */
81
+ name: string;
82
+ /** Scope-pattern include list (the same `acme::platform::**` grammar as `scope`). */
83
+ include: readonly string[];
84
+ /** Applied after `include`. */
85
+ exclude?: readonly string[];
86
+ /** Which of the publisher's own source files may contribute. Default: the run's
87
+ * own source files (`ctx.sourceFiles` — everything `files?` narrows). */
88
+ files?: readonly string[];
89
+ /** The published version. Default: the nearest `package.json` `version` walking
90
+ * up from `ctx.projectRoot`. */
91
+ version?: string;
92
+ /** Named output target, as on every other generator. */
93
+ target?: string;
94
+ }
95
+
96
+ /** Everything before the first `CHILD_REF_SEPARATOR` that follows the LAST
97
+ * `PACKAGE_SEPARATOR` — the object part of a possibly dotted ref (ADR-0029's
98
+ * addressing model: package segments never fold onto a non-root child, so a
99
+ * `.` before the final `::`-segment can't occur). Mirrors the private
100
+ * `splitChildTail` in `naming-refs.ts` (not exported); `extends` and the
101
+ * origin `@from`/`@of`/`@via` attrs are the only REF_BEARING_ATTR_NAMES that
102
+ * can carry a dotted child tail — every other ref-bearing attr is already a
103
+ * bare object ref, and stripping a tail that isn't there is a no-op. */
104
+ function refOwner(ref: string): string {
105
+ const lastSep = ref.lastIndexOf(PACKAGE_SEPARATOR);
106
+ const segStart = lastSep === -1 ? 0 : lastSep + PACKAGE_SEPARATOR.length;
107
+ const dotInSeg = ref.indexOf(CHILD_REF_SEPARATOR, segStart);
108
+ return dotInSeg === -1 ? ref : ref.slice(0, dotInSeg);
109
+ }
110
+
111
+ /** Depth-first walk of a node and its OWN descendants, node itself first. ADR-0039
112
+ * sanctioned own-only case: `serializeSharedDocument` emits each selected node's
113
+ * OWN layer only (`extends` preserved, not flattened — design §3.5), so the
114
+ * closure check must walk exactly what will be emitted, never what `extends`
115
+ * would additionally contribute (that is the base's own layer, checked
116
+ * separately if the base is itself selected). */
117
+ function walkOwn(node: MetaData): MetaData[] {
118
+ const out: MetaData[] = [node];
119
+ for (const child of node.ownChildren()) out.push(...walkOwn(child));
120
+ return out;
121
+ }
122
+
123
+ interface ClosurePair {
124
+ readonly referrer: string;
125
+ readonly target: string;
126
+ }
127
+
128
+ /** design §4.3 step 3. `standaloneRoot` already loaded successfully (step 1), so
129
+ * every ref found here is guaranteed to RESOLVE to some node in it — the loader's
130
+ * own validation passes reject a dangling `extends`/object-ref before this ever
131
+ * runs. What is NOT guaranteed is that the resolved node is in `selectedKeys`. */
132
+ function checkClosure(
133
+ standaloneRoot: MetaRoot,
134
+ selected: readonly MetaData[],
135
+ selectedKeys: ReadonlySet<string>,
136
+ ): ClosurePair[] {
137
+ const pairs: ClosurePair[] = [];
138
+ const seen = new Set<string>();
139
+ for (const top of selected) {
140
+ const referrerKey = top.resolutionKey();
141
+ for (const node of walkOwn(top)) {
142
+ const referrerPkg = effectivePackage(node) ?? "";
143
+ const candidates: string[] = [];
144
+ if (node.superRef !== undefined) candidates.push(node.superRef);
145
+ // ADR-0039 sanctioned own-only case (same rationale as walkOwn() above): an
146
+ // INHERITED ref-bearing attr belongs to the base node's own layer, not this
147
+ // one's, and is checked when the base itself is walked — reading it via the
148
+ // resolving `attr()` here would attribute the base's reference to this node.
149
+ for (const attrName of REF_BEARING_ATTR_NAMES) {
150
+ const value = node.ownAttr(attrName);
151
+ if (typeof value === "string") candidates.push(value);
152
+ }
153
+ for (const raw of candidates) {
154
+ const resolved = resolveObjectRef(standaloneRoot, refOwner(raw), referrerPkg).node;
155
+ if (resolved === undefined) continue; // already validated at load — defensive only
156
+ const targetKey = resolved.resolutionKey();
157
+ if (selectedKeys.has(targetKey)) continue;
158
+ const dedupeKey = `${referrerKey}${DEDUPE_KEY_SEPARATOR}${targetKey}`;
159
+ if (seen.has(dedupeKey)) continue;
160
+ seen.add(dedupeKey);
161
+ pairs.push({ referrer: referrerKey, target: targetKey });
162
+ }
163
+ }
164
+ }
165
+ return pairs.sort((a, b) =>
166
+ a.referrer === b.referrer ? a.target.localeCompare(b.target) : a.referrer.localeCompare(b.referrer),
167
+ );
168
+ }
169
+
170
+ /** design §2.2 — "version defaults to the host package's version". Walks up from
171
+ * `startDir` for the nearest `package.json` carrying a string `version`. */
172
+ function nearestPackageVersion(startDir: string): string | undefined {
173
+ let dir = startDir;
174
+ for (let i = 0; i < 64; i++) {
175
+ const pkgPath = join(dir, "package.json");
176
+ if (existsSync(pkgPath)) {
177
+ try {
178
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: unknown };
179
+ if (typeof pkg.version === "string") return pkg.version;
180
+ } catch {
181
+ // An unparsable package.json is not a version source — keep walking up.
182
+ }
183
+ }
184
+ const parent = dirname(dir);
185
+ if (parent === dir) return undefined;
186
+ dir = parent;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ export function sharedModelFile(opts: SharedModelFileOpts): Generator {
192
+ const generator: Generator = {
193
+ name: "shared-model",
194
+ generate: oncePerRun(async (_entities, ctx: GenContext): Promise<EmittedFile[]> => {
195
+ const files = opts.files ?? ctx.sourceFiles ?? [];
196
+ if (files.length === 0) {
197
+ throw new Error(
198
+ `sharedModelFile("${opts.name}"): no source files to load — pass \`files\`, or run ` +
199
+ `through \`meta gen\`, which supplies the project's own files as \`ctx.sourceFiles\`.`,
200
+ );
201
+ }
202
+
203
+ // Step 1 — standalone load, the run's own composed registry (so the publisher's
204
+ // own consumer-supplied providers are honoured here exactly as they were for
205
+ // the project's main load), strict.
206
+ const loader = new MetaDataLoader({
207
+ ...(ctx.registry !== undefined ? { registry: ctx.registry } : {}),
208
+ strict: true,
209
+ });
210
+ const loaded = await loader.load(files.map((p) => new FileSource(p)));
211
+ if (loaded.errors.length > 0) {
212
+ throw loaded.errors[0];
213
+ }
214
+ const standaloneRoot = loaded.root;
215
+
216
+ // Step 2 — select top-level nodes. ADR-0039 sanctioned own-only case: `MetaRoot`
217
+ // has no super chain (it never `extends`), so its own children ARE its effective
218
+ // children — the same case `MetaRoot.objects()`/`findObject()` document.
219
+ const compiled = compileScope({ include: opts.include, exclude: opts.exclude ?? [] });
220
+ const selected = standaloneRoot
221
+ .ownChildren()
222
+ .filter((node) => node.type !== TYPE_REQUIREMENT && node.type !== TYPE_TEMPLATE)
223
+ .filter((node) => matchesScope(node.resolutionKey(), compiled));
224
+ if (selected.length === 0) {
225
+ throw new Error(
226
+ `sharedModelFile("${opts.name}"): selected no nodes (include/exclude, always ` +
227
+ `excluding requirement.* and template.*) — nothing to publish. Check \`include\`.`,
228
+ );
229
+ }
230
+
231
+ // Step 3 — closure.
232
+ const selectedKeys = new Set(selected.map((n) => n.resolutionKey()));
233
+ const closureFailures = checkClosure(standaloneRoot, selected, selectedKeys);
234
+ if (closureFailures.length > 0) {
235
+ const lines = closureFailures.map((p) => ` ${p.referrer} → ${p.target}`).join("\n");
236
+ throw new Error(
237
+ `sharedModelFile("${opts.name}"): the selection is not closed — these references ` +
238
+ `point outside it; include it or exclude the referrer:\n${lines}`,
239
+ );
240
+ }
241
+
242
+ // Step 4 — serialize (already sorted by resolution key; the callee also
243
+ // throws if a selected node has no package, per its own contract).
244
+ const artifactContent = serializeSharedDocument(selected);
245
+
246
+ // Step 5 — re-load standalone with CORE providers only (registry omitted ⇒
247
+ // MetaDataLoader's own default registry), strict. A consumer loads this
248
+ // artifact with ITS OWN providers, never the publisher's — so an export
249
+ // needing publisher-only vocabulary must fail HERE, not at the consumer.
250
+ const artifactPath = `${opts.name}${ARTIFACT_SUFFIX}`;
251
+ const coreLoader = new MetaDataLoader({ strict: true });
252
+ const coreLoaded = await coreLoader.load([
253
+ new InMemoryStringSource(artifactContent, { id: artifactPath }),
254
+ ]);
255
+ if (coreLoaded.errors.length > 0) {
256
+ const first = coreLoaded.errors[0]!;
257
+ throw new Error(
258
+ `sharedModelFile("${opts.name}"): the export needs a provider this toolchain does ` +
259
+ `not ship; Phase 1 exports must load with core vocabulary: ${first.message}`,
260
+ );
261
+ }
262
+
263
+ // Step 6 — emit the artifact + manifest.
264
+ const integrity = `${INTEGRITY_PREFIX}${createHash("sha256").update(artifactContent, "utf-8").digest("hex")}`;
265
+ const packages = [...new Set(selected.map((n) => packageOfResolutionKey(n.resolutionKey())))].sort();
266
+ const nodes = selected.map((n) => n.resolutionKey()).sort();
267
+ const version = opts.version ?? nearestPackageVersion(ctx.projectRoot ?? process.cwd());
268
+ if (version === undefined) {
269
+ throw new Error(
270
+ `sharedModelFile("${opts.name}"): no version available — pass \`version\` explicitly, ` +
271
+ `or run inside a project with a package.json version at or above the project root.`,
272
+ );
273
+ }
274
+ const manifest = {
275
+ schema_version: MANIFEST_SCHEMA_VERSION,
276
+ name: opts.name,
277
+ version,
278
+ metamodelVersion: METAMODEL_VERSION,
279
+ artifact: artifactPath,
280
+ integrity,
281
+ packages,
282
+ nodes,
283
+ };
284
+ return [
285
+ { path: artifactPath, content: artifactContent },
286
+ { path: MANIFEST_FILE, content: JSON.stringify(manifest, null, 2) + "\n" },
287
+ ];
288
+ }),
289
+ };
290
+ if (opts.target !== undefined) generator.target = opts.target;
291
+ return generator;
292
+ }
@@ -28,9 +28,11 @@ import {
28
28
  TEMPLATE_ATTR_TEXT_REF,
29
29
  } from "@metaobjectsdev/metadata";
30
30
  import { responseFormatOf } from "../templates/find-inbound.js";
31
- import type { MetaObject } from "@metaobjectsdev/metadata";
31
+ import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
32
+ import { libraryManifests } from "@metaobjectsdev/metadata/library";
32
33
  import {
33
34
  type EmittedFile,
35
+ type GenContext,
34
36
  type Generator,
35
37
  type GeneratorFactory,
36
38
  perEntity,
@@ -39,8 +41,40 @@ import { generatePayloadInterfacesBatch } from "../payload-codegen.js";
39
41
  import { GENERATED_HEADER } from "../constants.js";
40
42
  import { tphDiscriminatorPin } from "../templates/zod-validators.js";
41
43
 
42
- /** Short name of the shipped abstract base every trace entity extends. */
43
- const LLM_CALL_BASE = "LlmCallBase";
44
+ /** This generator's stable name the key a library manifest declares its anchor under. */
45
+ const STABLE_NAME = "trace-helper";
46
+
47
+ /**
48
+ * The FQNs this generator keys on, from the LIBRARY MANIFESTS rather than a constant
49
+ * here (FR-043 §6).
50
+ *
51
+ * What it replaces: `const LLM_CALL_BASE = "LlmCallBase"`, compared against `.name`
52
+ * anywhere in the super chain — so any adopter entity called `LlmCallBase`, in any
53
+ * package, triggered the generator. An anchor is a fully-qualified node the library
54
+ * declares, and it is resolved to a node and compared by identity below.
55
+ *
56
+ * `ctx.libraries` undefined means the caller never said which libraries are selected
57
+ * (a programmatic `runGen()`); every shipped manifest's anchor is then a candidate,
58
+ * which is still FQN-anchored. An EMPTY array is the opposite and is honoured: the
59
+ * caller looked, this project opted into none, and the generator matches nothing.
60
+ */
61
+ function anchorFqns(ctx: GenContext): string[] {
62
+ const manifests = ctx.libraries ?? Object.values(libraryManifests());
63
+ const out: string[] = [];
64
+ for (const manifest of manifests) {
65
+ for (const g of manifest.generators ?? []) {
66
+ if (g.name === STABLE_NAME && g.anchor !== undefined) out.push(g.anchor);
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Resolve each anchor FQN to the node it names, skipping any the model does not hold. */
73
+ function anchorNodes(ctx: GenContext): MetaData[] {
74
+ const wanted = new Set(anchorFqns(ctx));
75
+ if (wanted.size === 0) return [];
76
+ return ctx.loadedRoot.children().filter((n) => wanted.has(n.resolutionKey()));
77
+ }
44
78
 
45
79
  export interface TraceHelperOpts {
46
80
  /** Output directory prefix relative to the target's outDir. Default: "" (root). */
@@ -49,12 +83,17 @@ export interface TraceHelperOpts {
49
83
  target?: string;
50
84
  }
51
85
 
52
- /** Walk the super chain looking for a node named LLM_CALL_BASE. */
53
- function extendsBase(obj: MetaObject): boolean {
54
- let cur = obj.superResolved;
55
- while (cur !== undefined) {
56
- if (cur.name === LLM_CALL_BASE) return true;
57
- cur = cur.superResolved;
86
+ /**
87
+ * Walk the super chain looking for one of the anchor NODES.
88
+ *
89
+ * Node identity, not `.name` and not even the FQN string: the anchors were resolved
90
+ * against this run's own loaded root, so an entity whose chain reaches one reaches
91
+ * exactly the node the library declared.
92
+ */
93
+ function extendsAnchor(obj: MetaObject, anchors: readonly MetaData[]): boolean {
94
+ if (anchors.length === 0) return false;
95
+ for (let cur = obj.superResolved; cur !== undefined; cur = cur.superResolved) {
96
+ if (anchors.includes(cur)) return true;
58
97
  }
59
98
  return false;
60
99
  }
@@ -66,12 +105,16 @@ function pascal(s: string): string {
66
105
 
67
106
  export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts): Generator {
68
107
  const dirPrefix = opts?.outDir ? `${opts.outDir.replace(/\/$/, "")}/` : "";
108
+ // Resolved once per run, on first use: `perEntity` calls back per entity and the
109
+ // anchor set is a property of the run, not of the entity.
110
+ let anchors: MetaData[] | undefined;
69
111
  const generator: Generator = {
70
- name: "trace-helper",
112
+ name: STABLE_NAME,
71
113
  generate: perEntity((entity, ctx) => {
72
- // Only concrete entities derived from LlmCallBase.
114
+ anchors ??= anchorNodes(ctx);
115
+ // Only concrete entities derived from a library's declared anchor.
73
116
  if (entity.isAbstract) return [];
74
- if (!extendsBase(entity)) return [];
117
+ if (!extendsAnchor(entity, anchors)) return [];
75
118
 
76
119
  // Find the nested template.prompt.
77
120
  // ADR-0039: resolving — a concrete trace entity may inherit its
package/src/index.ts CHANGED
@@ -38,8 +38,22 @@ export {
38
38
  generatorRegistry,
39
39
  listGenerators,
40
40
  getGenerator,
41
+ GENERATOR_LAYERS,
41
42
  } from "./generator-registry.js";
42
- export type { GeneratorRegistryEntry, GeneratorTier } from "./generator-registry.js";
43
+ export type {
44
+ GeneratorRegistryEntry,
45
+ GeneratorTier,
46
+ GeneratorFramework,
47
+ Layer,
48
+ } from "./generator-registry.js";
49
+
50
+ // The post-selection audits `meta gen` runs over a wired suite, and the impl-name →
51
+ // stable-name resolver both of them go through. Public so the CLI can gate it.
52
+ export {
53
+ warnUnsatisfiedRequires,
54
+ warnMixedApiFrameworks,
55
+ stableNameIndex,
56
+ } from "./catalog-gates.js";
43
57
 
44
58
  export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, DocsConfig, ResolvedDocsConfig, DocsSurface, ApiSurface, VerifyConfig } from "./metaobjects-config.js";
45
59
  export { defineConfig, normalizeConfig, resolveGenerators, resolveDocsConfig } from "./metaobjects-config.js";
@@ -82,7 +96,7 @@ export { decideAndWrite, GitMissingError, WRITE_STATUSES } from "./overwrite-pol
82
96
  export { contentHash, readGeneratedHash, listGeneratedPaths } from "./overwrite-policy.js";
83
97
 
84
98
  export { CodegenError } from "./errors.js";
85
- export { GENERATED_HEADER, NAMES_FILE_SUFFIX, DEFAULT_OUT_DIR, RETIRED_CODEGEN_ATTRS, type RetiredCodegenAttr } from "./constants.js";
99
+ export { GENERATED_HEADER, NAMES_FILE_SUFFIX, DEFAULT_OUT_DIR, RETIRED_CODEGEN_ATTRS, sidecarLine, type RetiredCodegenAttr } from "./constants.js";
86
100
  export { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js";
87
101
 
88
102
  export { formatTs } from "./format.js";
@@ -210,7 +224,7 @@ export { renderEntityConstants, resourcePath } from "./templates/entity-constant
210
224
  export { renderQueriesFile } from "./templates/queries-file.js";
211
225
  // #348 — which CRUD verbs a generated routes file mounts. Public because an OWNED
212
226
  // routes generator (ADR-0034) composes the same render call and needs the same option.
213
- export { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, resolveExpose, intersectExpose, exposeLine } from "./routes-expose.js";
227
+ export { CRUD_VERBS, TPH_POLYMORPHIC_VERBS, resolveExpose, intersectExpose, exposeLine, authSeamJsDoc } from "./routes-expose.js";
214
228
  export type { CrudVerb, ExposeOption } from "./routes-expose.js";
215
229
  export { renderRoutesFile } from "./templates/routes-file.js";
216
230
  export { renderRoutesFileHono } from "./templates/routes-file-hono.js";
@@ -198,21 +198,6 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
198
198
  * loader. Composed AFTER the default core+forge bundle.
199
199
  */
200
200
  providers?: readonly MetaDataTypeProvider[];
201
- /**
202
- * MetaObjects-shipped library packages this project loads alongside its own metadata —
203
- * `["ai"]` makes `extends: "metaobjects::ai::LlmCallBase"` resolve.
204
- *
205
- * Sits beside `providers` because it answers the same shape of question: what does this
206
- * project's model need in scope beyond the files it declares. Opt-in, because a library
207
- * registers real top-level nodes and a project that never references one should not find
208
- * them in its model, its generated output or its docs.
209
- *
210
- * Threaded to `loadMemory` by every CLI command that loads metadata. Before it existed,
211
- * `librarySources` was reachable only from `MetaDataLoader.fromDirectory` — which the
212
- * CLI does not use — so a generator that consumes a library was registered FOR the CLI
213
- * while its input was unreachable THROUGH it (#333).
214
- */
215
- libraries?: readonly string[];
216
201
  }
217
202
 
218
203
  /** MetaobjectsGenConfig after applying defaults. All fields required.
@@ -42,16 +42,19 @@ import {
42
42
  SORT_ORDER_DESC,
43
43
  RELATIONSHIP_ATTR_OBJECT_REF,
44
44
  RELATIONSHIP_ATTR_CARDINALITY,
45
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
45
46
  CARDINALITY_ONE,
46
47
  IDENTITY_SUBTYPE_REFERENCE,
47
48
  IDENTITY_REFERENCE_ATTR_REFERENCES,
48
49
  FIELD_ATTR_COLUMN,
49
50
  OBJECT_PROJECTION_ATTR_FILTER,
50
- findReferenceBetween,
51
+ findReferencesBetween,
52
+ resolveRelationshipReference,
51
53
  resolveObjectRef,
52
54
  type AggregateFunction,
55
+ type ReferenceLookup,
53
56
  } from "@metaobjectsdev/metadata";
54
- import { type MetaData, type MetaField, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
57
+ import { type MetaData, type MetaField, type MetaReferenceIdentity, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
55
58
  import { intValueMapOf } from "../enum-meta.js";
56
59
  import {
57
60
  columnNameFromField,
@@ -330,6 +333,43 @@ function resolveHop(
330
333
  return undefined;
331
334
  }
332
335
 
336
+ /**
337
+ * #368: which identity.reference does a resolved `@via` hop actually mean?
338
+ *
339
+ * `resolveHop` already found the EXACT node the hop segment named — a
340
+ * reference hop names the reference itself (nothing to disambiguate: two
341
+ * references onto the same target can coexist, but the hop picked one of them
342
+ * by name), and a relationship hop names a relationship whose backing
343
+ * reference `resolveRelationshipReference` resolves via the same SSOT ladder
344
+ * (unique candidate -> @sourceRefField -> unique name-pairing) that
345
+ * relation-resolver.ts already uses for the identical question elsewhere.
346
+ * Discarding `hop` and re-deriving purely from `holder`/`target` — as this
347
+ * file did before — throws away that specificity and reintroduces the exact
348
+ * ambiguity a named hop exists to resolve.
349
+ *
350
+ * Returns a single `ReferenceLookup` once resolved unambiguously, or the full
351
+ * candidate list when even the ladder cannot choose (a relationship hop whose
352
+ * name pairs with none of its candidates and no `@sourceRefField`) — the
353
+ * caller reports that list as a genuine ambiguity.
354
+ */
355
+ function resolveHopReference(
356
+ holder: MetaObject,
357
+ hop: MetaData,
358
+ hopName: string,
359
+ target: MetaObject,
360
+ ): ReferenceLookup | ReferenceLookup[] {
361
+ if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) {
362
+ return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity };
363
+ }
364
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
365
+ const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
366
+ const matching = resolveRelationshipReference(holder, hopName, target.name, sourceRefField);
367
+ if (matching) {
368
+ return { holder, other: target, referenceIdentity: matching };
369
+ }
370
+ return findReferencesBetween(holder, target);
371
+ }
372
+
333
373
  function viewName(projection: MetaObject, ctx: ExtractContext): string {
334
374
  // The read-only source carries the physical view name. FR-016: physicalName
335
375
  // implements the four-step rule (kind-matching alias → legacy @table →
@@ -750,14 +790,62 @@ function buildJoinTree(
750
790
  // a traversed relationship/reference may inherit its target via extends.
751
791
  const resolved = resolveHop(currentObj, relName);
752
792
  if (!resolved) break;
753
- const { targetName, cardinality } = resolved;
793
+ const { hop, targetName, cardinality } = resolved;
754
794
  // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it
755
795
  // package-aware relative to the hop's source entity (the loader qualifies a
756
796
  // same-package ref even when authored bare), so the join binds the exact target.
757
797
  const target = resolveEntityRef(root, targetName, packageOf(currentObj));
758
798
  if (!target) break;
759
799
 
760
- const ref = findReferenceBetween(currentObj as MetaObject, target);
800
+ // #368: two identity.reference declarations onto the same target are legal
801
+ // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers
802
+ // the SPECIFIC reference/relationship the hop already named over re-deriving
803
+ // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a
804
+ // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly.
805
+ // Only a relationship hop that even the ladder cannot choose reaches the throw.
806
+ const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target);
807
+ let ref: ReferenceLookup | undefined;
808
+ if (Array.isArray(resolvedRef)) {
809
+ if (resolvedRef.length > 1) {
810
+ // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and
811
+ // asserting the wrong reason for a given shape is itself a bug (fix round 1 of
812
+ // this cleanup caught exactly that). resolveRelationshipReference's ladder reads
813
+ // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...));
814
+ // it never even looks at `target`'s references. So:
815
+ // - If `currentObj` itself holds one of the ambiguous candidates, resolution
816
+ // already tried @sourceRefField/name-pairing against it and failed — and that
817
+ // is only reachable at all when @cardinality isn't "one": a @cardinality "one"
818
+ // relationship with 2+ own-side candidates is rejected at LOAD by rule (e)
819
+ // (validateOneSideReferenceResolution) using this exact same ladder, so if we
820
+ // got this far with an own-side candidate, @cardinality is provably not "one",
821
+ // and @sourceRefField is provably illegal here (rule (d)).
822
+ // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not
823
+ // have mattered regardless of @cardinality — it only ever consults the hop's
824
+ // OWN identity.reference children, and it has none targeting `target`. This is
825
+ // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too):
826
+ // a @cardinality "one" relationship can reach here with the FK entirely on the
827
+ // far side, so @cardinality itself must NOT be asserted in this branch.
828
+ const holderName = (currentObj as MetaObject).name;
829
+ const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName);
830
+ const whySourceRefFieldCannotHelp = holderOwnsACandidate
831
+ ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` +
832
+ `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` +
833
+ `is itself a load error)`
834
+ : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` +
835
+ `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` +
836
+ `of this join`;
837
+ throw new Error(
838
+ `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` +
839
+ `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` +
840
+ `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` +
841
+ `that disambiguates a hop like this -- remove the extra identity.reference between these two ` +
842
+ `entities, or restructure the model so only one remains.`,
843
+ );
844
+ }
845
+ ref = resolvedRef[0];
846
+ } else {
847
+ ref = resolvedRef;
848
+ }
761
849
  if (!ref) break;
762
850
 
763
851
  const fkField = ref.referenceIdentity.fields[0];
@@ -1090,7 +1178,40 @@ function buildSelectSpec(
1090
1178
  // The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
1091
1179
  // single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
1092
1180
  // a multi-hop @via on origin.first is not lowered here (rare, and validated away).
1093
- const ref = findReferenceBetween(base, childEntity);
1181
+ // #368: two identity.reference declarations onto the same target are legal, so
1182
+ // this correlation cannot silently take the first — refuse rather than guess.
1183
+ // Unlike buildJoinTree's @via hop (a named relationship/reference this file can
1184
+ // resolve via resolveHopReference), origin.first's OWN @via (ORIGIN_FIRST_ATTR_VIA)
1185
+ // is never read anywhere in this file — buildJoinTree explicitly skips it
1186
+ // (ORIGIN_SUBTYPE_FIRST falls through to `continue` there) and this branch derives
1187
+ // childEntity from @of alone, so there is no hop name here to prefer. Fixing that is
1188
+ // a separate, larger change (wiring @via/single-hop-unique inference into this
1189
+ // branch to match _validateViaPath/_inferViaSingleHop) — out of scope for #368's
1190
+ // silent-first-match fix; the message below reflects the real, narrower remedy.
1191
+ const refs = findReferencesBetween(base, childEntity);
1192
+ // findReferencesBetween walks BOTH directions ([base,child] then [child,base]),
1193
+ // so ">1 entry" is not by itself #368 ambiguity: a mutual 1:1 —
1194
+ // Customer.primaryAddressRef -> Address PLUS Address.customerRef -> Customer —
1195
+ // returns two entries pointing in OPPOSITE directions. That shape is legal
1196
+ // (findReferenceBetween's own contract calls it "rare, but legal", and the
1197
+ // referenceHolder: "source" | "target" branch below exists to serve it), and
1198
+ // refusing it broke codegen for a model #368 says nothing about. The real
1199
+ // ambiguity is two references declared by the SAME holder onto the other side —
1200
+ // that is what the first-match below cannot choose between.
1201
+ const ambiguousHolder = [base, childEntity].find(
1202
+ (holder) => refs.filter((r) => r.holder === holder).length > 1,
1203
+ );
1204
+ if (ambiguousHolder !== undefined) {
1205
+ const ambiguous = refs.filter((r) => r.holder === ambiguousHolder);
1206
+ throw new Error(
1207
+ `origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` +
1208
+ `"${ambiguousHolder.name}" declares ${ambiguous.length} identity.reference nodes ` +
1209
+ `onto the other side (${ambiguous.map((r) => r.referenceIdentity.name).join(", ")}). ` +
1210
+ `origin.first's own @via is not consulted for this correlation — reduce to a ` +
1211
+ `single identity.reference between these two entities.`,
1212
+ );
1213
+ }
1214
+ const ref = refs[0];
1094
1215
  if (!ref) continue;
1095
1216
  const fkField = ref.referenceIdentity.fields[0];
1096
1217
  if (!fkField) continue;
@@ -70,6 +70,7 @@ import {
70
70
  namesRef,
71
71
  namesConstArg,
72
72
  GENERATED_HEADER,
73
+ sidecarLine,
73
74
  } from "@metaobjectsdev/codegen-ts";
74
75
 
75
76
  export interface RenderEntityOpts {
@@ -143,8 +144,7 @@ function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntit
143
144
  const body = joinCode(sections, { on: "\n" }).toString();
144
145
  const header =
145
146
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
146
- `// Source metadata: ${entity.name} (${entity.fqn()})\n` +
147
- `// Customize via ${entity.name}.extra.ts in this directory.\n`;
147
+ `// Source metadata: ${entity.name} (${entity.fqn()})\n${sidecarLine(`${entity.name}.extra.ts`)}`;
148
148
  return header + body;
149
149
  }
150
150
 
@@ -175,7 +175,16 @@ export const entityFile = function entityFile(opts?: EntityFileOpts): Generator
175
175
  generate: async (ctx: GenContext): Promise<EmittedFile[]> => {
176
176
  const files = await perEntityEmit(ctx);
177
177
  // FR-019: emit the shared-enums module once per run (null → no file).
178
- const sharedEnums = renderSharedEnumsFile(ctx.loadedRoot);
178
+ // FR-023 §11.1 item 2: this is a REFERENCE TEMPLATE `meta init` copies into
179
+ // every scaffolded project's codegen/generators/ (ADR-0034) — the DEFAULT
180
+ // path, not an opt-in one. `select` (never `ctx.matches`, which never runs
181
+ // for a whole-root render like this one) excludes an enum used only by an
182
+ // imported, out-of-scope entity; without it every `meta init` project would
183
+ // silently emit shared enums the library path correctly excludes.
184
+ const sharedEnums = renderSharedEnumsFile(
185
+ ctx.loadedRoot,
186
+ ctx.select !== undefined ? { select: ctx.select } : undefined,
187
+ );
179
188
  if (sharedEnums !== null) {
180
189
  files.push({ path: `${SHARED_ENUMS_BASENAME}.ts`, content: await formatTs(sharedEnums) });
181
190
  }