@intentius/chant-lexicon-aws 0.124.43 → 0.124.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/codegen/generate-lexicon.d.ts +2 -6
  2. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  3. package/dist/deep-observe.d.ts +6 -2
  4. package/dist/deep-observe.d.ts.map +1 -1
  5. package/dist/deep-observe.js +17 -7
  6. package/dist/import/live-export.d.ts.map +1 -1
  7. package/dist/import/live-export.js +8 -4
  8. package/dist/integrity.json +5 -4
  9. package/dist/lint/audit-catalog.d.ts.map +1 -1
  10. package/dist/lint/audit-catalog.js +1 -0
  11. package/dist/lint/audit-lineage.d.ts.map +1 -1
  12. package/dist/lint/audit-lineage.js +4 -0
  13. package/dist/lint/post-synth/index.d.ts.map +1 -1
  14. package/dist/lint/post-synth/index.js +2 -0
  15. package/dist/lint/post-synth/waw017.d.ts.map +1 -1
  16. package/dist/lint/post-synth/waw017.js +3 -2
  17. package/dist/lint/post-synth/waw071.d.ts +19 -0
  18. package/dist/lint/post-synth/waw071.d.ts.map +1 -0
  19. package/dist/lint/post-synth/waw071.js +76 -0
  20. package/dist/manifest.json +1 -1
  21. package/dist/meta.json +3618 -1206
  22. package/dist/okf/index.md +1 -0
  23. package/dist/okf/rules/WAW071.md +19 -0
  24. package/dist/okf/types/Key.md +1 -0
  25. package/dist/okf/types/Map.md +1 -0
  26. package/dist/okf/types/SsmParameter.md +4 -0
  27. package/dist/okf/types/Type.md +1 -0
  28. package/dist/okf/types/UserPool.md +1 -0
  29. package/dist/rules/waw017.ts +3 -2
  30. package/dist/rules/waw071.ts +85 -0
  31. package/dist/serializer.d.ts +12 -0
  32. package/dist/serializer.d.ts.map +1 -1
  33. package/dist/serializer.js +44 -10
  34. package/dist/spec/parse.d.ts +27 -5
  35. package/dist/spec/parse.d.ts.map +1 -1
  36. package/dist/spec/parse.js +56 -0
  37. package/dist/taggable.d.ts +28 -0
  38. package/dist/taggable.d.ts.map +1 -1
  39. package/dist/taggable.js +60 -9
  40. package/package.json +2 -2
  41. package/src/codegen/generate-lexicon.ts +2 -2
  42. package/src/deep-observe.test.ts +19 -0
  43. package/src/deep-observe.ts +17 -6
  44. package/src/generated/lexicon-aws.json +3618 -1206
  45. package/src/import/live-export.test.ts +18 -0
  46. package/src/import/live-export.ts +8 -4
  47. package/src/lint/audit-catalog.ts +1 -0
  48. package/src/lint/audit-lineage.ts +4 -0
  49. package/src/lint/post-synth/index.ts +2 -0
  50. package/src/lint/post-synth/waw017.ts +3 -2
  51. package/src/lint/post-synth/waw071.test.ts +50 -0
  52. package/src/lint/post-synth/waw071.ts +85 -0
  53. package/src/serializer-tag-shapes.test.ts +83 -0
  54. package/src/serializer.ts +46 -9
  55. package/src/spec/parse.test.ts +39 -0
  56. package/src/spec/parse.ts +83 -1
  57. package/src/taggable.ts +78 -11
package/dist/okf/index.md CHANGED
@@ -1937,3 +1937,4 @@ okf_version: '0.2'
1937
1937
  * [WAW068](/rules/WAW068.md) - VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity
1938
1938
  * [WAW069](/rules/WAW069.md) - Template references a condition it never declares — resource/output Condition keys, Fn::If, and Condition references must name an entry in the Conditions sec...
1939
1939
  * [WAW070](/rules/WAW070.md) - Ref or Fn::GetAtt names something the template doesn't declare
1940
+ * [WAW071](/rules/WAW071.md) - Tags are in the wrong property or the wrong shape for the resource type
@@ -0,0 +1,19 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: WAW071
4
+ description: Tags are in the wrong property or the wrong shape for the resource type
5
+ id: WAW071
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: aws
9
+ docs: https://intentius.io/chant/lexicons/aws/rules/
10
+ ---
11
+ Tags are in the wrong property or the wrong shape for the resource type
12
+
13
+ ## Applies to
14
+
15
+ - [Key](/types/Key.md)
16
+ - [Map](/types/Map.md)
17
+ - [SsmParameter](/types/SsmParameter.md)
18
+ - [Type](/types/Type.md)
19
+ - [UserPool](/types/UserPool.md)
@@ -34,3 +34,4 @@ resource_type: AWS::PaymentCryptography::Key
34
34
 
