@intentius/chant-lexicon-render 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +89 -0
  2. package/dist/catalog.d.ts +100 -0
  3. package/dist/catalog.d.ts.map +1 -0
  4. package/dist/codegen/docs-cli.d.ts +3 -0
  5. package/dist/codegen/docs-cli.d.ts.map +1 -0
  6. package/dist/codegen/docs.d.ts +21 -0
  7. package/dist/codegen/docs.d.ts.map +1 -0
  8. package/dist/codegen/generate-cli.d.ts +6 -0
  9. package/dist/codegen/generate-cli.d.ts.map +1 -0
  10. package/dist/codegen/generate-lexicon.d.ts +18 -0
  11. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  12. package/dist/codegen/generate-typescript.d.ts +12 -0
  13. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +15 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/naming.d.ts +14 -0
  17. package/dist/codegen/naming.d.ts.map +1 -0
  18. package/dist/codegen/package.d.ts +17 -0
  19. package/dist/codegen/package.d.ts.map +1 -0
  20. package/dist/composites/render-deploy.d.ts +88 -0
  21. package/dist/composites/render-deploy.d.ts.map +1 -0
  22. package/dist/coverage.d.ts +15 -0
  23. package/dist/coverage.d.ts.map +1 -0
  24. package/dist/describe-resources.d.ts +60 -0
  25. package/dist/describe-resources.d.ts.map +1 -0
  26. package/dist/detect.d.ts +12 -0
  27. package/dist/detect.d.ts.map +1 -0
  28. package/dist/generated/index.d.ts +38 -0
  29. package/dist/generated/index.d.ts.map +1 -0
  30. package/dist/generated/runtime.d.ts +5 -0
  31. package/dist/generated/runtime.d.ts.map +1 -0
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/integrity.json +18 -0
  35. package/dist/lint/audit-catalog.d.ts +13 -0
  36. package/dist/lint/audit-catalog.d.ts.map +1 -0
  37. package/dist/lint/post-synth/index.d.ts +3 -0
  38. package/dist/lint/post-synth/index.d.ts.map +1 -0
  39. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.d.ts +12 -0
  40. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.d.ts.map +1 -0
  41. package/dist/lint/post-synth/ren011-service-requires-source.d.ts +12 -0
  42. package/dist/lint/post-synth/ren011-service-requires-source.d.ts.map +1 -0
  43. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.d.ts +11 -0
  44. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.d.ts.map +1 -0
  45. package/dist/lint/post-synth/render-helpers.d.ts +19 -0
  46. package/dist/lint/post-synth/render-helpers.d.ts.map +1 -0
  47. package/dist/lint/rules/index.d.ts +7 -0
  48. package/dist/lint/rules/index.d.ts.map +1 -0
  49. package/dist/lint/rules/no-secret-literals.d.ts +13 -0
  50. package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
  51. package/dist/lint/rules/valid-cron-schedule.d.ts +12 -0
  52. package/dist/lint/rules/valid-cron-schedule.d.ts.map +1 -0
  53. package/dist/lint/rules/valid-region.d.ts +16 -0
  54. package/dist/lint/rules/valid-region.d.ts.map +1 -0
  55. package/dist/lsp/completions.d.ts +8 -0
  56. package/dist/lsp/completions.d.ts.map +1 -0
  57. package/dist/lsp/hover.d.ts +6 -0
  58. package/dist/lsp/hover.d.ts.map +1 -0
  59. package/dist/manifest.json +6 -0
  60. package/dist/meta.json +304 -0
  61. package/dist/okf/index.md +29 -0
  62. package/dist/okf/rules/REN001.md +11 -0
  63. package/dist/okf/rules/REN002.md +15 -0
  64. package/dist/okf/rules/REN003.md +15 -0
  65. package/dist/okf/rules/REN010.md +11 -0
  66. package/dist/okf/rules/REN011.md +11 -0
  67. package/dist/okf/rules/REN012.md +11 -0
  68. package/dist/okf/types/BackgroundWorker.md +24 -0
  69. package/dist/okf/types/CronJob.md +28 -0
  70. package/dist/okf/types/CustomDomain.md +14 -0
  71. package/dist/okf/types/Disk.md +16 -0
  72. package/dist/okf/types/EnvGroup.md +22 -0
  73. package/dist/okf/types/Environment.md +17 -0
  74. package/dist/okf/types/KeyValue.md +20 -0
  75. package/dist/okf/types/Postgres.md +29 -0
  76. package/dist/okf/types/PrivateService.md +24 -0
  77. package/dist/okf/types/Project.md +15 -0
  78. package/dist/okf/types/RegistryCredential.md +17 -0
  79. package/dist/okf/types/StaticSite.md +24 -0
  80. package/dist/okf/types/WebService.md +24 -0
  81. package/dist/okf/types/Webhook.md +17 -0
  82. package/dist/op/activities/fake-render.d.ts +52 -0
  83. package/dist/op/activities/fake-render.d.ts.map +1 -0
  84. package/dist/op/activities/index.d.ts +10 -0
  85. package/dist/op/activities/index.d.ts.map +1 -0
  86. package/dist/op/activities/render-apply.d.ts +229 -0
  87. package/dist/op/activities/render-apply.d.ts.map +1 -0
  88. package/dist/ownership.d.ts +32 -0
  89. package/dist/ownership.d.ts.map +1 -0
  90. package/dist/package-cli.d.ts +3 -0
  91. package/dist/package-cli.d.ts.map +1 -0
  92. package/dist/plugin.d.ts +8 -0
  93. package/dist/plugin.d.ts.map +1 -0
  94. package/dist/pseudo.d.ts +21 -0
  95. package/dist/pseudo.d.ts.map +1 -0
  96. package/dist/rules/no-secret-literals.ts +73 -0
  97. package/dist/rules/ren010-native-runtime-requires-commands.ts +47 -0
  98. package/dist/rules/ren011-service-requires-source.ts +50 -0
  99. package/dist/rules/ren012-free-plan-cannot-scale.ts +43 -0
  100. package/dist/rules/render-helpers.ts +38 -0
  101. package/dist/rules/valid-cron-schedule.ts +51 -0
  102. package/dist/rules/valid-region.ts +49 -0
  103. package/dist/serializer.d.ts +99 -0
  104. package/dist/serializer.d.ts.map +1 -0
  105. package/dist/skills/chant-render-patterns.md +126 -0
  106. package/dist/skills/chant-render.md +112 -0
  107. package/dist/spec/fetch.d.ts +21 -0
  108. package/dist/spec/fetch.d.ts.map +1 -0
  109. package/dist/spec/parse.d.ts +120 -0
  110. package/dist/spec/parse.d.ts.map +1 -0
  111. package/dist/types/index.d.ts +573 -0
  112. package/dist/validate-cli.d.ts +3 -0
  113. package/dist/validate-cli.d.ts.map +1 -0
  114. package/dist/validate.d.ts +15 -0
  115. package/dist/validate.d.ts.map +1 -0
  116. package/package.json +73 -0
  117. package/src/catalog.ts +286 -0
  118. package/src/codegen/docs-cli.ts +7 -0
  119. package/src/codegen/docs.ts +120 -0
  120. package/src/codegen/generate-cli.ts +36 -0
  121. package/src/codegen/generate-lexicon.ts +52 -0
  122. package/src/codegen/generate-typescript.ts +70 -0
  123. package/src/codegen/generate.ts +106 -0
  124. package/src/codegen/naming.ts +55 -0
  125. package/src/codegen/package.ts +30 -0
  126. package/src/composites/render-deploy.ts +133 -0
  127. package/src/coverage.ts +49 -0
  128. package/src/describe-resources.test.ts +143 -0
  129. package/src/describe-resources.ts +366 -0
  130. package/src/detect.ts +29 -0
  131. package/src/generated/index.d.ts +573 -0
  132. package/src/generated/index.ts +43 -0
  133. package/src/generated/lexicon-render.json +304 -0
  134. package/src/generated/runtime.ts +4 -0
  135. package/src/index.ts +30 -0
  136. package/src/lint/audit-catalog.ts +47 -0
  137. package/src/lint/post-synth/index.ts +11 -0
  138. package/src/lint/post-synth/post-synth.test.ts +151 -0
  139. package/src/lint/post-synth/ren010-native-runtime-requires-commands.ts +47 -0
  140. package/src/lint/post-synth/ren011-service-requires-source.ts +50 -0
  141. package/src/lint/post-synth/ren012-free-plan-cannot-scale.ts +43 -0
  142. package/src/lint/post-synth/render-helpers.ts +38 -0
  143. package/src/lint/rules/index.ts +11 -0
  144. package/src/lint/rules/no-secret-literals.ts +73 -0
  145. package/src/lint/rules/rules.test.ts +73 -0
  146. package/src/lint/rules/valid-cron-schedule.ts +51 -0
  147. package/src/lint/rules/valid-region.ts +49 -0
  148. package/src/lsp/completions.test.ts +28 -0
  149. package/src/lsp/completions.ts +22 -0
  150. package/src/lsp/hover.test.ts +32 -0
  151. package/src/lsp/hover.ts +50 -0
  152. package/src/op/activities/fake-render.ts +275 -0
  153. package/src/op/activities/index.ts +51 -0
  154. package/src/op/activities/render-apply.test.ts +403 -0
  155. package/src/op/activities/render-apply.ts +1042 -0
  156. package/src/ownership.ts +44 -0
  157. package/src/package-cli.ts +23 -0
  158. package/src/plugin.test.ts +54 -0
  159. package/src/plugin.ts +258 -0
  160. package/src/pseudo.ts +26 -0
  161. package/src/serializer.test.ts +221 -0
  162. package/src/serializer.ts +286 -0
  163. package/src/skills/chant-render-patterns.md +126 -0
  164. package/src/skills/chant-render.md +112 -0
  165. package/src/spec/fetch.ts +33 -0
  166. package/src/spec/parse.ts +721 -0
  167. package/src/validate-cli.ts +5 -0
  168. package/src/validate.ts +47 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Live introspection of Render resources — the read-back seam for chant's
