@intentius/chant-lexicon-aws 0.27.0 → 0.29.0

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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "9deee69f09d9cec4b892b1bb97a0dad9b3354bc794cab5acad4d6fa237a0a396",
4
+ "manifest.json": "eac9b0bddda5b4b10352c04ef292ce7f707f72d7b9fe74a06426331963c8c8c8",
5
5
  "meta.json": "8d6445b7ea5f7803c83cf49daab639b1df66388c1512f466b888b0ad57f60a9a",
6
6
  "types/index.d.ts": "da899ff303a18a7fdb270570bce784703283d4081f806e69ac8c9dfd4bac7c50",
7
7
  "rules/hardcoded-region.ts": "5a0eaf7ab391231fe6cd51426ece29539cb4b36f31c8dd060956638fed55722a",
@@ -59,5 +59,5 @@
59
59
  "skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
60
60
  "skills/chant-aws-carve-terraform.md": "f4c6fe1c702250665f90d82b3bdaba5ea50ae75e380a8ae321dad6eb1c39683e"
61
61
  },
62
- "composite": "60753f891c32263de6e6c9116b8acef09ddcc19d0fd93472d2008aec80052cfe"
62
+ "composite": "46d41420d193e652b241c1388259670efee2423e1a8026507cd1fd5b2d5175a4"
63
63
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "AWS",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAA+G,MAAM,0BAA0B,CAAC;AAwB3K;;sFAEsF;AACtF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS,EAAE,aAkxBvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAkI,MAAM,0BAA0B,CAAC;AAwB9L;;sFAEsF;AACtF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS,EAAE,aAyyBvB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAiYlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAoE3B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA0YlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAoE3B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -80,7 +80,7 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "peerDependencies": {
83
- "@intentius/chant": "^0.27.0",
83
+ "@intentius/chant": "^0.29.0",
84
84
  "typescript": "^5.9.3"
85
85
  }
86
86
  }
@@ -2,10 +2,18 @@ import { describe, test, expect, vi, beforeEach } from "vitest";
2
2
 
3
3
  // AWS exportResources reaches the cloud through the runtime adapter's spawn
4
4
  // (not node:child_process), so the I/O seam is the runtime-adapter module.
5
+ // Partial mock (`importOriginal`) rather than a full replacement: this module
6
+ // is reachable — via `@intentius/chant`'s own root barrel, not just this
7
+ // test's direct imports — from other real exports the plugin/import path
8
+ // touches (e.g. `moduleDir`, which `../../lint/config.ts` calls at module
9
+ // scope), so replacing the whole module wholesale breaks anything that
10
+ // transitively loads one of those, for reasons entirely unrelated to what
11
+ // this test is mocking (`spawn`).
5
12
  const spawnMock = vi.fn();
6
- vi.mock("@intentius/chant/runtime-adapter", () => ({
7
- getRuntime: () => ({ spawn: spawnMock }),
8
- }));
13
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
15
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
16
+ });
9
17
 
10
18
  import { awsPlugin } from "../plugin";
11
19
 
@@ -11,14 +11,25 @@ import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
 
14
+ // Partial mock (`importOriginal`) rather than a full replacement: this module
15
+ // is reachable — via `@intentius/chant`'s own root barrel, not just this
16
+ // test's direct imports — from other real exports the plugin/import path
17
+ // touches (e.g. `moduleDir`, which `../../lint/config.ts` calls at module
18
+ // scope), so replacing the whole module wholesale breaks anything that
19
+ // transitively loads one of those, for reasons entirely unrelated to what
20
+ // this test is mocking (`spawn`).
14
21
  const spawnMock = vi.fn();
15
- vi.mock("@intentius/chant/runtime-adapter", () => ({
16
- getRuntime: () => ({ spawn: spawnMock }),
17
- }));
22
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
24
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
25
+ });
18
26
 
19
27
  const { awsPlugin } = await import("./plugin");
20
28
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
21
29
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
30
+ const { normalizeObservation } = await import("@intentius/chant/observation");
31
+ const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
32
+ const { describeObservationConformance } = await import("@intentius/chant-test-utils");
22
33
 