35
35
  - [WAW024](/rules/WAW024.md): Application Load Balancer does not have access logging enabled
36
36
  - [WAW043](/rules/WAW043.md): KMS customer-managed key does not have automatic key rotation enabled
37
+ - [WAW071](/rules/WAW071.md): Tags are in the wrong property or the wrong shape for the resource type
@@ -40,3 +40,4 @@ resource_type: AWS::Location::Map
40
40
  - [WAW066](/rules/WAW066.md): Private subnet's route table has no default route, or its default route targets a gateway that does not exist in the template
41
41
  - [WAW067](/rules/WAW067.md): A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress
42
42
  - [WAW068](/rules/WAW068.md): VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity
43
+ - [WAW071](/rules/WAW071.md): Tags are in the wrong property or the wrong shape for the resource type
@@ -24,3 +24,7 @@ resource_type: AWS::SSM::Parameter
24
24
  ## Attributes
25
25
 
26
26
  - `Arn`
27
+
28
+ ## Governed by
29
+
30
+ - [WAW071](/rules/WAW071.md): Tags are in the wrong property or the wrong shape for the resource type
@@ -83,3 +83,4 @@ resource_type: AWS::Cassandra::Type
83
83
  - [WAW066](/rules/WAW066.md): Private subnet's route table has no default route, or its default route targets a gateway that does not exist in the template
84
84
  - [WAW067](/rules/WAW067.md): A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress
85
85
  - [WAW068](/rules/WAW068.md): VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity
86
+ - [WAW071](/rules/WAW071.md): Tags are in the wrong property or the wrong shape for the resource type
@@ -59,3 +59,4 @@ resource_type: AWS::Cognito::UserPool
59
59
  - [WAW050](/rules/WAW050.md): Cognito UserPool does not have advanced security (risk-based auth) enabled
60
60
  - [WAW051](/rules/WAW051.md): Cognito UserPoolClient allows the deprecated implicit OAuth grant
61
61
  - [WAW052](/rules/WAW052.md): Cognito UserPool does not require MFA (full tier)
62
+ - [WAW071](/rules/WAW071.md): Tags are in the wrong property or the wrong shape for the resource type
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
9
  import { parseCFTemplate } from "./cf-refs";
10
- import { loadTaggableResources } from "../../taggable";
10
+ import { loadTaggableResources, tagPlacementFor } from "../../taggable";
11
11
 
