@intentius/chant-lexicon-aws 0.0.8 → 0.0.9

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 (115) hide show
  1. package/dist/integrity.json +25 -10
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +5743 -896
  4. package/dist/rules/cf-refs.ts +99 -0
  5. package/dist/rules/ext001.ts +30 -21
  6. package/dist/rules/hardcoded-region.ts +1 -0
  7. package/dist/rules/iam-wildcard.ts +1 -0
  8. package/dist/rules/s3-encryption.ts +1 -0
  9. package/dist/rules/waw016.ts +86 -0
  10. package/dist/rules/waw017.ts +53 -0
  11. package/dist/rules/waw018.ts +71 -0
  12. package/dist/rules/waw019.ts +82 -0
  13. package/dist/rules/waw020.ts +64 -0
  14. package/dist/rules/waw021.ts +53 -0
  15. package/dist/rules/waw022.ts +43 -0
  16. package/dist/rules/waw023.ts +47 -0
  17. package/dist/rules/waw024.ts +54 -0
  18. package/dist/rules/waw025.ts +43 -0
  19. package/dist/rules/waw026.ts +46 -0
  20. package/dist/rules/waw027.ts +50 -0
  21. package/dist/rules/waw028.ts +47 -0
  22. package/dist/rules/waw029.ts +62 -0
  23. package/dist/rules/waw030.ts +246 -0
  24. package/dist/skills/chant-aws.md +388 -30
  25. package/dist/types/index.d.ts +1552 -1528
  26. package/package.json +2 -2
  27. package/src/actions/actions.test.ts +75 -0
  28. package/src/actions/dynamodb.ts +36 -0
  29. package/src/actions/ecr.ts +9 -0
  30. package/src/actions/ecs.ts +5 -0
  31. package/src/actions/iam.ts +3 -0
  32. package/src/actions/index.ts +9 -0
  33. package/src/actions/lambda.ts +11 -0
  34. package/src/actions/logs.ts +4 -0
  35. package/src/actions/s3.ts +34 -0
  36. package/src/actions/sns.ts +5 -0
  37. package/src/actions/sqs.ts +15 -0
  38. package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
  39. package/src/codegen/docs-links.test.ts +143 -0
  40. package/src/codegen/docs.ts +247 -132
  41. package/src/codegen/generate-lexicon.ts +8 -0
  42. package/src/codegen/generate-typescript.ts +25 -1
  43. package/src/composites/composites.test.ts +442 -0
  44. package/src/composites/fargate-alb.ts +253 -0
  45. package/src/composites/index.ts +20 -0
  46. package/src/composites/lambda-api.ts +20 -0
  47. package/src/composites/lambda-dynamodb.ts +64 -0
  48. package/src/composites/lambda-eventbridge.ts +36 -0
  49. package/src/composites/lambda-function.ts +76 -0
  50. package/src/composites/lambda-s3.ts +72 -0
  51. package/src/composites/lambda-sns.ts +30 -0
  52. package/src/composites/lambda-sqs.ts +44 -0
  53. package/src/composites/scheduled-lambda.ts +37 -0
  54. package/src/composites/vpc-default.ts +148 -0
  55. package/src/default-tags.test.ts +38 -0
  56. package/src/default-tags.ts +77 -0
  57. package/src/generated/index.d.ts +1552 -1528
  58. package/src/generated/lexicon-aws.json +5743 -896
  59. package/src/import/roundtrip-fixtures.test.ts +1 -1
  60. package/src/index.ts +21 -0
  61. package/src/integration.test.ts +71 -0
  62. package/src/intrinsics.ts +24 -13
  63. package/src/lint/post-synth/cf-refs.ts +99 -0
  64. package/src/lint/post-synth/ext001.test.ts +214 -31
  65. package/src/lint/post-synth/ext001.ts +30 -21
  66. package/src/lint/post-synth/waw013.test.ts +120 -0
  67. package/src/lint/post-synth/waw014.test.ts +121 -0
  68. package/src/lint/post-synth/waw015.test.ts +147 -0
  69. package/src/lint/post-synth/waw016.test.ts +141 -0
  70. package/src/lint/post-synth/waw016.ts +86 -0
  71. package/src/lint/post-synth/waw017.test.ts +130 -0
  72. package/src/lint/post-synth/waw017.ts +53 -0
  73. package/src/lint/post-synth/waw018.test.ts +109 -0
  74. package/src/lint/post-synth/waw018.ts +71 -0
  75. package/src/lint/post-synth/waw019.test.ts +138 -0
  76. package/src/lint/post-synth/waw019.ts +82 -0
  77. package/src/lint/post-synth/waw020.test.ts +125 -0
  78. package/src/lint/post-synth/waw020.ts +64 -0
  79. package/src/lint/post-synth/waw021.test.ts +81 -0
  80. package/src/lint/post-synth/waw021.ts +53 -0
  81. package/src/lint/post-synth/waw022.test.ts +54 -0
  82. package/src/lint/post-synth/waw022.ts +43 -0
  83. package/src/lint/post-synth/waw023.test.ts +53 -0
  84. package/src/lint/post-synth/waw023.ts +47 -0
  85. package/src/lint/post-synth/waw024.test.ts +64 -0
  86. package/src/lint/post-synth/waw024.ts +54 -0
  87. package/src/lint/post-synth/waw025.test.ts +42 -0
  88. package/src/lint/post-synth/waw025.ts +43 -0
  89. package/src/lint/post-synth/waw026.test.ts +54 -0
  90. package/src/lint/post-synth/waw026.ts +46 -0
  91. package/src/lint/post-synth/waw027.test.ts +63 -0
  92. package/src/lint/post-synth/waw027.ts +50 -0
  93. package/src/lint/post-synth/waw028.test.ts +68 -0
  94. package/src/lint/post-synth/waw028.ts +47 -0
  95. package/src/lint/post-synth/waw029.test.ts +179 -0
  96. package/src/lint/post-synth/waw029.ts +62 -0
  97. package/src/lint/post-synth/waw030.test.ts +800 -0
  98. package/src/lint/post-synth/waw030.ts +246 -0
  99. package/src/lint/rules/hardcoded-region.ts +1 -0
  100. package/src/lint/rules/iam-wildcard.ts +1 -0
  101. package/src/lint/rules/s3-encryption.ts +1 -0
  102. package/src/lsp/hover.ts +15 -0
  103. package/src/nested-stack-integration.test.ts +100 -0
  104. package/src/nested-stack.ts +1 -1
  105. package/src/plugin.ts +468 -36
  106. package/src/serializer.test.ts +330 -2
  107. package/src/serializer.ts +62 -1
  108. package/src/spec/fetch.ts +10 -0
  109. package/src/spec/parse.test.ts +141 -0
  110. package/src/spec/parse.ts +40 -0
  111. package/src/taggable.ts +44 -0
  112. package/src/testdata/nested-stacks/app.ts +26 -0
  113. package/src/testdata/nested-stacks/network/outputs.ts +17 -0
  114. package/src/testdata/nested-stacks/network/security.ts +17 -0
  115. package/src/testdata/nested-stacks/network/vpc.ts +54 -0
