@metaobjectsdev/codegen-ts 1.0.2 → 1.0.3

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 (39) hide show
  1. package/dist/enum-shared.d.ts +9 -2
  2. package/dist/enum-shared.d.ts.map +1 -1
  3. package/dist/enum-shared.js +12 -3
  4. package/dist/enum-shared.js.map +1 -1
  5. package/dist/generator-registry.d.ts.map +1 -1
  6. package/dist/generator-registry.js +11 -1
  7. package/dist/generator-registry.js.map +1 -1
  8. package/dist/generator.d.ts +31 -1
  9. package/dist/generator.d.ts.map +1 -1
  10. package/dist/generator.js.map +1 -1
  11. package/dist/generators/entity-file.d.ts.map +1 -1
  12. package/dist/generators/entity-file.js +4 -1
  13. package/dist/generators/entity-file.js.map +1 -1
  14. package/dist/generators/index.d.ts +1 -0
  15. package/dist/generators/index.d.ts.map +1 -1
  16. package/dist/generators/index.js +5 -0
  17. package/dist/generators/index.js.map +1 -1
  18. package/dist/generators/shared-model-file.d.ts +23 -0
  19. package/dist/generators/shared-model-file.d.ts.map +1 -0
  20. package/dist/generators/shared-model-file.js +235 -0
  21. package/dist/generators/shared-model-file.js.map +1 -0
  22. package/dist/runner.d.ts +9 -0
  23. package/dist/runner.d.ts.map +1 -1
  24. package/dist/runner.js +15 -1
  25. package/dist/runner.js.map +1 -1
  26. package/dist/templates/enums-file.d.ts +11 -1
  27. package/dist/templates/enums-file.d.ts.map +1 -1
  28. package/dist/templates/enums-file.js +10 -2
  29. package/dist/templates/enums-file.js.map +1 -1
  30. package/package.json +6 -6
  31. package/src/enum-shared.ts +17 -3
  32. package/src/generator-registry.ts +11 -0
  33. package/src/generator.ts +31 -1
  34. package/src/generators/entity-file.ts +7 -1
  35. package/src/generators/index.ts +11 -0
  36. package/src/generators/shared-model-file.ts +292 -0
  37. package/src/reference/entity.ts +10 -1
  38. package/src/runner.ts +28 -1
  39. package/src/templates/enums-file.ts +13 -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
+ }
@@ -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
  }
package/src/runner.ts CHANGED
@@ -6,7 +6,10 @@ import { tmpdir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
9
- import { isMetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata";
9
+ import {
10
+ isMetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE,
11
+ composeRegistry, coreProviders,
12
+ } from "@metaobjectsdev/metadata";
10
13
  import { assignEmittedNames } from "./naming/collision-names.js";
11
14
  import { isAbstract } from "./instance-artifacts.js";
12
15
  import { dbEmittingObjects, missingDialectMessage } from "./db-emitting.js";
@@ -106,6 +109,15 @@ export interface RunGenOpts {
106
109
  * they pass straight through.
107
110
  */
108
111
  scope?: (fqn: string) => boolean;
112
+ /**
113
+ * FR-023 §4.3 — the collection's own source files (`Collection.ownFiles`; never
114
+ * a dependency's snapshot artifact), threaded onto `GenContext.sourceFiles` for
115
+ * `sharedModelFile()`'s default `files` selection. The CLI's `gen` command
116
+ * always passes `genCollection.ownFiles`; a programmatic caller that never
117
+ * wires `sharedModelFile()` (or always passes that generator's own `files`
118
+ * option explicitly) can omit this with no behavior change elsewhere.
119
+ */
120
+ sourceFiles?: readonly string[];
109
121
  }
110
122
 
111
123
  export interface RunGenResult {
@@ -374,6 +386,13 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
374
386
  // 2. Resolve targets + entity-module target.
375
387
  const config = normalizeConfig(opts.config);
376
388
 
389
+ // FR-023 §4.3 — the run's composed registry, threaded onto every GenContext.
390
+ // Mirrors sdk's `loadMemory` exactly (`defaultLoadMemoryProviders` = core
391
+ // providers, then the project's own `config.providers` appended) so
392
+ // `sharedModelFile()`'s standalone re-load of a `files` subset sees the SAME
393
+ // vocabulary `opts.metadata` was originally loaded with.
394
+ const registry = composeRegistry([...coreProviders, ...(config.providers ?? [])]);
395
+
377
396
  // The compile break (`value` → `fetcher` on the provider) sends an adopter to the right
378
397
  // line; this names the value to put there, once, for the projects that had one.
379
398
  if (shouldNoteBaseUrlMove(config.apiPrefix, recordedEngine)) {
@@ -605,6 +624,12 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
605
624
  entities: safeEntities,
606
625
  loadedRoot: root,
607
626
  matches: (e) => generator.filter?.(e) ?? true,
627
+ // FR-023 §11.1 item 2 — the SAME `scope` predicate that already narrowed
628
+ // `entities` above, re-exposed for a template that renders once over the
629
+ // WHOLE loaded root (the shared-enums module, so far the only one) rather
630
+ // than per matched entity, where `matches` never runs. Absent scope ⇒ no
631
+ // `select` at all, byte-identical to a project with no `scope` declared.
632
+ ...(scope !== undefined && { select: scope }),
608
633
  config: {
609
634
  outDir: selfTarget.outDir,
610
635
  extStyle: config.extStyle,
@@ -617,6 +642,8 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
617
642
  },
618
643
  renderContext,
619
644
  ...(projectRoot !== undefined && { projectRoot }),
645
+ registry,
646
+ ...(opts.sourceFiles !== undefined && { sourceFiles: opts.sourceFiles }),
620
647
  warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
621
648
  };
622
649
 
@@ -37,9 +37,20 @@ export const ${sharedEnumZodConstName(e.name)} = ${z}.enum([${members}]);
37
37
  /**
38
38
  * The full shared-enums module body, or null when the model has no materialized
39
39
  * shared enums (so the generator emits no file at all).
40
+ *
41
+ * `opts.select` (FR-023 §11.1 item 2) — the model-wide scope predicate over a
42
+ * CONSUMING entity's `resolutionKey()`: an enum extended only by entities
43
+ * `select` excludes is not materialized. This module renders once per run from
44
+ * the WHOLE loaded root (never per-generator-filtered — see #266), so it is the
45
+ * one shared-enums call site that must be threaded the scope predicate
46
+ * separately from `ctx.matches`. Omitted ⇒ every entity counts, byte-identical
47
+ * to a caller that never passes `opts` at all.
40
48
  */
41
- export function renderSharedEnumsFile(root: MetaRoot): string | null {
42
- const enums = materializedSharedEnums(root);
49
+ export function renderSharedEnumsFile(
50
+ root: MetaRoot,
51
+ opts?: { select?: (fqn: string) => boolean },
52
+ ): string | null {
53
+ const enums = materializedSharedEnums(root, opts?.select);
43
54
  if (enums.length === 0) return null;
44
55
 
45
56
  const body = joinCode(enums.map(renderOneSharedEnum), { on: "\n" }).toString();