@intentius/chant-lexicon-azure 0.44.14 → 0.45.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.
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAwFH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAwEH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E"}
@@ -1 +1 @@
1
- {"version":3,"file":"deploy-scopes.d.ts","sourceRoot":"","sources":["../src/deploy-scopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAEjF,MAAM,MAAM,WAAW,GAAG,eAAe,GAAG,cAAc,GAAG,iBAAiB,GAAG,QAAQ,CAAC;AAE1F,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,eAA4C,CAAC;AAEjF;;;GAGG;AACH,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,QAAQ,CAAC,CAAC,uBAAuB,CAAC,EAAE,IAAI,CAAC;IACzC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,6BAA6B,CAAC;IACnD,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;CAC7B;AAED,sCAAsC;AACtC,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAO1E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CAQnE;AAED,iDAAiD;AACjD,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAKxD,CAAC;AAeF;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CA2B7D;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,EAAE,CAEnE;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,WAAW,CAiBjF;AAED,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,OAAO,GAAG,WAAW,CAOnE"}
1
+ {"version":3,"file":"deploy-scopes.d.ts","sourceRoot":"","sources":["../src/deploy-scopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAEjF,MAAM,MAAM,WAAW,GAAG,eAAe,GAAG,cAAc,GAAG,iBAAiB,GAAG,QAAQ,CAAC;AAE1F,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,eAA4C,CAAC;AAEjF;;;GAGG;AACH,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,QAAQ,CAAC,CAAC,uBAAuB,CAAC,EAAE,IAAI,CAAC;IACzC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,6BAA6B,CAAC;IACnD,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;CAC7B;AAED,sCAAsC;AACtC,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAO1E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CAQnE;AAED,iDAAiD;AACjD,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAKxD,CAAC;AA4BF;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CA4B7D;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,EAAE,CAEnE;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,WAAW,CAiBjF;AAED,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,OAAO,GAAG,WAAW,CAOnE"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "6bfaee2a8ba210e1c5915b0ae0d616288ad2ba9ff0a5861e29c20980c6ddfa34",
4
+ "manifest.json": "4609a8f5c9124e4231b979bb8d752c0e30548aee93dab288629af7cc575d4ac3",
5
5
  "meta.json": "760a8d2e359be71f29a990339cb075701fe668e6275dfa923b9d112474ba660a",
6
6
  "types/index.d.ts": "c5a8a3e107b07a11ac992fa7d6d136fe1a71c99a89c57921b3e4bc86361adb3f",
7
7
  "rules/hardcoded-location.ts": "a9b1d1cec93f2ca9c5206641f53fc9621cefcfd2c00b3ab89c194b30a75fa949",
@@ -37,5 +37,5 @@
37
37
  "skills/chant-azure-patterns.md": "1a3bacfac612826b77332d2692a59195638db9f1b5e8ea2eda7579621d25f603",
38
38
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
39
39
  },
40
- "composite": "cd86ba2797bc6c29617b29a0adf4aa9d1f927f9034ab574cd38b237f82a1965b"
40
+ "composite": "b01efebfbcfbced31dbbb52f7f3ae3b656ba2a0ff41be474bbe9c27aad9961bf"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.44.14",
3
+ "version": "0.45.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
@@ -177,37 +177,51 @@ export interface AzNotPrunable {
177
177
  type: string;
178
178
  name: string;
179
179
  /**
180
- * `no-api-version` — the resource is owned and undeclared, but its type is
181
- * absent from the current template, which is the only place an `apiVersion`
182
- * comes from. `deleteArmResource` cannot build a URL without one.
180
+ * `no-api-version` — the resource is owned and undeclared, but no apiVersion
181
+ * could be found for its type: the current template does not declare the
182
+ * type, the lexicon's pinned registry has no entry for it, and ARM's
183
+ * provider metadata listed no apiVersion for it (or the call failed).
184
+ * `deleteArmResource` cannot build a URL without one.
183
185
  */
184
186
  reason: "no-api-version";
185
187
  }
186
188
  /**
187
- * Owned-only prune: for each resource type present in the template, delete the
188
- * chant-owned live resources of that type whose (evaluated) name is not in the
189
- * template. Foreign (non-chant) resources are never touched.
189
+ * Resolve an apiVersion for a live resource type from ARM's provider metadata:
190
+ * `GET /subscriptions/{sub}/providers/{namespace}?api-version=2021-04-01`
191
+ * returns `resourceTypes[].apiVersions[]`. Picks the newest stable version,
192
+ * falling back to the newest preview when no stable one exists. One call per
193
+ * namespace per prune run; the result is memoised in `cache` so a group with
194
+ * many orphans of one provider costs one round trip. Returns `undefined` when
195
+ * the endpoint is unavailable (floci-az does not implement it) or the type is
196
+ * not listed.
197
+ */
198
+ export declare function providerApiVersion(resourceType: string, ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal, cache?: Map<string, Map<string, string>>): Promise<string | undefined>;
199
+ /**
200
+ * Owned-only prune: delete every chant-owned live resource in the group whose
201
+ * (evaluated) name is not declared in the template. Foreign (non-chant)
202
+ * resources are never touched.
203
+ *
204
+ * ## Where the apiVersion comes from (#1457, #1472)
190
205
  *
191
- * ## The type-left-the-template hole (#1457)
206
+ * `deleteArmResource` needs an `apiVersion`, and ARM's resource-group listing
207
+ * does not return one per resource. The template is the obvious source, but it
208
+ * only covers types it still declares: declare one storage account, apply,
209
+ * delete it from source, and the template has zero resources of that type.
210
+ * Keying the delete scope off the template alone made the last resource of a
211
+ * type a permanent orphan (#1457 made that a reported skip rather than a
212
+ * silent one).
192
213
  *
193
- * `byType` is keyed off the resources the template CURRENTLY declares, and the
194
- * `!entry` guard skipped any live resource of a type the template no longer
195
- * mentions — before `isChantOwned` was ever consulted.
214
+ * So the apiVersion is resolved in order:
196
215
  *
197
- * So: declare one storage account, apply, then delete it from source. The
198
- * template now has zero resources of that type, `byType` has no entry, and the
199
- * live account is skipped. It is owned, it is undeclared, and prune would never
200
- * touch it on that run or any future one. **Prune the last resource of a type
201
- * and you created a permanent orphan.** Prune one of several and it works,
202
- * which is why it survived testing.
216
+ * 1. the template, when it still declares the type;
217
+ * 2. the lexicon's pinned per-provider registry (`lookupApiVersion`, the same
218
+ * registry the serializer stamps into the template) deterministic, no
219
+ * extra call, covers every type the lexicon can emit;
220
+ * 3. ARM's provider metadata (`providerApiVersion`) covers a type the
221
+ * lexicon has never heard of, at one extra call per namespace per run.
203
222
  *
204
- * The guard cannot simply be removed: `deleteArmResource` needs an
205
- * `apiVersion`, and the template is the only source of one — ARM's
206
- * resource-group listing does not return it per resource. So the skip stays,
207
- * and is now REPORTED rather than silent: a permanent invisible orphan becomes
208
- * a permanent visible one, which is the difference between a bug and a known
209
- * limitation. Resolving it properly wants a per-type apiVersion source (the
210
- * provider metadata endpoint, or a lexicon-side map).
223
+ * Only when all three come up empty is the resource reported as
224
+ * `notPrunable: no-api-version`.
211
225
  *
212
226
  * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
213
227
  * `azApply` — before that, the composite reached `--mode Complete` and never
@@ -1 +1 @@
1
- {"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,6 @@ export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteComman
7
7
  export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure.js";
8
8
  export { flociAzUp, flociAzDown, flociAzRunCommand, flociAzRmCommand, flociAzExistsCommand, flociAzHealthUrl, flociAzEndpoint, } from "./floci-az.js";
9
9
  export type { FlociAzUpArgs, FlociAzDownArgs } from "./floci-az.js";
10
- export { azApply, azDelete, pruneArmOrphans, toApplyResult, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
10
+ export { azApply, azDelete, pruneArmOrphans, providerApiVersion, toApplyResult, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
11
11
  export type { AzApplyArgs, ArmEvalCtx, ArmResource, ArmListItem, AzHttp } from "./az-apply.js";
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AACnG,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIpE,OAAO,EACL,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,YAAY,EACZ,OAAO,EACP,aAAa,EACb,cAAc,EACd,eAAe,EACf,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AACnG,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIpE,OAAO,EACL,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,eAAe,EACf,kBAAkB,EAClB,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,YAAY,EACZ,OAAO,EACP,aAAa,EACb,cAAc,EACd,eAAe,EACf,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
@@ -10,6 +10,20 @@
10
10
  * - parameters, outputs sections
11
11
  */
12
12
  import type { Serializer } from "@intentius/chant/serializer";
13
+ export declare function apiVersionRegistryCandidates(dir?: string): string[];
14
+ export declare function loadApiVersions(candidates?: string[]): Map<string, string>;
15
+ /** Test hook: drop the cached registry so the next lookup reloads it. */
16
+ export declare function resetApiVersionCache(): void;
17
+ /**
18
+ * Registry lookup without the serializer's fallback: the pinned apiVersion for
19
+ * `resourceType`, or `undefined` when neither the overrides nor the generated
20
+ * registry know the type. ARM resource types are case-insensitive and the live
21
+ * listing does not always echo the casing the registry uses, so the match is
22
+ * case-insensitive too. Used by prune (#1472), which needs an apiVersion for a
23
+ * type the current template no longer declares.
24
+ */
25
+ export declare function lookupApiVersion(resourceType: string): string | undefined;
26
+ export declare function getApiVersion(resourceType: string): string;
13
27
  /**
14
28
  * Azure ARM template serializer implementation.
15
29
  */
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAkblG;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,UA0C7B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA8ElG,wBAAgB,4BAA4B,CAAC,GAAG,GAAE,MAAqB,GAAG,MAAM,EAAE,CAKjF;AAED,wBAAgB,eAAe,CAAC,UAAU,GAAE,MAAM,EAAmC,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAwB1G;AAcD,yEAAyE;AACzE,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWzE;AAED,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAO1D;AA2TD;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,UA0C7B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAGlG,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AA2BvF,wBAAsB,QAAQ,CAAC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAgBpF"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAGlG,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AA2BvF,wBAAsB,QAAQ,CAAC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAsBpF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.44.14",
3
+ "version": "0.45.0",
4
4
  "description": "Azure lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -74,7 +74,7 @@
74
74
  "typescript": "^5.9.3"
75
75
  },
76
76
  "peerDependencies": {
77
- "@intentius/chant": "^0.44.14",
77
+ "@intentius/chant": "^0.45.0",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -75,22 +75,6 @@ const docsConfig: DocsConfig = {
75
75
  // `lint-rules` below is the rules documentation; the generated `rules` table
76
76
  // would duplicate it and land in no sidebar (#1312). aws, k8s, gitlab and
77
77
  // github already suppress it for the same reason.
78
- // These pages are hand-written files under docs/src/content/docs/. The
79
- // pipeline preserves pages it did not emit, but Starlight has no
80
- // auto-discovery, so without an entry here they are reachable only by URL —
81
- // which is what had happened to all ten of them.
82
- sidebarExtra: [
83
- { label: "Getting Started", slug: "getting-started" },
84
- { label: "Resources", slug: "resources" },
85
- { label: "Parameters & Outputs", slug: "parameters-outputs" },
86
- { label: "Composites", slug: "composites" },
87
- { label: "Linked Templates", slug: "linked-templates" },
88
- { label: "Lint Rules", slug: "lint-rules" },
89
- { label: "Importing ARM Templates", slug: "importing" },
90
- { label: "Deploying to AKS", slug: "aks-kubernetes" },
91
- { label: "Examples", slug: "examples" },
92
- { label: "AI Skills", slug: "skills" },
93
- ],
94
78
  };
95
79
 
96
80
  /**
@@ -85,7 +85,20 @@ interface LexiconEntry {
85
85
 
86
86
  let _cached: Map<string, DeployScope[]> | undefined;
87
87
 
88
- const __dirname_ = dirname(fileURLToPath(import.meta.url));
88
+ /**
89
+ * Directory of this module, resolved lazily. On workerd `import.meta.url`
90
+ * is undefined and `fileURLToPath` throws, so this must never run at module
91
+ * scope (#1621): the post-synth barrel imports this file and a module-scope
92
+ * throw kills the worker at startup. Returns undefined when unresolvable;
93
+ * callers then take the documented resource-group-only fallback.
94
+ */
95
+ function moduleDir(): string | undefined {
96
+ try {
97
+ return dirname(fileURLToPath(import.meta.url));
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
89
102
 
90
103
  /**
91
104
  * Load per-resource deployment scopes from the lexicon JSON.
@@ -98,11 +111,12 @@ export function loadDeployScopes(): Map<string, DeployScope[]> {
98
111
  const map = new Map<string, DeployScope[]>();
99
112
  try {
100
113
  let content: string | undefined;
114
+ const dir = moduleDir();
101
115
  // Try generated/ (dev) first, then dist/meta.json (installed package)
102
- for (const candidate of [
103
- join(__dirname_, "generated", "lexicon-azure.json"),
104
- join(__dirname_, "..", "dist", "meta.json"),
105
- ]) {
116
+ for (const candidate of dir ? [
117
+ join(dir, "generated", "lexicon-azure.json"),
118
+ join(dir, "..", "dist", "meta.json"),
119
+ ] : []) {
106
120
  try { content = readFileSync(candidate, "utf-8"); break; } catch {}
107
121
  }
108
122
  if (content) {
@@ -19,7 +19,9 @@ import {
19
19
  type ArmEvalCtx,
20
20
  type ArmResource,
21
21
  type AzHttp,
22
+ providerApiVersion,
22
23
  } from "./az-apply";
24
+ import { lookupApiVersion } from "../../serializer";
23
25
 
24
26
  const noHttp: AzHttp = async () => ({ status: 200, text: "{}" });
25
27
 
@@ -278,7 +280,7 @@ describe("pruneArmOrphans (#azure-prune)", () => {
278
280
  { id: "/1", name: "keep1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // in template → keep
279
281
  { id: "/2", name: "orphan1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // owned, not in template → prune
280
282
  { id: "/3", name: "foreign", type: "Microsoft.Storage/storageAccounts", tags: {} }, // not owned → skip
281
- { id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → skip
283
+ { id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → registry apiVersion
282
284
  ],
283
285
  };
284
286
  const deletes: string[] = [];
@@ -287,15 +289,16 @@ describe("pruneArmOrphans (#azure-prune)", () => {
287
289
  return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
288
290
  };
289
291
  const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
290
- expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true }]);
291
- expect(deletes).toHaveLength(1);
292
- expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
293
- // #1457: "othertype" is chant-owned and undeclared, and its TYPE is absent
294
- // from the template — so there is no apiVersion to delete it with. It used
295
- // to be dropped by the `!entry` guard in silence; now it is reported.
296
- expect(notPrunable).toEqual([
297
- { type: "Microsoft.Web/sites", name: "othertype", reason: "no-api-version" },
292
+ expect(pruned).toEqual([
293
+ { type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true },
294
+ { type: "Microsoft.Web/sites", name: "othertype", deleted: true },
298
295
  ]);
296
+ expect(deletes).toHaveLength(2);
297
+ expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
298
+ // #1472: "othertype" is chant-owned and undeclared, and its TYPE is absent
299
+ // from the template — the apiVersion comes from the lexicon registry instead.
300
+ expect(deletes[1]).toContain(`/sites/othertype?api-version=${lookupApiVersion("Microsoft.Web/sites")}`);
301
+ expect(notPrunable).toEqual([]);
299
302
  });
300
303
 
301
304
  // #1448 routes ApplyOp's `arm` target here, so this is now the delete scope for
@@ -412,7 +415,7 @@ describe("azDelete (#azure-prune)", () => {
412
415
  * unreachable by prune forever. Prune one of SEVERAL and it works, which is why
413
416
  * this survived testing.
414
417
  */
415
- describe("pruneArmOrphans: a type that left the template (#1457)", () => {
418
+ describe("pruneArmOrphans: a type that left the template (#1457, #1472)", () => {
416
419
  const ctx = (): ArmEvalCtx => ({
417
420
  subscriptionId: "sub",
418
421
  resourceGroup: "rg",
@@ -422,30 +425,193 @@ describe("pruneArmOrphans: a type that left the template (#1457)", () => {
422
425
  base: "http://x",
423
426
  });
424
427
 
425
- const liveHttp = (value: unknown[], deletes: string[]): AzHttp => async (method, url) => {
426
- if (method === "DELETE") deletes.push(url);
427
- return { status: 200, text: method === "GET" ? JSON.stringify({ value }) : "" };
428
- };
428
+ // Only the resource-group listing answers; the provider-metadata endpoint
429
+ // 404s, the way floci-az does.
430
+ const liveHttp = (value: unknown[], deletes: string[], providers: string[] = []): AzHttp =>
431
+ async (method, url) => {
432
+ if (method === "DELETE") deletes.push(url);
433
+ if (method === "GET" && url.includes("/resources?")) return { status: 200, text: JSON.stringify({ value }) };
434
+ if (method === "GET" && url.includes("/providers/")) {
435
+ providers.push(url);
436
+ return { status: 404, text: "" };
437
+ }
438
+ return { status: 200, text: "" };
439
+ };
429
440
 
430
- test("an owned orphan whose type is gone is reported, not silently skipped", async () => {
441
+ test("an owned orphan whose type is gone is deleted with the registry apiVersion", async () => {
431
442
  // The template now declares a DIFFERENT type entirely — the storage account
432
- // was the last of its kind and has been removed from source.
443
+ // was the last of its kind and has been removed from source. #1457 only
444
+ // reported this; the registry the serializer pins from now supplies the
445
+ // apiVersion, so the orphan is actually deleted.
433
446
  const desired: ArmResource[] = [
434
447
  { type: "Microsoft.Web/sites", apiVersion: "2022-03-01", name: "site" },
435
448
  ];
436
449
  const deletes: string[] = [];
450
+ const providers: string[] = [];
437
451
  const http = liveHttp(
438
452
  [{ id: "/1", name: "leftover", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }],
439
453
  deletes,
454
+ providers,
440
455
  );
441
456
  const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
442
- expect(pruned).toEqual([]);
443
- expect(notPrunable).toEqual([
444
- { type: "Microsoft.Storage/storageAccounts", name: "leftover", reason: "no-api-version" },
457
+ expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "leftover", deleted: true }]);
458
+ expect(notPrunable).toEqual([]);
459
+ const registryVersion = lookupApiVersion("Microsoft.Storage/storageAccounts");
460
+ expect(registryVersion).toBeDefined();
461
+ expect(deletes).toEqual([
462
+ `http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/leftover?api-version=${registryVersion}`,
463
+ ]);
464
+ // The registry answered, so ARM's provider metadata was never consulted.
465
+ expect(providers).toEqual([]);
466
+ });
467
+
468
+ test("the live listing's type casing need not match the registry's", async () => {
469
+ const desired: ArmResource[] = [];
470
+ const deletes: string[] = [];
471
+ const http = liveHttp(
472
+ [{ id: "/1", name: "leftover", type: "microsoft.storage/storageaccounts", tags: { "managed-by": "chant" } }],
473
+ deletes,
474
+ );
475
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
476
+ expect(pruned).toEqual([{ type: "microsoft.storage/storageaccounts", name: "leftover", deleted: true }]);
477
+ expect(notPrunable).toEqual([]);
478
+ expect(deletes[0]).toContain(`?api-version=${lookupApiVersion("Microsoft.Storage/storageAccounts")}`);
479
+ });
480
+
481
+ test("a type the registry does not know falls back to ARM provider metadata, newest stable", async () => {
482
+ const desired: ArmResource[] = [];
483
+ const deletes: string[] = [];
484
+ const gets: string[] = [];
485
+ const http: AzHttp = async (method, url) => {
486
+ if (method === "DELETE") deletes.push(url);
487
+ if (method === "GET") gets.push(url);
488
+ if (method === "GET" && url.includes("/resources?")) {
489
+ return {
490
+ status: 200,
491
+ text: JSON.stringify({
492
+ value: [
493
+ { id: "/1", name: "a", type: "Microsoft.Example/widgets", tags: { "managed-by": "chant" } },
494
+ { id: "/2", name: "b", type: "Microsoft.Example/widgets", tags: { "managed-by": "chant" } },
495
+ ],
496
+ }),
497
+ };
498
+ }
499
+ if (method === "GET" && url.includes("/providers/Microsoft.Example?")) {
500
+ return {
501
+ status: 200,
502
+ text: JSON.stringify({
503
+ namespace: "Microsoft.Example",
504
+ resourceTypes: [
505
+ { resourceType: "widgets", apiVersions: ["2024-05-01-preview", "2023-09-01", "2024-01-01", "2022-01-01"] },
506
+ { resourceType: "gadgets", apiVersions: ["2020-01-01"] },
507
+ ],
508
+ }),
509
+ };
510
+ }
511
+ return { status: 200, text: "" };
512
+ };
513
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
514
+ expect(pruned).toEqual([
515
+ { type: "Microsoft.Example/widgets", name: "a", deleted: true },
516
+ { type: "Microsoft.Example/widgets", name: "b", deleted: true },
445
517
  ]);
446
- // Still not deleted — the apiVersion genuinely is not available. The fix is
447
- // that it is now visible rather than that it is now deleted.
518
+ expect(notPrunable).toEqual([]);
519
+ // Newest STABLE wins over the newer preview.
520
+ expect(deletes).toEqual([
521
+ "http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Example/widgets/a?api-version=2024-01-01",
522
+ "http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Example/widgets/b?api-version=2024-01-01",
523
+ ]);
524
+ // One provider-metadata call per namespace per run, not per orphan.
525
+ expect(gets.filter((u) => u.includes("/providers/Microsoft.Example?api-version=2021-04-01"))).toHaveLength(1);
526
+ expect(gets.find((u) => u.includes("/providers/Microsoft.Example?"))).toBe(
527
+ "http://x/subscriptions/sub/providers/Microsoft.Example?api-version=2021-04-01",
528
+ );
529
+ });
530
+
531
+ test("no template, no registry entry, no provider metadata → still reported, never deleted", async () => {
532
+ const desired: ArmResource[] = [];
533
+ const deletes: string[] = [];
534
+ const providers: string[] = [];
535
+ const http = liveHttp(
536
+ [{ id: "/1", name: "mystery", type: "Microsoft.Nowhere/things", tags: { "managed-by": "chant" } }],
537
+ deletes,
538
+ providers,
539
+ );
540
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
541
+ expect(pruned).toEqual([]);
542
+ expect(notPrunable).toEqual([{ type: "Microsoft.Nowhere/things", name: "mystery", reason: "no-api-version" }]);
448
543
  expect(deletes).toEqual([]);
544
+ // All three sources were tried; the last one is the provider endpoint.
545
+ expect(providers).toEqual(["http://x/subscriptions/sub/providers/Microsoft.Nowhere?api-version=2021-04-01"]);
546
+ });
547
+
548
+ test("providerApiVersion: preview-only types, missing types, and failed calls", async () => {
549
+ const cache = new Map<string, Map<string, string>>();
550
+ let calls = 0;
551
+ const http: AzHttp = async () => {
552
+ calls++;
553
+ return {
554
+ status: 200,
555
+ text: JSON.stringify({
556
+ resourceTypes: [{ resourceType: "previewOnly", apiVersions: ["2023-01-01-preview", "2024-01-01-preview"] }],
557
+ }),
558
+ };
559
+ };
560
+ expect(await providerApiVersion("Microsoft.P/previewOnly", ctx(), http, undefined, cache)).toBe("2024-01-01-preview");
561
+ expect(await providerApiVersion("Microsoft.P/absent", ctx(), http, undefined, cache)).toBeUndefined();
562
+ expect(calls).toBe(1);
563
+ expect(await providerApiVersion("notatype", ctx(), http)).toBeUndefined();
564
+ const failing: AzHttp = async () => { throw new Error("network"); };
565
+ expect(await providerApiVersion("Microsoft.P/previewOnly", ctx(), failing)).toBeUndefined();
566
+ });
567
+
568
+ test("azApply end to end: declare one storage account, apply, remove it, prune → deleted", async () => {
569
+ // The Floci-az style fake: PUT records the resource (with the ownership tag
570
+ // chant stamps), the group listing echoes what was recorded, DELETE removes
571
+ // it. The provider-metadata endpoint is absent, as on floci-az.
572
+ const store = new Map<string, { id: string; name: string; type: string; tags?: Record<string, string> }>();
573
+ const deletes: string[] = [];
574
+ const http: AzHttp = async (method, url, body) => {
575
+ const m = url.match(/\/providers\/([^/]+\/[^/]+)\/([^/?]+)\?api-version=([^&]+)$/);
576
+ if (method === "PUT" && m) {
577
+ const tags = (body as { tags?: Record<string, string> }).tags;
578
+ store.set(`${m[1]}/${m[2]}`, { id: `/${m[2]}`, name: m[2], type: m[1], tags });
579
+ return { status: 200, text: JSON.stringify({ properties: {} }) };
580
+ }
581
+ if (method === "DELETE" && m) {
582
+ deletes.push(url);
583
+ const had = store.delete(`${m[1]}/${m[2]}`);
584
+ return { status: had ? 200 : 404, text: "" };
585
+ }
586
+ if (method === "GET" && url.includes("/resources?")) {
587
+ return { status: 200, text: JSON.stringify({ value: [...store.values()] }) };
588
+ }
589
+ return { status: 404, text: "" };
590
+ };
591
+ const path = `/tmp/chant-1472-az-${process.pid}.json`;
592
+ const account = { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "lastone" };
593
+ try {
594
+ writeFileSync(path, JSON.stringify({ resources: [account] }));
595
+ const first = await azApply({ templatePath: path, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
596
+ expect(first.applied).toEqual([{ type: account.type, name: "lastone" }]);
597
+ expect(store.size).toBe(1);
598
+
599
+ // Remove the only storage account from source: the template now declares
600
+ // no resource of that type at all.
601
+ writeFileSync(path, JSON.stringify({ resources: [] }));
602
+ const second = await azApply({ templatePath: path, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
603
+ expect(second.applied).toEqual([]);
604
+ expect(second.pruned).toEqual([{ type: account.type, name: "lastone", deleted: true }]);
605
+ expect(second.notPrunable).toEqual([]);
606
+ expect(store.size).toBe(0);
607
+ expect(deletes).toEqual([
608
+ `http://x/subscriptions/${"00000000-0000-0000-0000-000000000001"}/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/lastone?api-version=${lookupApiVersion(account.type)}`,
609
+ ]);
610
+ // Core's envelope agrees: nothing left over that could not be attempted.
611
+ expect(toApplyResult(second).notAttempted ?? []).toEqual([]);
612
+ } finally {
613
+ unlinkSync(path);
614
+ }
449
615
  });
450
616
 
451
617
  test("pruning one of several of a type still works — the case that hid this", async () => {
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { safeHeartbeat } from "@intentius/chant/op";
4
4
  import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
5
  import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership";
6
+ import { lookupApiVersion } from "../../serializer";
7
+ import { compareApiDates } from "../../spec/api-versions";
6
8
  import {
7
9
  applyResult,
8
10
  type ApplyResult,
@@ -501,38 +503,96 @@ export interface AzNotPrunable {
501
503
  type: string;
502
504
  name: string;
503
505
  /**
504
- * `no-api-version` — the resource is owned and undeclared, but its type is
505
- * absent from the current template, which is the only place an `apiVersion`
506
- * comes from. `deleteArmResource` cannot build a URL without one.
506
+ * `no-api-version` — the resource is owned and undeclared, but no apiVersion
507
+ * could be found for its type: the current template does not declare the
508
+ * type, the lexicon's pinned registry has no entry for it, and ARM's
509
+ * provider metadata listed no apiVersion for it (or the call failed).
510
+ * `deleteArmResource` cannot build a URL without one.
507
511
  */
508
512
  reason: "no-api-version";
509
513
  }
510
514
 
515
+ /** The ARM provider-metadata apiVersion (`GET /subscriptions/{sub}/providers/{ns}`). */
516
+ const PROVIDER_METADATA_API_VERSION = "2021-04-01";
517
+
518
+ /**
519
+ * Resolve an apiVersion for a live resource type from ARM's provider metadata:
520
+ * `GET /subscriptions/{sub}/providers/{namespace}?api-version=2021-04-01`
521
+ * returns `resourceTypes[].apiVersions[]`. Picks the newest stable version,
522
+ * falling back to the newest preview when no stable one exists. One call per
523
+ * namespace per prune run; the result is memoised in `cache` so a group with
524
+ * many orphans of one provider costs one round trip. Returns `undefined` when
525
+ * the endpoint is unavailable (floci-az does not implement it) or the type is
526
+ * not listed.
527
+ */
528
+ export async function providerApiVersion(
529
+ resourceType: string,
530
+ ctx: ArmEvalCtx,
531
+ http: AzHttp = defaultHttp,
532
+ signal?: AbortSignal,
533
+ cache: Map<string, Map<string, string>> = new Map(),
534
+ ): Promise<string | undefined> {
535
+ const slash = resourceType.indexOf("/");
536
+ if (slash < 0) return undefined;
537
+ const namespace = resourceType.slice(0, slash);
538
+ const typeName = resourceType.slice(slash + 1).toLowerCase();
539
+ let byType = cache.get(namespace.toLowerCase());
540
+ if (!byType) {
541
+ byType = new Map();
542
+ cache.set(namespace.toLowerCase(), byType);
543
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/providers/${namespace}?api-version=${PROVIDER_METADATA_API_VERSION}`;
544
+ let res: { status: number; text: string };
545
+ try {
546
+ res = await http("GET", url, undefined, signal);
547
+ } catch {
548
+ return undefined;
549
+ }
550
+ if (res.status >= 300) return undefined;
551
+ let parsed: { resourceTypes?: Array<{ resourceType?: string; apiVersions?: string[] }> };
552
+ try {
553
+ parsed = JSON.parse(res.text);
554
+ } catch {
555
+ return undefined;
556
+ }
557
+ for (const rt of parsed.resourceTypes ?? []) {
558
+ if (!rt?.resourceType || !Array.isArray(rt.apiVersions) || rt.apiVersions.length === 0) continue;
559
+ const versions = rt.apiVersions.filter((v): v is string => typeof v === "string");
560
+ const stable = versions.filter((v) => !v.endsWith("-preview"));
561
+ const pool = stable.length > 0 ? stable : versions;
562
+ if (pool.length === 0) continue;
563
+ const newest = pool.reduce((a, b) => (compareApiDates(b, a) > 0 ? b : a));
564
+ byType.set(rt.resourceType.toLowerCase(), newest);
565
+ }
566
+ }
567
+ return byType.get(typeName);
568
+ }
569
+
511
570
  /**
512
- * Owned-only prune: for each resource type present in the template, delete the
513
- * chant-owned live resources of that type whose (evaluated) name is not in the
514
- * template. Foreign (non-chant) resources are never touched.
571
+ * Owned-only prune: delete every chant-owned live resource in the group whose
572
+ * (evaluated) name is not declared in the template. Foreign (non-chant)
573
+ * resources are never touched.
574
+ *
575
+ * ## Where the apiVersion comes from (#1457, #1472)
515
576
  *
516
- * ## The type-left-the-template hole (#1457)
577
+ * `deleteArmResource` needs an `apiVersion`, and ARM's resource-group listing
578
+ * does not return one per resource. The template is the obvious source, but it
579
+ * only covers types it still declares: declare one storage account, apply,
580
+ * delete it from source, and the template has zero resources of that type.
581
+ * Keying the delete scope off the template alone made the last resource of a
582
+ * type a permanent orphan (#1457 made that a reported skip rather than a
583
+ * silent one).
517
584
  *
518
- * `byType` is keyed off the resources the template CURRENTLY declares, and the
519
- * `!entry` guard skipped any live resource of a type the template no longer
520
- * mentions — before `isChantOwned` was ever consulted.
585
+ * So the apiVersion is resolved in order:
521
586
  *
522
- * So: declare one storage account, apply, then delete it from source. The
523
- * template now has zero resources of that type, `byType` has no entry, and the
524
- * live account is skipped. It is owned, it is undeclared, and prune would never
525
- * touch it on that run or any future one. **Prune the last resource of a type
526
- * and you created a permanent orphan.** Prune one of several and it works,
527
- * which is why it survived testing.
587
+ * 1. the template, when it still declares the type;
588
+ * 2. the lexicon's pinned per-provider registry (`lookupApiVersion`, the same
589
+ * registry the serializer stamps into the template) deterministic, no
590
+ * extra call, covers every type the lexicon can emit;
591
+ * 3. ARM's provider metadata (`providerApiVersion`) covers a type the
592
+ * lexicon has never heard of, at one extra call per namespace per run.
528
593
  *
529
- * The guard cannot simply be removed: `deleteArmResource` needs an
530
- * `apiVersion`, and the template is the only source of one — ARM's
531
- * resource-group listing does not return it per resource. So the skip stays,
532
- * and is now REPORTED rather than silent: a permanent invisible orphan becomes
533
- * a permanent visible one, which is the difference between a bug and a known
534
- * limitation. Resolving it properly wants a per-type apiVersion source (the
535
- * provider metadata endpoint, or a lexicon-side map).
594
+ * Only when all three come up empty is the resource reported as
595
+ * `notPrunable: no-api-version`.
536
596
  *
537
597
  * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
538
598
  * `azApply` — before that, the composite reached `--mode Complete` and never
@@ -555,24 +615,39 @@ export async function pruneArmOrphans(
555
615
  byType.set(r.type, entry);
556
616
  }
557
617
 
618
+ const providerCache = new Map<string, Map<string, string>>();
619
+ const resolveApiVersion = async (type: string): Promise<string | undefined> => {
620
+ const fromTemplate = byType.get(type)?.apiVersion;
621
+ if (fromTemplate) return fromTemplate;
622
+ let fromRegistry: string | undefined;
623
+ try {
624
+ fromRegistry = lookupApiVersion(type);
625
+ } catch {
626
+ // A missing registry is the serializer's problem to report; prune still
627
+ // has the provider metadata to fall back on.
628
+ }
629
+ if (fromRegistry) return fromRegistry;
630
+ return providerApiVersion(type, ctx, http, signal, providerCache);
631
+ };
632
+
558
633
  const pruned: Array<{ type: string; name: string; deleted: boolean }> = [];
559
634
  const notPrunable: AzNotPrunable[] = [];
560
635
  for (const item of await listGroupResources(ctx, http, signal)) {
561
636
  // Ownership first, so a foreign resource never reaches the report either —
562
637
  // it is not an orphan chant failed to prune, it is simply not chant's.
563
638
  if (!isChantOwned(item.tags)) continue;
564
- const entry = byType.get(item.type);
565
- if (!entry) {
639
+ if (byType.get(item.type)?.keep.has(item.name)) continue;
640
+ const apiVersion = await resolveApiVersion(item.type);
641
+ if (!apiVersion) {
566
642
  console.log(
567
- `prune: ${item.type}/${item.name} is chant-owned and undeclared, but its type is absent ` +
568
- `from the template so no apiVersion is available — not deleted`,
643
+ `prune: ${item.type}/${item.name} is chant-owned and undeclared, but no apiVersion is ` +
644
+ `available for its type (not in the template, the lexicon registry, or ARM provider metadata) — not deleted`,
569
645
  );
570
646
  notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" });
571
647
  continue;
572
648
  }
573
- if (entry.keep.has(item.name)) continue;
574
649
  safeHeartbeat({ step: "azPrune", type: item.type, name: item.name });
575
- const result = await deleteArmResource(item.type, item.name, entry.apiVersion, ctx, http, signal);
650
+ const result = await deleteArmResource(item.type, item.name, apiVersion, ctx, http, signal);
576
651
  console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
577
652
  pruned.push(result);
578
653
  }
@@ -23,6 +23,7 @@ export {
23
23
  azApply,
24
24
  azDelete,
25
25
  pruneArmOrphans,
26
+ providerApiVersion,
26
27
  toApplyResult,
27
28
  deleteArmResource,
28
29
  listGroupResources,
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { execFileSync } from "child_process";
7
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
8
+ import {
9
+ azureSerializer,
10
+ apiVersionRegistryCandidates,
11
+ getApiVersion,
12
+ loadApiVersions,
13
+ resetApiVersionCache,
14
+ } from "./serializer";
15
+ import { PROVIDER_VERSION_OVERRIDES } from "./spec/api-versions";
16
+
17
+ function makeEntity(entityType: string, props: Record<string, unknown> = {}): any {
18
+ return { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props };
19
+ }
20
+
21
+ // This file runs as ESM (package.json "type": "module"), so `require` is not
22
+ // defined in module scope — the path #1581 broke.
23
+ describe("apiVersion registry under ESM (#1581)", () => {
24
+ afterEach(() => resetApiVersionCache());
25
+
26
+ it("resolves pinned versions in a real ESM process (no require shim)", () => {
27
+ // vitest defines a module-scope `require`, which hid #1581. Run the
28
+ // serializer under plain tsx ESM, where `require` is undefined.
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const script = [
31
+ `import { azureSerializer } from ${JSON.stringify(join(here, "serializer.ts"))};`,
32
+ `import { DECLARABLE_MARKER } from "@intentius/chant/declarable";`,
33
+ `const entities = new Map();`,
34
+ `entities.set("vm", { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType: "Microsoft.Compute/virtualMachines", kind: "resource", props: { name: "vm", location: "eastus" } });`,
35
+ `const t = JSON.parse(azureSerializer.serialize(entities));`,
36
+ `console.log(JSON.stringify({ req: typeof require, apiVersion: t.resources[0].apiVersion }));`,
37
+ ].join("\n");
38
+ const dir = mkdtempSync(join(tmpdir(), "azure-esm-"));
39
+ try {
40
+ const file = join(dir, "probe.mts");
41
+ writeFileSync(file, script);
42
+ const out = execFileSync("npx", ["tsx", file], { cwd: here, encoding: "utf-8", timeout: 120_000 });
43
+ const { req, apiVersion } = JSON.parse(out.trim().split("\n").pop()!);
44
+ expect(req).toBe("undefined");
45
+ expect(PROVIDER_VERSION_OVERRIDES["Microsoft.Compute"]).toContain(apiVersion);
46
+ expect(apiVersion).not.toBe("2023-01-01");
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ it("loads the registry via import.meta.url and uses pinned versions", () => {
53
+ const versions = loadApiVersions();
54
+ expect(versions.size).toBeGreaterThan(100);
55
+
56
+ // The pinned Microsoft.Compute version (spec/api-versions.ts) must reach
57
+ // the serializer, not the uniform 2023-01-01 fallback.
58
+ const computePins = PROVIDER_VERSION_OVERRIDES["Microsoft.Compute"];
59
+ expect(computePins?.length).toBeGreaterThan(0);
60
+ expect(computePins).toContain(versions.get("Microsoft.Compute/virtualMachines"));
61
+
62
+ const entities = new Map<string, any>();
63
+ entities.set("vm", makeEntity("Microsoft.Compute/virtualMachines", { name: "vm", location: "eastus" }));
64
+ entities.set("sa", makeEntity("Microsoft.Storage/storageAccounts", { name: "sa", location: "eastus" }));
65
+ const template = JSON.parse(azureSerializer.serialize(entities) as string);
66
+ const byType = Object.fromEntries(template.resources.map((r: any) => [r.type, r.apiVersion]));
67
+ expect(computePins).toContain(byType["Microsoft.Compute/virtualMachines"]);
68
+ expect(byType["Microsoft.Storage/storageAccounts"]).toBe(versions.get("Microsoft.Storage/storageAccounts"));
69
+ expect(byType["Microsoft.Storage/storageAccounts"]).not.toBe("2023-01-01");
70
+ });
71
+
72
+ it("throws loudly when no registry can be found", () => {
73
+ const dir = mkdtempSync(join(tmpdir(), "azure-apiv-"));
74
+ try {
75
+ expect(() => loadApiVersions(apiVersionRegistryCandidates(dir))).toThrow(/apiVersion registry not found/);
76
+ } finally {
77
+ rmSync(dir, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ it("throws on an empty registry rather than falling back uniformly", () => {
82
+ const dir = mkdtempSync(join(tmpdir(), "azure-apiv-"));
83
+ try {
84
+ writeFileSync(join(dir, "meta.json"), "{}");
85
+ expect(() => loadApiVersions([join(dir, "meta.json")])).toThrow(/registry is empty/);
86
+ } finally {
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it("keeps explicit overrides ahead of the registry", () => {
92
+ expect(getApiVersion("Microsoft.Authorization/roleAssignments")).toBe("2022-04-01");
93
+ });
94
+ });
package/src/serializer.ts CHANGED
@@ -18,6 +18,9 @@ import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership";
18
18
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
19
19
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
20
20
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { dirname, join } from "path";
23
+ import { fileURLToPath } from "url";
21
24
  import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
22
25
  import { isAttrRefLike } from "@intentius/chant/utils";
23
26
  import { resolveDependsOn } from "@intentius/chant/resource-attributes";
@@ -74,42 +77,48 @@ const RESOURCE_LEVEL_FIELDS = new Set([
74
77
 
75
78
  /**
76
79
  * Load apiVersion for a resource type from the lexicon registry.
80
+ *
81
+ * Reads the generated registry (src/generated/lexicon-azure.json in a dev
82
+ * checkout, dist/meta.json in the installed package). Resolves the package
83
+ * directory from import.meta.url so it works under pure ESM as well as
84
+ * tsx/vitest — the old `require()`-based resolver threw under ESM and the
85
+ * throw was swallowed, so every resource emitted the fallback apiVersion
86
+ * (#1581). A missing registry is now an error: a uniform fallback apiVersion
87
+ * silently bypasses the per-provider pins in spec/api-versions.ts.
77
88
  */
78
89
  function resolveDir(): string {
79
- const { dirname } = require("path");
80
- // Bun / ESM
81
- try {
82
- const { fileURLToPath } = require("url");
83
- return dirname(fileURLToPath(import.meta.url));
84
- } catch {}
85
- // CJS / tsx fallback
86
- if (typeof __dirname !== "undefined") return __dirname;
87
- return dirname(require.resolve("./serializer"));
90
+ return dirname(fileURLToPath(import.meta.url));
91
+ }
92
+
93
+ export function apiVersionRegistryCandidates(dir: string = resolveDir()): string[] {
94
+ return [
95
+ join(dir, "generated", "lexicon-azure.json"),
96
+ join(dir, "..", "dist", "meta.json"),
97
+ ];
88
98
  }
89
99
 
90
- function loadApiVersions(): Map<string, string> {
100
+ export function loadApiVersions(candidates: string[] = apiVersionRegistryCandidates()): Map<string, string> {
91
101
  const map = new Map<string, string>();
92
- try {
93
- const { readFileSync } = require("fs");
94
- const { join } = require("path");
95
- const dir = resolveDir();
96
- // Try generated/ (dev) first, then dist/meta.json (installed package)
97
- let content: string | undefined;
98
- for (const candidate of [
99
- join(dir, "generated", "lexicon-azure.json"),
100
- join(dir, "..", "dist", "meta.json"),
101
- ]) {
102
- try { content = readFileSync(candidate, "utf-8"); break; } catch {}
103
- }
104
- if (!content) return map;
105
- const data = JSON.parse(content) as Record<string, { resourceType: string; apiVersion?: string }>;
106
- for (const entry of Object.values(data)) {
107
- if (entry.apiVersion && entry.resourceType) {
108
- map.set(entry.resourceType, entry.apiVersion);
109
- }
102
+ let content: string | undefined;
103
+ for (const candidate of candidates) {
104
+ if (!existsSync(candidate)) continue;
105
+ content = readFileSync(candidate, "utf-8");
106
+ break;
107
+ }
108
+ if (content === undefined) {
109
+ throw new Error(
110
+ `azure: apiVersion registry not found (tried ${candidates.join(", ")}). ` +
111
+ "Run `npm run generate` (dev) or reinstall the package (dist/meta.json).",
112
+ );
113
+ }
114
+ const data = JSON.parse(content) as Record<string, { resourceType: string; apiVersion?: string }>;
115
+ for (const entry of Object.values(data)) {
116
+ if (entry.apiVersion && entry.resourceType) {
117
+ map.set(entry.resourceType, entry.apiVersion);
110
118
  }
111
- } catch {
112
- // Lexicon not available
119
+ }
120
+ if (map.size === 0) {
121
+ throw new Error("azure: apiVersion registry is empty — regenerate with `npm run generate`");
113
122
  }
114
123
  return map;
115
124
  }
@@ -126,11 +135,39 @@ const API_VERSION_OVERRIDES = new Map<string, string>([
126
135
 
127
136
  let _apiVersions: Map<string, string> | undefined;
128
137
 
129
- function getApiVersion(resourceType: string): string {
138
+ /** Test hook: drop the cached registry so the next lookup reloads it. */
139
+ export function resetApiVersionCache(): void {
140
+ _apiVersions = undefined;
141
+ }
142
+
143
+ /**
144
+ * Registry lookup without the serializer's fallback: the pinned apiVersion for
145
+ * `resourceType`, or `undefined` when neither the overrides nor the generated
146
+ * registry know the type. ARM resource types are case-insensitive and the live
147
+ * listing does not always echo the casing the registry uses, so the match is
148
+ * case-insensitive too. Used by prune (#1472), which needs an apiVersion for a
149
+ * type the current template no longer declares.
150
+ */
151
+ export function lookupApiVersion(resourceType: string): string | undefined {
130
152
  const override = API_VERSION_OVERRIDES.get(resourceType);
131
153
  if (override) return override;
132
154
  if (!_apiVersions) _apiVersions = loadApiVersions();
133
- return _apiVersions.get(resourceType) ?? "2023-01-01";
155
+ const exact = _apiVersions.get(resourceType);
156
+ if (exact) return exact;
157
+ const wanted = resourceType.toLowerCase();
158
+ for (const [type, version] of _apiVersions) {
159
+ if (type.toLowerCase() === wanted) return version;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ export function getApiVersion(resourceType: string): string {
165
+ const version = lookupApiVersion(resourceType);
166
+ if (version) return version;
167
+ console.warn(
168
+ `azure: no pinned apiVersion for ${resourceType} in the lexicon registry; falling back to 2023-01-01`,
169
+ );
170
+ return "2023-01-01";
134
171
  }
135
172
 
136
173
  /**
package/src/validate.ts CHANGED
@@ -44,6 +44,12 @@ export async function validate(opts?: { basePath?: string }): Promise<ValidateRe
44
44
  // after bounding (#438), so match required names by substring.
45
45
  requiredNamesMatchSubstring: true,
46
46
  basePath,
47
+ // chant #1475 — azure generates from a commit sha of
48
+ // azure-resource-manager-schemas (#1144), so a fresh generate is
49
+ // deterministic. Gate the surface on every validate: the #1144 pin left
50
+ // the snapshot 483 entries behind until #1572 re-baselined it, because
51
+ // nothing compared the two on a PR.
52
+ checkSurfaceSnapshot: "always",
47
53
  coverageThresholds: {
48
54
  minPropertyPct: 1,
49
55
  minLifecyclePct: 1,