@intentius/chant 0.18.14 → 0.18.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,20 @@
1
1
  import { INTRINSIC_MARKER, type Intrinsic } from "./intrinsic.js";
2
2
  import { AttrRef } from "./attrref.js";
3
+ /**
4
+ * Sanitize auto-generated Output name parts into a valid CloudFormation
5
+ * logical id. Real CloudFormation logical ids (including `Outputs` keys)
6
+ * must match `^[A-Za-z0-9]+$` — no dots, underscores, or other punctuation.
7
+ *
8
+ * Splits every part on runs of non-alphanumeric characters (dots from a
9
+ * nested `Fn::GetAtt` attribute path, underscores, hyphens, etc.) and
10
+ * re-joins the resulting segments in camelCase. This keeps the id
11
+ * deterministic and unique per (entityName, attribute) pair while
12
+ * discarding only punctuation — no information is lost, so two distinct
13
+ * inputs only collide if they already differ solely by punctuation.
14
+ *
15
+ * @internal
16
+ */
17
+ export declare function sanitizeLogicalId(...parts: string[]): string;
3
18
  /**
4
19
  * Represents a cross-lexicon output that bridges a producing lexicon (e.g. AWS)
5
20
  * with any consuming lexicon (e.g. GitHub, Cloudflare).
@@ -36,9 +51,14 @@ export declare class LexiconOutput implements Intrinsic {
36
51
  * Create a LexiconOutput with an auto-generated name from entity name and attribute.
37
52
  * Used during cross-lexicon ref auto-detection.
38
53
  *
54
+ * The name is sanitized to a valid CloudFormation logical id (see
55
+ * {@link sanitizeLogicalId}) since it is used verbatim as the `Outputs`
56
+ * key by lexicon serializers (e.g. AWS CloudFormation), which reject
57
+ * dots or underscores in logical ids.
58
+ *
39
59
  * @param ref - The AttrRef pointing to the source entity
40
60
  * @param entityName - The logical name of the source entity
41
- * @returns A LexiconOutput with name `{entityName}_{attribute}`
61
+ * @returns A LexiconOutput with a name derived from `{entityName}{Attribute}`
42
62
  */
43
63
  static auto(ref: AttrRef, entityName: string): LexiconOutput;
