@intentius/chant-lexicon-aws 0.18.15 → 0.18.17

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.
@@ -41,14 +41,24 @@ describe("aws exportResources I/O glue (#160)", () => {
41
41
  expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyBucket"]);
42
42
  });
43
43
 
44
- test("a non-zero exit throws with the stderr surfaced", async () => {
44
+ test("a not-yet-deployed stack returns empty live state (pre-first-apply), not an error", async () => {
45
45
  spawnMock.mockResolvedValue({
46
46
  stdout: "",
47
- stderr: "Stack with id ghost does not exist",
47
+ stderr: "An error occurred (ValidationError) …: Stack with id ghost does not exist",
48
48
  exitCode: 254,
49
49
  });
50
- await expect(awsPlugin.exportResources!({ environment: "ghost" })).rejects.toThrow(
51
- /Failed to get template for stack "ghost".*does not exist/,
50
+ const ir = await awsPlugin.exportResources!({ environment: "ghost" });
51
+ expect(ir.resources).toEqual([]);
52
+ });
53
+
54
+ test("a genuine failure (not 'does not exist') still throws with the stderr surfaced", async () => {
55
+ spawnMock.mockResolvedValue({
56
+ stdout: "",
57
+ stderr: "An error occurred (AccessDenied) …: not authorized",
58
+ exitCode: 254,
59
+ });
60
+ await expect(awsPlugin.exportResources!({ environment: "prod" })).rejects.toThrow(
61
+ /Failed to get template for stack "prod".*AccessDenied/,
52
62
  );
53
63
  });
54
64
 
@@ -116,6 +116,22 @@ describe("Join intrinsic", () => {
116
116
  "Fn::Join": ["-", [{ Ref: "Prefix" }, "bucket"]],
117
117
  });
118
118
  });
119
+
120
+ test("accepts a single list-returning intrinsic (not only an array) (#517)", () => {
121
+ // Fn::Join's second arg legitimately takes a list-returning intrinsic
122
+ // (GetAtt of a list attr, Split, Ref to a List<>) — emitted as-is, no `.map`.
123
+ expect(Join(",", GetAtt("Zone", "NameServers")).toJSON()).toEqual({
124
+ "Fn::Join": [",", { "Fn::GetAtt": ["Zone", "NameServers"] }],
125
+ });
126
+ expect(Join(",", Split(",", "a,b")).toJSON()).toEqual({
127
+ "Fn::Join": [",", { "Fn::Split": [",", "a,b"] }],
128
+ });
129
+ });
130
+
131
+ test("throws a clear error for a non-array, non-intrinsic value (#517)", () => {
132
+ // @ts-expect-error — exercising the runtime guard
133
+ expect(() => Join(",", "oops")).toThrow(/array or a list-returning intrinsic/);
134
+ });
119
135
  });
120
136
 