@@ -11,6 +11,7 @@ import { createResource } from "@intentius/chant/runtime";
11
11
  import type { SerializerResult } from "@intentius/chant/serializer";
12
12
  import type { BuildResult } from "@intentius/chant/build";
13
13
  import { Parameter } from "./parameter";
14
+ import { defaultTags } from "./default-tags";
14
15
 
15
16
  // Mock S3 Bucket for testing
16
17
  class MockBucket implements Declarable {
@@ -18,9 +19,9 @@ class MockBucket implements Declarable {
18
19
  readonly lexicon = "aws";
19
20
  readonly entityType = "AWS::S3::Bucket";
20
21
  readonly arn: AttrRef;
21
- readonly props: { BucketName?: string; VersioningConfiguration?: { Status: string } };
22
+ readonly props: Record<string, unknown>;
22
23
 
23
- constructor(props: { BucketName?: string; VersioningConfiguration?: { Status: string } } = {}) {
24
+ constructor(props: { BucketName?: string; VersioningConfiguration?: { Status: string }; Tags?: unknown[] } = {}) {
24
25
  this.props = props;
25
26
  this.arn = new AttrRef(this, "Arn");
26
27
  }
@@ -489,3 +490,330 @@ describe("nested stack serialization", () => {
489
490
  });
490
491
  });
491
492
  });