3
+ * plan/drift machinery.
4
+ *
5
+ * The write path (renderApply) already GETs live state to decide
6
+ * create/update/unchanged and to prune owned orphans. This surfaces that same
7
+ * read side through the standard `describeResources` seam so core's change set
8
+ * and `chant lifecycle plan` can see live Render state and classify
9
+ * create/update/delete/adopt/noop.
10
+ *
11
+ * Peer of fly's describe-resources: list what is live, key it by chant entity
12
+ * name, attach an ownership verdict. Two-tier ownership, matching the applier:
13
+ *
14
+ * - Services and env groups carry the primary marker (`CHANT_MANAGED_BY`,
15
+ * RENDER_ENV_OWNERSHIP_KEYS) in their env vars, so their verdict is
16
+ * per-resource: `owned` when marked, else `foreign`.
17
+ * - Disks and custom domains carry no marker but hang off a service, so
18
+ * they inherit the parent's verdict (the service boundary — fly's app
19
+ * boundary, one level down); undeclared ones under an owned declared
20
+ * service surface as owned orphans.
21
+ * - Datastores, projects, environments, registry credentials, and webhooks
22
+ * carry no marker channel and no boundary, so their verdict is `unknown`
23
+ * — the change set never escalates `unknown` to a delete. When the
24
+ * `owned` filter asks for those types, this logs that no verdict is
25
+ * available rather than silently returning everything.
26
+ *
27
+ * Endpoint + auth reuse the applier verbatim (resolveEndpoint /
28
+ * RENDER_API_BASE_URL / RENDER_API_KEY), so plan reads the same target
29
+ * renderApply writes. Undeclared chant-marked services and env groups in the
30
+ * workspace are also returned (as orphans keyed by `<type>/<name>`), so a
31
+ * declared-then-removed service shows up as a delete candidate.
32
+ */
33
+
34
+ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
35
+ import { observation, unobservedAll } from "@intentius/chant/observation";
36
+ import { CATALOG, ENTITY_TYPES, ENTITY_TYPE_OF_SERVICE, catalogEntry, isServiceEntityType } from "./catalog";
37
+ import { isOwnerMarker, isRefMarker, type RenderPlan, type RenderRequest } from "./serializer";
38
+ import {
39
+ resolveEndpoint,
40
+ defaultRenderHttp,
41
+ parsePlan,
42
+ orderPlan,
43
+ listAll,
44
+ getOne,
45
+ readServiceEnvVars,
46
+ envVarsToMap,
47
+ isChantOwned,
48
+ resolveOwner,
49
+ type RenderHttp,
50
+ type ApplyCtx,
51
+ } from "./op/activities/render-apply";
52
+
53
+ export interface DescribeResourcesOptions {
54
+ environment: string;
55
+ buildOutput: string;
56
+ entityNames: string[];
57
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
58
+ /** Restrict to chant-owned resources (#119). */
59
+ owned?: boolean;
60
+ /** API endpoint override (tests). Defaults to resolveEndpoint() → RENDER_API_BASE_URL. */
61
+ endpoint?: string;
62
+ /** Workspace override (tests). Defaults to RENDER_OWNER_ID, else the sole visible owner. */
63
+ ownerId?: string;
64
+ }
65
+
66
+ /** Live service/datastore states that mean "gone or going" — not live. */
67
+ const NOT_LIVE = new Set(["deleted", "deleting"]);
68
+
69
+ /** Live resource → chant status string. */
70
+ function statusOf(rec: Record<string, unknown>): string {
71
+ if (typeof rec.status === "string") return rec.status; // datastores
72
+ if (rec.suspended === "suspended") return "suspended";
73
+ if (typeof rec.verificationStatus === "string") return rec.verificationStatus; // custom domains
74
+ return "deployed";
75
+ }
76
+
77
+ /**
78
+ * List live Render resources and return them keyed by chant entity name, each
79
+ * tagged with an ownership verdict the core change set reads to decide whether
80
+ * an orphan is a delete (owned) or an adopt candidate (foreign). Reused by
81
+ * `renderPlugin.describeResources`.
82
+ *
83
+ * `http` is injectable for tests; the default reuses the applier's fetch client
84
+ * (bearer token from RENDER_API_KEY).
85
+ */
86
+ export async function describeResources(
87
+ options: DescribeResourcesOptions,
88
+ http: RenderHttp = defaultRenderHttp(),
89
+ signal?: AbortSignal,
90
+ ): Promise<ObservationResult> {
91
+ const result: Record<string, ResourceMetadata> = {};
92
+ const unobserved: Record<string, UnobservedEntity> = {};
93
+
94
+ // Without a readable plan there is nothing to key a read by, so chant never
95
+ // asks Render anything — every declared entity is unobserved, not absent.
96
+ if (!options.buildOutput) {
97
+ return observation(
98
+ result,
99
+ unobservedAll(options.entities.keys(), "read-failed", "no render plan in the build output to read back", options.entities),
100
+ );
101
+ }
102
+
103
+ let plan: RenderPlan;
104
+ try {
105
+ plan = parsePlan(options.buildOutput);
106
+ } catch (err) {
107
+ return observation(
108
+ result,
109
+ unobservedAll(
110
+ options.entities.keys(),
111
+ "read-failed",
112
+ `build output is not a readable render plan: ${err instanceof Error ? err.message : String(err)}`,
113
+ options.entities,
114
+ ),
115
+ );
116
+ }
117
+
118
+ const ctx: ApplyCtx = { base: resolveEndpoint({ endpoint: options.endpoint }) };
119
+ const owned = options.owned ?? false;
120
+ const typeOf = (entityName: string, fallback: string): string =>
121
+ options.entities.get(entityName)?.entityType ?? fallback;
122
+
123
+ // The workspace: needed to scope every list. Resolved once; a failure here
124
+ // is a whole-read failure (no credentials / no workspace), reported per entity.
125
+ let ownerId: string;
126
+ try {
127
+ ownerId = await resolveOwner(ctx, { ownerId: options.ownerId }, http, signal);
128
+ } catch (err) {
129
+ return observation(
130
+ result,
131
+ unobservedAll(options.entities.keys(), "no-credentials", err instanceof Error ? err.message : String(err), options.entities),
132
+ );
133
+ }
134
+
135
+ let ordered: Array<[string, RenderRequest]>;
136
+ try {
137
+ ordered = orderPlan(plan);
138
+ } catch (err) {
139
+ return observation(
140
+ result,
141
+ unobservedAll(options.entities.keys(), "read-failed", err instanceof Error ? err.message : String(err), options.entities),
142
+ );
143
+ }
144
+
145
+ // Live ids by entity, so a child's `{ $ref }` (Disk.serviceId, Environment.
146
+ // projectId, CustomDomain path) resolves to the parent's live id when the
147
+ // parent exists. A missing parent means the child cannot exist yet — it is
148
+ // reported absent, which is exactly the create the plan should propose.
149
+ const liveId = new Map<string, string>();
150
+ // Service verdicts by live id, so a disk/domain can inherit its parent's.
151
+ const serviceVerdict = new Map<string, "owned" | "foreign">();
152
+ // Declared children by `${serviceId}:${name}`, for the orphan scan.
153
+ const declaredChildren = new Set<string>();
154
+
155
+ // Declared services and env groups by natural key, so the workspace-wide
156
+ // marker scan below can tell an orphan from a declared one.
157
+ const declaredServices = new Set<string>();
158
+ const declaredGroups = new Set<string>();
159
+
160
+ let loggedUnknown = false;
161
+ const noteUnknown = (): void => {
162
+ if (loggedUnknown || !owned) return;
163
+ loggedUnknown = true;
164
+ console.warn(
165
+ "[render] datastores, projects, environments, registry credentials and webhooks carry no ownership marker — their verdict is unknown; --owned withholds them rather than guessing",
166
+ );
167
+ };
168
+
169
+ for (const [entityName, req] of ordered) {
170
+ const entry = catalogEntry(req.entityType);
171
+ if (isServiceEntityType(req.entityType)) declaredServices.add(`${req.body.type}:${req.name}`);
172
+ if (req.entityType === ENTITY_TYPES.envGroup) declaredGroups.add(req.name);
173
+
174
+ // Resolve the path/body references this read depends on.
175
+ let endpoint = req.endpoint;
176
+ let unresolved = false;
177
+ for (const [k, v] of Object.entries(req.pathParams ?? {})) {
178
+ const id = isRefMarker(v) ? liveId.get(v.$ref) : typeof v === "string" ? v : undefined;
179
+ if (!id) {
180
+ unresolved = true;
181
+ break;
182
+ }
183
+ endpoint = endpoint.replace(`{${k}}`, encodeURIComponent(id));
184
+ }
185
+ if (unresolved) continue; // parent absent → child absent (in neither map)
186
+
187
+ const query: Record<string, string | undefined> = {};
188
+ if (entry.filters.name) query.name = req.name;
189
+ if (entry.filters.ownerId) query.ownerId = typeof req.body.ownerId === "string" && !isOwnerMarker(req.body.ownerId) ? req.body.ownerId : ownerId;
190
+ let scopeId: string | undefined;
191
+ if (req.entityType === ENTITY_TYPES.environment) {
192
+ scopeId = isRefMarker(req.body.projectId) ? liveId.get(req.body.projectId.$ref) : typeof req.body.projectId === "string" ? req.body.projectId : undefined;
193
+ if (!scopeId) continue;
194
+ query.projectId = scopeId;
195
+ }
196
+ if (req.entityType === ENTITY_TYPES.disk) {
197
+ scopeId = isRefMarker(req.body.serviceId) ? liveId.get(req.body.serviceId.$ref) : typeof req.body.serviceId === "string" ? req.body.serviceId : undefined;
198
+ if (!scopeId) continue;
199
+ query.serviceId = scopeId;
200
+ }
201
+ const listPath = req.entityType === ENTITY_TYPES.customDomain ? endpoint : entry.collection;
202
+
203
+ let candidates: Record<string, unknown>[];
204
+ try {
205
+ candidates = await listAll(ctx, listPath, query, entry.listKey, http, signal);
206
+ } catch (err) {
207
+ unobserved[entityName] = {
208
+ type: req.entityType,
209
+ reason: "read-failed",
210
+ detail: err instanceof Error ? err.message : String(err),
211
+ queried: `${ctx.base}${listPath}`,
212
+ };
213
+ continue;
214
+ }
215
+ const live = candidates.find((c) => {
216
+ if (c.name !== req.name) return false;
217
+ if (isServiceEntityType(req.entityType) && c.type !== req.body.type) return false;
218
+ if (scopeId && req.entityType === ENTITY_TYPES.environment && c.projectId !== scopeId) return false;
219
+ if (scopeId && req.entityType === ENTITY_TYPES.disk && c.serviceId !== scopeId) return false;
220
+ return true;
221
+ });
222
+ if (!live || (typeof live.status === "string" && NOT_LIVE.has(live.status))) continue; // absent
223
+
224
+ const id = typeof live.id === "string" ? live.id : undefined;
225
+ if (id) liveId.set(entityName, id);
226
+
227
+ // Ownership verdict: the marker for services and env groups; the parent
228
+ // service's verdict for disks and custom domains (service boundary);
229
+ // unknown for the rest.
230
+ let ownership: ResourceMetadata["ownership"];
231
+ if (entry.marked && id) {
232
+ let env: Record<string, string>;
233
+ if (isServiceEntityType(req.entityType)) {
234
+ env = await readServiceEnvVars(ctx, id, http, signal);
235
+ } else {
236
+ const full = (await getOne(ctx, `${entry.collection}/${id}`, http, signal)) ?? live;
237
+ env = envVarsToMap(full.envVars);
238
+ }
239
+ ownership = isChantOwned(env) ? "owned" : "foreign";
240
+ if (isServiceEntityType(req.entityType)) serviceVerdict.set(id, ownership);
241
+ } else if (entry.boundary === "service") {
242
+ const raw = req.entityType === ENTITY_TYPES.customDomain ? req.pathParams?.serviceId : req.body.serviceId;
243
+ const parentId = isRefMarker(raw) ? liveId.get(raw.$ref) : typeof raw === "string" ? raw : undefined;
244
+ if (parentId) declaredChildren.add(`${parentId}:${req.name}`);
245
+ let verdict = parentId ? serviceVerdict.get(parentId) : undefined;
246
+ if (!verdict && parentId) {
247
+ // Parent named by literal id (not declared here): read its marker.
248
+ verdict = isChantOwned(await readServiceEnvVars(ctx, parentId, http, signal)) ? "owned" : "foreign";
249
+ serviceVerdict.set(parentId, verdict);
250
+ }
251
+ ownership = verdict ?? "unknown";
252
+ } else {
253
+ ownership = "unknown";
254
+ noteUnknown();
255
+ }
256
+
257
+ if (owned && ownership !== "owned") {
258
+ unobserved[entityName] = {
259
+ type: req.entityType,
260
+ reason: "filtered",
261
+ detail:
262
+ ownership === "foreign"
263
+ ? "live resource carries no chant ownership marker and --owned was requested"
264
+ : "resource kind has no ownership marker channel and --owned was requested",
265
+ };
266
+ continue;
267
+ }
268
+
269
+ result[entityName] = {
270
+ type: typeOf(entityName, req.entityType),
271
+ physicalId: id,
272
+ status: statusOf(live),
273
+ lastUpdated: typeof live.updatedAt === "string" ? live.updatedAt : undefined,
274
+ ownership,
275
+ attributes: pruneUndefined({
276
+ id,
277
+ name: live.name,
278
+ dashboardUrl: live.dashboardUrl,
279
+ type: live.type,
280
+ region: (live.serviceDetails as Record<string, unknown> | undefined)?.region ?? live.region,
281
+ environmentId: live.environmentId,
282
+ projectId: live.projectId,
283
+ serviceId: live.serviceId,
284
+ }),
285
+ };
286
+ }
287
+
288
+ // Orphans: chant-marked services and env groups in the workspace that the
289
+ // plan does not declare — delete candidates for the change set.
290
+ try {
291
+ for (const svc of await listAll(ctx, "/services", { ownerId }, "service", http, signal)) {
292
+ const id = typeof svc.id === "string" ? svc.id : undefined;
293
+ const name = typeof svc.name === "string" ? svc.name : "";
294
+ const type = typeof svc.type === "string" ? svc.type : "";
295
+ if (!id) continue;
296
+ const declared = declaredServices.has(`${type}:${name}`);
297
+ const verdict = serviceVerdict.get(id) ?? (isChantOwned(await readServiceEnvVars(ctx, id, http, signal)) ? "owned" : "foreign");
298
+ if (verdict !== "owned") continue;
299
+ if (!declared) {
300
+ const entityType = ENTITY_TYPE_OF_SERVICE[type] ?? ENTITY_TYPES.webService;
301
+ result[`${CATALOG[entityType].kind}/${name}`] = {
302
+ type: entityType,
303
+ physicalId: id,
304
+ status: statusOf(svc),
305
+ lastUpdated: typeof svc.updatedAt === "string" ? svc.updatedAt : undefined,
306
+ ownership: "owned",
307
+ attributes: pruneUndefined({ id, name, dashboardUrl: svc.dashboardUrl, type }),
308
+ };
309
+ continue; // its children go with it
310
+ }
311
+ // Service boundary: undeclared disks and custom domains under a declared
312
+ // owned service are owned orphans too.
313
+ for (const disk of await listAll(ctx, "/disks", { serviceId: id, ownerId }, "disk", http, signal)) {
314
+ const diskId = typeof disk.id === "string" ? disk.id : undefined;
315
+ const diskName = typeof disk.name === "string" ? disk.name : "";
316
+ if (!diskId || disk.serviceId !== id || declaredChildren.has(`${id}:${diskName}`)) continue;
317
+ result[`Disk/${diskName}`] = {
318
+ type: ENTITY_TYPES.disk,
319
+ physicalId: diskId,
320
+ status: "deployed",
321
+ ownership: "owned",
322
+ attributes: pruneUndefined({ id: diskId, name: diskName, serviceId: id, mountPath: disk.mountPath, sizeGB: disk.sizeGB }),
323
+ };
324
+ }
325
+ for (const domain of await listAll(ctx, `/services/${id}/custom-domains`, {}, "customDomain", http, signal)) {
326
+ const domainId = typeof domain.id === "string" ? domain.id : undefined;
327
+ const domainName = typeof domain.name === "string" ? domain.name : "";
328
+ if (!domainId || declaredChildren.has(`${id}:${domainName}`)) continue;
329
+ result[`CustomDomain/${domainName}`] = {
330
+ type: ENTITY_TYPES.customDomain,
331
+ physicalId: domainId,
332
+ status: statusOf(domain),
333
+ ownership: "owned",
334
+ attributes: pruneUndefined({ id: domainId, name: domainName, serviceId: id }),
335
+ };
336
+ }
337
+ }
338
+ for (const grp of await listAll(ctx, "/env-groups", { ownerId }, "envGroup", http, signal)) {
339
+ const id = typeof grp.id === "string" ? grp.id : undefined;
340
+ const name = typeof grp.name === "string" ? grp.name : "";
341
+ if (!id || declaredGroups.has(name)) continue;
342
+ const full = (await getOne(ctx, `/env-groups/${id}`, http, signal)) ?? grp;
343
+ if (!isChantOwned(envVarsToMap(full.envVars))) continue;
344
+ result[`EnvGroup/${name}`] = {
345
+ type: ENTITY_TYPES.envGroup,
346
+ physicalId: id,
347
+ status: "deployed",
348
+ lastUpdated: typeof grp.updatedAt === "string" ? grp.updatedAt : undefined,
349
+ ownership: "owned",
350
+ attributes: { id, name },
351
+ };
352
+ }
353
+ } catch (err) {
354
+ console.warn(`[render] orphan scan failed: ${err instanceof Error ? err.message : String(err)}`);
355
+ }
356
+
357
+ return observation(result, unobserved);
358
+ }
359
+
360
+ function pruneUndefined(obj: Record<string, unknown>): Record<string, unknown> {
361
+ const out: Record<string, unknown> = {};
362
+ for (const [k, v] of Object.entries(obj)) {
363
+ if (v !== undefined) out[k] = v;
364
+ }
365
+ return out;
366
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Template detection for the render lexicon.
3
+ *
4
+ * `chant import` parses a template file as JSON and asks each installed lexicon
5
+ * whether it recognizes the shape. render's importable source is the serializer
6
+ * plan (what `chant build` emits and `renderApply` POSTs): an object keyed by
7
+ * entity name whose values carry a `Render::…` entityType and a `POST` request.
8
+ */
9
+
10
+ function isRecord(v: unknown): v is Record<string, unknown> {
11
+ return typeof v === "object" && v !== null && !Array.isArray(v);
12
+ }
13
+
14
+ /** A single request as the serializer emits it. */
15
+ function isRenderRequest(v: unknown): boolean {
16
+ if (!isRecord(v)) return false;
17
+ return typeof v.entityType === "string" && v.entityType.startsWith("Render::") && typeof v.endpoint === "string" && "body" in v;
18
+ }
19
+
20
+ /** The serializer's whole output: entity name → request. */
21
+ export function isSerializerPlan(v: unknown): boolean {
22
+ if (!isRecord(v)) return false;
23
+ const values = Object.values(v);
24
+ return values.length > 0 && values.every(isRenderRequest);
25
+ }
26
+
27
+ export function detectTemplate(data: unknown): boolean {
28
+ return isSerializerPlan(data);
29
+ }