121
137
  describe("Select intrinsic", () => {
package/src/intrinsics.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { INTRINSIC_MARKER, resolveIntrinsicValue, type Intrinsic } from "@intentius/chant/intrinsic";
1
+ import { INTRINSIC_MARKER, resolveIntrinsicValue, isIntrinsic, type Intrinsic } from "@intentius/chant/intrinsic";
2
2
  import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
3
3
  import { type Declarable } from "@intentius/chant/declarable";
4
4
  import { getLogicalName } from "@intentius/chant/utils";
@@ -129,22 +129,35 @@ export function If(conditionName: string, valueIfTrue: unknown, valueIfFalse: un
129
129
  export class JoinIntrinsic implements Intrinsic {
130
130
  readonly [INTRINSIC_MARKER] = true as const;
131
131
  private delimiter: string;
132
- private values: unknown[];
132
+ private values: unknown[] | Intrinsic;
133
133
 
134
- constructor(delimiter: string, values: unknown[]) {
134
+ constructor(delimiter: string, values: unknown[] | Intrinsic) {
135
135
  this.delimiter = delimiter;
136
136
  this.values = values;
137
137
  }
138
138
 
139
- toJSON(): { "Fn::Join": [string, unknown[]] } {
140
- return { "Fn::Join": [this.delimiter, this.values.map(resolveIntrinsicValue)] };
139
+ toJSON(): { "Fn::Join": [string, unknown] } {
140
+ // Fn::Join's second arg is either a literal list of values OR a single
141
+ // list-returning intrinsic (GetAtt of a list attr, Split, Ref to a List<>
142
+ // param). Only the array form gets `.map`; a lone intrinsic is emitted as-is
143
+ // (#517 — mapping over it dereferenced undefined and crashed the build).
144
+ const list = Array.isArray(this.values)
145
+ ? this.values.map(resolveIntrinsicValue)
146
+ : resolveIntrinsicValue(this.values);
147
+ return { "Fn::Join": [this.delimiter, list] };
141
148
  }
142
149
  }
143
150
 
144
151
  /**
145
- * Create a Join intrinsic
152
+ * Create a Join intrinsic. `values` is a literal array, or a single
153
+ * list-returning intrinsic (e.g. `Join(",", zone.NameServers)`).
146
154
  */
147
- export function Join(delimiter: string, values: unknown[]): JoinIntrinsic {
155
+ export function Join(delimiter: string, values: unknown[] | Intrinsic): JoinIntrinsic {
156
+ if (!Array.isArray(values) && !isIntrinsic(values)) {
157
+ throw new Error(
158
+ "Join(delimiter, values): values must be an array or a list-returning intrinsic (GetAtt/Split/Ref to a List)",
159
+ );
160
+ }
148
161
  return new JoinIntrinsic(delimiter, values);
149
162
  }
150
163
 
@@ -1,7 +1,19 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { awsPlugin } from "./plugin";
2
+ import { awsPlugin, stackDoesNotExist } from "./plugin";
3
3
  import { isLexiconPlugin } from "@intentius/chant/lexicon";
4
4
 
5
+ describe("stackDoesNotExist (pre-first-apply live state)", () => {
6
+ test("true for the CloudFormation 'does not exist' error", () => {
7
+ expect(stackDoesNotExist(
8
+ "An error occurred (ValidationError) when calling the DescribeStackResources operation: Stack with id prod does not exist",
9
+ )).toBe(true);
10
+ });
11
+ test("false for other failures (auth, network) — those still throw", () => {
12
+ expect(stackDoesNotExist("An error occurred (InvalidClientTokenId) ...")).toBe(false);
13
+ expect(stackDoesNotExist("Could not connect to the endpoint URL")).toBe(false);
14
+ });
15
+ });
16
+
5
17
  describe("awsPlugin", () => {
6
18
  // -----------------------------------------------------------------------
7
19
  // Basic interface
package/src/plugin.ts CHANGED
@@ -24,6 +24,13 @@ import { parseStackTemplate } from "./import/live-export";
24
24
  import { awsCompletions } from "./lsp/completions";
25
25
  import { awsHover } from "./lsp/hover";
26
26
 
27
+ /** True when a CloudFormation CLI error means the stack simply isn't there yet
28
+ * (`ValidationError … does not exist`) — the pre-first-apply state, which live
29
+ * queries should treat as "nothing deployed", not a failure. Exported for testing. */
30
+ export function stackDoesNotExist(stderr: string): boolean {
31
+ return /does not exist/i.test(stderr);
32
+ }
33
+
27
34
  /**
28
35
  * AWS CloudFormation lexicon plugin.
29
36
  *
@@ -497,6 +504,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
497
504
  ], process.env.AWS_ENDPOINT_URL));
498
505
 
499
506
  if (listResult.exitCode !== 0) {
507
+ // A stack that doesn't exist yet is the pre-first-apply state: nothing is
508
+ // deployed for this env, so there are no live resources (every declared
509
+ // resource is "pending") — not an error. Returning empty lets `lifecycle
510
+ // diff --live` / the overlay show pending nodes instead of failing hard.
511
+ if (stackDoesNotExist(listResult.stderr)) {
512
+ return resources;
513
+ }
500
514
  throw new Error(`Failed to describe stack "${stackName}": ${listResult.stderr}`);
501
515
  }
502
516
 
@@ -579,6 +593,12 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
579
593
  "--output", "json",
580
594
  ], process.env.AWS_ENDPOINT_URL));
581
595
  if (result.exitCode !== 0) {
596
+ // Not deployed yet → no template to export (nothing live), not an error.
597
+ // Keeps `chant graph --live` edge enrichment and `import --from` quiet
598
+ // before the first apply.
599
+ if (stackDoesNotExist(result.stderr)) {
600
+ return parseStackTemplate({ Resources: {} }, options.selector, options.owned);
601
+ }
582
602
  throw new Error(`Failed to get template for stack "${stackName}": ${result.stderr}`);
583
603
  }
584
604
 
@@ -3,7 +3,7 @@ import { awsSerializer } from "./serializer";
3
3
  import { AttrRef } from "@intentius/chant/attrref";
4
4
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
5
5
  import { LexiconOutput } from "@intentius/chant/lexicon-output";
6
- import { Sub } from "./intrinsics";
6
+ import { Sub, Join } from "./intrinsics";
7
7
  import { AWS } from "./pseudo";
8
8
  import { nestedStack, NestedStackOutputRef } from "./nested-stack";
9
9
  import { stackOutput } from "@intentius/chant/stack-output";
@@ -262,6 +262,23 @@ describe("intrinsic serialization", () => {
262
262
  });
263
263
 
264
264
  describe("LexiconOutput serialization", () => {
265
+ test("resolves AttrRef nested in a Join output to Fn::GetAtt, not the __attrRef envelope (chant#935)", () => {
266
+ const s1 = new MockBucket({ BucketName: "subnet-1" });
267
+ const s2 = new MockBucket({ BucketName: "subnet-2" });
268
+ (s1.arn as Record<string, unknown>)._setLogicalName("Subnet1");
269
+ (s2.arn as Record<string, unknown>)._setLogicalName("Subnet2");
270
+ const joined = new LexiconOutput(Join(":", [s1.arn, s2.arn]), "SubnetIds");
271
+ joined._setSourceEntity("Subnet1");
272
+ const entities = new Map<string, Declarable>();
273
+ entities.set("Subnet1", s1);
274
+ entities.set("Subnet2", s2);
275
+ const template = JSON.parse(awsSerializer.serialize(entities, [joined]));
276
+ const value = JSON.stringify(template.Outputs.SubnetIds.Value);
277
+ expect(value).not.toContain("__attrRef");
278
+ expect(value).toContain("Fn::GetAtt");
279
+ expect(value).toContain("Fn::Join");
280
+ });
281
+
265
282
  test("generates CF Outputs section for LexiconOutputs", () => {
266
283
  const bucket = new MockBucket({ BucketName: "data-bucket" });
267
284
  const lexiconOutput = new LexiconOutput(bucket.arn, "DataBucketArn");
@@ -305,6 +322,23 @@ describe("LexiconOutput serialization", () => {
305
322
  });
306
323
  });
307
324
 
325
+ test("resolves an AttrRef nested in an intrinsic output → Fn::GetAtt, not __attrRef (#935)", () => {
326
+ const b1 = new MockBucket({ BucketName: "b1" });
327
+ const b2 = new MockBucket({ BucketName: "b2" });
328
+ const r1 = new AttrRef(b1, "Arn"); r1._setLogicalName("B1");
329
+ const r2 = new AttrRef(b2, "Arn"); r2._setLogicalName("B2");
330
+ const joined = new LexiconOutput(Join(",", [r1, r2]), "oArns");
331
+
332
+ const entities = new Map<string, Declarable>();
333
+ entities.set("B1", b1);
334
+ entities.set("B2", b2);
335
+
336
+ const template = JSON.parse(awsSerializer.serialize(entities, [joined]) as string);
337
+ expect(template.Outputs.oArns.Value).toEqual({
338
+ "Fn::Join": [",", [{ "Fn::GetAtt": ["B1", "Arn"] }, { "Fn::GetAtt": ["B2", "Arn"] }]],
339
+ });
340
+ });
341
+
308
342
  test("omits Outputs section when no LexiconOutputs provided", () => {
309
343
  const entities = new Map<string, Declarable>();
310
344
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
@@ -364,6 +398,23 @@ describe("stackOutput serialization", () => {
364
398
  "Fn::GetAtt": ["MyBucket", "Arn"],
365
399
  });
366
400
  });
401
+
402
+ test("intrinsic-wrapped output (Join over a ref) → Fn::Join + Fn::GetAtt (#517)", () => {
403
+ const bucket = new MockBucket({ BucketName: "my-bucket" });
404
+ // Deliberately NOT pre-resolved — the serializer must set the nested ref's
405
+ // logical name from the entity map (resolveAttrRefs doesn't reach it here).
406
+ const arnRef = new AttrRef(bucket, "Arn");
407
+ const output = stackOutput(Join(",", arnRef));
408
+
409
+ const entities = new Map<string, Declarable>();
410
+ entities.set("MyBucket", bucket);
411
+ entities.set("MyBucketJoined", output as unknown as Declarable);
412
+
413
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
414
+ expect(template.Outputs.MyBucketJoined.Value).toEqual({
415
+ "Fn::Join": [",", { "Fn::GetAtt": ["MyBucket", "Arn"] }],
416
+ });
417
+ });
367
418
  });
368
419
 
369
420
  // ── Nested Stack Serialization ──────────────────────────
package/src/serializer.ts CHANGED
@@ -7,6 +7,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
7
7
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
8
8
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
9
9
  import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
10
+ import { isAttrRefLike } from "@intentius/chant/utils";
10
11
  import { resolveDependsOn } from "@intentius/chant/resource-attributes";
11
12
  import { isDefaultTags, type TagEntry } from "./default-tags";
12
13
  import { loadTaggableResources } from "./taggable";
@@ -101,6 +102,31 @@ function toCFValue(value: unknown, entityNames: Map<Declarable, string>): unknow
101
102
  return walkValue(value, entityNames, cfnVisitor(entityNames));
102
103
  }
103
104
 
105
+ /**
106
+ * Set logical names on any AttrRefs nested inside a value (e.g. inside a `Join`
107
+ * that a `stackOutput` exports). resolveAttrRefs only reaches entity attributes,
108
+ * not refs buried in an output's intrinsic — without this the walker would throw
109
+ * "logical name not set" for them (#517).
110
+ */
111
+ function resolveNestedAttrRefs(
112
+ value: unknown,
113
+ entityNames: Map<Declarable, string>,
114
+ seen = new Set<unknown>(),
115
+ ): void {
116
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
117
+ seen.add(value);
118
+ if (isAttrRefLike(value)) {
119
+ if (!value.getLogicalName()) {
120
+ const parent = value.parent.deref();
121
+ const parentName = parent ? entityNames.get(parent as Declarable) : undefined;
122
+ if (parentName) value._setLogicalName(parentName);
123
+ }
124
+ return;
125
+ }
126
+ const children = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>);
127
+ for (const child of children) resolveNestedAttrRefs(child, entityNames, seen);
128
+ }
129
+
104
130
  /**
105
131
  * Convert entity props to CF properties
106
132
  */
@@ -289,31 +315,42 @@ function serializeToTemplate(
289
315
  template.Outputs = {};
290
316
  }
291
317
  const stackOutput = entity as StackOutput;
292
- const ref = stackOutput.sourceRef;
293
- const logicalName = ref.getLogicalName();
294
- if (logicalName) {
318
+ // Typed `unknown` so `isAttrRefLike` narrows cleanly regardless of how the
319
+ // AttrRef type resolves across the workspace/published boundary.
320
+ const ref: unknown = stackOutput.sourceRef;
321
+ let value: unknown;
322
+ if (isAttrRefLike(ref)) {
323
+ const logicalName = ref.getLogicalName();
324
+ if (!logicalName) continue;
295
325
  // Use Ref for primary identifier ("Id") since not all resources
296
326
  // support Fn::GetAtt for their primary identifier (e.g. ACM Certificate).
297
327
  // Ref always returns the primary identifier for any CF resource.
298
- const output: CFOutput = {
299
- Value: ref.attribute === "Id"
300
- ? { Ref: logicalName }
301
- : { "Fn::GetAtt": [logicalName, ref.attribute] },
302
- };
303
- if (stackOutput.description) {
304
- output.Description = stackOutput.description;
305
- }
306
- template.Outputs[name] = output;
328
+ value = ref.attribute === "Id" ? { Ref: logicalName } : { "Fn::GetAtt": [logicalName, ref.attribute] };
329
+ } else {
330
+ // An intrinsic wrapping refs (e.g. Join(",", zone.NameServers), #517):
331
+ // resolve the nested AttrRefs' logical names from the entity map, then
332
+ // serialize through the CF walker → {Fn::Join:[",",{Fn::GetAtt:[…]}]}.
333
+ resolveNestedAttrRefs(ref, entityNames);
334
+ value = toCFValue(ref, entityNames);
335
+ }
336
+ const output: CFOutput = { Value: value };
337
+ if (stackOutput.description) {
338
+ output.Description = stackOutput.description;
307
339
  }
340
+ template.Outputs[name] = output;
308
341
  }
309
342
  }
310
343
 
311
- // Add CF Outputs for LexiconOutputs produced by this lexicon
344
+ // Add CF Outputs for LexiconOutputs produced by this lexicon. Run the value
345
+ // through the CF walker so an AttrRef nested in an intrinsic (e.g.
346
+ // Join(",", [subnet1.SubnetId, …])) becomes Fn::GetAtt instead of leaking the
347
+ // generic `{__attrRef}` envelope — the same conversion resource Properties get
348
+ // (#935). A bare Fn::GetAtt value walks through unchanged.
312
349
  if (outputs && outputs.length > 0) {
313
350
  template.Outputs = template.Outputs ?? {};
314
351
  for (const output of outputs) {
315
352
  template.Outputs[output.outputName] = {
316
- Value: output.getOutputValue(),
353
+ Value: toCFValue(output.getOutputValue(), entityNames),
317
354
  };
318
355
  }
319
356
  }