493
+
494
+ // ── Resource-Level CF Attributes ──────────────────────────
495
+
496
+ // Mock resource that supports the second constructor `attributes` argument
497
+ class MockResourceWithAttrs implements Declarable {
498
+ readonly [DECLARABLE_MARKER] = true as const;
499
+ readonly lexicon = "aws";
500
+ readonly entityType: string;
501
+ readonly props: Record<string, unknown>;
502
+ readonly attributes: Record<string, unknown>;
503
+
504
+ constructor(
505
+ type: string,
506
+ props: Record<string, unknown>,
507
+ attributes: Record<string, unknown> = {},
508
+ ) {
509
+ this.entityType = type;
510
+ this.props = props;
511
+ Object.defineProperty(this, "attributes", { value: attributes, enumerable: false, configurable: true });
512
+ }
513
+ }
514
+
515
+ describe("resource-level CF attributes", () => {
516
+ function serialize(...entries: [string, Declarable][]) {
517
+ const entities = new Map<string, Declarable>(entries);
518
+ const output = awsSerializer.serialize(entities);
519
+ return JSON.parse(output as string);
520
+ }
521
+
522
+ test("DependsOn with string logical name", () => {
523
+ const res = new MockResourceWithAttrs(
524
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" }, { DependsOn: "Database" },
525
+ );
526
+ const template = serialize(["Server", res]);
527
+ expect(template.Resources.Server.DependsOn).toBe("Database");
528
+ });
529
+
530
+ test("DependsOn with Declarable reference resolves to logical name", () => {
531
+ const bucket = new MockBucket({ BucketName: "data" });
532
+ const fn = new MockResourceWithAttrs(
533
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: bucket },
534
+ );
535
+ const template = serialize(["DataBucket", bucket], ["Handler", fn]);
536
+ expect(template.Resources.Handler.DependsOn).toBe("DataBucket");
537
+ });
538
+
539
+ test("DependsOn with array of mixed strings and Declarables", () => {
540
+ const bucket = new MockBucket({ BucketName: "data" });
541
+ const fn = new MockResourceWithAttrs(
542
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" },
543
+ { DependsOn: [bucket, "ExternalResource"] },
544
+ );
545
+ const template = serialize(["DataBucket", bucket], ["Handler", fn]);
546
+ expect(template.Resources.Handler.DependsOn).toEqual(["DataBucket", "ExternalResource"]);
547
+ });
548
+
549
+ test("single DependsOn array item serializes as string, not array", () => {
550
+ const fn = new MockResourceWithAttrs(
551
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: ["OnlyOne"] },
552
+ );
553
+ const template = serialize(["Handler", fn]);
554
+ expect(template.Resources.Handler.DependsOn).toBe("OnlyOne");
555
+ });
556
+
557
+ test("Condition attribute", () => {
558
+ const res = new MockResourceWithAttrs(
559
+ "AWS::S3::Bucket", { BucketName: "cond-bucket" }, { Condition: "CreateProdResources" },
560
+ );
561
+ const template = serialize(["MyBucket", res]);
562
+ expect(template.Resources.MyBucket.Condition).toBe("CreateProdResources");
563
+ });
564
+
565
+ test("DeletionPolicy attribute", () => {
566
+ const res = new MockResourceWithAttrs(
567
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { DeletionPolicy: "Retain" },
568
+ );
569
+ const template = serialize(["Database", res]);
570
+ expect(template.Resources.Database.DeletionPolicy).toBe("Retain");
571
+ });
572
+
573
+ test("UpdateReplacePolicy attribute", () => {
574
+ const res = new MockResourceWithAttrs(
575
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { UpdateReplacePolicy: "Snapshot" },
576
+ );
577
+ const template = serialize(["Database", res]);
578
+ expect(template.Resources.Database.UpdateReplacePolicy).toBe("Snapshot");
579
+ });
580
+
581
+ test("UpdatePolicy attribute", () => {
582
+ const policy = {
583
+ AutoScalingRollingUpdate: {
584
+ MaxBatchSize: 2,
585
+ MinInstancesInService: 1,
586
+ PauseTime: "PT5M",
587
+ WaitOnResourceSignals: true,
588
+ },
589
+ };
590
+ const res = new MockResourceWithAttrs(
591
+ "AWS::AutoScaling::AutoScalingGroup", { MinSize: "1", MaxSize: "4" },
592
+ { UpdatePolicy: policy },
593
+ );
594
+ const template = serialize(["ASG", res]);
595
+ expect(template.Resources.ASG.UpdatePolicy).toEqual(policy);
596
+ });
597
+
598
+ test("CreationPolicy attribute", () => {
599
+ const policy = { ResourceSignal: { Count: 3, Timeout: "PT15M" } };
600
+ const res = new MockResourceWithAttrs(
601
+ "AWS::AutoScaling::AutoScalingGroup", { MinSize: "3", MaxSize: "3" },
602
+ { CreationPolicy: policy },
603
+ );
604
+ const template = serialize(["ASG", res]);
605
+ expect(template.Resources.ASG.CreationPolicy).toEqual(policy);
606
+ });
607
+
608
+ test("Metadata attribute with plain object", () => {
609
+ const res = new MockResourceWithAttrs(
610
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" },
611
+ { Metadata: { "AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } } } },
612
+ );
613
+ const template = serialize(["Server", res]);
614
+ expect(template.Resources.Server.Metadata).toEqual({
615
+ "AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } },
616
+ });
617
+ });
618
+
619
+ test("Metadata with intrinsic values resolves them", () => {
620
+ const res = new MockResourceWithAttrs(
621
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" },
622
+ { Metadata: { StackInfo: Sub`${AWS.StackName}-metadata` } },
623
+ );
624
+ const template = serialize(["Server", res]);
625
+ expect(template.Resources.Server.Metadata.StackInfo).toEqual({
626
+ "Fn::Sub": "${AWS::StackName}-metadata",
627
+ });
628
+ });
629
+
630
+ test("all 7 attributes on a single resource", () => {
631
+ const dependency = new MockBucket({ BucketName: "dep" });
632
+ const res = new MockResourceWithAttrs(
633
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro", Engine: "postgres" },
634
+ {
635
+ DependsOn: dependency,
636
+ Condition: "CreateDatabase",
637
+ DeletionPolicy: "Snapshot",
638
+ UpdateReplacePolicy: "Retain",
639
+ UpdatePolicy: { AutoScalingReplacingUpdate: { WillReplace: true } },
640
+ CreationPolicy: { ResourceSignal: { Count: 1, Timeout: "PT10M" } },
641
+ Metadata: { Version: "1.0" },
642
+ },
643
+ );
644
+ const template = serialize(["DepBucket", dependency], ["Database", res]);
645
+
646
+ const db = template.Resources.Database;
647
+ expect(db.DependsOn).toBe("DepBucket");
648
+ expect(db.Condition).toBe("CreateDatabase");
649
+ expect(db.DeletionPolicy).toBe("Snapshot");
650
+ expect(db.UpdateReplacePolicy).toBe("Retain");
651
+ expect(db.UpdatePolicy).toEqual({ AutoScalingReplacingUpdate: { WillReplace: true } });
652
+ expect(db.CreationPolicy).toEqual({ ResourceSignal: { Count: 1, Timeout: "PT10M" } });
653
+ expect(db.Metadata).toEqual({ Version: "1.0" });
654
+ });
655
+
656
+ test("undefined attributes are omitted from CF template", () => {
657
+ const res = new MockResourceWithAttrs(
658
+ "AWS::S3::Bucket", { BucketName: "bucket" },
659
+ { DependsOn: undefined, Condition: undefined },
660
+ );
661
+ const template = serialize(["MyBucket", res]);
662
+ expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
663
+ expect(template.Resources.MyBucket.Condition).toBeUndefined();
664
+ });
665
+
666
+ test("empty attributes object produces no resource-level attributes", () => {
667
+ const res = new MockResourceWithAttrs("AWS::S3::Bucket", { BucketName: "bucket" }, {});
668
+ const template = serialize(["MyBucket", res]);
669
+ const r = template.Resources.MyBucket;
670
+ expect(r.DependsOn).toBeUndefined();
671
+ expect(r.Condition).toBeUndefined();
672
+ expect(r.DeletionPolicy).toBeUndefined();
673
+ expect(r.Metadata).toBeUndefined();
674
+ });
675
+
676
+ test("resource without attributes property works unchanged", () => {
677
+ // MockBucket has no `attributes` property — should still serialize fine
678
+ const bucket = new MockBucket({ BucketName: "legacy" });
679
+ const template = serialize(["MyBucket", bucket]);
680
+ expect(template.Resources.MyBucket.Type).toBe("AWS::S3::Bucket");
681
+ expect(template.Resources.MyBucket.Properties.BucketName).toBe("legacy");
682
+ expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
683
+ });
684
+ });
685
+
686
+ // ── Default Tags Serialization ──────────────────────────
687
+
688
+ // Mock Lambda Permission (non-taggable) for testing
689
+ class MockPermission implements Declarable {
690
+ readonly [DECLARABLE_MARKER] = true as const;
691
+ readonly lexicon = "aws";
692
+ readonly entityType = "AWS::Lambda::Permission";
693
+ readonly props: { Action: string; FunctionName: string; Principal: string };
694
+
695
+ constructor(props: { Action: string; FunctionName: string; Principal: string }) {
696
+ this.props = props;
697
+ }
698
+ }
699
+
700
+ // Mock Lambda Function (taggable) for testing
701
+ class MockFunction implements Declarable {
702
+ readonly [DECLARABLE_MARKER] = true as const;
703
+ readonly lexicon = "aws";
704
+ readonly entityType = "AWS::Lambda::Function";
705
+ readonly props: Record<string, unknown>;
706
+
707
+ constructor(props: Record<string, unknown> = {}) {
708
+ this.props = props;
709
+ }
710
+ }
711
+
712
+ describe("default tags serialization", () => {
713
+ test("DefaultTags entity is not emitted as a CF Resource", () => {
714
+ const entities = new Map<string, Declarable>();
715
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
716
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
717
+
718
+ const output = awsSerializer.serialize(entities);
719
+ const template = JSON.parse(output as string);
720
+
721
+ expect(template.Resources.tags).toBeUndefined();
722
+ expect(template.Resources.MyBucket).toBeDefined();
723
+ });
724
+
725
+ test("taggable resource gets default tags injected", () => {
726
+ const entities = new Map<string, Declarable>();
727
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
728
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
729
+
730
+ const output = awsSerializer.serialize(entities);
731
+ const template = JSON.parse(output as string);
732
+
733
+ expect(template.Resources.MyBucket.Properties.Tags).toEqual([
734
+ { Key: "Env", Value: "prod" },
735
+ ]);
736
+ });
737
+
738
+ test("non-taggable resource does NOT get tags", () => {
739
+ const entities = new Map<string, Declarable>();
740
+ entities.set("Perm", new MockPermission({
741
+ Action: "lambda:InvokeFunction",
742
+ FunctionName: "fn",
743
+ Principal: "apigateway.amazonaws.com",
744
+ }));
745
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
746
+
747
+ const output = awsSerializer.serialize(entities);
748
+ const template = JSON.parse(output as string);
749
+
750
+ expect(template.Resources.Perm.Properties.Tags).toBeUndefined();
751
+ });
752
+
753
+ test("explicit tags win over defaults on same key", () => {
754
+ const entities = new Map<string, Declarable>();
755
+ entities.set("MyBucket", new MockBucket({
756
+ BucketName: "bucket",
757
+ Tags: [{ Key: "Env", Value: "staging" }],
758
+ }));
759
+ entities.set("tags", defaultTags([
760
+ { Key: "Env", Value: "prod" },
761
+ { Key: "Team", Value: "platform" },
762
+ ]) as unknown as Declarable);
763
+
764
+ const output = awsSerializer.serialize(entities);
765
+ const template = JSON.parse(output as string);
766
+
767
+ const tags = template.Resources.MyBucket.Properties.Tags;
768
+ expect(tags).toHaveLength(2);
769
+ // Explicit "Env" wins, default "Team" is added
770
+ const envTag = tags.find((t: { Key: string }) => t.Key === "Env");
771
+ const teamTag = tags.find((t: { Key: string }) => t.Key === "Team");
772
+ expect(envTag.Value).toBe("staging");
773
+ expect(teamTag.Value).toBe("platform");
774
+ });
775
+
776
+ test("intrinsic tag values resolve correctly", () => {
777
+ const entities = new Map<string, Declarable>();
778
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
779
+ entities.set("tags", defaultTags([
780
+ { Key: "Stack", Value: Sub`${AWS.StackName}` },
781
+ ]) as unknown as Declarable);
782
+
783
+ const output = awsSerializer.serialize(entities);
784
+ const template = JSON.parse(output as string);
785
+
786
+ const tags = template.Resources.MyBucket.Properties.Tags;
787
+ expect(tags).toHaveLength(1);
788
+ expect(tags[0].Key).toBe("Stack");
789
+ expect(tags[0].Value).toEqual({ "Fn::Sub": "${AWS::StackName}" });
790
+ });
791
+
792
+ test("parameter tag values resolve to Ref", () => {
793
+ const env = new Parameter("String", { defaultValue: "dev" });
794
+ const entities = new Map<string, Declarable>();
795
+ entities.set("Env", env as unknown as Declarable);
796
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
797
+ entities.set("tags", defaultTags([
798
+ { Key: "Environment", Value: env },
799
+ ]) as unknown as Declarable);
800
+
801
+ const output = awsSerializer.serialize(entities);
802
+ const template = JSON.parse(output as string);
803
+
804
+ const tags = template.Resources.MyBucket.Properties.Tags;
805
+ expect(tags).toHaveLength(1);
806
+ expect(tags[0].Key).toBe("Environment");
807
+ expect(tags[0].Value).toEqual({ Ref: "Env" });
808
+ });
809
+
810
+ test("no defaultTags = no injection (existing behavior)", () => {
811
+ const entities = new Map<string, Declarable>();
812
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
813
+
814
+ const output = awsSerializer.serialize(entities);
815
+ const template = JSON.parse(output as string);
816
+
817
+ expect(template.Resources.MyBucket.Properties.Tags).toBeUndefined();
818
+ });
819
+ });
package/src/serializer.ts CHANGED
@@ -5,6 +5,9 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
5
5
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
6
6
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
7
7
  import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
