@intentius/chant-lexicon-aws 0.124.43 → 0.124.44

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
@@ -66,6 +66,24 @@ describe("AWS exportResources mapping (#115)", () => {
66
66
  expect(ir.resources.map((r) => r.logicalId)).toEqual(["Mine"]);
67
67
  });
68
68
 
69
+ test("owned filter reads map tags, UserPoolTags and the Metadata marker (#4350)", () => {
70
+ const tpl = {
71
+ AWSTemplateFormatVersion: "2010-09-09",
72
+ Resources: {
73
+ Param: { Type: "AWS::SSM::Parameter", Properties: { Type: "String", Value: "v", Tags: { "chant:managed-by": "chant" } } },
74
+ Pool: { Type: "AWS::Cognito::UserPool", Properties: { UserPoolTags: { "chant:managed-by": "chant" } } },
75
+ Reservation: {
76
+ Type: "AWS::EC2::CapacityReservation",
77
+ Metadata: { "chant:ownership": { "chant:managed-by": "chant" } },
78
+ Properties: { InstanceType: "t3.micro" },
79
+ },
80
+ Theirs: { Type: "AWS::SSM::Parameter", Properties: { Type: "String", Value: "v", Tags: { team: "other" } } },
81
+ },
82
+ };
83
+ const ir = parseStackTemplate(tpl, undefined, true);
84
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["Param", "Pool", "Reservation"]);
85
+ });
86
+
69
87
  test("export IR feeds CFGenerator (templateGenerator) unchanged", () => {
70
88
  const ir = parseStackTemplate(liveTemplate);
71
89
  const files = new CFGenerator().generate(ir);
@@ -1,6 +1,7 @@
1
1
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
2
- import { hasOwnershipMarker, tagArrayToMap } from "@intentius/chant/ownership";
3
- import { AWS_TAG_OWNERSHIP_KEYS } from "../ownership";
2
+ import { hasOwnershipMarker } from "@intentius/chant/ownership";
3
+ import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "../ownership";
4
+ import { readResourceTags, readTagValue } from "../taggable";
4
5
  import { CFParser } from "./parser";
5
6
 
6
7
  /**
@@ -30,8 +31,11 @@ export function parseStackTemplate(
30
31
  if (selector?.type !== undefined && r.type !== selector.type) return false;
31
32
  if (selector?.name !== undefined && r.logicalId !== selector.name) return false;
32
33
  if (owned) {
33
- const tags = (r.properties as { Tags?: Array<{ Key?: string; Value?: unknown }> }).Tags;
34
- if (!hasOwnershipMarker(tagArrayToMap(tags), AWS_TAG_OWNERSHIP_KEYS)) return false;
34
+ // The marker sits in the type's own tag property, in either shape, or
35
+ // in the resource's Metadata for a type whose tags can't be stamped (#4350).
36
+ const tags = readResourceTags(r.type, r.properties as Record<string, unknown>);
37
+ const marker = readTagValue(r.metadata?.[OWNERSHIP_METADATA_KEY]);
38
+ if (!hasOwnershipMarker(tags, AWS_TAG_OWNERSHIP_KEYS) && !hasOwnershipMarker(marker, AWS_TAG_OWNERSHIP_KEYS)) return false;
35
39
  }
36
40
  return true;
37
41
  }),
@@ -91,6 +91,7 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
91
91
  WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
92
92
  WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
93
93
  WAW070: auditRule("WAW070", "merge-worthy", "guidance", "Ref or Fn::GetAtt names something the template doesn't declare", "Reference a resource or parameter in this template. A value from another lexicon's resource arrives as a parameter.", { category: "correctness" }),
94
+ WAW071: auditRule("WAW071", "merge-worthy", "guidance", "Tags are in the wrong property or the wrong shape for the resource type", "Write the tags in the property the type uses and in the shape it takes, which is a list of Key and Value pairs for most types and a map for some, such as UserPoolTags on AWS::Cognito::UserPool.", { category: "correctness" }),
94
95
  };
95
96
 
96
97
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
@@ -160,4 +160,8 @@ export const awsAuditLineage: Record<string, Lineage[]> = {
160
160
  { tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
161
161
  { tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
162
162
  ],
163
+ WAW071: [
164
+ { tool: "cfn-lint", rule: "E3002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E3002", relation: "overlaps" },
165
+ { tool: "cfn-lint", rule: "E3012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E3012", relation: "overlaps" },
166
+ ],
163
167
  };
@@ -62,6 +62,7 @@ import { waw067 } from "./waw067";
62
62
  import { waw068 } from "./waw068";
63
63
  import { waw069 } from "./waw069";
64
64
  import { waw070 } from "./waw070";
65
+ import { waw071 } from "./waw071";
65
66
 
66
67
  export const postSynthChecks: PostSynthCheck[] = [
67
68
  cor020,
@@ -126,4 +127,5 @@ export const postSynthChecks: PostSynthCheck[] = [
126
127
  waw068,
127
128
  waw069,
128
129
  waw070,
130
+ waw071,
129
131
  ];
@@ -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,50 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw071, checkTagShapes } from "./waw071";
4
+ import type { TagPlacement } from "../../taggable";
5
+
6
+ const placements = new Map<string, TagPlacement | null>([
7
+ ["AWS::S3::Bucket", { property: "Tags", shape: "list" }],
8
+ ["AWS::SSM::Parameter", { property: "Tags", shape: "map" }],
9
+ ["AWS::Cognito::UserPool", { property: "UserPoolTags", shape: "map" }],
10
+ ["AWS::EC2::CapacityReservation", null],
11
+ ]);
12
+
13
+ function check(resources: Record<string, unknown>) {
14
+ return checkTagShapes(createPostSynthContext({ aws: { Resources: resources } }), placements);
15
+ }
16
+
17
+ describe("WAW071: tags in the wrong property or shape", () => {
18
+ test("check metadata", () => {
19
+ expect(waw071.id).toBe("WAW071");
20
+ });
21
+
22
+ test("flags Tags on a user pool", () => {
23
+ const d = check({ Pool: { Type: "AWS::Cognito::UserPool", Properties: { Tags: [{ Key: "a", Value: "b" }] } } });
24
+ expect(d).toHaveLength(1);
25
+ expect(d[0].message).toContain("UserPoolTags");
26
+ expect(d[0].severity).toBe("error");
27
+ });
28
+
29
+ test("flags a list on a map type", () => {
30
+ const d = check({ P: { Type: "AWS::SSM::Parameter", Properties: { Tags: [{ Key: "a", Value: "b" }] } } });
31
+ expect(d).toHaveLength(1);
32
+ expect(d[0].message).toContain("map");
33
+ });
34
+
35
+ test("flags a map on a list type", () => {
36
+ const d = check({ B: { Type: "AWS::S3::Bucket", Properties: { Tags: { a: "b" } } } });
37
+ expect(d).toHaveLength(1);
38
+ });
39
+
40
+ test("passes the right shapes, intrinsics and unplaceable types", () => {
41
+ const d = check({
42
+ B: { Type: "AWS::S3::Bucket", Properties: { Tags: [{ Key: "a", Value: { Ref: "X" } }, { "Fn::If": ["C", { Key: "k", Value: "v" }, { Ref: "AWS::NoValue" }] }] } },
43
+ P: { Type: "AWS::SSM::Parameter", Properties: { Tags: { a: "b" } } },
44
+ Pool: { Type: "AWS::Cognito::UserPool", Properties: { UserPoolTags: { a: "b" } } },
45
+ Q: { Type: "AWS::SSM::Parameter", Properties: { Tags: { "Fn::If": ["C", { a: "b" }, {}] } } },
46
+ CR: { Type: "AWS::EC2::CapacityReservation", Properties: { Tags: [] } },
47
+ });
48
+ expect(d).toEqual([]);
49
+ });
50
+ });
@@ -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
+ };
@@ -0,0 +1,83 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { awsSerializer, mergeTags } from "./serializer";
3
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
+ import { loadTagPlacements } from "./taggable";
5
+
6
+ class MockResource implements Declarable {
7
+ readonly [DECLARABLE_MARKER] = true as const;
8
+ readonly lexicon = "aws";
9
+ constructor(readonly entityType: string, readonly props: Record<string, unknown> = {}) {}
10
+ }
11
+
12
+ const OWNERSHIP = { stack: "loom", env: "dev" };
13
+ const MARKER = { "chant:managed-by": "chant", "chant:stack": "loom", "chant:env": "dev" };
14
+
15
+ function build(type: string, props: Record<string, unknown> = {}) {
16
+ const entities = new Map<string, Declarable>([["R", new MockResource(type, props)]]);
17
+ const template = JSON.parse(awsSerializer.serialize(entities, [], { ownership: OWNERSHIP }) as string);
18
+ return template.Resources.R as { Properties?: Record<string, unknown>; Metadata?: Record<string, unknown> };
19
+ }
20
+
21
+ describe("tag placements from the generated lexicon (#4350)", () => {
22
+ test("reads property and shape per type", () => {
23
+ const p = loadTagPlacements();
24
+ expect(p.get("AWS::S3::Bucket")).toEqual({ property: "Tags", shape: "list" });
25
+ expect(p.get("AWS::SSM::Parameter")).toEqual({ property: "Tags", shape: "map" });
26
+ expect(p.get("AWS::BedrockAgentCore::Runtime")).toEqual({ property: "Tags", shape: "map" });
27
+ expect(p.get("AWS::BedrockAgentCore::Memory")).toEqual({ property: "Tags", shape: "map" });
28
+ expect(p.get("AWS::Cognito::UserPool")).toEqual({ property: "UserPoolTags", shape: "map" });
29
+ expect(p.get("AWS::EC2::CapacityReservation")).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("ownership stamping by tag shape (#4350)", () => {
34
+ test("list type gets a [{Key, Value}] list in Tags", () => {
35
+ const r = build("AWS::S3::Bucket", { BucketName: "b" });
36
+ expect(r.Properties!.Tags).toEqual(Object.entries(MARKER).map(([Key, Value]) => ({ Key, Value })));
37
+ });
38
+
39
+ test("map type gets a map in Tags", () => {
40
+ const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v" });
41
+ expect(r.Properties!.Tags).toEqual(MARKER);
42
+ });
43
+
44
+ test("Cognito user pool gets UserPoolTags, and no Tags", () => {
45
+ const r = build("AWS::Cognito::UserPool", { UserPoolName: "p", UserPoolTags: { component: "loom-cognito" } });
46
+ expect(r.Properties!.Tags).toBeUndefined();
47
+ expect(r.Properties!.UserPoolTags).toEqual({ ...MARKER, component: "loom-cognito" });
48
+ });
49
+
50
+ test("explicit map tags merge on a map type, explicit key wins", () => {
51
+ const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v", Tags: { team: "a", "chant:env": "override" } });
52
+ expect(r.Properties!.Tags).toEqual({ ...MARKER, team: "a", "chant:env": "override" });
53
+ });
54
+
55
+ test("explicit list tags on a map type are converted", () => {
56
+ const r = build("AWS::SSM::Parameter", { Type: "String", Value: "v", Tags: [{ Key: "team", Value: "a" }] });
57
+ expect(r.Properties!.Tags).toEqual({ ...MARKER, team: "a" });
58
+ });
59
+
60
+ test("explicit map tags on a list type are converted", () => {
61
+ const r = build("AWS::S3::Bucket", { Tags: { team: "a" } });
62
+ expect(r.Properties!.Tags).toContainEqual({ Key: "team", Value: "a" });
63
+ expect(r.Properties!.Tags).toContainEqual({ Key: "chant:managed-by", Value: "chant" });
64
+ });
65
+
66
+ test("a type it can't place gets the marker in its own Metadata, not a Tags property", () => {
67
+ const r = build("AWS::EC2::CapacityReservation", { InstanceType: "t3.micro" });
68
+ expect(r.Properties!.Tags).toBeUndefined();
69
+ expect(r.Metadata!["chant:ownership"]).toEqual(MARKER);
70
+ });
71
+ });
72
+
73
+ describe("mergeTags", () => {
74
+ const stamped = [{ Key: "chant:managed-by", Value: "chant" }];
75
+ test("an intrinsic explicit value can't be merged", () => {
76
+ expect(mergeTags({ "Fn::If": ["C", [], []] }, stamped, { property: "Tags", shape: "list" })).toBeUndefined();
77
+ expect(mergeTags({ Ref: "T" }, stamped, { property: "Tags", shape: "map" })).toBeUndefined();
78
+ });
79
+ test("no explicit value stamps alone", () => {
80
+ expect(mergeTags(undefined, stamped, { property: "Tags", shape: "map" })).toEqual({ "chant:managed-by": "chant" });
81
+ expect(mergeTags(undefined, stamped, { property: "Tags", shape: "list" })).toEqual(stamped);
82
+ });
83
+ });
package/src/serializer.ts CHANGED
@@ -26,7 +26,7 @@ import { isMapping } from "./mapping";
26
26
  import { isDefaultTags, type TagEntry } from "./default-tags";
27
27
  import { isTemplateTransform } from "./template-transform";
28
28
  import { isTemplateSection } from "./template-sections";
29
- import { loadTaggableResources } from "./taggable";
29
+ import { isTagIntrinsic, loadTagPlacements, readTagValue, type TagPlacement } from "./taggable";
30
30
 
31
31
  /**
32
32
  * Check if a declarable is a CoreParameter
@@ -292,6 +292,33 @@ function renderReceiptRows(
292
292
  return rows;
293
293
  }
294
294
 
295
+ /**
296
+ * Merge stamped default tags under a resource's explicit tags, in the type's
297
+ * tag shape (#4350). Explicit tags win on a key clash. Explicit tags of the
298
+ * other shape are converted, so a map written on a list type still merges.
299
+ * Returns undefined when the explicit value is an intrinsic, which can't be
300
+ * merged into.
301
+ */
302
+ export function mergeTags(
303
+ explicit: unknown,
304
+ stamped: Array<{ Key: string; Value: unknown }>,
305
+ placement: TagPlacement,
306
+ ): unknown {
307
+ if (explicit !== undefined && (isTagIntrinsic(explicit) || (typeof explicit !== "object" || explicit === null))) {
308
+ return undefined;
309
+ }
310
+ if (placement.shape === "map") {
311
+ const merged: Record<string, unknown> = {};
312
+ for (const t of stamped) merged[t.Key] = t.Value;
313
+ return { ...merged, ...readTagValue(explicit) };
314
+ }
315
+ const explicitList = Array.isArray(explicit)
316
+ ? (explicit as Array<{ Key?: unknown }>)
317
+ : Object.entries(readTagValue(explicit)).map(([Key, Value]) => ({ Key, Value }));
318
+ const explicitKeys = new Set(explicitList.map(t => t?.Key));
319
+ return [...stamped.filter(t => !explicitKeys.has(t.Key)), ...explicitList];
320
+ }
321
+
295
322
  /**
296
323
  * Serialize a set of entities into a CFTemplate object (without JSON.stringify).
297
324
  */
@@ -510,20 +537,30 @@ function serializeToTemplate(
510
537
  }
511
538
  }
512
539
 
513
- // Inject default tags into taggable resources
540
+ // Inject default tags into taggable resources, each through its own tag
541
+ // property and shape (#4350): `UserPoolTags` on a Cognito user pool, a map on
542
+ // an SSM parameter, a `[{Key, Value}]` list elsewhere.
514
543
  if (defaultTagEntries.length > 0) {
515
- const taggable = loadTaggableResources();
544
+ const placements = loadTagPlacements();
516
545
  for (const [, resource] of Object.entries(template.Resources)) {
517
- if (!taggable.has(resource.Type)) continue;
546
+ const placement = placements.get(resource.Type);
547
+ if (placement === undefined) continue;
518
548
  const resolved = defaultTagEntries.map(t => ({
519
549
  Key: t.Key,
520
550
  Value: toCFValue(t.Value, entityNames),
521
551
  }));
522
- const explicit = (resource.Properties?.Tags ?? []) as Array<{ Key: string }>;
523
- const explicitKeys = new Set(explicit.map(t => t.Key));
524
- const merged = [...resolved.filter(t => !explicitKeys.has(t.Key)), ...explicit];
525
- if (!resource.Properties) resource.Properties = {};
526
- resource.Properties.Tags = merged;
552
+ const stamped = placement ? mergeTags(resource.Properties?.[placement.property], resolved, placement) : undefined;
553
+ if (stamped !== undefined) {
554
+ if (!resource.Properties) resource.Properties = {};
555
+ resource.Properties[placement!.property] = stamped;
556
+ } else if (ownership) {
557
+ // Nowhere valid to write the tags, so the marker goes on the resource's
558
+ // own Metadata instead of an invalid property.
559
+ resource.Metadata = {
560
+ ...(resource.Metadata ?? {}),
561
+ [OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
562
+ };
563
+ }
527
564
  }
528
565
  }
529
566
 
@@ -4,6 +4,7 @@ import {
4
4
  cfnShortName,
5
5
  cfnServiceName,
6
6
  stripPointerPath,
7
+ tagPlacement,
7
8
  } from "./parse";
8
9
  import type { CFNSchema } from "./fetch";
9
10
 
@@ -496,3 +497,41 @@ describe("cfnServiceName", () => {
496
497
  expect(cfnServiceName("AWS::IAM::Role")).toBe("IAM");
497
498
  });
498
499
  });
