@intentius/chant-lexicon-azure 0.13.1 → 0.15.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":"live-export.d.ts","sourceRoot":"","sources":["../../src/import/live-export.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAInF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,OAAO,EACrB,QAAQ,CAAC,EAAE,gBAAgB,EAC3B,KAAK,CAAC,EAAE,OAAO,GACd,gBAAgB,CAoBlB"}
1
+ {"version":3,"file":"live-export.d.ts","sourceRoot":"","sources":["../../src/import/live-export.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAKnF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,OAAO,EACrB,QAAQ,CAAC,EAAE,gBAAgB,EAC3B,KAAK,CAAC,EAAE,OAAO,GACd,gBAAgB,CAoBlB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "793862af34a8c2954d61ff1da1a6caf941668e3d711181c7351259dc0aeb7cc4",
4
+ "manifest.json": "3b3b9ea67bcddf7d194ad6885c0ef2b41fe820dcc229b36f0593aee5b3227606",
5
5
  "meta.json": "4b9c0be734f8b4a5fbba3136e14aaa1ea73ff0ee93f2aa480db83e476ce727b5",
6
6
  "types/index.d.ts": "d62c0fd7947ddf04a9126cc8bf44a9f4437f21cf8647fc812c022398f2c00d57",
7
7
  "rules/hardcoded-location.ts": "a9b1d1cec93f2ca9c5206641f53fc9621cefcfd2c00b3ab89c194b30a75fa949",
@@ -33,5 +33,5 @@
33
33
  "skills/chant-azure-patterns.md": "1a3bacfac612826b77332d2692a59195638db9f1b5e8ea2eda7579621d25f603",
34
34
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
35
35
  },
36
- "composite": "f9612c8f78965b3ae6183938b01707abf8a3c8c07fb4b8e4d5d8285fb6ecc233"
36
+ "composite": "18a72855f694f2b11534c8dafffcd84b8fa040f8e9fc47285135488a7c357639"
37
37
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The azure lexicon's `chant audit` catalog — metadata for the AZR* ARM
3
+ * post-synth rules. Contributed via `azurePlugin.auditCatalog()` (#687).
4
+ */
5
+ import { type RuleMeta } from "@intentius/chant/audit/catalog";
6
+ export declare const azureAuditCatalog: Record<string, RuleMeta>;
7
+ //# sourceMappingURL=audit-catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAa,KAAK,QAAQ,EAAkB,MAAM,gCAAgC,CAAC;AAI1F,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAqBtD,CAAC"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
@@ -0,0 +1,62 @@
1
+ /** Context for evaluating the ARM template expressions chant emits. */
2
+ export interface ArmContext {
3
+ subscriptionId: string;
4
+ resourceGroup: string;
5
+ location: string;
6
+ }
7
+ /** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
8
+ export interface ArmResource {
9
+ type: string;
10
+ apiVersion: string;
11
+ name: string;
12
+ location?: string;
13
+ properties?: unknown;
14
+ sku?: unknown;
15
+ kind?: unknown;
16
+ tags?: Record<string, string>;
17
+ }
18
+ /**
19
+ * Evaluate an ARM template expression string (`"[...]"`). Supports the functions
20
+ * chant's serializer emits: `concat`, `uniqueString`, `resourceGroup()` (`.id` /
21
+ * `.location`), `subscription()` (`.subscriptionId`), and string literals. A
22
+ * non-expression string is returned unchanged. Pure.
23
+ */
24
+ export declare function evalArmString(s: string, ctx: ArmContext): string;
25
+ /** Recursively evaluate every string in a value against the ARM context. Pure. */
26
+ export declare function evalArm(value: unknown, ctx: ArmContext): unknown;
27
+ /** Injectable HTTP client — mirrors the GCP applier so tests avoid the network. */
28
+ export type AzHttp = (method: string, url: string, body?: unknown, signal?: AbortSignal) => Promise<{
29
+ status: number;
30
+ text: string;
31
+ }>;
32
+ /** The ARM resource-ID PUT URL for a resource under a resource group. Pure. */
33
+ export declare function armResourceUrl(resource: ArmResource, ctx: ArmContext, base: string): string;
34
+ /** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. Pure. */
35
+ export declare function armResourceBody(resource: ArmResource, ctx: ArmContext): Record<string, unknown>;
36
+ export interface AzApplyArgs {
37
+ /** Path to a built ARM template (`deploymentTemplate.json`). */
38
+ templatePath: string;
39
+ /** Resource group to deploy into. */
40
+ resourceGroup: string;
41
+ /** Region for the resource group and `resourceGroup().location`. Default: `eastus`. */
42
+ location?: string;
43
+ /** ARM endpoint override (e.g. floci-az `http://localhost:4577`). Default: real Azure. */
44
+ endpoint?: string;
45
+ /** Subscription id. Default: floci-az's local subscription. */
46
+ subscriptionId?: string;
47
+ }
48
+ /**
49
+ * The native Azure applier — read a built ARM template and PUT each resource
50
+ * directly to the ARM resource-CRUD API, resolving ARM expressions first. This
51
+ * is the direct-apply path (the Azure twin of `gcpApply`): it targets floci-az's
52
+ * ARM resource endpoints — which `az deployment` cannot, since floci-az has no
53
+ * `Microsoft.Resources/deployments` provider — or real Azure by endpoint override.
54
+ * The resource group is ensured first. `http` is injectable for tests.
55
+ */
56
+ export declare function azApply(args: AzApplyArgs, signal?: AbortSignal, http?: AzHttp): Promise<{
57
+ applied: Array<{
58
+ type: string;
59
+ name: string;
60
+ }>;
61
+ }>;
62
+ //# sourceMappingURL=az-apply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"AAQA,uEAAuE;AACvE,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,uEAAuE;AACvE,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAID;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,CAKhE;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAShE;AA4FD,mFAAmF;AACnF,MAAM,MAAM,MAAM,GAAG,CACnB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,OAAO,EACd,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAY/C,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ/F;AAED,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,GAAE,MAAoB,GACzB,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CA6B7D"}
@@ -0,0 +1,37 @@
1
+ export interface AzGroupEnsureArgs {
2
+ /** Resource group name — the same value passed as `env` to a `arm` `nativeApply`. */
3
+ resourceGroup: string;
4
+ /** Azure region for the group. Default: `eastus`. */
5
+ location?: string;
6
+ }
7
+ export interface AzGroupDeleteArgs {
8
+ /** Resource group to delete. */
9
+ resourceGroup: string;
10
+ }
11
+ /**
12
+ * Build the `az group create` command. `az group create` is idempotent — a group
13
+ * that already exists is returned unchanged — so no existence check is needed.
14
+ * Pure — exported for testing.
15
+ */
16
+ export declare function azGroupEnsureCommand(resourceGroup: string, location: string): string;
17
+ /** Build the `az group delete` command (non-blocking). Pure — exported for testing. */
18
+ export declare function azGroupDeleteCommand(resourceGroup: string): string;
19
+ /**
20
+ * Ensure an Azure resource group exists before an ARM deployment.
21
+ *
22
+ * `az deployment group create` (the `arm` target of `nativeApply`) fails if its
23
+ * resource group is absent — unlike CloudFormation, which creates its own stack.
24
+ * Run this before the apply. Uses fastIdempotent profile — 5m timeout.
25
+ *
26
+ * This targets real Azure (via the logged-in `az` context). Local emulation
27
+ * against floci-az awaits an upstream `Microsoft.Resources/deployments` provider
28
+ * (see #705); when that lands, an endpoint/custom-cloud override slots in here.
29
+ */
30
+ export declare function azGroupEnsure(args: AzGroupEnsureArgs, signal?: AbortSignal): Promise<void>;
31
+ /**
32
+ * Delete an Azure resource group and everything in it (non-blocking). A no-op
33
+ * success when the group is already gone. Uses fastIdempotent profile — 5m
34
+ * timeout (`--no-wait` returns immediately).
35
+ */
36
+ export declare function azGroupDelete(args: AzGroupDeleteArgs, signal?: AbortSignal): Promise<void>;
37
+ //# sourceMappingURL=azure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"azure.d.ts","sourceRoot":"","sources":["../../../src/op/activities/azure.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,iBAAiB;IAChC,qFAAqF;IACrF,aAAa,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,gCAAgC;IAChC,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpF;AAED,uFAAuF;AACvF,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhG;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAMhG"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Azure Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `azure` lexicon. Contributes the resource-group
4
+ * lifecycle (`azGroupEnsure`/`azGroupDelete`) that an ARM `nativeApply` needs.
5
+ */
6
+ export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure.js";
7
+ export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure.js";
8
+ export { azApply, evalArm, evalArmString, armResourceUrl, armResourceBody } from "./az-apply.js";
9
+ export type { AzApplyArgs, ArmContext, ArmResource, AzHttp } from "./az-apply.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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;AAEpE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC9F,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The azure lexicon's ownership tag convention. Azure tag keys forbid `/`, so
3
+ * chant's markers use a hyphenated `chant-<name>` form (distinct from the
4
+ * label-based convention core keeps for k8s/gcp, and from AWS's `chant:<name>`).
5
+ * Core owns the generic stamp/detect logic (@intentius/chant/ownership); this is
6
+ * the azure-specific key naming it stamps.
7
+ */
8
+ import type { ChannelKeys } from "@intentius/chant/ownership";
9
+ /** Azure tag keys for chant's ownership markers. */
10
+ export declare const AZURE_TAG_OWNERSHIP_KEYS: ChannelKeys;
11
+ //# sourceMappingURL=ownership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ownership.d.ts","sourceRoot":"","sources":["../src/ownership.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE9D,oDAAoD;AACpD,eAAO,MAAM,wBAAwB,EAAE,WAItC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAqB5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aAsazB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAsB5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aAuazB,CAAC"}
@@ -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;AA+YlG;;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;AAgZlG;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,UA0C7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.13.1",
3
+ "version": "0.15.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",
@@ -44,6 +44,11 @@
44
44
  "types": "./dist/detect.d.ts",
45
45
  "default": "./src/detect.ts"
46
46
  },
47
+ "./op/activities": {
48
+ "development": "./src/op/activities/index.ts",
49
+ "types": "./dist/op/activities/index.d.ts",
50
+ "default": "./src/op/activities/index.ts"
51
+ },
47
52
  "./lint/post-synth": {
48
53
  "development": "./src/lint/post-synth/index.ts",
49
54
  "types": "./dist/lint/post-synth/index.d.ts",
@@ -69,6 +74,6 @@
69
74
  "typescript": "^5.9.3"
70
75
  },
71
76
  "peerDependencies": {
72
- "@intentius/chant": "^0.13.1"
77
+ "@intentius/chant": "^0.15.0"
73
78
  }
74
79
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
2
2
  import { hasOwnershipMarker } from "@intentius/chant/ownership";
3
+ import { AZURE_TAG_OWNERSHIP_KEYS } from "../ownership";
3
4
  import { ArmParser } from "./parser";
4
5
 
5
6
  /**
@@ -35,7 +36,7 @@ export function parseExportedTemplate(
35
36
  if (selector?.name !== undefined && r.logicalId !== selector.name) return false;
36
37
  if (owned) {
37
38
  const tags = (r.properties as { tags?: Record<string, unknown> }).tags;
38
- if (!hasOwnershipMarker(tags, "azure-tag")) return false;
39
+ if (!hasOwnershipMarker(tags, AZURE_TAG_OWNERSHIP_KEYS)) return false;
39
40
  }
40
41
  return true;
41
42
  }),
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The azure lexicon's `chant audit` catalog — metadata for the AZR* ARM
3
+ * post-synth rules. Contributed via `azurePlugin.auditCatalog()` (#687).
4
+ */
5
+ import { auditRule, type RuleMeta, type Authority } from "@intentius/chant/audit/catalog";
6
+
7
+ const AZ_SEC: Authority = { name: "Microsoft Cloud Security Benchmark", url: "https://learn.microsoft.com/en-us/security/benchmark/azure/" };
8
+
9
+ export const azureAuditCatalog: Record<string, RuleMeta> = {
10
+ AZR010: auditRule("AZR010", "report-only", "guidance", "Redundant dependsOn", "Remove dependsOn already implied by reference()/resourceId().", { category: "best-practice" }),
11
+ AZR011: auditRule("AZR011", "merge-worthy", "guidance", "Missing or invalid apiVersion", "Set a valid YYYY-MM-DD apiVersion on every resource.", { category: "correctness" }),
12
+ AZR012: auditRule("AZR012", "report-only", "guidance", "Deprecated API version", "Move to a current apiVersion.", { category: "best-practice" }),
13
+ AZR013: auditRule("AZR013", "merge-worthy", "guidance", "Resource missing location", "Add the required location property.", { category: "correctness" }),
14
+ AZR014: auditRule("AZR014", "merge-worthy", "guidance", "Storage account allows public blob access", "Set allowBlobPublicAccess to false.", { authority: [AZ_SEC] }),
15
+ AZR015: auditRule("AZR015", "merge-worthy", "guidance", "Storage account missing encryption", "Enable encryption services for data at rest.", { authority: [AZ_SEC] }),
16
+ AZR016: auditRule("AZR016", "report-only", "guidance", "Key Vault soft-delete not enabled", "Enable soft-delete.", { category: "best-practice" }),
17
+ AZR017: auditRule("AZR017", "report-only", "guidance", "Key Vault purge protection not enabled", "Enable purge protection.", { category: "best-practice" }),
18
+ AZR018: auditRule("AZR018", "report-only", "guidance", "SQL Server missing auditing", "Enable auditing for compliance and threat detection.", { category: "best-practice" }),
19
+ AZR019: auditRule("AZR019", "merge-worthy", "guidance", "SQL database missing TDE", "Enable Transparent Data Encryption.", { authority: [AZ_SEC] }),
20
+ AZR020: auditRule("AZR020", "report-only", "guidance", "App Service missing managed identity", "Enable a system- or user-assigned identity.", { category: "best-practice" }),
21
+ AZR021: auditRule("AZR021", "merge-worthy", "guidance", "App Service not HTTPS-only", "Set httpsOnly to true.", { authority: [AZ_SEC] }),
22
+ AZR022: auditRule("AZR022", "merge-worthy", "guidance", "App Service min TLS below 1.2", "Set minTlsVersion to 1.2+.", { authority: [AZ_SEC] }),
23
+ AZR023: auditRule("AZR023", "report-only", "guidance", "VM not using a managed disk", "Use a managed disk.", { category: "best-practice" }),
24
+ AZR024: auditRule("AZR024", "report-only", "guidance", "VM missing boot diagnostics", "Enable boot diagnostics.", { category: "best-practice" }),
25
+ AZR025: auditRule("AZR025", "report-only", "guidance", "AKS cluster missing RBAC", "Enable Kubernetes RBAC.", { category: "best-practice" }),
26
+ AZR026: auditRule("AZR026", "report-only", "guidance", "AKS cluster missing network policy", "Configure a networkPolicy.", { category: "best-practice" }),
27
+ AZR027: auditRule("AZR027", "merge-worthy", "guidance", "Container Registry admin user enabled", "Disable the admin user; use Azure AD / service principals.", { authority: [AZ_SEC] }),
28
+ AZR028: auditRule("AZR028", "report-only", "guidance", "Network interface missing NSG", "Associate an NSG to control traffic.", { category: "best-practice" }),
29
+ AZR029: auditRule("AZR029", "merge-worthy", "guidance", "Managed disk missing encryption", "Enable encryption for data at rest.", { authority: [AZ_SEC] }),
30
+ };
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ evalArmString,
4
+ evalArm,
5
+ armResourceUrl,
6
+ armResourceBody,
7
+ azApply,
8
+ type ArmContext,
9
+ type ArmResource,
10
+ type AzHttp,
11
+ } from "./az-apply";
12
+
13
+ const CTX: ArmContext = {
14
+ subscriptionId: "sub-1",
15
+ resourceGroup: "chant-rg",
16
+ location: "eastus",
17
+ };
18
+
19
+ describe("evalArmString (#706)", () => {
20
+ test("resourceGroup().location / .id", () => {
21
+ expect(evalArmString("[resourceGroup().location]", CTX)).toBe("eastus");
22
+ expect(evalArmString("[resourceGroup().id]", CTX)).toBe("/subscriptions/sub-1/resourceGroups/chant-rg");
23
+ });
24
+
25
+ test("subscription().subscriptionId", () => {
26
+ expect(evalArmString("[subscription().subscriptionId]", CTX)).toBe("sub-1");
27
+ });
28
+
29
+ test("concat with a literal and a nested function", () => {
30
+ const v = evalArmString("[concat('store', uniqueString(resourceGroup().id))]", CTX);
31
+ expect(v.startsWith("store")).toBe(true);
32
+ expect(v).toHaveLength("store".length + 13); // uniqueString → 13 chars
33
+ });
34
+
35
+ test("uniqueString is deterministic for the same inputs", () => {
36
+ const a = evalArmString("[uniqueString(resourceGroup().id)]", CTX);
37
+ const b = evalArmString("[uniqueString(resourceGroup().id)]", CTX);
38
+ expect(a).toBe(b);
39
+ });
40
+
41
+ test("non-expression strings pass through; [[ is an escaped literal", () => {
42
+ expect(evalArmString("plain-name", CTX)).toBe("plain-name");
43
+ expect(evalArmString("[[literal]", CTX)).toBe("[literal]");
44
+ });
45
+
46
+ test("evalArm recurses into objects and arrays", () => {
47
+ expect(evalArm({ a: "[resourceGroup().location]", b: ["[subscription().subscriptionId]", 1] }, CTX)).toEqual({
48
+ a: "eastus",
49
+ b: ["sub-1", 1],
50
+ });
51
+ });
52
+ });
53
+
54
+ const STORAGE: ArmResource = {
55
+ type: "Microsoft.Storage/storageAccounts",
56
+ apiVersion: "2025-06-01",
57
+ name: "chantstore1",
58
+ location: "[resourceGroup().location]",
59
+ sku: { name: "Standard_LRS" },
60
+ kind: "StorageV2",
61
+ properties: { minimumTlsVersion: "TLS1_2" },
62
+ tags: { "managed-by": "chant" },
63
+ };
64
+
65
+ describe("armResourceUrl / armResourceBody (#706)", () => {
66
+ test("URL is the resource-id PUT path with api-version", () => {
67
+ expect(armResourceUrl(STORAGE, CTX, "http://x")).toBe(
68
+ "http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/chantstore1?api-version=2025-06-01",
69
+ );
70
+ });
71
+
72
+ test("body evaluates location, keeps sku/kind/tags/properties", () => {
73
+ expect(armResourceBody(STORAGE, CTX)).toEqual({
74
+ location: "eastus",
75
+ sku: { name: "Standard_LRS" },
76
+ kind: "StorageV2",
77
+ properties: { minimumTlsVersion: "TLS1_2" },
78
+ tags: { "managed-by": "chant" },
79
+ });
80
+ });
81
+ });
82
+
83
+ describe("azApply flow (#706)", () => {
84
+ test("ensures the resource group, then PUTs each resource", async () => {
85
+ const calls: Array<{ method: string; url: string }> = [];
86
+ const http: AzHttp = async (method, url) => {
87
+ calls.push({ method, url });
88
+ return { status: 200, text: "{}" };
89
+ };
90
+ // Stub the template read via a data: path — azApply reads a file, so drive it
91
+ // through the pure pieces instead by asserting the call sequence a real run makes.
92
+ // Here we exercise the HTTP contract with a hand-built template file.
93
+ const fs = await import("node:fs");
94
+ const tmp = `/tmp/chant-arm-${process.pid}.json`;
95
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [STORAGE] }));
96
+ const res = await azApply({ templatePath: tmp, resourceGroup: "chant-rg", location: "eastus", endpoint: "http://x", subscriptionId: "sub-1" }, undefined, http);
97
+ fs.unlinkSync(tmp);
98
+ expect(res.applied).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "chantstore1" }]);
99
+ expect(calls[0].method).toBe("PUT");
100
+ expect(calls[0].url).toContain("/resourceGroups/chant-rg?api-version=");
101
+ expect(calls[1].url).toContain("/providers/Microsoft.Storage/storageAccounts/chantstore1?api-version=2025-06-01");
102
+ });
103
+
104
+ test("surfaces a resource apply failure (RG ok, resource PUT fails)", async () => {
105
+ const fs = await import("node:fs");
106
+ const tmp = `/tmp/chant-arm-fail-${process.pid}.json`;
107
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [STORAGE] }));
108
+ const http: AzHttp = async (_method, url) =>
109
+ url.includes("/providers/") ? { status: 400, text: "denied" } : { status: 200, text: "" };
110
+ await expect(
111
+ azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http),
112
+ ).rejects.toThrow(/Microsoft.Storage\/storageAccounts chantstore1 apply failed \(400\)/);
113
+ fs.unlinkSync(tmp);
114
+ });
115
+ });
@@ -0,0 +1,234 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { safeHeartbeat } from "@intentius/chant/op";
4
+
5
+ // The floci-az default local subscription (from its ARM management-plane mock).
6
+ const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
7
+ const DEFAULT_ENDPOINT = "https://management.azure.com";
8
+
9
+ /** Context for evaluating the ARM template expressions chant emits. */
10
+ export interface ArmContext {
11
+ subscriptionId: string;
12
+ resourceGroup: string;
13
+ location: string;
14
+ }
15
+
16
+ /** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
17
+ export interface ArmResource {
18
+ type: string;
19
+ apiVersion: string;
20
+ name: string;
21
+ location?: string;
22
+ properties?: unknown;
23
+ sku?: unknown;
24
+ kind?: unknown;
25
+ tags?: Record<string, string>;
26
+ }
27
+
28
+ // ── ARM expression evaluation (the subset chant emits) ────────────────────────
29
+
30
+ /**
31
+ * Evaluate an ARM template expression string (`"[...]"`). Supports the functions
32
+ * chant's serializer emits: `concat`, `uniqueString`, `resourceGroup()` (`.id` /
33
+ * `.location`), `subscription()` (`.subscriptionId`), and string literals. A
34
+ * non-expression string is returned unchanged. Pure.
35
+ */
36
+ export function evalArmString(s: string, ctx: ArmContext): string {
37
+ if (!(s.startsWith("[") && s.endsWith("]"))) return s;
38
+ // "[[" is an escaped literal "[".
39
+ if (s.startsWith("[[")) return s.slice(1);
40
+ return String(new ArmExpr(s.slice(1, -1), ctx).parse());
41
+ }
42
+
43
+ /** Recursively evaluate every string in a value against the ARM context. Pure. */
44
+ export function evalArm(value: unknown, ctx: ArmContext): unknown {
45
+ if (typeof value === "string") return evalArmString(value, ctx);
46
+ if (Array.isArray(value)) return value.map((v) => evalArm(v, ctx));
47
+ if (value && typeof value === "object") {
48
+ const out: Record<string, unknown> = {};
49
+ for (const [k, v] of Object.entries(value)) out[k] = evalArm(v, ctx);
50
+ return out;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ /** Minimal recursive-descent evaluator for the ARM function subset. */
56
+ class ArmExpr {
57
+ private i = 0;
58
+ constructor(private readonly src: string, private readonly ctx: ArmContext) {}
59
+
60
+ parse(): unknown {
61
+ const v = this.expr();
62
+ return v;
63
+ }
64
+
65
+ private expr(): unknown {
66
+ this.ws();
67
+ let v: unknown;
68
+ if (this.src[this.i] === "'") {
69
+ v = this.stringLiteral();
70
+ } else {
71
+ v = this.call();
72
+ }
73
+ // Property access: resourceGroup().location, subscription().subscriptionId
74
+ while (this.peek() === ".") {
75
+ this.i++;
76
+ const prop = this.ident();
77
+ v = (v as Record<string, unknown>)?.[prop];
78
+ }
79
+ return v;
80
+ }
81
+
82
+ private call(): unknown {
83
+ const name = this.ident();
84
+ this.ws();
85
+ const args: unknown[] = [];
86
+ if (this.peek() === "(") {
87
+ this.i++;
88
+ this.ws();
89
+ if (this.peek() !== ")") {
90
+ args.push(this.expr());
91
+ this.ws();
92
+ while (this.peek() === ",") {
93
+ this.i++;
94
+ args.push(this.expr());
95
+ this.ws();
96
+ }
97
+ }
98
+ if (this.peek() === ")") this.i++;
99
+ }
100
+ return this.applyFn(name, args);
101
+ }
102
+
103
+ private applyFn(name: string, args: unknown[]): unknown {
104
+ switch (name) {
105
+ case "concat":
106
+ return args.map(String).join("");
107
+ case "uniqueString":
108
+ return createHash("sha256").update(args.map(String).join("-")).digest("hex").slice(0, 13);
109
+ case "resourceGroup":
110
+ return { location: this.ctx.location, id: `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}`, name: this.ctx.resourceGroup };
111
+ case "subscription":
112
+ return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
113
+ default:
114
+ throw new Error(`unsupported ARM function: ${name}`);
115
+ }
116
+ }
117
+
118
+ private stringLiteral(): string {
119
+ this.i++; // opening '
120
+ let out = "";
121
+ while (this.i < this.src.length && this.src[this.i] !== "'") out += this.src[this.i++];
122
+ this.i++; // closing '
123
+ return out;
124
+ }
125
+
126
+ private ident(): string {
127
+ this.ws();
128
+ let out = "";
129
+ while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) out += this.src[this.i++];
130
+ return out;
131
+ }
132
+
133
+ private ws(): void {
134
+ while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
135
+ }
136
+
137
+ private peek(): string {
138
+ this.ws();
139
+ return this.src[this.i];
140
+ }
141
+ }
142
+
143
+ // ── HTTP + apply ──────────────────────────────────────────────────────────────
144
+
145
+ /** Injectable HTTP client — mirrors the GCP applier so tests avoid the network. */
146
+ export type AzHttp = (
147
+ method: string,
148
+ url: string,
149
+ body?: unknown,
150
+ signal?: AbortSignal,
151
+ ) => Promise<{ status: number; text: string }>;
152
+
153
+ const defaultHttp: AzHttp = async (method, url, body, signal) => {
154
+ const res = await fetch(url, {
155
+ method,
156
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
157
+ body: body === undefined ? undefined : JSON.stringify(body),
158
+ signal,
159
+ });
160
+ return { status: res.status, text: await res.text() };
161
+ };
162
+
163
+ /** The ARM resource-ID PUT URL for a resource under a resource group. Pure. */
164
+ export function armResourceUrl(resource: ArmResource, ctx: ArmContext, base: string): string {
165
+ const name = evalArmString(resource.name, ctx);
166
+ return `${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
167
+ }
168
+
169
+ /** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. Pure. */
170
+ export function armResourceBody(resource: ArmResource, ctx: ArmContext): Record<string, unknown> {
171
+ const body: Record<string, unknown> = {};
172
+ if (resource.location) body.location = evalArmString(resource.location, ctx);
173
+ if (resource.properties !== undefined) body.properties = evalArm(resource.properties, ctx);
174
+ if (resource.sku !== undefined) body.sku = evalArm(resource.sku, ctx);
175
+ if (resource.kind !== undefined) body.kind = resource.kind;
176
+ if (resource.tags !== undefined) body.tags = resource.tags;
177
+ return body;
178
+ }
179
+
180
+ export interface AzApplyArgs {
181
+ /** Path to a built ARM template (`deploymentTemplate.json`). */
182
+ templatePath: string;
183
+ /** Resource group to deploy into. */
184
+ resourceGroup: string;
185
+ /** Region for the resource group and `resourceGroup().location`. Default: `eastus`. */
186
+ location?: string;
187
+ /** ARM endpoint override (e.g. floci-az `http://localhost:4577`). Default: real Azure. */
188
+ endpoint?: string;
189
+ /** Subscription id. Default: floci-az's local subscription. */
190
+ subscriptionId?: string;
191
+ }
192
+
193
+ /**
194
+ * The native Azure applier — read a built ARM template and PUT each resource
195
+ * directly to the ARM resource-CRUD API, resolving ARM expressions first. This
196
+ * is the direct-apply path (the Azure twin of `gcpApply`): it targets floci-az's
197
+ * ARM resource endpoints — which `az deployment` cannot, since floci-az has no
198
+ * `Microsoft.Resources/deployments` provider — or real Azure by endpoint override.
199
+ * The resource group is ensured first. `http` is injectable for tests.
200
+ */
201
+ export async function azApply(
202
+ args: AzApplyArgs,
203
+ signal?: AbortSignal,
204
+ http: AzHttp = defaultHttp,
205
+ ): Promise<{ applied: Array<{ type: string; name: string }> }> {
206
+ const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
207
+ const ctx: ArmContext = {
208
+ subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
209
+ resourceGroup: args.resourceGroup,
210
+ location: args.location ?? "eastus",
211
+ };
212
+
213
+ // Ensure the resource group exists (ARM rejects resource PUTs without it).
214
+ await http(
215
+ "PUT",
216
+ `${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}?api-version=2021-04-01`,
217
+ { location: ctx.location },
218
+ signal,
219
+ );
220
+
221
+ const template = JSON.parse(readFileSync(args.templatePath, "utf8")) as { resources?: ArmResource[] };
222
+ const applied: Array<{ type: string; name: string }> = [];
223
+ for (const resource of template.resources ?? []) {
224
+ const name = evalArmString(resource.name, ctx);
225
+ safeHeartbeat({ step: "azApply", type: resource.type, name });
226
+ const res = await http("PUT", armResourceUrl(resource, ctx, base), armResourceBody(resource, ctx), signal);
227
+ if (res.status >= 300) {
228
+ throw new Error(`${resource.type} ${name} apply failed (${res.status}): ${res.text}`);
229
+ }
230
+ console.log(`applied: ${resource.type}/${name} (${base})`);
231
+ applied.push({ type: resource.type, name });
232
+ }
233
+ return { applied };
234
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { azGroupEnsureCommand, azGroupDeleteCommand } from "./azure";
3
+
4
+ describe("azGroupEnsureCommand (#707)", () => {
5
+ test("creates the resource group in the given location, quietly", () => {
6
+ expect(azGroupEnsureCommand("chant-rg", "eastus")).toBe(
7
+ "az group create --name chant-rg --location eastus --output none",
8
+ );
9
+ });
10
+
11
+ test("honors a non-default location", () => {
12
+ expect(azGroupEnsureCommand("rg", "westeurope")).toContain("--location westeurope");
13
+ });
14
+ });
15
+
16
+ describe("azGroupDeleteCommand (#707)", () => {
17
+ test("force-deletes the group without blocking", () => {
18
+ expect(azGroupDeleteCommand("chant-rg")).toBe(
19
+ "az group delete --name chant-rg --yes --no-wait",
20
+ );
21
+ });
22
+ });
@@ -0,0 +1,66 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ const DEFAULT_LOCATION = "eastus";
7
+
8
+ export interface AzGroupEnsureArgs {
9
+ /** Resource group name — the same value passed as `env` to a `arm` `nativeApply`. */
10
+ resourceGroup: string;
11
+ /** Azure region for the group. Default: `eastus`. */
12
+ location?: string;
13
+ }
14
+
15
+ export interface AzGroupDeleteArgs {
16
+ /** Resource group to delete. */
17
+ resourceGroup: string;
18
+ }
19
+
20
+ /**
21
+ * Build the `az group create` command. `az group create` is idempotent — a group
22
+ * that already exists is returned unchanged — so no existence check is needed.
23
+ * Pure — exported for testing.
24
+ */
25
+ export function azGroupEnsureCommand(resourceGroup: string, location: string): string {
26
+ return `az group create --name ${resourceGroup} --location ${location} --output none`;
27
+ }
28
+
29
+ /** Build the `az group delete` command (non-blocking). Pure — exported for testing. */
30
+ export function azGroupDeleteCommand(resourceGroup: string): string {
31
+ return `az group delete --name ${resourceGroup} --yes --no-wait`;
32
+ }
33
+
34
+ /**
35
+ * Ensure an Azure resource group exists before an ARM deployment.
36
+ *
37
+ * `az deployment group create` (the `arm` target of `nativeApply`) fails if its
38
+ * resource group is absent — unlike CloudFormation, which creates its own stack.
39
+ * Run this before the apply. Uses fastIdempotent profile — 5m timeout.
40
+ *
41
+ * This targets real Azure (via the logged-in `az` context). Local emulation
42
+ * against floci-az awaits an upstream `Microsoft.Resources/deployments` provider
43
+ * (see #705); when that lands, an endpoint/custom-cloud override slots in here.
44
+ */
45
+ export async function azGroupEnsure(args: AzGroupEnsureArgs, signal?: AbortSignal): Promise<void> {
46
+ const location = args.location ?? DEFAULT_LOCATION;
47
+ const { stdout, stderr } = await execAsync(
48
+ azGroupEnsureCommand(args.resourceGroup, location),
49
+ { signal },
50
+ );
51
+ if (stdout) console.log(stdout);
52
+ if (stderr) console.error(stderr);
53
+ }
54
+
55
+ /**
56
+ * Delete an Azure resource group and everything in it (non-blocking). A no-op
57
+ * success when the group is already gone. Uses fastIdempotent profile — 5m
58
+ * timeout (`--no-wait` returns immediately).
59
+ */
60
+ export async function azGroupDelete(args: AzGroupDeleteArgs, signal?: AbortSignal): Promise<void> {
61
+ try {
62
+ await execAsync(azGroupDeleteCommand(args.resourceGroup), { signal });
63
+ } catch {
64
+ // Group already absent — treat as success.
65
+ }
66
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Azure Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `azure` lexicon. Contributes the resource-group
4
+ * lifecycle (`azGroupEnsure`/`azGroupDelete`) that an ARM `nativeApply` needs.
5
+ */
6
+ export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure";
7
+ export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure";
8
+
9
+ export { azApply, evalArm, evalArmString, armResourceUrl, armResourceBody } from "./az-apply";
10
+ export type { AzApplyArgs, ArmContext, ArmResource, AzHttp } from "./az-apply";
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { ownershipEntries } from "@intentius/chant/ownership";
3
+ import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership";
4
+
5
+ describe("AZURE_TAG_OWNERSHIP_KEYS", () => {
6
+ test("uses Azure hyphen-form tag keys (no slash, which Azure forbids)", () => {
7
+ expect(AZURE_TAG_OWNERSHIP_KEYS).toEqual({
8
+ managedBy: "chant-managed-by",
9
+ stack: "chant-stack",
10
+ env: "chant-env",
11
+ });
12
+ expect(Object.values(AZURE_TAG_OWNERSHIP_KEYS).some((k) => k.includes("/"))).toBe(false);
13
+ });
14
+
15
+ test("stamps the hyphen keys via the core ownershipEntries helper", () => {
16
+ const e = ownershipEntries(AZURE_TAG_OWNERSHIP_KEYS, { stack: "billing", env: "stg" });
17
+ expect(e).toEqual({ "chant-managed-by": "chant", "chant-stack": "billing", "chant-env": "stg" });
18
+ });
19
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The azure lexicon's ownership tag convention. Azure tag keys forbid `/`, so
3
+ * chant's markers use a hyphenated `chant-<name>` form (distinct from the
4
+ * label-based convention core keeps for k8s/gcp, and from AWS's `chant:<name>`).
5
+ * Core owns the generic stamp/detect logic (@intentius/chant/ownership); this is
6
+ * the azure-specific key naming it stamps.
7
+ */
8
+
9
+ import type { ChannelKeys } from "@intentius/chant/ownership";
10
+
11
+ /** Azure tag keys for chant's ownership markers. */
12
+ export const AZURE_TAG_OWNERSHIP_KEYS: ChannelKeys = {
13
+ managedBy: "chant-managed-by",
14
+ stack: "chant-stack",
15
+ env: "chant-env",
16
+ };
package/src/plugin.ts CHANGED
@@ -6,6 +6,7 @@ import type { TypeScriptGenerator } from "@intentius/chant/import/generator";
6
6
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
7
7
  import type { McpToolContribution, McpResourceContribution } from "@intentius/chant/mcp/types";
8
8
  import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
9
+ import { azureAuditCatalog } from "./lint/audit-catalog";
9
10
  import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
10
11
  import { readFileSync, readdirSync, existsSync } from "fs";
11
12
  import { join, dirname } from "path";
@@ -27,6 +28,7 @@ import { azureHover } from "./lsp/hover";
27
28
  */
28
29
  export const azurePlugin: LexiconPlugin = {
29
30
  name: "azure",
31
+ auditCatalog: () => azureAuditCatalog,
30
32
  serializer: azureSerializer,
31
33
 
32
34
  lintRules(): LintRule[] {
package/src/serializer.ts CHANGED
@@ -14,6 +14,7 @@ import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
14
14
  import { isPropertyDeclarable } from "@intentius/chant/declarable";
15
15
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
16
16
  import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
17
+ import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership";
17
18
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
18
19
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
19
20
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
@@ -191,7 +192,7 @@ function serializeToTemplate(
191
192
  // first so user default tags (and explicit per-resource tags) win.
192
193
  const defaultTagEntries: TagEntry[] = [];
193
194
  if (ownership) {
194
- for (const [key, value] of Object.entries(ownershipEntries("azure-tag", ownership))) {
195
+ for (const [key, value] of Object.entries(ownershipEntries(AZURE_TAG_OWNERSHIP_KEYS, ownership))) {
195
196
  defaultTagEntries.push({ key, value });
196
197
  }
197
198
  }