@intentius/chant-lexicon-render 0.124.31 → 0.124.32

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 (49) hide show
  1. package/dist/catalog.js +221 -0
  2. package/dist/codegen/docs-cli.js +6 -0
  3. package/dist/codegen/docs.js +110 -0
  4. package/dist/codegen/generate-cli.js +26 -0
  5. package/dist/codegen/generate-lexicon.js +38 -0
  6. package/dist/codegen/generate-typescript.js +46 -0
  7. package/dist/codegen/generate.js +82 -0
  8. package/dist/codegen/naming.js +43 -0
  9. package/dist/codegen/package.js +27 -0
  10. package/dist/composites/render-deploy.js +71 -0
  11. package/dist/coverage.js +29 -0
  12. package/dist/describe-resources.js +325 -0
  13. package/dist/detect.js +27 -0
  14. package/dist/generated/index.js +40 -0
  15. package/dist/generated/runtime.js +4 -0
  16. package/dist/index.js +21 -0
  17. package/dist/integrity.json +2 -2
  18. package/dist/lint/audit-catalog.js +23 -0
  19. package/dist/lint/audit-lineage.js +8 -0
  20. package/dist/lint/post-synth/index.js +8 -0
  21. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.js +43 -0
  22. package/dist/lint/post-synth/ren011-service-requires-source.js +45 -0
  23. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.js +41 -0
  24. package/dist/lint/post-synth/render-helpers.js +34 -0
  25. package/dist/lint/rules/index.js +8 -0
  26. package/dist/lint/rules/no-secret-literals.js +68 -0
  27. package/dist/lint/rules/valid-cron-schedule.js +44 -0
  28. package/dist/lint/rules/valid-region.js +43 -0
  29. package/dist/lsp/completions.d.ts.map +1 -1
  30. package/dist/lsp/completions.js +20 -0
  31. package/dist/lsp/hover.d.ts.map +1 -1
  32. package/dist/lsp/hover.js +49 -0
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/fake-render.js +271 -0
  35. package/dist/op/activities/index.js +8 -0
  36. package/dist/op/activities/render-apply.js +873 -0
  37. package/dist/ownership.js +38 -0
  38. package/dist/package-cli.js +17 -0
  39. package/dist/plugin.js +229 -0
  40. package/dist/pseudo.js +23 -0
  41. package/dist/serializer.js +238 -0
  42. package/dist/spec/fetch.js +30 -0
  43. package/dist/spec/parse.js +600 -0
  44. package/dist/validate-cli.js +4 -0
  45. package/dist/validate.js +41 -0
  46. package/package.json +7 -7
  47. package/src/codegen/package.ts +2 -2
  48. package/src/lsp/completions.ts +2 -1
  49. package/src/lsp/hover.ts +2 -1
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Render Public API OpenAPI 3.0 parser.
3
+ *
4
+ * Render's spec is a REST API description — request/response DTOs, not a
5
+ * resource catalog — so, like fly, we generate a *curated* set of resources
6
+ * rather than every schema. Each curated resource pairs a create-request
7
+ * schema (the writable authoring surface) with a response schema (read-only
8
+ * attributes). The named object schemas reachable from the request schemas
9
+ * (`webServiceDetailsPOST`, `image`, `serviceDisk`, `cidrBlockAndDescription`,
10
+ * ...) become standalone property-type classes so nested config is typed all
11
+ * the way down; anonymous inline objects (Render leans on `#/paths/...` JSON
12
+ * pointers for a few of these — autoscaling, env vars, secret files) are
13
+ * emitted as inline TypeScript object literals, and enums inline as
14
+ * string-literal unions.
15
+ *
16
+ * Services are split by type. Render's `POST /services` takes a `type`
17
+ * discriminator plus a `serviceDetails` oneOf; authoring `new WebService({...})`
18
+ * with `serviceDetails` typed as `WebServiceDetails` is both tighter and more
19
+ * honest than one `Service` class with a five-way union, so each service type
20
+ * is its own resource with `type` fixed and `serviceDetails` narrowed.
21
+ */
22
+ import { extractConstraints as coreExtractConstraints, primaryType, isEnumDefinition, } from "@intentius/chant/codegen/json-schema";
23
+ // ── Curated manifest ───────────────────────────────────────────────
24
+ /** The type-name namespace every render entity lives under. */
25
+ export const NAMESPACE = "Render";
26
+ const SERVICES = "Services";
27
+ const DATASTORES = "Datastores";
28
+ const CONFIG = "Config";
29
+ const PROJECTS = "Projects";
30
+ const OWNER_OPTIONAL = ["ownerId"];
31
+ const SERVICE_REF = "WebService | PrivateService | BackgroundWorker | CronJob | StaticSite";
32
+ /** Common shape of the five service resources — the `servicePOST` body with `type` fixed and `serviceDetails` narrowed. */
33
+ function service(name, type, details, requireDetails = true) {
34
+ return {
35
+ typeName: `${NAMESPACE}::${SERVICES}::${name}`,
36
+ request: "servicePOST",
37
+ response: "service",
38
+ fixed: { type },
39
+ overrides: { serviceDetails: details },
40
+ optional: OWNER_OPTIONAL,
41
+ require: requireDetails ? ["serviceDetails"] : [],
42
+ widen: { environmentId: "Environment" },
43
+ };
44
+ }
45
+ /**
46
+ * Required fields on property-type schemas that chant defaults at build time,
47
+ * so authors need not repeat them: `image.ownerId` follows the owning service's
48
+ * `ownerId` (Render requires the two to match anyway).
49
+ */
50
+ const PROPERTY_OPTIONAL = {
51
+ image: ["ownerId"],
52
+ };
53
+ /**
54
+ * Cross-resource id fields on property-type schemas, widened to accept the
55
+ * declared resource (see `ResourceSpec.widen`).
56
+ */
57
+ const PROPERTY_WIDEN = {
58
+ image: { registryCredentialId: "RegistryCredential" },
59
+ dockerDetailsPOST: { registryCredentialId: "RegistryCredential" },
60
+ };
61
+ const CONNECTION_STRING_ATTRS = [
62
+ { name: "internalConnectionString", tsType: "string" },
63
+ { name: "externalConnectionString", tsType: "string" },
64
+ ];
65
+ export const RESOURCES = [
66
+ service("WebService", "web_service", "webServiceDetailsPOST"),
67
+ service("StaticSite", "static_site", "staticSiteDetailsPOST", false),
68
+ service("PrivateService", "private_service", "privateServiceDetailsPOST"),
69
+ service("BackgroundWorker", "background_worker", "backgroundWorkerDetailsPOST"),
70
+ service("CronJob", "cron_job", "cronJobDetailsPOST"),
71
+ {
72
+ typeName: `${NAMESPACE}::${DATASTORES}::Postgres`,
73
+ request: "postgresPOSTInput",
74
+ response: "postgresDetail",
75
+ optional: OWNER_OPTIONAL,
76
+ widen: { environmentId: "Environment" },
77
+ extraAttributes: [...CONNECTION_STRING_ATTRS, { name: "psqlCommand", tsType: "string" }],
78
+ },
79
+ {
80
+ typeName: `${NAMESPACE}::${DATASTORES}::KeyValue`,
81
+ request: "keyValuePOSTInput",
82
+ response: "keyValueDetail",
83
+ optional: OWNER_OPTIONAL,
84
+ widen: { environmentId: "Environment" },
85
+ extraAttributes: [...CONNECTION_STRING_ATTRS, { name: "cliCommand", tsType: "string" }],
86
+ },
87
+ {
88
+ typeName: `${NAMESPACE}::${CONFIG}::EnvGroup`,
89
+ request: "envGroupPOSTInput",
90
+ response: "envGroup",
91
+ optional: OWNER_OPTIONAL,
92
+ widen: { environmentId: "Environment" },
93
+ retype: { serviceIds: `(string | ${SERVICE_REF})[]` },
94
+ },
95
+ {
96
+ typeName: `${NAMESPACE}::${CONFIG}::RegistryCredential`,
97
+ request: "#/paths/~1registrycredentials/post/requestBody/content/application~1json/schema",
98
+ response: "registryCredential",
99
+ optional: OWNER_OPTIONAL,
100
+ },
101
+ {
102
+ typeName: `${NAMESPACE}::${CONFIG}::Webhook`,
103
+ request: "#/paths/~1webhooks/post/requestBody/content/application~1json/schema",
104
+ response: "#/paths/~1webhooks/post/responses/201/content/application~1json/schema",
105
+ optional: OWNER_OPTIONAL,
106
+ },
107
+ {
108
+ typeName: `${NAMESPACE}::${PROJECTS}::Project`,
109
+ request: "projectPOSTInput",
110
+ response: "project",
111
+ optional: OWNER_OPTIONAL,
112
+ },
113
+ {
114
+ typeName: `${NAMESPACE}::${PROJECTS}::Environment`,
115
+ request: "environmentPOSTInput",
116
+ response: "environment",
117
+ widen: { projectId: "Project" },
118
+ },
119
+ {
120
+ typeName: `${NAMESPACE}::${SERVICES}::Disk`,
121
+ request: "#/paths/~1disks/post/requestBody/content/application~1json/schema",
122
+ response: "#/paths/~1disks/post/responses/201/content/application~1json/schema",
123
+ widen: { serviceId: "WebService | PrivateService | BackgroundWorker" },
124
+ },
125
+ {
126
+ typeName: `${NAMESPACE}::${SERVICES}::CustomDomain`,
127
+ request: "#/paths/~1services~1{serviceId}~1custom-domains/post/requestBody/content/application~1json/schema",
128
+ response: "customDomain",
129
+ extraProperties: [
130
+ {
131
+ name: "serviceId",
132
+ tsType: `string | WebService | StaticSite`,
133
+ required: true,
134
+ description: "The service the domain is attached to — an id, or a declared WebService/StaticSite.",
135
+ constraints: {},
136
+ },
137
+ ],
138
+ },
139
+ ];
140
+ /**
141
+ * Schema-name aliases: the spec's `cronJobDetailsPOST.envSpecificDetails`
142
+ * points at the response-shaped `envSpecificDetails` rather than the POST one
143
+ * every other service type uses. Route it to the POST shape so the lexicon has
144
+ * one `EnvSpecificDetails` union, not two colliding classes.
145
+ */
146
+ const SCHEMA_ALIASES = {
147
+ envSpecificDetails: "envSpecificDetailsPOST",
148
+ dockerDetails: "dockerDetailsPOST",
149
+ nativeEnvironmentDetails: "nativeEnvironmentDetailsPOST",
150
+ };
151
+ /**
152
+ * Anonymous inline schemas Render leaves at `#/paths/...` pointers that deserve
153
+ * a class of their own — env var entries, secret files, autoscaling — hoisted
154
+ * into `components.schemas` under these names before parsing, so authors write
155
+ * `new EnvVar({ key, value })` and `new Autoscaling({...})` like every other
156
+ * property type (chant's COR001 wants each config value to be a Declarable,
157
+ * not an inline object). Innermost first: an outer hoist then sees its inner
158
+ * one as a `$ref`.
159
+ */
160
+ const HOISTED = [
161
+ {
162
+ pointer: "#/paths/~1services~1{serviceId}~1env-vars/put/requestBody/content/application~1json/schema/items/oneOf/0",
163
+ name: "EnvVar",
164
+ },
165
+ {
166
+ pointer: "#/paths/~1services~1{serviceId}~1env-vars/put/requestBody/content/application~1json/schema/items/oneOf/1",
167
+ name: "GeneratedEnvVar",
168
+ },
169
+ {
170
+ pointer: "#/paths/~1services~1{serviceId}~1secret-files/put/requestBody/content/application~1json/schema/items",
171
+ name: "SecretFile",
172
+ },
173
+ {
174
+ pointer: "#/paths/~1services~1{serviceId}~1autoscaling/put/requestBody/content/application~1json/schema/properties/criteria/properties/cpu",
175
+ name: "AutoscalingCriterion",
176
+ },
177
+ {
178
+ pointer: "#/paths/~1services~1{serviceId}~1autoscaling/put/requestBody/content/application~1json/schema/properties/criteria/properties/memory",
179
+ name: "AutoscalingCriterion",
180
+ },
181
+ {
182
+ pointer: "#/paths/~1services~1{serviceId}~1autoscaling/put/requestBody/content/application~1json/schema/properties/criteria",
183
+ name: "AutoscalingCriteria",
184
+ },
185
+ {
186
+ pointer: "#/paths/~1services~1{serviceId}~1autoscaling/put/requestBody/content/application~1json/schema",
187
+ name: "Autoscaling",
188
+ },
189
+ ];
190
+ /** Class-name overrides where the suffix-stripping rule below misfires. */
191
+ const CLASS_NAME_OVERRIDES = {
192
+ projectPOSTEnvironmentInput: "ProjectEnvironment",
193
+ };
194
+ const COMPONENT_PREFIX = "#/components/schemas/";
195
+ // ── Parser ─────────────────────────────────────────────────────────
196
+ /**
197
+ * Parse the Render OpenAPI spec into the curated resources and the property
198
+ * types reachable from their request schemas.
199
+ */
200
+ export function parseRenderOpenAPI(data) {
201
+ const spec = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
202
+ const schemas = spec.components?.schemas ?? {};
203
+ const ctx = new ParseContext(spec, schemas);
204
+ ctx.hoist(HOISTED);
205
+ // Phase 1: discover the emitted property-type set — named object schemas
206
+ // reachable (transitively) from the request schemas' properties.
207
+ ctx.collectPropertyTypes();
208
+ const results = [];
209
+ // Phase 2: resources.
210
+ for (const rspec of RESOURCES) {
211
+ const req = ctx.locate(rspec.request);
212
+ const res = ctx.locate(rspec.response);
213
+ const reqProps = ctx.flattenProperties(req);
214
+ const requiredSet = new Set(ctx.flattenRequired(req));
215
+ const fixed = rspec.fixed ?? {};
216
+ const optional = new Set(rspec.optional ?? []);
217
+ const forced = new Set(rspec.require ?? []);
218
+ const properties = [];
219
+ for (const [name, prop] of Object.entries(reqProps)) {
220
+ if (name in fixed)
221
+ continue;
222
+ const override = rspec.overrides?.[name];
223
+ let tsType = override ? ctx.resolveLocator(override) : ctx.resolveType(prop);
224
+ const widen = rspec.widen?.[name];
225
+ if (widen)
226
+ tsType = `${tsType} | ${widen}`;
227
+ const retype = rspec.retype?.[name];
228
+ if (retype)
229
+ tsType = retype;
230
+ properties.push({
231
+ name,
232
+ tsType,
233
+ required: (requiredSet.has(name) || forced.has(name)) && !optional.has(name),
234
+ description: prop.description,
235
+ constraints: coreExtractConstraints(prop),
236
+ });
237
+ }
238
+ for (const extra of rspec.extraProperties ?? [])
239
+ properties.push(extra);
240
+ // Attributes = response props not present in the request schema.
241
+ const attributes = [];
242
+ for (const [name, prop] of Object.entries(ctx.flattenProperties(res))) {
243
+ if (name in reqProps)
244
+ continue;
245
+ attributes.push({ name, tsType: ctx.resolveType(prop) });
246
+ }
247
+ for (const extra of rspec.extraAttributes ?? [])
248
+ attributes.push(extra);
249
+ results.push({
250
+ resource: { typeName: rspec.typeName, description: req?.description, properties, attributes },
251
+ propertyTypes: [],
252
+ enums: [],
253
+ });
254
+ }
255
+ // Phase 3: property-type classes.
256
+ for (const schemaName of ctx.emitted) {
257
+ const def = schemas[schemaName];
258
+ const requiredSet = new Set(ctx.flattenRequired(def));
259
+ const optional = new Set(PROPERTY_OPTIONAL[schemaName] ?? []);
260
+ const widen = PROPERTY_WIDEN[schemaName] ?? {};
261
+ const properties = [];
262
+ for (const [name, prop] of Object.entries(ctx.flattenProperties(def))) {
263
+ const base = ctx.resolveType(prop);
264
+ properties.push({
265
+ name,
266
+ tsType: widen[name] ? `${base} | ${widen[name]}` : base,
267
+ required: requiredSet.has(name) && !optional.has(name),
268
+ description: prop.description,
269
+ constraints: coreExtractConstraints(prop),
270
+ });
271
+ }
272
+ results.push({
273
+ resource: {
274
+ typeName: `${NAMESPACE}::${SERVICES}::${schemaToClassName(schemaName)}`,
275
+ description: def.description,
276
+ properties,
277
+ attributes: [],
278
+ },
279
+ propertyTypes: [],
280
+ enums: [],
281
+ isProperty: true,
282
+ });
283
+ }
284
+ return results;
285
+ }
286
+ class ParseContext {
287
+ constructor(spec, schemas) {
288
+ this.spec = spec;
289
+ this.schemas = schemas;
290
+ /** Emitted property-type schema names, in insertion order. */
291
+ this.emitted = [];
292
+ this.emittedSet = new Set();
293
+ this.classNames = new Map();
294
+ }
295
+ /**
296
+ * Hoist anonymous inline schemas into named components: the node at each
297
+ * pointer moves to `schemas[name]` and a `$ref` to it takes its place. Two
298
+ * pointers may share a name when their shapes are identical (autoscaling's
299
+ * cpu/memory criteria) — the first wins, the second becomes a `$ref` to it.
300
+ */
301
+ hoist(entries) {
302
+ for (const { pointer, name } of entries) {
303
+ const parts = pointer.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
304
+ const key = parts.pop();
305
+ let parent = this.spec;
306
+ for (const part of parts) {
307
+ if (!parent || typeof parent !== "object") {
308
+ parent = undefined;
309
+ break;
310
+ }
311
+ parent = parent[part];
312
+ }
313
+ if (!parent || typeof parent !== "object")
314
+ continue;
315
+ const node = parent[key];
316
+ if (!node || typeof node !== "object")
317
+ continue;
318
+ if (node.$ref) {
319
+ // Already a reference (Render points `memory` at `cpu`): when the
320
+ // hoisted class exists, retarget the reference at it — the original
321
+ // pointer may pass through a node an earlier hoist has since replaced.
322
+ if (this.schemas[name])
323
+ parent[key] = { $ref: `${COMPONENT_PREFIX}${name}` };
324
+ continue;
325
+ }
326
+ if (!this.schemas[name])
327
+ this.schemas[name] = node;
328
+ parent[key] = { $ref: `${COMPONENT_PREFIX}${name}` };
329
+ }
330
+ }
331
+ /** Resolve a schema locator (bare component name or `#/...` pointer) to its schema node. */
332
+ locate(locator) {
333
+ if (locator.startsWith("#/"))
334
+ return this.pointer(locator);
335
+ return this.schemas[canonicalSchemaName(locator)];
336
+ }
337
+ /** Resolve a JSON pointer inside the spec document (`#/paths/~1disks/post/...`). */
338
+ pointer(ref) {
339
+ const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
340
+ let node = this.spec;
341
+ for (const part of parts) {
342
+ if (!node || typeof node !== "object")
343
+ return undefined;
344
+ node = node[part];
345
+ }
346
+ return node;
347
+ }
348
+ /** Follow a `$ref` (component or pointer) to its target node. */
349
+ deref(ref) {
350
+ if (isComponentRef(ref))
351
+ return this.schemas[canonicalSchemaName(ref.slice(COMPONENT_PREFIX.length))];
352
+ if (ref.startsWith("#/"))
353
+ return this.pointer(ref);
354
+ return undefined;
355
+ }
356
+ /** Properties of a schema, merging `allOf` members (Render's `envGroup` is `allOf: [envGroupMeta, {...}]`). */
357
+ flattenProperties(def) {
358
+ if (!def)
359
+ return {};
360
+ const out = {};
361
+ for (const member of def.allOf ?? []) {
362
+ const target = member.$ref ? this.deref(member.$ref) : member;
363
+ Object.assign(out, this.flattenProperties(target));
364
+ }
365
+ Object.assign(out, def.properties ?? {});
366
+ return out;
367
+ }
368
+ flattenRequired(def) {
369
+ if (!def)
370
+ return [];
371
+ const out = [];
372
+ for (const member of def.allOf ?? []) {
373
+ const target = member.$ref ? this.deref(member.$ref) : member;
374
+ out.push(...this.flattenRequired(target));
375
+ }
376
+ out.push(...(def.required ?? []));
377
+ return out;
378
+ }
379
+ /**
380
+ * Walk the curated request schemas (and their overrides) and collect the
381
+ * named object schemas reachable through their properties — following $ref,
382
+ * allOf, oneOf, arrays, and maps. Only `#/components/schemas/X` targets become
383
+ * classes; anonymous inline objects stay inline.
384
+ */
385
+ collectPropertyTypes() {
386
+ const queue = [];
387
+ const seed = (node) => {
388
+ for (const ref of collectRefs(node)) {
389
+ if (!isComponentRef(ref)) {
390
+ // A pointer into paths (or a nested component property): walk its target for further component refs.
391
+ const target = this.deref(ref);
392
+ if (target)
393
+ seed(target);
394
+ continue;
395
+ }
396
+ const name = canonicalSchemaName(ref.slice(COMPONENT_PREFIX.length));
397
+ const target = this.schemas[name];
398
+ if (!target)
399
+ continue;
400
+ if (this.isObjectSchema(target) && !this.emittedSet.has(name)) {
401
+ this.emittedSet.add(name);
402
+ this.emitted.push(name);
403
+ queue.push(name);
404
+ }
405
+ else if (!this.isObjectSchema(target)) {
406
+ // A oneOf-of-refs (envSpecificDetailsPOST) or array alias: walk through it.
407
+ seed(target);
408
+ }
409
+ }
410
+ };
411
+ for (const rspec of RESOURCES) {
412
+ const req = this.locate(rspec.request);
413
+ const props = this.flattenProperties(req);
414
+ for (const [name, prop] of Object.entries(props)) {
415
+ if (rspec.fixed && name in rspec.fixed)
416
+ continue;
417
+ const override = rspec.overrides?.[name];
418
+ if (override) {
419
+ seed({ $ref: override.startsWith("#/") ? override : `${COMPONENT_PREFIX}${override}` });
420
+ }
421
+ else {
422
+ seed(prop);
423
+ }
424
+ }
425
+ }
426
+ while (queue.length > 0) {
427
+ const name = queue.shift();
428
+ seed(this.flattenProperties(this.schemas[name]));
429
+ }
430
+ }
431
+ /** An object schema with properties (directly or via allOf), not a pure enum. */
432
+ isObjectSchema(def) {
433
+ if (isEnumDefinition(def))
434
+ return false;
435
+ return Object.keys(this.flattenProperties(def)).length > 0;
436
+ }
437
+ /** Resolve a locator (as used by `overrides`) to a TS type. */
438
+ resolveLocator(locator) {
439
+ if (locator.startsWith("#/"))
440
+ return this.resolveType({ $ref: locator });
441
+ return this.resolveType({ $ref: `${COMPONENT_PREFIX}${locator}` });
442
+ }
443
+ /**
444
+ * Resolve an OpenAPI schema node to its TypeScript type string. Emitted object
445
+ * schemas resolve to their class name; enums inline as string-literal unions;
446
+ * anonymous objects inline as object literals; everything else falls back to
447
+ * primitives or `Record<string, any>`.
448
+ */
449
+ resolveType(prop, depth = 0) {
450
+ if (!prop)
451
+ return "any";
452
+ if (depth > 8)
453
+ return "any";
454
+ if (prop.$ref)
455
+ return this.resolveRefType(prop.$ref, depth);
456
+ // allOf: [{ $ref }] — the OpenAPI idiom for "typed as this schema"; a
457
+ // multi-member allOf (envGroup) is an object merge — inline it.
458
+ if (prop.allOf && prop.allOf.length > 0) {
459
+ if (prop.allOf.length === 1 && prop.allOf[0].$ref)
460
+ return this.resolveRefType(prop.allOf[0].$ref, depth);
461
+ return this.inlineObject(prop, depth);
462
+ }
463
+ const variants = prop.oneOf ?? prop.anyOf;
464
+ if (variants && variants.length > 0) {
465
+ const members = variants.map((v) => this.resolveType(v, depth + 1));
466
+ return dedupe(members).join(" | ");
467
+ }
468
+ if (prop.enum && prop.enum.length > 0) {
469
+ return enumUnion(prop.enum);
470
+ }
471
+ const pt = primaryType(prop.type);
472
+ switch (pt) {
473
+ case "string":
474
+ return "string";
475
+ case "integer":
476
+ case "number":
477
+ return "number";
478
+ case "boolean":
479
+ return "boolean";
480
+ case "array": {
481
+ if (prop.items) {
482
+ const itemType = this.resolveType(prop.items, depth + 1);
483
+ return itemType.includes(" | ") ? `(${itemType})[]` : `${itemType}[]`;
484
+ }
485
+ return "any[]";
486
+ }
487
+ case "object":
488
+ if (prop.properties && Object.keys(prop.properties).length > 0) {
489
+ return this.inlineObject(prop, depth);
490
+ }
491
+ if (prop.additionalProperties && typeof prop.additionalProperties === "object") {
492
+ return `Record<string, ${this.resolveType(prop.additionalProperties, depth + 1)}>`;
493
+ }
494
+ return "Record<string, any>";
495
+ default:
496
+ // Untyped node with properties (some inline bodies omit `type`).
497
+ if (prop.properties && Object.keys(prop.properties).length > 0)
498
+ return this.inlineObject(prop, depth);
499
+ return "any";
500
+ }
501
+ }
502
+ /** Render an anonymous object schema as a TS object literal type. */
503
+ inlineObject(def, depth) {
504
+ const props = this.flattenProperties(def);
505
+ const required = new Set(this.flattenRequired(def));
506
+ const members = Object.entries(props).map(([name, p]) => {
507
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
508
+ return `${key}${required.has(name) ? "" : "?"}: ${this.resolveType(p, depth + 1)}`;
509
+ });
510
+ return `{ ${members.join("; ")} }`;
511
+ }
512
+ /** Resolve a `$ref` (component or pointer) to a TypeScript type. */
513
+ resolveRefType(ref, depth) {
514
+ if (!isComponentRef(ref)) {
515
+ // A pointer into paths, or into a component's nested property
516
+ // (`#/components/schemas/redisDetail/properties/maintenance`) — no
517
+ // class, resolve structurally.
518
+ return ref.startsWith("#/") ? this.resolveType(this.pointer(ref), depth + 1) : "any";
519
+ }
520
+ const name = canonicalSchemaName(ref.slice(COMPONENT_PREFIX.length));
521
+ const def = this.schemas[name];
522
+ if (!def)
523
+ return "any";
524
+ // Emitted object schemas reference their class by name.
525
+ if (this.emittedSet.has(name))
526
+ return schemaToClassName(name);
527
+ // Enums inline as string-literal unions.
528
+ if (isEnumDefinition(def))
529
+ return enumUnion(def.enum ?? []);
530
+ // Non-object aliases (oneOf-of-refs, arrays, primitives): resolve through.
531
+ if (!this.isObjectSchema(def))
532
+ return this.resolveType(def, depth + 1);
533
+ // Object schemas we did not emit (response-only shapes) loosen to a map.
534
+ return "Record<string, any>";
535
+ }
536
+ }
537
+ // ── Helpers ────────────────────────────────────────────────────────
538
+ /** Collect every `$ref` string nested anywhere in a node. */
539
+ function collectRefs(node, acc = new Set()) {
540
+ if (!node || typeof node !== "object")
541
+ return acc;
542
+ if (Array.isArray(node)) {
543
+ for (const item of node)
544
+ collectRefs(item, acc);
545
+ return acc;
546
+ }
547
+ const obj = node;
548
+ const ref = obj.$ref;
549
+ if (typeof ref === "string")
550
+ acc.add(ref);
551
+ for (const [key, value] of Object.entries(obj)) {
552
+ if (key === "$ref")
553
+ continue;
554
+ collectRefs(value, acc);
555
+ }
556
+ return acc;
557
+ }
558
+ /** A `$ref` to a whole component schema (`#/components/schemas/X`), not a pointer into one. */
559
+ function isComponentRef(ref) {
560
+ return ref.startsWith(COMPONENT_PREFIX) && !ref.slice(COMPONENT_PREFIX.length).includes("/");
561
+ }
562
+ function enumUnion(values) {
563
+ return [...values].map((v) => JSON.stringify(v)).sort().join(" | ");
564
+ }
565
+ function dedupe(values) {
566
+ return [...new Set(values)];
567
+ }
568
+ /** Apply the schema aliases (spec quirks) to a component name. */
569
+ export function canonicalSchemaName(name) {
570
+ return SCHEMA_ALIASES[name] ?? name;
571
+ }
572
+ // ── Naming helpers ─────────────────────────────────────────────────
573
+ /**
574
+ * Convert a component schema name into a PascalCase class name, dropping the
575
+ * request-shape suffixes Render uses so the authoring surface reads as the
576
+ * concept, not the wire DTO:
577
+ * "webServiceDetailsPOST" → "WebServiceDetails", "readReplicaInput" →
578
+ * "ReadReplica", "routePost" → "Route", "cidrBlockAndDescription" →
579
+ * "CidrBlockAndDescription".
580
+ */
581
+ export function schemaToClassName(schemaName) {
582
+ const override = CLASS_NAME_OVERRIDES[schemaName];
583
+ if (override)
584
+ return override;
585
+ let base = schemaName;
586
+ base = base.replace(/Input$/, "");
587
+ base = base.replace(/(POST|PATCH|PUT|Post|Patch|Put)$/, "");
588
+ base = base.replace(/Input$/, "");
589
+ return base.charAt(0).toUpperCase() + base.slice(1);
590
+ }
591
+ /** Extract short name: "Render::Services::WebService" → "WebService". */
592
+ export function renderShortName(typeName) {
593
+ const parts = typeName.split("::");
594
+ return parts[parts.length - 1];
595
+ }
596
+ /** Extract service name: "Render::Services::WebService" → "Services". */
597
+ export function renderServiceName(typeName) {
598
+ const parts = typeName.split("::");
599
+ return parts.length >= 2 ? parts[1] : SERVICES;
600
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env tsx
2
+ import { validate } from "./validate.js";
3
+ import { printValidationResult } from "@intentius/chant/codegen/validate";
4
+ printValidationResult(await validate());
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Validate generated lexicon-render artifacts.
3
+ *
4
+ * Thin wrapper around the core validation framework with render-specific
5
+ * configuration.
6
+ */
7
+ import { dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
10
+ /**
11
+ * The curated top-level Render resources. A regeneration that loses one — an
12
+ * upstream rename, a spec fetch that returned a partial document — must fail
13
+ * validation rather than ship a lexicon missing a kind.
14
+ */
15
+ const REQUIRED_NAMES = [
16
+ "WebService",
17
+ "StaticSite",
18
+ "PrivateService",
19
+ "BackgroundWorker",
20
+ "CronJob",
21
+ "Postgres",
22
+ "KeyValue",
23
+ "EnvGroup",
24
+ "Project",
25
+ "Environment",
26
+ "Disk",
27
+ "CustomDomain",
28
+ "RegistryCredential",
29
+ "Webhook",
30
+ ];
31
+ /**
32
+ * Validate the generated lexicon-render artifacts.
33
+ */
34
+ export async function validate(opts) {
35
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
36
+ return validateLexiconArtifacts({
37
+ lexiconJsonFilename: "lexicon-render.json",
38
+ requiredNames: REQUIRED_NAMES,
39
+ basePath,
40
+ });
41
+ }