500
+
501
+ describe("tagPlacement (#4350)", () => {
502
+ const base = { typeName: "AWS::Test::T", additionalProperties: false };
503
+ test("a Key/Value list in Tags", () => {
504
+ expect(tagPlacement({
505
+ ...base,
506
+ properties: { Tags: { type: "array", items: { $ref: "#/definitions/Tag" } } },
507
+ definitions: { Tag: { type: "object", properties: { Key: { type: "string" }, Value: { type: "string" } } } },
508
+ tagging: { taggable: true, tagProperty: "/properties/Tags" },
509
+ } as CFNSchema)).toEqual({ tagProperty: "Tags", tagShape: "list" });
510
+ });
511
+ test("a map under another property name", () => {
512
+ expect(tagPlacement({
513
+ ...base,
514
+ properties: { UserPoolTags: { type: "object", patternProperties: { ".*": { type: "string" } } } as never },
515
+ tagging: { taggable: true, tagProperty: "/properties/UserPoolTags" },
516
+ } as CFNSchema)).toEqual({ tagProperty: "UserPoolTags", tagShape: "map" });
517
+ });
518
+ test("Tags when tagProperty is absent", () => {
519
+ expect(tagPlacement({
520
+ ...base,
521
+ properties: { Tags: { type: "object" } },
522
+ tagging: { taggable: true },
523
+ } as CFNSchema)).toEqual({ tagProperty: "Tags", tagShape: "map" });
524
+ });
525
+ test("nothing for a path inside an array or a wrapper object", () => {
526
+ expect(tagPlacement({
527
+ ...base,
528
+ properties: { TagSpecifications: { type: "array" } },
529
+ tagging: { taggable: true, tagProperty: "/properties/TagSpecifications/*/Tags" },
530
+ } as CFNSchema)).toEqual({});
531
+ expect(tagPlacement({
532
+ ...base,
533
+ properties: { Tags: { type: "object", properties: { Items: { type: "array" } } } },
534
+ tagging: { taggable: true, tagProperty: "/properties/Tags" },
535
+ } as CFNSchema)).toEqual({});
536
+ });
537
+ });
package/src/spec/parse.ts CHANGED
@@ -79,7 +79,24 @@ export interface ParsedResource {
79
79
  inferredDeprecations: string[];
80
80
  conditionalCreateOnly: string[];
81
81
  replacementStrategy?: "delete_then_create" | "create_then_delete";
82
- tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
82
+ tagging?: ResourceTagging;
83
+ }
84
+
85
+ /** How a type's tags are written: a `[{Key, Value}]` list or a key-to-value map. */
86
+ export type TagShape = "list" | "map";
87
+
88
+ export interface ResourceTagging {
89
+ taggable: boolean;
90
+ tagOnCreate: boolean;
91
+ tagUpdatable: boolean;
92
+ /**
93
+ * The top-level property the type's tags live in (`Tags`, `UserPoolTags`, ...)
94
+ * and its shape, read from the schema's `tagging.tagProperty` (#4350). Both are
95
+ * absent when the tags sit somewhere the serializer can't stamp, such as
96
+ * a path inside an array (`TagSpecifications[].Tags`) or an `{Items: [...]}` wrapper.
97
+ */
98
+ tagProperty?: string;
99
+ tagShape?: TagShape;
83
100
  }