44
64
  toJSON(): {
@@ -1 +1 @@
1
- {"version":3,"file":"lexicon-output.d.ts","sourceRoot":"","sources":["../src/lexicon-output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC;;;;;;;;GAQG;AACH,qBAAa,aAAc,YAAW,SAAS;IAC7C,QAAQ,CAAC,CAAC,gBAAgB,CAAC,EAAG,IAAI,CAAU;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC/C,uFAAuF;IACvF,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAmB;gBAElC,GAAG,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,EAAE,IAAI,EAAE,MAAM;IA8B3D;;;;OAIG;IACH,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIpC;;;;OAIG;IACH,cAAc,IAAI,OAAO;IAOzB;;;;;;;OAOG;IACH,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,GAAG,aAAa;IAO5D,MAAM,IAAI;QAAE,eAAe,EAAE,MAAM,CAAA;KAAE;CAGtC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAErF;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE"}
1
+ {"version":3,"file":"lexicon-output.d.ts","sourceRoot":"","sources":["../src/lexicon-output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAW5D;AAED;;;;;;;;GAQG;AACH,qBAAa,aAAc,YAAW,SAAS;IAC7C,QAAQ,CAAC,CAAC,gBAAgB,CAAC,EAAG,IAAI,CAAU;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC/C,uFAAuF;IACvF,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAmB;gBAElC,GAAG,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,EAAE,IAAI,EAAE,MAAM;IA8B3D;;;;OAIG;IACH,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIpC;;;;OAIG;IACH,cAAc,IAAI,OAAO;IAOzB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,GAAG,aAAa;IAO5D,MAAM,IAAI;QAAE,eAAe,EAAE,MAAM,CAAA;KAAE;CAGtC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAErF;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.18.14",
3
+ "version": "0.18.16",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.test.ts CHANGED
@@ -323,7 +323,8 @@ describe("detectCrossLexiconRefs", () => {
323
323
  expect(detected[0].sourceLexicon).toBe("alpha");
324
324
  expect(detected[0].sourceEntity).toBe("dataBucket");
325
325
  expect(detected[0].sourceAttribute).toBe("Endpoint");
326
- expect(detected[0].outputName).toBe("dataBucket_Endpoint");
326
+ expect(detected[0].outputName).toBe("dataBucketEndpoint");
327
+ expect(detected[0].outputName).toMatch(/^[A-Za-z0-9]+$/);
327
328
  });
328
329
 
329
330
  test("explicit output() overrides auto-detected name", () => {
@@ -351,7 +352,7 @@ describe("detectCrossLexiconRefs", () => {
351
352
  // Auto-detect finds the cross-lexicon ref
352
353
  const autoDetected = detectCrossLexiconRefs(entities);
353
354
  expect(autoDetected).toHaveLength(1);
354
- expect(autoDetected[0].outputName).toBe("dataBucket_Arn");
355
+ expect(autoDetected[0].outputName).toBe("dataBucketArn");
355
356
 
356
357
  // But when collecting explicit outputs, the explicit one is found
357
358
  const explicitOutputs = collectLexiconOutputs(entities);
@@ -459,7 +460,44 @@ describe("detectCrossLexiconRefs", () => {
459
460
 
460
461
  const detected = detectCrossLexiconRefs(entities);
461
462
  expect(detected).toHaveLength(1);
462
- expect(detected[0].outputName).toBe("dataBucket_Endpoint");
463
+ expect(detected[0].outputName).toBe("dataBucketEndpoint");
464
+ });
465
+
466
+ // chant#930 — a nested Fn::GetAtt attribute path (dots) on a cross-lexicon
467
+ // ref must not leak into the auto-generated Output logical id: CFN logical
468
+ // ids (including Outputs keys) are alphanumeric-only (^[A-Za-z0-9]+$), and
469
+ // cfn-lint (E6001) rejects anything else.
470
+ test("auto-generated Output logical id is valid CFN form for a nested attribute path", () => {
471
+ const alphaBucket = {
472
+ lexicon: "alpha",
473
+ entityType: "Alpha::Storage::Bucket",
474
+ [DECLARABLE_MARKER]: true,
475
+ } as Declarable;
476
+
477
+ const nestedRef = new AttrRef(
478
+ alphaBucket,
479
+ "MetadataConfiguration.AnnotationTableConfiguration.TableArn"
480
+ );
481
+
482
+ const ghAction = {
483
+ lexicon: "github",
484
+ entityType: "Action",
485
+ [DECLARABLE_MARKER]: true,
486
+ props: { arn: nestedRef },
487
+ } as unknown as Declarable;
488
+
489
+ const entities = new Map<string, Declarable>([
490
+ ["foundationArtifactBucket", alphaBucket],
491
+ ["deployAction", ghAction],
492
+ ]);
493
+
494
+ const detected = detectCrossLexiconRefs(entities);
495
+
496
+ expect(detected).toHaveLength(1);
497
+ expect(detected[0].outputName).toMatch(/^[A-Za-z0-9]+$/);
498
+ expect(detected[0].outputName).toBe(
499
+ "foundationArtifactBucketMetadataConfigurationAnnotationTableConfigurationTableArn"
500
+ );
463
501
  });
464
502
  });
465
503
 
@@ -100,18 +100,18 @@ describe("LexiconOutput.auto", () => {
100
100
  const result = LexiconOutput.auto(bucket.arn, "dataBucket");
101
101
 
102
102
  expect(result).toBeInstanceOf(LexiconOutput);
103
- expect(result.outputName).toBe("dataBucket_Arn");
103
+ expect(result.outputName).toBe("dataBucketArn");
104
104
  expect(result.sourceLexicon).toBe("testdom");
105
105
  expect(result.sourceEntity).toBe("dataBucket");
106
106
  expect(result.sourceAttribute).toBe("Arn");
107
107
  });
108
108
 
109
- test("auto-generated name follows {entityName}_{attribute} pattern", () => {
109
+ test("auto-generated name follows {entityName}{Attribute} camelCase pattern", () => {
110
110
  const bucket = new MockResource();
111
111
  const endpointRef = new AttrRef(bucket, "Endpoint");
112
112
  const result = LexiconOutput.auto(endpointRef, "myBucket");
113
113
 
114
- expect(result.outputName).toBe("myBucket_Endpoint");
114
+ expect(result.outputName).toBe("myBucketEndpoint");
115
115
  });
116
116
 
117
117
  test("sets sourceEntity immediately", () => {
@@ -121,6 +121,37 @@ describe("LexiconOutput.auto", () => {
121
121
  // sourceEntity should be set right away, not empty
122
122
  expect(result.sourceEntity).toBe("logsBucket");
123
123
  });
124
+
125
+ // chant#930 — CFN logical ids (including Outputs keys) must be alphanumeric
126
+ // only. A nested Fn::GetAtt attribute path (dots) must not leak through.
127
+ test("sanitizes dotted nested-attribute paths to a valid CFN logical id", () => {
128
+ const bucket = new MockResource();
129
+ const nestedRef = new AttrRef(
130
+ bucket,
131
+ "MetadataConfiguration.AnnotationTableConfiguration.TableArn"
132
+ );
133
+ const result = LexiconOutput.auto(nestedRef, "foundationArtifactBucket");
134
+
135
+ expect(result.outputName).toMatch(/^[A-Za-z0-9]+$/);
136
+ expect(result.outputName).toBe(
137
+ "foundationArtifactBucketMetadataConfigurationAnnotationTableConfigurationTableArn"
138
+ );
139
+ });
140
+
141
+ test("auto-generated output names are always valid CFN logical ids", () => {
142
+ const bucket = new MockResource();
143
+ const cases: Array<[string, string]> = [
144
+ ["dataBucket", "Arn"],
145
+ ["my-bucket_2", "Some.Nested.Attr"],
146
+ ["logsBucket", "Endpoint.Hostname"],
147
+ ];
148
+
149
+ for (const [entityName, attribute] of cases) {
150
+ const ref = new AttrRef(bucket, attribute);
151
+ const result = LexiconOutput.auto(ref, entityName);
152
+ expect(result.outputName).toMatch(/^[A-Za-z0-9]+$/);
153
+ }
154
+ });
124
155
  });
125
156
 
126
157
  describe("output() helper", () => {
@@ -1,6 +1,33 @@
1
1
  import { INTRINSIC_MARKER, type Intrinsic } from "./intrinsic";
2
2
  import { AttrRef } from "./attrref";
3
3
 
4
+ /**
5
+ * Sanitize auto-generated Output name parts into a valid CloudFormation
6
+ * logical id. Real CloudFormation logical ids (including `Outputs` keys)
7
+ * must match `^[A-Za-z0-9]+$` — no dots, underscores, or other punctuation.
8
+ *
9
+ * Splits every part on runs of non-alphanumeric characters (dots from a
10
+ * nested `Fn::GetAtt` attribute path, underscores, hyphens, etc.) and
11
+ * re-joins the resulting segments in camelCase. This keeps the id
12
+ * deterministic and unique per (entityName, attribute) pair while
13
+ * discarding only punctuation — no information is lost, so two distinct
14
+ * inputs only collide if they already differ solely by punctuation.
15
+ *
16
+ * @internal
17
+ */
18
+ export function sanitizeLogicalId(...parts: string[]): string {
19
+ const segments = parts
20
+ .join("_")
21
+ .split(/[^A-Za-z0-9]+/)
22
+ .filter((segment) => segment.length > 0);
23
+
24
+ if (segments.length === 0) return "Output";
25
+
26
+ return segments
27
+ .map((segment, i) => (i === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1)))
28
+ .join("");
29
+ }
30
+
4
31
  /**
5
32
  * Represents a cross-lexicon output that bridges a producing lexicon (e.g. AWS)
6
33
  * with any consuming lexicon (e.g. GitHub, Cloudflare).
@@ -76,12 +103,17 @@ export class LexiconOutput implements Intrinsic {
76
103
  * Create a LexiconOutput with an auto-generated name from entity name and attribute.
77
104
  * Used during cross-lexicon ref auto-detection.
78
105
  *
106
+ * The name is sanitized to a valid CloudFormation logical id (see
107
+ * {@link sanitizeLogicalId}) since it is used verbatim as the `Outputs`
108
+ * key by lexicon serializers (e.g. AWS CloudFormation), which reject
109
+ * dots or underscores in logical ids.
110
+ *
79
111
  * @param ref - The AttrRef pointing to the source entity
80
112
  * @param entityName - The logical name of the source entity
81
- * @returns A LexiconOutput with name `{entityName}_{attribute}`
113
+ * @returns A LexiconOutput with a name derived from `{entityName}{Attribute}`
82
114
  */
83
115
  static auto(ref: AttrRef, entityName: string): LexiconOutput {
84
- const name = `${entityName}_${ref.attribute}`;
116
+ const name = sanitizeLogicalId(entityName, ref.attribute);
85
117
  const output = new LexiconOutput(ref, name);
86
118
  output._setSourceEntity(entityName);
87
119
  return output;