12
12
  /**
13
13
  * Core detection logic — exported for direct testing with synthetic data.
@@ -28,7 +28,8 @@ export function checkMissingTags(
28
28
  if (!taggable.has(resource.Type)) continue;
29
29
 
30
30
  const props = resource.Properties ?? {};
31
- if (!("Tags" in props)) {
31
+ const tagProperty = tagPlacementFor(resource.Type)?.property ?? "Tags";
32
+ if (!(tagProperty in props)) {
32
33
  diagnostics.push({
33
34
  checkId: "WAW017",
34
35
  severity: "warning",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WAW071: Tags are in the wrong property or the wrong shape for the type
3
+ *
4
+ * Each taggable type names its tag property and shape in its schema
5
+ * (`tagging.tagProperty`): a `[{Key, Value}]` list for most types, a map for
6
+ * types such as `AWS::SSM::Parameter`, and a property other than `Tags` for
7
+ * types such as `AWS::Cognito::UserPool` (`UserPoolTags`). CloudFormation
8
+ * rejects a template that puts tags anywhere else. chant stamped the ownership
9
+ * marker as a `Tags` list on every type until #4350; this check catches the
10
+ * next such template.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { parseCFTemplate } from "./cf-refs";
15
+ import { isTagIntrinsic, loadTagPlacements, type TagPlacement } from "../../taggable";
16
+
17
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ /** Why `value` doesn't fit `placement`, or undefined when it does. */
22
+ function shapeProblem(value: unknown, placement: TagPlacement): string | undefined {
23
+ if (isTagIntrinsic(value)) return undefined;
24
+ if (placement.shape === "map") {
25
+ return isPlainObject(value) ? undefined : "a list, but the type takes a key-to-value map";
26
+ }
27
+ if (!Array.isArray(value)) return "a map, but the type takes a [{Key, Value}] list";
28
+ const bad = value.some((t) => !isTagIntrinsic(t) && !(isPlainObject(t) && "Key" in t && "Value" in t));
29
+ return bad ? "a list with an entry that has no Key and Value" : undefined;
30
+ }
31
+
32
+ /**
33
+ * Core detection logic — exported for direct testing with synthetic data.
34
+ */
35
+ export function checkTagShapes(
36
+ ctx: PostSynthContext,
37
+ placements: Map<string, TagPlacement | null>,
38
+ ): PostSynthDiagnostic[] {
39
+ const diagnostics: PostSynthDiagnostic[] = [];
40
+
41
+ for (const [lexicon, output] of ctx.outputs) {
42
+ if (lexicon !== "aws") continue;
43
+ const template = parseCFTemplate(output);
44
+ if (!template?.Resources) continue;
45
+
46
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
47
+ const placement = placements.get(resource.Type);
48
+ if (!placement) continue;
49
+ const props = resource.Properties ?? {};
50
+
51
+ if (placement.property !== "Tags" && "Tags" in props) {
52
+ diagnostics.push({
53
+ checkId: "WAW071",
54
+ severity: "error",
55
+ message: `Resource "${logicalId}" (${resource.Type}) has Tags, but the type takes its tags in ${placement.property}`,
56
+ entity: logicalId,
57
+ lexicon: "aws",
58
+ });
59
+ }
60
+
61
+ if (!(placement.property in props)) continue;
62
+ const problem = shapeProblem(props[placement.property], placement);
63
+ if (problem) {
64
+ diagnostics.push({
65
+ checkId: "WAW071",
66
+ severity: "error",
67
+ message: `Resource "${logicalId}" (${resource.Type}) has ${placement.property} as ${problem}`,
68
+ entity: logicalId,
69
+ lexicon: "aws",
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ return diagnostics;
76
+ }
77
+
78
+ export const waw071: PostSynthCheck = {
79
+ id: "WAW071",
80
+ description: "Tags are in the wrong property or the wrong shape for the resource type",
81
+
82
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
83
+ return checkTagShapes(ctx, loadTagPlacements());
84
+ },
85
+ };
@@ -1,4 +1,16 @@
1
1
  import type { Serializer } from "@intentius/chant/serializer";
2
+ import { type TagPlacement } from "./taggable.js";
3
+ /**
4
+ * Merge stamped default tags under a resource's explicit tags, in the type's
5
+ * tag shape (#4350). Explicit tags win on a key clash. Explicit tags of the
6
+ * other shape are converted, so a map written on a list type still merges.
7
+ * Returns undefined when the explicit value is an intrinsic, which can't be
8
+ * merged into.
9
+ */
10
+ export declare function mergeTags(explicit: unknown, stamped: Array<{
11
+ Key: string;
12
+ Value: unknown;
13
+ }>, placement: TagPlacement): unknown;
2
14
  /**
3
15
  * AWS CloudFormation serializer implementation
4
16
  */
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA0nBlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAgF3B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA0BlG,OAAO,EAAmD,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AA0QhG;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,OAAO,EACjB,OAAO,EAAE,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC,EAC/C,SAAS,EAAE,YAAY,GACtB,OAAO,CAcT;AAkWD;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAgF3B,CAAC"}
@@ -14,7 +14,7 @@ import { isMapping } from "./mapping.js";
14
14
  import { isDefaultTags } from "./default-tags.js";
15
15
  import { isTemplateTransform } from "./template-transform.js";
16
16
  import { isTemplateSection } from "./template-sections.js";
17
- import { loadTaggableResources } from "./taggable.js";
17
+ import { isTagIntrinsic, loadTagPlacements, readTagValue } from "./taggable.js";
18
18
  /**
19
19
  * Check if a declarable is a CoreParameter
20
20
  */
@@ -172,6 +172,29 @@ function renderReceiptRows(receipts, ownership) {
172
172
  }
173
173
  return rows;
174
174
  }
175
+ /**
176
+ * Merge stamped default tags under a resource's explicit tags, in the type's
177
+ * tag shape (#4350). Explicit tags win on a key clash. Explicit tags of the
178
+ * other shape are converted, so a map written on a list type still merges.
179
+ * Returns undefined when the explicit value is an intrinsic, which can't be
180
+ * merged into.
181
+ */
182
+ export function mergeTags(explicit, stamped, placement) {
183
+ if (explicit !== undefined && (isTagIntrinsic(explicit) || (typeof explicit !== "object" || explicit === null))) {
184
+ return undefined;
185
+ }
186
+ if (placement.shape === "map") {
187
+ const merged = {};
188
+ for (const t of stamped)
189
+ merged[t.Key] = t.Value;
190
+ return { ...merged, ...readTagValue(explicit) };
191
+ }
192
+ const explicitList = Array.isArray(explicit)
193
+ ? explicit
194
+ : Object.entries(readTagValue(explicit)).map(([Key, Value]) => ({ Key, Value }));
195
+ const explicitKeys = new Set(explicitList.map(t => t?.Key));
196
+ return [...stamped.filter(t => !explicitKeys.has(t.Key)), ...explicitList];
197
+ }
175
198
  /**
176
199
  * Serialize a set of entities into a CFTemplate object (without JSON.stringify).
177
200
  */
@@ -371,22 +394,33 @@ function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, o
371
394
  template.Resources[name] = resource;
372
395
  }
373
396
  }