84
101
 
85
102
  export interface SchemaParseResult {
@@ -202,6 +219,7 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
202
219
  taggable: true,
203
220
  tagOnCreate: schema.tagging.tagOnCreate ?? false,
204
221
  tagUpdatable: schema.tagging.tagUpdatable ?? false,
222
+ ...tagPlacement(schema),
205
223
  };
206
224
  }
207
225
 
@@ -242,6 +260,70 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
242
260
 
243
261
  // --- Type resolution (delegated to core) ---
244
262
 
263
+ /**
264
+ * Where a taggable type's tags go and in which shape (#4350). The property is
265
+ * `tagging.tagProperty` when it names one top-level property, else `Tags` when
266
+ * the type has one. The shape is a list when the items carry `Key` and `Value`
267
+ * (directly or as one `oneOf` branch) and a map when the property is an object
268
+ * with no fixed properties. Anything else returns `{}`, so the type is skipped.
269
+ */
270
+ export function tagPlacement(schema: CFNSchema): { tagProperty?: string; tagShape?: TagShape } {
271
+ const pointer = schema.tagging?.tagProperty;
272
+ let name: string | undefined;
273
+ if (pointer !== undefined) {
274
+ const m = /^\/properties\/([^/]+)$/.exec(pointer);
275
+ if (!m) return {};
276
+ name = m[1];
277
+ } else if (schema.properties?.Tags) {
278
+ name = "Tags";
279
+ }
280
+ if (name === undefined) return {};
281
+ const prop = resolveLocalRef(schema.properties?.[name], schema);
282
+ if (!prop) return {};
283
+ const shape = tagShapeOf(prop, schema);
284
+ return shape ? { tagProperty: name, tagShape: shape } : {};
285
+ }
286
+
287
+ type LooseSchema = Record<string, unknown> & {
288
+ $ref?: string;
289
+ type?: string | string[];
290
+ items?: unknown;
291
+ properties?: Record<string, unknown>;
292
+ oneOf?: unknown[];
293
+ anyOf?: unknown[];
294
+ patternProperties?: unknown;
295
+ additionalProperties?: unknown;
296
+ };
297
+
298
+ function resolveLocalRef(value: unknown, schema: CFNSchema): LooseSchema | undefined {
299
+ let node = value as LooseSchema | undefined;
300
+ for (let i = 0; i < 8 && node && typeof node.$ref === "string"; i++) {
301
+ const def = refDefinitionName(node.$ref);
302
+ node = def ? (schema.definitions?.[def] as LooseSchema | undefined) : undefined;
303
+ }
304
+ return node && typeof node === "object" ? node : undefined;
305
+ }
306
+
307
+ function hasType(node: LooseSchema, t: string): boolean {
308
+ return node.type === t || (Array.isArray(node.type) && node.type.includes(t));
309
+ }
310
+
311
+ function isKeyValueItem(node: LooseSchema | undefined, schema: CFNSchema): boolean {
312
+ if (!node) return false;
313
+ if (node.properties && "Key" in node.properties && "Value" in node.properties) return true;
314
+ const branches = [...(node.oneOf ?? []), ...(node.anyOf ?? [])];
315
+ return branches.some((b) => isKeyValueItem(resolveLocalRef(b, schema), schema));
316
+ }
317
+
318
+ function tagShapeOf(prop: LooseSchema, schema: CFNSchema): TagShape | undefined {
319
+ if (hasType(prop, "array")) {
320
+ return isKeyValueItem(resolveLocalRef(prop.items, schema), schema) ? "list" : undefined;
321
+ }
322
+ const objectish = hasType(prop, "object") || prop.patternProperties !== undefined || prop.additionalProperties !== undefined;
323
+ if (objectish && (!prop.properties || Object.keys(prop.properties).length === 0)) return "map";
324
+ return undefined;
325
+ }
326
+
245
327
  function resolvePropertyType(prop: SchemaProperty | undefined, schema: CFNSchema): string {
246
328
  const shortName = cfnShortName(schema.typeName);
247
329
  return coreResolvePropertyType(