8
+ import { resolveDependsOn } from "@intentius/chant/resource-attributes";
9
+ import { isDefaultTags, type TagEntry } from "./default-tags";
10
+ import { loadTaggableResources } from "./taggable";
8
11
 
9
12
  /**
10
13
  * Check if a declarable is a CoreParameter
@@ -49,6 +52,10 @@ interface CFResource {
49
52
  Properties?: Record<string, unknown>;
50
53
  DependsOn?: string | string[];
51
54
  Condition?: string;
55
+ DeletionPolicy?: string;
56
+ UpdateReplacePolicy?: string;
57
+ UpdatePolicy?: unknown;
58
+ CreationPolicy?: unknown;
52
59
  Metadata?: Record<string, unknown>;
53
60
  }
54
61
 
@@ -141,6 +148,14 @@ function serializeToTemplate(
141
148
  entityNames.set(entity, name);
142
149
  }
143
150
 
151
+ // Collect default tags
152
+ const defaultTagEntries: TagEntry[] = [];
153
+ for (const [, entity] of entities) {
154
+ if (isDefaultTags(entity)) {
155
+ defaultTagEntries.push(...entity.tags);
156
+ }
157
+ }
158
+
144
159
  // Process entities
145
160
  for (const [name, entity] of entities) {
146
161
  // Skip StackOutput entities — they go in the Outputs section
@@ -148,6 +163,11 @@ function serializeToTemplate(
148
163
  continue;
149
164
  }
150
165
 
166
+ // Skip DefaultTags entities — handled via tag injection below
167
+ if (isDefaultTags(entity)) {
168
+ continue;
169
+ }
170
+
151
171
  if (isCoreParameter(entity)) {
152
172
  if (!template.Parameters) {
153
173
  template.Parameters = {};
@@ -202,15 +222,56 @@ function serializeToTemplate(
202
222
  Type: entity.entityType,
203
223
  };
204
224
 
225
+ // Read resource-level attributes from the second constructor arg
226
+ const attrs = ("attributes" in entity && typeof entity.attributes === "object" && entity.attributes !== null)
227
+ ? entity.attributes as Record<string, unknown>
228
+ : undefined;
229
+
230
+ if (attrs) {
231
+ // DependsOn — resolve Declarable refs to logical names
232
+ if (attrs.DependsOn !== undefined) {
233
+ const resolved = resolveDependsOn(attrs.DependsOn, entityNames, name);
234
+ if (resolved.length > 0) {
235
+ resource.DependsOn = resolved.length === 1 ? resolved[0] : resolved;
236
+ }
237
+ }
238
+ // Pass-through attributes
239
+ if (attrs.Condition) resource.Condition = attrs.Condition as string;
240
+ if (attrs.DeletionPolicy) resource.DeletionPolicy = attrs.DeletionPolicy as string;
241
+ if (attrs.UpdateReplacePolicy) resource.UpdateReplacePolicy = attrs.UpdateReplacePolicy as string;
242
+ if (attrs.UpdatePolicy) resource.UpdatePolicy = attrs.UpdatePolicy;
243
+ if (attrs.CreationPolicy) resource.CreationPolicy = attrs.CreationPolicy;
244
+ if (attrs.Metadata) resource.Metadata = toCFValue(attrs.Metadata, entityNames) as Record<string, unknown>;
245
+ }
246
+
205
247
  const properties = toProperties(entity, entityNames);
206
248
  if (properties) {
207
- resource.Properties = properties;
249
+ if (Object.keys(properties).length > 0) {
250
+ resource.Properties = properties;
251
+ }
208
252
  }
209
253
 
210
254
  template.Resources[name] = resource;
211
255
  }
212
256
  }
213
257
 
258
+ // Inject default tags into taggable resources
259
+ if (defaultTagEntries.length > 0) {
260
+ const taggable = loadTaggableResources();
261
+ for (const [, resource] of Object.entries(template.Resources)) {
262
+ if (!taggable.has(resource.Type)) continue;
263
+ const resolved = defaultTagEntries.map(t => ({
264
+ Key: t.Key,
265
+ Value: toCFValue(t.Value, entityNames),
266
+ }));
267
+ const explicit = (resource.Properties?.Tags ?? []) as Array<{ Key: string }>;
268
+ const explicitKeys = new Set(explicit.map(t => t.Key));
269
+ const merged = [...resolved.filter(t => !explicitKeys.has(t.Key)), ...explicit];
270
+ if (!resource.Properties) resource.Properties = {};
271
+ resource.Properties.Tags = merged;
272
+ }
273
+ }
274
+
214
275
  // Emit StackOutput entities as CF Outputs
215
276
  for (const [name, entity] of entities) {
216
277
  if (isStackOutput(entity)) {
package/src/spec/fetch.ts CHANGED
@@ -15,6 +15,16 @@ export interface CFNSchema {
15
15
  createOnlyProperties?: string[];
16
16
  writeOnlyProperties?: string[];
17
17
  primaryIdentifier?: string[];
18
+ deprecatedProperties?: string[];
19
+ conditionalCreateOnlyProperties?: string[];
20
+ replacementStrategy?: string;
21
+ tagging?: {
22
+ taggable?: boolean;
23
+ tagOnCreate?: boolean;
24
+ tagUpdatable?: boolean;
25
+ cloudFormationSystemTags?: boolean;
26
+ tagProperty?: string;
27
+ };
18
28
  additionalProperties?: boolean;
19
29
  }
20
30
 
@@ -20,6 +20,7 @@ const sampleBucketSchema = JSON.stringify({
20
20
  },
21
21
  AccessControl: {
22
22
  type: "string",
23
+ description: "This is a legacy property, and it is not recommended for most use cases.",
23
24
  enum: ["Private", "PublicRead", "PublicReadWrite"],
24
25
  },
25
26
  },
@@ -42,6 +43,7 @@ const sampleBucketSchema = JSON.stringify({
42
43
  additionalProperties: false,
43
44
  },
44
45
  },
46
+ deprecatedProperties: ["/properties/AccessControl"],
45
47
  readOnlyProperties: [
46
48
  "/properties/Arn",
47
49
  "/properties/DomainName",
@@ -134,6 +136,145 @@ describe("parseCFNSchema", () => {
134
136
  expect(result.resource.properties).toEqual([]);
135
137
  expect(result.resource.attributes).toEqual([]);
136
138
  });
139
+
140
+ // --- Deprecated properties ---
141
+
142
+ test("parses explicit deprecatedProperties", () => {
143
+ const result = parseCFNSchema(sampleBucketSchema);
144
+ expect(result.resource.deprecatedProperties).toContain("AccessControl");
145
+ });
146
+
147
+ test("mines deprecation from property description", () => {
148
+ const schema = JSON.stringify({
149
+ typeName: "AWS::Test::DescMined",
150
+ properties: {
151
+ OldProp: { type: "string", description: "This property is deprecated. Use NewProp instead." },
152
+ NewProp: { type: "string", description: "The replacement property" },
153
+ },
154
+ additionalProperties: false,
155
+ });
156
+ const result = parseCFNSchema(schema);
157
+ expect(result.resource.deprecatedProperties).toContain("OldProp");
158
+ expect(result.resource.deprecatedProperties).not.toContain("NewProp");
159
+ });
160
+
161
+ test("mines 'legacy' keyword from description", () => {
162
+ const schema = JSON.stringify({
163
+ typeName: "AWS::Test::Legacy",
164
+ properties: {
165
+ LegacyProp: { type: "string", description: "This is a legacy property, not recommended." },
166
+ },
167
+ additionalProperties: false,
168
+ });
169
+ const result = parseCFNSchema(schema);
170
+ expect(result.resource.deprecatedProperties).toContain("LegacyProp");
171
+ });
172
+
173
+ test("deduplicates when both explicit and description flag same property", () => {
174
+ // sampleBucketSchema has AccessControl in both deprecatedProperties array and description
175
+ const result = parseCFNSchema(sampleBucketSchema);
176
+ const count = result.resource.deprecatedProperties.filter((p) => p === "AccessControl").length;
177
+ expect(count).toBe(1);
178
+ });
179
+
180
+ test("empty deprecatedProperties when none found", () => {
181
+ const schema = JSON.stringify({
182
+ typeName: "AWS::Test::Clean",
183
+ properties: {
184
+ Name: { type: "string", description: "A normal property" },
185
+ },
186
+ additionalProperties: false,
187
+ });
188
+ const result = parseCFNSchema(schema);
189
+ expect(result.resource.deprecatedProperties).toEqual([]);
190
+ });
191
+
192
+ // --- Tagging metadata ---
193
+
194
+ test("parses tagging metadata when taggable", () => {
195
+ const schema = JSON.stringify({
196
+ typeName: "AWS::Test::Taggable",
197
+ properties: { Tags: { type: "array" } },
198
+ tagging: { taggable: true, tagOnCreate: true, tagUpdatable: true },
199
+ additionalProperties: false,
200
+ });
201
+ const result = parseCFNSchema(schema);
202
+ expect(result.resource.tagging).toEqual({
203
+ taggable: true,
204
+ tagOnCreate: true,
205
+ tagUpdatable: true,
206
+ });
207
+ });
208
+
209
+ test("omits tagging when not taggable", () => {
210
+ const schema = JSON.stringify({
211
+ typeName: "AWS::Test::NotTaggable",
212
+ properties: { Name: { type: "string" } },
213
+ tagging: { taggable: false },
214
+ additionalProperties: false,
215
+ });
216
+ const result = parseCFNSchema(schema);
217
+ expect(result.resource.tagging).toBeUndefined();
218
+ });
219
+
220
+ test("omits tagging when absent", () => {
221
+ const schema = JSON.stringify({
222
+ typeName: "AWS::Test::NoTagging",
223
+ properties: { Name: { type: "string" } },
224
+ additionalProperties: false,
225
+ });
226
+ const result = parseCFNSchema(schema);
227
+ expect(result.resource.tagging).toBeUndefined();
228
+ });
229
+
230
+ // --- Replacement strategy ---
231
+
232
+ test("parses replacementStrategy", () => {
233
+ const schema = JSON.stringify({
234
+ typeName: "AWS::Test::DeleteFirst",
235
+ properties: { Name: { type: "string" } },
236
+ replacementStrategy: "delete_then_create",
237
+ additionalProperties: false,
238
+ });
239
+ const result = parseCFNSchema(schema);
240
+ expect(result.resource.replacementStrategy).toBe("delete_then_create");
241
+ });
242
+
243
+ test("omits replacementStrategy when absent", () => {
244
+ const schema = JSON.stringify({
245
+ typeName: "AWS::Test::NoStrategy",
246
+ properties: { Name: { type: "string" } },
247
+ additionalProperties: false,
248
+ });
249
+ const result = parseCFNSchema(schema);
250
+ expect(result.resource.replacementStrategy).toBeUndefined();
251
+ });
252
+
253
+ // --- Conditional create-only ---
254
+
255
+ test("parses conditionalCreateOnlyProperties", () => {
256
+ const schema = JSON.stringify({
257
+ typeName: "AWS::Test::ConditionalCreate",
258
+ properties: {
259
+ Name: { type: "string" },
260
+ Engine: { type: "string" },
261
+ },
262
+ conditionalCreateOnlyProperties: ["/properties/Engine"],
263
+ additionalProperties: false,
264
+ });
265
+ const result = parseCFNSchema(schema);
266
+ expect(result.resource.conditionalCreateOnly).toContain("Engine");
267
+ });
268
+
269
+ test("empty conditionalCreateOnly when absent", () => {
270
+ const schema = JSON.stringify({
271
+ typeName: "AWS::Test::NoConditional",
272
+ properties: { Name: { type: "string" } },
273
+ additionalProperties: false,
274
+ });
275
+ const result = parseCFNSchema(schema);
276
+ expect(result.resource.conditionalCreateOnly).toEqual([]);
277
+ });
137
278
  });
138
279
 
139
280
  describe("cfnShortName", () => {
package/src/spec/parse.ts CHANGED
@@ -52,6 +52,10 @@ export interface ParsedResource {
52
52
  createOnly: string[];
53
53
  writeOnly: string[];
54
54
  primaryIdentifier: string[];
55
+ deprecatedProperties: string[];
56
+ conditionalCreateOnly: string[];
57
+ replacementStrategy?: "delete_then_create" | "create_then_delete";
58
+ tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
55
59
  }
56
60
 
57
61
  export interface SchemaParseResult {
@@ -137,6 +141,38 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
137
141
  }
138
142
  }
139
143
 
144
+ // --- Deprecated properties: explicit + description-mined ---
145
+ const deprecatedSet = new Set<string>(
146
+ stripPointerPaths(schema.deprecatedProperties ?? []),
147
+ );
148
+
149
+ const DEPRECATION_RE = /\bdeprecated\b|\blegacy\b|no longer (available|recommended|used|supported)|is not recommended|has been discontinued/i;
150
+
151
+ // Mine top-level property descriptions
152
+ if (schema.properties) {
153
+ for (const [name, prop] of Object.entries(schema.properties)) {
154
+ if (prop.description && DEPRECATION_RE.test(prop.description)) {
155
+ deprecatedSet.add(name);
156
+ }
157
+ }
158
+ }
159
+
160
+ // --- Tagging ---
161
+ let tagging: ParsedResource["tagging"];
162
+ if (schema.tagging && schema.tagging.taggable) {
163
+ tagging = {
164
+ taggable: true,
165
+ tagOnCreate: schema.tagging.tagOnCreate ?? false,
166
+ tagUpdatable: schema.tagging.tagUpdatable ?? false,
167
+ };
168
+ }
169
+
170
+ // --- Replacement strategy ---
171
+ let replacementStrategy: ParsedResource["replacementStrategy"];
172
+ if (schema.replacementStrategy === "delete_then_create" || schema.replacementStrategy === "create_then_delete") {
173
+ replacementStrategy = schema.replacementStrategy;
174
+ }
175
+
140
176
  return {
141
177
  resource: {
142
178
  typeName: schema.typeName,
@@ -145,6 +181,10 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
145
181
  createOnly: stripPointerPaths(schema.createOnlyProperties ?? []),
146
182
  writeOnly: stripPointerPaths(schema.writeOnlyProperties ?? []),
147
183
  primaryIdentifier: stripPointerPaths(schema.primaryIdentifier ?? []),
184
+ deprecatedProperties: [...deprecatedSet],
185
+ conditionalCreateOnly: stripPointerPaths(schema.conditionalCreateOnlyProperties ?? []),
186
+ ...(replacementStrategy && { replacementStrategy }),
187
+ ...(tagging && { tagging }),
148
188
  },
149
189
  propertyTypes,
150
190
  enums,