23
34
  const liveTemplate = {
24
35
  AWSTemplateFormatVersion: "2010-09-09",
@@ -76,12 +87,18 @@ describe("aws lifecycle integration (#163)", () => {
76
87
  return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
77
88
  });
78
89
 
79
- const observedNow = await awsPlugin.describeResources!({
80
- environment: "prod",
81
- buildOutput: "",
82
- entityNames: ["MyBucket"],
83
- });
90
+ const { resources: observedNow } = normalizeObservation(
91
+ await awsPlugin.describeResources!({
92
+ environment: "prod",
93
+ buildOutput: "",
94
+ entityNames: ["MyBucket"],
95
+ entities: new Map(),
96
+ }),
97
+ );
84
98
  expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
99
+ // Ownership verdicts are total (#1089): describe-stack-resources carries no
100
+ // tags, so the verdict is an explicit `unknown`, not a missing field.
101
+ expect(observedNow.MyBucket?.ownership).toBe("unknown");
85
102
 
86
103
  // Declared "MyQueue" is absent from live → create; live "MyBucket" is
87
104
  // undeclared and unmarked → adopt (never delete without ownership).
@@ -136,4 +153,129 @@ describe("aws lifecycle integration (#163)", () => {
136
153
  expect(obs).toBeNull();
137
154
  });
138
155
  });
156
+
157
+ /**
158
+ * The #1089 chain on the real plugin: a CloudFormation read that fails for
159
+ * any reason other than "stack does not exist" reports every declared entity
160
+ * NOT-OBSERVED, and that survives describe → plan → component status.
161
+ */
162
+ test("tri-state chain: a failed stack read stays unobserved through describe → plan → status (#1089)", async () => {
163
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "Unable to locate credentials", exitCode: 255 });
164
+
165
+ const observed = normalizeObservation(
166
+ await awsPlugin.describeResources!({
167
+ environment: "prod",
168
+ buildOutput: "",
169
+ entityNames: ["MyBucket", "MyQueue"],
170
+ entities: new Map(),
171
+ }),
172
+ );
173
+ expect(observed.resources).toEqual({});
174
+ expect(observed.unobserved.MyBucket.reason).toBe("no-credentials");
175
+
176
+ const cs = buildChangeSet("prod", {
177
+ declared: new Set(["MyBucket", "MyQueue"]),
178
+ observedNow: observed.resources,
179
+ observedThen: undefined,
180
+ unobserved: observed.unobserved,
181
+ });
182
+ expect(cs.entries.map((e) => e.action)).toEqual(["unobserved", "unobserved"]);
183
+
184
+ const rows = reconcileStatus("prod", [
185
+ { component: "MyBucket", env: "prod", digest: "sha256:abc", gitSha: "g", runId: "r", timestamp: "2026-01-01T00:00:00Z", actor: "ci" },
186
+ ], { liveEvidence: liveEvidenceFromChangeSet(cs) });
187
+ expect(rows[0].reconciliation).toBe("unknown");
188
+ expect(rows[0].live).toBeUndefined();
189
+ expect(rows[0].unobserved?.reason).toBe("no-credentials");
190
+ });
191
+
192
+ test("a stack that does not exist is a real absence — every declared entity is a create", async () => {
193
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "ValidationError: Stack with id prod does not exist", exitCode: 255 });
194
+
195
+ const observed = normalizeObservation(
196
+ await awsPlugin.describeResources!({
197
+ environment: "prod",
198
+ buildOutput: "",
199
+ entityNames: ["MyBucket"],
200
+ entities: new Map(),
201
+ }),
202
+ );
203
+ expect(observed.resources).toEqual({});
204
+ expect(observed.unobserved).toEqual({});
205
+
206
+ const cs = buildChangeSet("prod", {
207
+ declared: new Set(["MyBucket"]),
208
+ observedNow: observed.resources,
209
+ observedThen: undefined,
210
+ unobserved: observed.unobserved,
211
+ });
212
+ expect(cs.entries[0].action).toBe("create");
213
+ });
214
+ });
215
+
216
+ // The shared conformance suite (#1089).
217
+ describeObservationConformance({
218
+ lexicon: "aws",
219
+ scenarios: [
220
+ {
221
+ name: "a stack read that fails on credentials",
222
+ declared: ["MyBucket", "MyQueue"],
223
+ expectUnobserved: ["MyBucket", "MyQueue"],
224
+ run: () => {
225
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "Unable to locate credentials", exitCode: 255 });
226
+ return awsPlugin.describeResources!({
227
+ environment: "prod",
228
+ buildOutput: "",
229
+ entityNames: ["MyBucket", "MyQueue"],
230
+ entities: new Map(),
231
+ });
232
+ },
233
+ },
234
+ {
235
+ name: "a stack that does not exist yet",
236
+ declared: ["MyBucket"],
237
+ expectAbsent: ["MyBucket"],
238
+ run: () => {
239
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "ValidationError: Stack with id prod does not exist", exitCode: 255 });
240
+ return awsPlugin.describeResources!({
241
+ environment: "prod",
242
+ buildOutput: "",
243
+ entityNames: ["MyBucket"],
244
+ entities: new Map(),
245
+ });
246
+ },
247
+ },
248
+ {
249
+ name: "a healthy stack read",
250
+ declared: ["MyBucket"],
251
+ expectPresent: ["MyBucket"],
252
+ run: () => {
253
+ spawnMock.mockImplementation((argv?: string[]) =>
254
+ Promise.resolve(
255
+ argv?.includes("describe-stack-resources")
256
+ ? ok(
257
+ JSON.stringify({
258
+ StackResources: [
259
+ {
260
+ LogicalResourceId: "MyBucket",
261
+ ResourceType: "AWS::S3::Bucket",
262
+ PhysicalResourceId: "my-bucket",
263
+ ResourceStatus: "CREATE_COMPLETE",
264
+ Timestamp: "2026-01-01T00:00:00Z",
265
+ },
266
+ ],
267
+ }),
268
+ )
269
+ : ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })),
270
+ ),
271
+ );
272
+ return awsPlugin.describeResources!({
273
+ environment: "prod",
274
+ buildOutput: "",
275
+ entityNames: ["MyBucket"],
276
+ entities: new Map(),
277
+ });
278
+ },
279
+ },
280
+ ],
139
281
  });