374
- // Inject default tags into taggable resources
397
+ // Inject default tags into taggable resources, each through its own tag
398
+ // property and shape (#4350): `UserPoolTags` on a Cognito user pool, a map on
399
+ // an SSM parameter, a `[{Key, Value}]` list elsewhere.
375
400
  if (defaultTagEntries.length > 0) {
376
- const taggable = loadTaggableResources();
401
+ const placements = loadTagPlacements();
377
402
  for (const [, resource] of Object.entries(template.Resources)) {
378
- if (!taggable.has(resource.Type))
403
+ const placement = placements.get(resource.Type);
404
+ if (placement === undefined)
379
405
  continue;
380
406
  const resolved = defaultTagEntries.map(t => ({
381
407
  Key: t.Key,
382
408
  Value: toCFValue(t.Value, entityNames),
383
409
  }));
384
- const explicit = (resource.Properties?.Tags ?? []);
385
- const explicitKeys = new Set(explicit.map(t => t.Key));
386
- const merged = [...resolved.filter(t => !explicitKeys.has(t.Key)), ...explicit];
387
- if (!resource.Properties)
388
- resource.Properties = {};
389
- resource.Properties.Tags = merged;
410
+ const stamped = placement ? mergeTags(resource.Properties?.[placement.property], resolved, placement) : undefined;
411
+ if (stamped !== undefined) {
412
+ if (!resource.Properties)
413
+ resource.Properties = {};
414
+ resource.Properties[placement.property] = stamped;
415
+ }
416
+ else if (ownership) {
417
+ // Nowhere valid to write the tags, so the marker goes on the resource's
418
+ // own Metadata instead of an invalid property.
419
+ resource.Metadata = {
420
+ ...(resource.Metadata ?? {}),
421
+ [OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
422
+ };
423
+ }
390
424
  }
391
425
  }
392
426
  // Emit StackOutput entities as CF Outputs
@@ -46,11 +46,22 @@ export interface ParsedResource {
46
46
  inferredDeprecations: string[];
47
47
  conditionalCreateOnly: string[];
48
48
  replacementStrategy?: "delete_then_create" | "create_then_delete";
49
- tagging?: {
50
- taggable: boolean;
51
- tagOnCreate: boolean;
52
- tagUpdatable: boolean;
53
- };
49
+ tagging?: ResourceTagging;
50
+ }
51
+ /** How a type's tags are written: a `[{Key, Value}]` list or a key-to-value map. */
52
+ export type TagShape = "list" | "map";
53
+ export interface ResourceTagging {
54
+ taggable: boolean;
55
+ tagOnCreate: boolean;
56
+ tagUpdatable: boolean;
57
+ /**
58
+ * The top-level property the type's tags live in (`Tags`, `UserPoolTags`, ...)
59
+ * and its shape, read from the schema's `tagging.tagProperty` (#4350). Both are
60
+ * absent when the tags sit somewhere the serializer can't stamp, such as
61
+ * a path inside an array (`TagSpecifications[].Tags`) or an `{Items: [...]}` wrapper.
62
+ */
63
+ tagProperty?: string;
64
+ tagShape?: TagShape;
54
65
  }
55
66
  export interface SchemaParseResult {
56
67
  resource: ParsedResource;
@@ -61,6 +72,17 @@ export interface SchemaParseResult {
61
72
  * Parse a CloudFormation Registry JSON Schema into typed structures.
62
73
  */
63
74
  export declare function parseCFNSchema(data: string | Buffer): SchemaParseResult;
75
+ /**
76
+ * Where a taggable type's tags go and in which shape (#4350). The property is
77
+ * `tagging.tagProperty` when it names one top-level property, else `Tags` when
78
+ * the type has one. The shape is a list when the items carry `Key` and `Value`
79
+ * (directly or as one `oneOf` branch) and a map when the property is an object
80
+ * with no fixed properties. Anything else returns `{}`, so the type is skipped.
81
+ */
82
+ export declare function tagPlacement(schema: CFNSchema): {
83
+ tagProperty?: string;
84
+ tagShape?: TagShape;
85
+ };
64
86
  export declare const constraintsIsEmpty: typeof coreConstraintsIsEmpty;
65
87
  /**
66
88
  * Extract short resource name: "AWS::S3::Bucket" → "Bucket"
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAoC,MAAM,SAAS,CAAC;AAC3E,OAAO,EAGL,kBAAkB,IAAI,sBAAsB,EAE5C,KAAK,mBAAmB,EAIzB,MAAM,sCAAsC,CAAC;AAG9C,YAAY,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAkBhF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,mBAAmB,CAAC,EAAE,oBAAoB,GAAG,oBAAoB,CAAC;IAClE,OAAO,CAAC,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,OAAO,CAAC;QAAC,YAAY,EAAE,OAAO,CAAA;KAAE,CAAC;CAC9E;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,CAmJvE;AAiBD,eAAO,MAAM,kBAAkB,+BAAyB,CAAC;AAMzD;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvD;AAuDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,MAAM,CAYzE"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAoC,MAAM,SAAS,CAAC;AAC3E,OAAO,EAGL,kBAAkB,IAAI,sBAAsB,EAE5C,KAAK,mBAAmB,EAIzB,MAAM,sCAAsC,CAAC;AAG9C,YAAY,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAkBhF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,mBAAmB,CAAC,EAAE,oBAAoB,GAAG,oBAAoB,CAAC;IAClE,OAAO,CAAC,EAAE,eAAe,CAAC;CAC3B;AAED,oFAAoF;AACpF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAEtC,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,CAoJvE;AAID;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CAAE,CAe7F;AAuDD,eAAO,MAAM,kBAAkB,+BAAyB,CAAC;AAMzD;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvD;AAuDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,MAAM,CAYzE"}
@@ -122,6 +122,7 @@ export function parseCFNSchema(data) {
122
122
  taggable: true,
123
123
  tagOnCreate: schema.tagging.tagOnCreate ?? false,
124
124
  tagUpdatable: schema.tagging.tagUpdatable ?? false,
125
+ ...tagPlacement(schema),
125
126
  };
126
127
  }
127
128
  // --- Replacement strategy ---
@@ -151,6 +152,61 @@ export function parseCFNSchema(data) {
151
152
  };
152
153
  }
153
154
  // --- Type resolution (delegated to core) ---
155
+ /**
156
+ * Where a taggable type's tags go and in which shape (#4350). The property is
157
+ * `tagging.tagProperty` when it names one top-level property, else `Tags` when
158
+ * the type has one. The shape is a list when the items carry `Key` and `Value`
159
+ * (directly or as one `oneOf` branch) and a map when the property is an object
160
+ * with no fixed properties. Anything else returns `{}`, so the type is skipped.
161
+ */
162
+ export function tagPlacement(schema) {
163
+ const pointer = schema.tagging?.tagProperty;
164
+ let name;
165
+ if (pointer !== undefined) {
166
+ const m = /^\/properties\/([^/]+)$/.exec(pointer);
167
+ if (!m)
168
+ return {};
169
+ name = m[1];
170
+ }
171
+ else if (schema.properties?.Tags) {
172
+ name = "Tags";
173
+ }
174
+ if (name === undefined)
175
+ return {};
176
+ const prop = resolveLocalRef(schema.properties?.[name], schema);
177
+ if (!prop)
178
+ return {};
179
+ const shape = tagShapeOf(prop, schema);
180
+ return shape ? { tagProperty: name, tagShape: shape } : {};
181
+ }
182
+ function resolveLocalRef(value, schema) {
183
+ let node = value;
184
+ for (let i = 0; i < 8 && node && typeof node.$ref === "string"; i++) {
185
+ const def = refDefinitionName(node.$ref);
186
+ node = def ? schema.definitions?.[def] : undefined;
187
+ }
188
+ return node && typeof node === "object" ? node : undefined;
189
+ }
190
+ function hasType(node, t) {
191
+ return node.type === t || (Array.isArray(node.type) && node.type.includes(t));
192
+ }
193
+ function isKeyValueItem(node, schema) {
194
+ if (!node)
195
+ return false;
196
+ if (node.properties && "Key" in node.properties && "Value" in node.properties)
197
+ return true;
198
+ const branches = [...(node.oneOf ?? []), ...(node.anyOf ?? [])];
199
+ return branches.some((b) => isKeyValueItem(resolveLocalRef(b, schema), schema));
200
+ }
201
+ function tagShapeOf(prop, schema) {
202
+ if (hasType(prop, "array")) {
203
+ return isKeyValueItem(resolveLocalRef(prop.items, schema), schema) ? "list" : undefined;
204
+ }
205
+ const objectish = hasType(prop, "object") || prop.patternProperties !== undefined || prop.additionalProperties !== undefined;
206
+ if (objectish && (!prop.properties || Object.keys(prop.properties).length === 0))
207
+ return "map";
208
+ return undefined;
209
+ }
154
210
  function resolvePropertyType(prop, schema) {
155
211
  const shortName = cfnShortName(schema.typeName);
156
212
  return coreResolvePropertyType(prop, schema, (defName) => `${shortName}_${defName}`);
@@ -3,9 +3,37 @@
3
3
  *
4
4
  * Lazy-loaded and cached for the lifetime of the process.
5
5
  */
6
+ /** How a type's tags are written: a `[{Key, Value}]` list or a key-to-value map. */
7
+ export type TagShape = "list" | "map";
8
+ /** The property a type's tags live in, and its shape (#4350). */
9
+ export interface TagPlacement {
10
+ property: string;
11
+ shape: TagShape;
12
+ }
13
+ /**
14
+ * Every taggable type, mapped to where its tags go. `null` means the schema
15
+ * puts the tags somewhere chant can't stamp (inside an array, or under a
16
+ * wrapper object), so the type gets no stamped tags.
17
+ */
18
+ export declare function loadTagPlacements(): Map<string, TagPlacement | null>;
6
19
  /**
7
20
  * Load taggable resource types from the lexicon JSON.
8
21
  * Result is cached after first call.
9
22
  */
10
23
  export declare function loadTaggableResources(): Set<string>;
24
+ /** Where `type`'s tags go, or undefined when it isn't taggable or can't be placed. */
25
+ export declare function tagPlacementFor(type: string): TagPlacement | undefined;
26
+ /** True for a CloudFormation intrinsic (`Ref`, `Fn::*`), which can't be merged into. */
27
+ export declare function isTagIntrinsic(value: unknown): boolean;
28
+ /**
29
+ * Read a tag value of either shape into a key-to-value map: a `[{Key, Value}]`
30
+ * list or a plain map. An intrinsic or anything else reads as `{}`.
31
+ */
32
+ export declare function readTagValue(value: unknown): Record<string, unknown>;
33
+ /**
34
+ * The tags on a resource's property tree, read through the type's own tag
35
+ * property (`UserPoolTags` on a Cognito user pool), falling back to `Tags`
36
+ * when the type is unknown. Either shape reads (#4350).
37
+ */
38
+ export declare function readResourceTags(type: string | undefined, properties: Record<string, unknown> | undefined): Record<string, unknown>;
11
39
  //# sourceMappingURL=taggable.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"taggable.d.ts","sourceRoot":"","sources":["../src/taggable.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAeH;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,GAAG,CAAC,MAAM,CAAC,CAoBnD"}
1
+ {"version":3,"file":"taggable.d.ts","sourceRoot":"","sources":["../src/taggable.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,oFAAoF;AACpF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAEtC,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,QAAQ,CAAC;CACjB;AAiBD;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC,CAoBpE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,GAAG,CAAC,MAAM,CAAC,CAEnD;AAED,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEtE;AAMD,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAItD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUpE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAInI"}
package/dist/taggable.js CHANGED
@@ -8,26 +8,77 @@ import { join } from "path";
8
8
  import { moduleSourceDir } from "@intentius/chant/module-source";
9
9
  let _cached;
10
10
  /**
11
- * Load taggable resource types from the lexicon JSON.
12
- * Result is cached after first call.
11
+ * Every taggable type, mapped to where its tags go. `null` means the schema
12
+ * puts the tags somewhere chant can't stamp (inside an array, or under a
13
+ * wrapper object), so the type gets no stamped tags.
13
14
  */
14
- export function loadTaggableResources() {
15
+ export function loadTagPlacements() {
15
16
  if (_cached)
16
17
  return _cached;
17
- const set = new Set();
18
+ const map = new Map();
18
19
  try {
19
20
  const lexiconPath = join(moduleSourceDir(import.meta.url), "generated", "lexicon-aws.json");
20
21
  const content = readFileSync(lexiconPath, "utf-8");
21
22
  const data = JSON.parse(content);
22
23
  for (const [_name, entry] of Object.entries(data)) {
23
- if (entry.kind === "resource" && entry.resourceType && entry.tagging?.taggable) {
24
- set.add(entry.resourceType);
25
- }
24
+ if (entry.kind !== "resource" || !entry.resourceType || !entry.tagging?.taggable)
25
+ continue;
26
+ const { tagProperty, tagShape } = entry.tagging;
27
+ map.set(entry.resourceType, tagProperty && tagShape ? { property: tagProperty, shape: tagShape } : null);
26
28
  }
27
29
  }
28
30
  catch {
29
31
  // Lexicon not available — skip
30
32
  }
31
- _cached = set;
32
- return set;
33
+ _cached = map;
34
+ return map;
35
+ }
36
+ /**
37
+ * Load taggable resource types from the lexicon JSON.
38
+ * Result is cached after first call.
39
+ */
40
+ export function loadTaggableResources() {
41
+ return new Set(loadTagPlacements().keys());
42
+ }
43
+ /** Where `type`'s tags go, or undefined when it isn't taggable or can't be placed. */
44
+ export function tagPlacementFor(type) {
45
+ return loadTagPlacements().get(type) ?? undefined;
46
+ }
47
+ function isPlainObject(value) {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+ /** True for a CloudFormation intrinsic (`Ref`, `Fn::*`), which can't be merged into. */
51
+ export function isTagIntrinsic(value) {
52
+ if (!isPlainObject(value))
53
+ return false;
54
+ const keys = Object.keys(value);
55
+ return keys.length === 1 && (keys[0] === "Ref" || keys[0].startsWith("Fn::"));
56
+ }
57
+ /**
58
+ * Read a tag value of either shape into a key-to-value map: a `[{Key, Value}]`
59
+ * list or a plain map. An intrinsic or anything else reads as `{}`.
60
+ */
61
+ export function readTagValue(value) {
62
+ const out = {};
63
+ if (Array.isArray(value)) {
64
+ for (const t of value) {
65
+ if (isPlainObject(t) && typeof t.Key === "string")
66
+ out[t.Key] = t.Value;
67
+ }
68
+ return out;
69
+ }
70
+ if (isPlainObject(value) && !isTagIntrinsic(value))
71
+ return { ...value };
72
+ return out;
73
+ }
74
+ /**
75
+ * The tags on a resource's property tree, read through the type's own tag
76
+ * property (`UserPoolTags` on a Cognito user pool), falling back to `Tags`
77
+ * when the type is unknown. Either shape reads (#4350).
78
+ */
79
+ export function readResourceTags(type, properties) {
80
+ if (!properties)
81
+ return {};
82
+ const placement = type ? tagPlacementFor(type) : undefined;
83
+ return readTagValue(properties[placement?.property ?? "Tags"]);
33
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.124.43",
3
+ "version": "0.124.45",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -80,6 +80,6 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "peerDependencies": {
83
- "@intentius/chant": "^0.124.43"
83
+ "@intentius/chant": "^0.124.45"
84
84
  }
85
85
  }
@@ -4,7 +4,7 @@
4
4
  * Output is a Record<string, LexiconEntry> keyed by generated class name.
5
5
  */
6
6
 
7
- import type { SchemaParseResult } from "../spec/parse";
7
+ import type { ResourceTagging, SchemaParseResult } from "../spec/parse";
8
8
  import { cfnShortName } from "../spec/parse";
9
9
  import type { NamingStrategy } from "./naming";
10
10
  import type { ExtensionConstraint } from "./extensions";
@@ -33,7 +33,7 @@ export interface LexiconEntry {
33
33
  inferredDeprecations?: string[];
34
34
  conditionalCreateOnly?: string[];
35
35
  replacementStrategy?: "delete_then_create" | "create_then_delete";
36
- tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
36
+ tagging?: ResourceTagging;
37
37
  runtimeDeprecations?: Record<string, string>;
38
38
  }
39
39
 
@@ -353,6 +353,25 @@ describe("hasOwnershipMarker", () => {
353
353
  expect(hasOwnershipMarker({ Tags: [{ Key: "env", Value: "prod" }] })).toBe(false);
354
354
  expect(hasOwnershipMarker({})).toBe(false);
355
355
  });
356
+
357
+ test("reads a map, and the type's own tag property (#4350)", () => {
358
+ expect(hasOwnershipMarker({ Tags: { "chant:managed-by": "chant" } }, "AWS::SSM::Parameter")).toBe(true);
359
+ expect(hasOwnershipMarker({ UserPoolTags: { "chant:managed-by": "chant" } }, "AWS::Cognito::UserPool")).toBe(true);
360
+ expect(hasOwnershipMarker({ Tags: { "chant:managed-by": "chant" } }, "AWS::Cognito::UserPool")).toBe(false);
361
+ });
362
+
363
+ test("prunes the live map entry of the marker on a map-tagged type (#4350)", () => {
364
+ const out = normalizeDeepProperties(
365
+ { UserPoolTags: { "chant:managed-by": "chant", team: "a" } },
366
+ {
367
+ entityType: "AWS::Cognito::UserPool",
368
+ side: "live",
369
+ hooks: awsDeepNormalizationHooks,
370
+ counterpartPaths: new Set<string>(),
371
+ },
372
+ );
373
+ expect(out).toEqual({ UserPoolTags: { team: "a" } });
374
+ });
356
375
  });
357
376
 
358
377
  describe("observeResourcesDeepAws", () => {
@@ -63,6 +63,7 @@ import {
63
63
  } from "./api/read-client";
64
64
  import { declaredIdentifier } from "./identity-observe";
65
65
  import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
66
+ import { readResourceTags, tagPlacementFor } from "./taggable";
66
67
  import {
67
68
  AWS_DEEP_BLIND_SPOTS,
68
69
  EC2_TOPOLOGY_GENERATED_NAMES,
@@ -409,6 +410,8 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
409
410
  // drift. Counterpart-gated, so a template that declares the tag itself is
410
411
  // still compared against what is live.
411
412
  if (isOwnershipTag(node.value)) return true;
413
+ // The same marker as a map entry, on a map-tagged type (#4350).
414
+ if (OWNERSHIP_TAG_KEYS.has(node.key) && isMapTagEntry(node.entityType, node.pattern, node.key)) return true;
412
415
 
413
416
  // A name the service generated because source did not supply one.
414
417
  if (AWS_GENERATED_NAMES[node.entityType]?.has(node.pattern)) return true;
@@ -533,11 +536,19 @@ function isOwnershipTag(value: unknown): boolean {
533
536
  return isRecord(value) && typeof value.Key === "string" && OWNERSHIP_TAG_KEYS.has(value.Key);
534
537
  }
535
538
 
536
- /** True when the live property tree carries chant's ownership marker tag. */
537
- export function hasOwnershipMarker(properties: Record<string, unknown>): boolean {
538
- const tags = properties.Tags;
539
- if (!Array.isArray(tags)) return false;
540
- return tags.some((t) => isRecord(t) && t.Key === AWS_TAG_OWNERSHIP_KEYS.managedBy);
539
+ /** True when `pattern` is `<tagProperty>.<key>` on a type whose tags are a map. */
540
+ function isMapTagEntry(type: string, pattern: string, key: string): boolean {
541
+ const placement = tagPlacementFor(type);
542
+ return placement?.shape === "map" && pattern === `${placement.property}.${key}`;
543
+ }
544
+
545
+ /**
546
+ * True when the live property tree carries chant's ownership marker tag. With
547
+ * a `type`, the tags are read from that type's own tag property
548
+ * (`UserPoolTags`); either way a list or a map reads (#4350).
549
+ */
550
+ export function hasOwnershipMarker(properties: Record<string, unknown>, type?: string): boolean {
551
+ return readResourceTags(type, properties)[AWS_TAG_OWNERSHIP_KEYS.managedBy] !== undefined;
541
552
  }
542
553
 
543
554
  export interface AwsDeepObserveOptions {
@@ -680,7 +691,7 @@ export async function observeResourcesDeepAws(
680
691
  // thin path this one can answer the ownership question (#1015's open note).
681
692
  // A resource withheld by the filter is `filtered`, never absent: it exists,
682
693
  // it just isn't chant's.
683
- const owned = hasOwnershipMarker(properties);
694
+ const owned = hasOwnershipMarker(properties, type);
684
695
  if (options.owned && !owned) {
685
696
  unobserved[entityName] = {
686
697
  type,