package/src/plugin.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "module";
2
2
  import { detectTemplate } from "./detect";
3
- import type { LexiconPlugin, IntrinsicDef, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
3
+ import type { LexiconPlugin, IntrinsicDef, ObservationResult, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
4
4
  const require = createRequire(import.meta.url);
5
5
  import type { LintRule } from "@intentius/chant/lint/rule";
6
6
  import type { TemplateParser } from "@intentius/chant/import/parser";
@@ -534,8 +534,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
534
534
  entityNames: string[];
535
535
  stack?: string;
536
536
  owned?: boolean;
537
- }): Promise<Record<string, ResourceMetadata>> {
537
+ }): Promise<ObservationResult> {
538
538
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
539
+ const { observation, unobservedAll } = await import("@intentius/chant/observation");
539
540
  const rt = getRuntime();
540
541
  const resources: Record<string, ResourceMetadata> = {};
541
542
 
@@ -544,7 +545,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
544
545
  // determined here. Degrade to detect-only rather than silently filtering.
545
546
  // eslint-disable-next-line no-console
546
547
  console.warn(
547
- "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all; use `chant import --from <env> --owned` for ownership-filtered export",
548
+ "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
548
549
  );
549
550
  }
550
551
 
@@ -565,12 +566,26 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
565
566
  if (listResult.exitCode !== 0) {
566
567
  // A stack that doesn't exist yet is the pre-first-apply state: nothing is
567
568
  // deployed for this env, so there are no live resources (every declared
568
- // resource is "pending") — not an error. Returning empty lets `lifecycle
569
- // diff --live` / the overlay show pending nodes instead of failing hard.
569
+ // resource is "pending") — not an error. That is a real absence, so the
570
+ // empty result is the honest one and `create` is the right proposal.
570
571
  if (stackDoesNotExist(listResult.stderr)) {
571
- return resources;
572
+ return observation(resources);
572
573
  }
573
- throw new Error(`Failed to describe stack "${stackName}": ${listResult.stderr}`);
574
+ // Any other failure (credentials, throttling, a region that can't be
575
+ // reached) establishes nothing about what is deployed. Reporting every
576
+ // declared entity as NOT-OBSERVED (#1089) is what keeps a broken read
577
+ // from arriving downstream as "none of this exists".
578
+ const reason = /credential|token|expired|AccessDenied|not authorized|UnauthorizedOperation/i.test(listResult.stderr)
579
+ ? "no-credentials"
580
+ : "read-failed";
581
+ return observation(
582
+ {},
583
+ unobservedAll(
584
+ options.entityNames,
585
+ reason,
586
+ `describe-stack-resources failed for stack "${stackName}": ${listResult.stderr.trim().split("\n")[0] ?? ""}`,
587
+ ),
588
+ );
574
589
  }
575
590
 
576
591
  const data = JSON.parse(listResult.stdout) as {
@@ -627,11 +642,19 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
627
642
  physicalId: stackResource.PhysicalResourceId,
628
643
  status: stackResource.ResourceStatus,
629
644
  lastUpdated: stackResource.Timestamp,
645
+ // Total verdict (#1089): describe-stack-resources returns no tags, so
646
+ // this path cannot read the ownership marker. Say `unknown` explicitly
647
+ // rather than leaving the field off and letting each consumer guess —
648
+ // the change set never escalates `unknown` to a delete.
649
+ ownership: "unknown",
630
650
  attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
631
651
  };
632
652
  }
633
653
 
634
- return resources;
654
+ // Every entity the stack answered for was answered for: an entity the
655
+ // template doesn't carry is genuinely not in this stack, which is an
656
+ // absence, not a hole.
657
+ return observation(resources);
635
658
  },
636
659
 
637
660
  async describeStackStatus(options: { environment: string; stack: string }): Promise<StackStatusObservation | null> {
@@ -941,3 +941,31 @@ describe("default tags serialization", () => {
941
941
  expect(template.Resources.MyBucket.Properties.Tags).toBeUndefined();
942
942
  });
943
943
  });
944
+
945
+
946
+ describe("stack output exports and literals", () => {
947
+ test("exportName emits Output.Export.Name", () => {
948
+ const bucket = new MockBucket({ BucketName: "my-bucket" });
949
+ const arnRef = new AttrRef(bucket, "Arn");
950
+ arnRef._setLogicalName("MyBucket");
951
+ const output = stackOutput(arnRef, { exportName: "my-stack-BucketArn" });
952
+
953
+ const entities = new Map<string, Declarable>();
954
+ entities.set("MyBucket", bucket);
955
+ entities.set("MyBucketArn", output as unknown as Declarable);
956
+
957
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
958
+ expect(template.Outputs.MyBucketArn.Export).toEqual({ Name: "my-stack-BucketArn" });
959
+ });
960
+
961
+ test("literal output serializes its string value with export", () => {
962
+ const output = stackOutput("22", { lexicon: "aws", exportName: "my-stack-OpenSSHPort" });
963
+
964
+ const entities = new Map<string, Declarable>();
965
+ entities.set("OpenSSHPort", output as unknown as Declarable);
966
+
967
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
968
+ expect(template.Outputs.OpenSSHPort.Value).toBe("22");
969
+ expect(template.Outputs.OpenSSHPort.Export).toEqual({ Name: "my-stack-OpenSSHPort" });
970
+ });
971
+ });
package/src/serializer.ts CHANGED
@@ -341,7 +341,10 @@ function serializeToTemplate(
341
341
  // AttrRef type resolves across the workspace/published boundary.
342
342
  const ref: unknown = stackOutput.sourceRef;
343
343
  let value: unknown;
344
- if (isAttrRefLike(ref)) {
344
+ if (typeof ref === "string") {
345
+ // Literal output (constants a stack publishes, e.g. a port number).
346
+ value = ref;
347
+ } else if (isAttrRefLike(ref)) {
345
348
  const logicalName = ref.getLogicalName();
346
349
  if (!logicalName) continue;
347
350
  // Use Ref for primary identifier ("Id") since not all resources
@@ -359,6 +362,12 @@ function serializeToTemplate(
359
362
  if (stackOutput.description) {
360
363
  output.Description = stackOutput.description;
361
364
  }
365
+ // Read defensively: a project may pair this lexicon with an older
366
+ // published core whose StackOutput type predates exportName.
367
+ const exportName = (stackOutput as { exportName?: string }).exportName;
368
+ if (exportName) {
369
+ output.Export = { Name: exportName };
370
+ }
362
371
  template.Outputs[name] = output;
363
372
  }
364
373
  }