@intentius/chant-lexicon-aws 0.1.13 → 0.1.15

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.
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ // AWS exportResources reaches the cloud through the runtime adapter's spawn
4
+ // (not node:child_process), so the I/O seam is the runtime-adapter module.
5
+ const spawnMock = vi.fn();
6
+ vi.mock("@intentius/chant/runtime-adapter", () => ({
7
+ getRuntime: () => ({ spawn: spawnMock }),
8
+ }));
9
+
10
+ import { awsPlugin } from "../plugin";
11
+
12
+ const liveTemplate = {
13
+ AWSTemplateFormatVersion: "2010-09-09",
14
+ Resources: {
15
+ MyBucket: {
16
+ Type: "AWS::S3::Bucket",
17
+ Properties: { BucketName: "my-bucket" },
18
+ },
19
+ },
20
+ };
21
+
22
+ describe("aws exportResources I/O glue (#160)", () => {
23
+ beforeEach(() => spawnMock.mockReset());
24
+
25
+ test("spawns `cloudformation get-template` for the env stack and maps the body", async () => {
26
+ spawnMock.mockResolvedValue({
27
+ stdout: JSON.stringify({ TemplateBody: liveTemplate }),
28
+ stderr: "",
29
+ exitCode: 0,
30
+ });
31
+ const ir = await awsPlugin.exportResources!({ environment: "prod" });
32
+ expect(spawnMock).toHaveBeenCalledTimes(1);
33
+ const argv = spawnMock.mock.calls[0][0] as string[];
34
+ expect(argv).toEqual(
35
+ expect.arrayContaining([
36
+ "aws", "cloudformation", "get-template",
37
+ "--stack-name", "prod",
38
+ "--output", "json",
39
+ ]),
40
+ );
41
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyBucket"]);
42
+ });
43
+
44
+ test("a non-zero exit throws with the stderr surfaced", async () => {
45
+ spawnMock.mockResolvedValue({
46
+ stdout: "",
47
+ stderr: "Stack with id ghost does not exist",
48
+ exitCode: 254,
49
+ });
50
+ await expect(awsPlugin.exportResources!({ environment: "ghost" })).rejects.toThrow(
51
+ /Failed to get template for stack "ghost".*does not exist/,
52
+ );
53
+ });
54
+
55
+ test("a stack with no TemplateBody throws", async () => {
56
+ spawnMock.mockResolvedValue({ stdout: JSON.stringify({}), stderr: "", exitCode: 0 });
57
+ await expect(awsPlugin.exportResources!({ environment: "prod" })).rejects.toThrow(
58
+ /no TemplateBody/,
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { parseStackTemplate } from "./live-export";
3
+ import { CFGenerator } from "./generator";
4
+
5
+ // Mimics what `aws cloudformation get-template --output json` returns:
6
+ // a JSON object under TemplateBody.
7
+ const liveTemplate = {
8
+ AWSTemplateFormatVersion: "2010-09-09",
9
+ Resources: {
10
+ MyBucket: {
11
+ Type: "AWS::S3::Bucket",
12
+ Properties: { BucketName: "my-bucket", VersioningConfiguration: { Status: "Enabled" } },
13
+ },
14
+ MyQueue: {
15
+ Type: "AWS::SQS::Queue",
16
+ Properties: { QueueName: "my-queue" },
17
+ },
18
+ },
19
+ };
20
+
21
+ describe("AWS exportResources mapping (#115)", () => {
22
+ test("maps a live CloudFormation template body to export IR", () => {
23
+ const ir = parseStackTemplate(liveTemplate);
24
+ expect(ir.resources.map((r) => r.logicalId).sort()).toEqual(["MyBucket", "MyQueue"]);
25
+ const bucket = ir.resources.find((r) => r.logicalId === "MyBucket")!;
26
+ expect(bucket.type).toBe("AWS::S3::Bucket");
27
+ expect(bucket.properties.BucketName).toBe("my-bucket");
28
+ });
29
+
30
+ test("accepts a stringified template body (YAML-origin stacks)", () => {
31
+ const ir = parseStackTemplate(JSON.stringify(liveTemplate));
32
+ expect(ir.resources).toHaveLength(2);
33
+ });
34
+
35
+ test("selector by name narrows the export", () => {
36
+ const ir = parseStackTemplate(liveTemplate, { name: "MyQueue" });
37
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyQueue"]);
38
+ });
39
+
40
+ test("selector by type narrows the export", () => {
41
+ const ir = parseStackTemplate(liveTemplate, { type: "AWS::S3::Bucket" });
42
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyBucket"]);
43
+ });
44
+
45
+ test("owned filter keeps only resources carrying the chant marker (#120)", () => {
46
+ const mixed = {
47
+ AWSTemplateFormatVersion: "2010-09-09",
48
+ Resources: {
49
+ Mine: {
50
+ Type: "AWS::S3::Bucket",
51
+ Properties: {
52
+ BucketName: "mine",
53
+ Tags: [
54
+ { Key: "chant:managed-by", Value: "chant" },
55
+ { Key: "chant:stack", Value: "billing" },
56
+ ],
57
+ },
58
+ },
59
+ Theirs: {
60
+ Type: "AWS::S3::Bucket",
61
+ Properties: { BucketName: "theirs", Tags: [{ Key: "team", Value: "other" }] },
62
+ },
63
+ },
64
+ };
65
+ const ir = parseStackTemplate(mixed, undefined, true);
66
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["Mine"]);
67
+ });
68
+
69
+ test("export IR feeds CFGenerator (templateGenerator) unchanged", () => {
70
+ const ir = parseStackTemplate(liveTemplate);
71
+ const files = new CFGenerator().generate(ir);
72
+ expect(files.length).toBeGreaterThan(0);
73
+ const all = files.map((f) => f.content).join("\n");
74
+ // CFGenerator emits chant TypeScript (constructors), not raw CFN type strings.
75
+ expect(all).toContain("new Bucket(");
76
+ expect(all).toContain("BucketName");
77
+ });
78
+ });
@@ -0,0 +1,38 @@
1
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
2
+ import { hasOwnershipMarker, tagArrayToMap } from "@intentius/chant/ownership";
3
+ import { CFParser } from "./parser";
4
+
5
+ /**
6
+ * Map a live CloudFormation template body to full-fidelity export IR.
7
+ *
8
+ * `aws cloudformation get-template` returns the template body as a JSON object
9
+ * (for JSON templates) or a string (for YAML). `CFParser` already turns either
10
+ * into `TemplateIR` — the same IR the import path uses — so the result feeds
11
+ * `templateGenerator()` (CFGenerator) unchanged. Pure: all I/O stays in the
12
+ * caller.
13
+ */
14
+ export function parseStackTemplate(
15
+ templateBody: unknown,
16
+ selector?: ResourceSelector,
17
+ owned?: boolean,
18
+ ): ExportedTemplate {
19
+ const content =
20
+ typeof templateBody === "string" ? templateBody : JSON.stringify(templateBody);
21
+ const ir = new CFParser().parse(content);
22
+
23
+ const hasSelector = selector && (selector.type !== undefined || selector.name !== undefined);
24
+ if (!hasSelector && !owned) return ir;
25
+
26
+ return {
27
+ ...ir,
28
+ resources: ir.resources.filter((r) => {
29
+ if (selector?.type !== undefined && r.type !== selector.type) return false;
30
+ if (selector?.name !== undefined && r.logicalId !== selector.name) return false;
31
+ if (owned) {
32
+ const tags = (r.properties as { Tags?: Array<{ Key?: string; Value?: unknown }> }).Tags;
33
+ if (!hasOwnershipMarker(tagArrayToMap(tags), "aws-tag")) return false;
34
+ }
35
+ return true;
36
+ }),
37
+ };
38
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cross-lexicon lifecycle integration (#163) — AWS row.
3
+ *
4
+ * Drives the REAL awsPlugin through core's live-import driver
5
+ * (`liveImportFromPlugins`) and the changeset path (`buildChangeSet`), with the
6
+ * cloud edge (the runtime adapter's spawn) mocked. Proves the seam between core
7
+ * and a real lexicon — not a `createMockPlugin` fixture.
8
+ */
9
+ import { describe, test, expect, vi, beforeEach } from "vitest";
10
+ import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const spawnMock = vi.fn();
15
+ vi.mock("@intentius/chant/runtime-adapter", () => ({
16
+ getRuntime: () => ({ spawn: spawnMock }),
17
+ }));
18
+
19
+ const { awsPlugin } = await import("./plugin");
20
+ const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
21
+ const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
22
+
23
+ const liveTemplate = {
24
+ AWSTemplateFormatVersion: "2010-09-09",
25
+ Resources: {
26
+ MyBucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: "my-bucket" } },
27
+ },
28
+ };
29
+
30
+ const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
31
+
32
+ describe("aws lifecycle integration (#163)", () => {
33
+ beforeEach(() => spawnMock.mockReset());
34
+
35
+ test("live-import driver: real exportResources → IR → generated source", async () => {
36
+ spawnMock.mockResolvedValue(ok(JSON.stringify({ TemplateBody: liveTemplate })));
37
+ const output = mkdtempSync(join(tmpdir(), "chant-aws-li-"));
38
+ try {
39
+ const result = await liveImportFromPlugins([awsPlugin], {
40
+ environment: "prod",
41
+ output,
42
+ force: true,
43
+ });
44
+ expect(result.success).toBe(true);
45
+ expect(result.generatedFiles.length).toBeGreaterThan(0);
46
+ const all = readdirSync(output)
47
+ .map((f) => readFileSync(join(output, f), "utf-8"))
48
+ .join("\n");
49
+ expect(all).toContain("new Bucket(");
50
+ expect(all).toContain("BucketName");
51
+ } finally {
52
+ rmSync(output, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
57
+ // describe-stack-resources then describe-stacks.
58
+ spawnMock.mockImplementation((argv?: string[]) => {
59
+ if (argv?.includes("describe-stack-resources")) {
60
+ return Promise.resolve(
61
+ ok(
62
+ JSON.stringify({
63
+ StackResources: [
64
+ {
65
+ LogicalResourceId: "MyBucket",
66
+ ResourceType: "AWS::S3::Bucket",
67
+ PhysicalResourceId: "my-bucket",
68
+ ResourceStatus: "CREATE_COMPLETE",
69
+ Timestamp: "2026-01-01T00:00:00Z",
70
+ },
71
+ ],
72
+ }),
73
+ ),
74
+ );
75
+ }
76
+ return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
77
+ });
78
+
79
+ const observedNow = await awsPlugin.describeResources!({
80
+ environment: "prod",
81
+ buildOutput: "",
82
+ entityNames: ["MyBucket"],
83
+ });
84
+ expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
85
+
86
+ // Declared "MyQueue" is absent from live → create; live "MyBucket" is
87
+ // undeclared and unmarked → adopt (never delete without ownership).
88
+ const cs = buildChangeSet("prod", {
89
+ declared: new Set(["MyQueue"]),
90
+ observedNow,
91
+ observedThen: undefined,
92
+ });
93
+ const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
94
+ expect(byName.MyQueue).toBe("create");
95
+ expect(byName.MyBucket).toBe("adopt");
96
+
97
+ // Declared + live with no drift → noop.
98
+ const cs2 = buildChangeSet("prod", {
99
+ declared: new Set(["MyBucket"]),
100
+ observedNow,
101
+ observedThen: undefined,
102
+ });
103
+ expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
104
+ });
105
+ });
package/src/plugin.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "module";
2
- import type { LexiconPlugin, IntrinsicDef, ResourceMetadata } from "@intentius/chant/lexicon";
2
+ import type { LexiconPlugin, IntrinsicDef, ResourceMetadata, ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
3
3
  const require = createRequire(import.meta.url);
4
4
  import type { LintRule } from "@intentius/chant/lint/rule";
5
5
  import type { TemplateParser } from "@intentius/chant/import/parser";
@@ -13,6 +13,7 @@ import { fileURLToPath } from "url";
13
13
  import { awsSerializer } from "./serializer";
14
14
  import { CFParser } from "./import/parser";
15
15
  import { CFGenerator } from "./import/generator";
16
+ import { parseStackTemplate } from "./import/live-export";
16
17
  import { awsCompletions } from "./lsp/completions";
17
18
  import { awsHover } from "./lsp/hover";
18
19
 
@@ -475,11 +476,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
475
476
  environment: string;
476
477
  buildOutput: string;
477
478
  entityNames: string[];
479
+ owned?: boolean;
478
480
  }): Promise<Record<string, ResourceMetadata>> {
479
481
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
480
482
  const rt = getRuntime();
481
483
  const resources: Record<string, ResourceMetadata> = {};
482
484
 
485
+ if (options.owned) {
486
+ // describe-stack-resources does not return tags, so ownership cannot be
487
+ // determined here. Degrade to detect-only rather than silently filtering.
488
+ // eslint-disable-next-line no-console
489
+ console.warn(
490
+ "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all; use `chant import --from <env> --owned` for ownership-filtered export",
491
+ );
492
+ }
493
+
483
494
  // Derive stack name: environment-based convention
484
495
  // Try to parse the build output to detect stack name from Metadata or use convention
485
496
  const stackName = `${options.environment}`;
@@ -556,6 +567,35 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
556
567
  return resources;
557
568
  },
558
569
 
570
+ async exportResources(options: {
571
+ environment: string;
572
+ selector?: ResourceSelector;
573
+ owned?: boolean;
574
+ }): Promise<ExportedTemplate> {
575
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
576
+ const rt = getRuntime();
577
+
578
+ // Same stack-name convention as describeResources.
579
+ const stackName = `${options.environment}`;
580
+
581
+ const result = await rt.spawn([
582
+ "aws", "cloudformation", "get-template",
583
+ "--stack-name", stackName,
584
+ "--template-stage", "Original",
585
+ "--output", "json",
586
+ ]);
587
+ if (result.exitCode !== 0) {
588
+ throw new Error(`Failed to get template for stack "${stackName}": ${result.stderr}`);
589
+ }
590
+
591
+ const parsed = JSON.parse(result.stdout) as { TemplateBody?: unknown };
592
+ if (parsed.TemplateBody === undefined) {
593
+ throw new Error(`Stack "${stackName}" returned no TemplateBody`);
594
+ }
595
+
596
+ return parseStackTemplate(parsed.TemplateBody, options.selector, options.owned);
597
+ },
598
+
559
599
  mcpTools() {
560
600
  return [
561
601
  {
@@ -0,0 +1,37 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { awsSerializer } from "./serializer";
3
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
+ import { AttrRef } from "@intentius/chant/attrref";
5
+
6
+ class MockBucket implements Declarable {
7
+ readonly [DECLARABLE_MARKER] = true as const;
8
+ readonly lexicon = "aws";
9
+ readonly entityType = "AWS::S3::Bucket";
10
+ readonly arn: AttrRef;
11
+ readonly props: Record<string, unknown>;
12
+ constructor(props: Record<string, unknown> = {}) {
13
+ this.props = props;
14
+ this.arn = new AttrRef(this, "Arn");
15
+ }
16
+ }
17
+
18
+ describe("awsSerializer ownership stamping (#119)", () => {
19
+ test("stamps the ownership marker as tags when context.ownership is set", () => {
20
+ const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
21
+ const out = awsSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
22
+ const template = JSON.parse(out as string);
23
+ const tags = template.Resources.MyBucket.Properties.Tags as Array<{ Key: string; Value: string }>;
24
+ const byKey = Object.fromEntries(tags.map((t) => [t.Key, t.Value]));
25
+ expect(byKey["chant:managed-by"]).toBe("chant");
26
+ expect(byKey["chant:stack"]).toBe("billing");
27
+ expect(byKey["chant:env"]).toBe("prod");
28
+ });
29
+
30
+ test("no ownership context → no chant tags injected", () => {
31
+ const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
32
+ const out = awsSerializer.serialize(entities, []);
33
+ const template = JSON.parse(out as string);
34
+ const tags = (template.Resources.MyBucket.Properties?.Tags ?? []) as Array<{ Key: string }>;
35
+ expect(tags.some((t) => t.Key.startsWith("chant:"))).toBe(false);
36
+ });
37
+ });
package/src/serializer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
2
2
  import { isPropertyDeclarable } from "@intentius/chant/declarable";
3
- import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
3
+ import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
4
+ import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
4
5
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
5
6
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
6
7
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
@@ -131,6 +132,7 @@ function serializeToTemplate(
131
132
  outputs?: LexiconOutput[],
132
133
  extraParameters?: Record<string, CFParameter>,
133
134
  extraOutputs?: Record<string, CFOutput>,
135
+ ownership?: OwnershipMarker,
134
136
  ): CFTemplate {
135
137
  const template: CFTemplate = {
136
138
  AWSTemplateFormatVersion: "2010-09-09",
@@ -148,8 +150,15 @@ function serializeToTemplate(
148
150
  entityNames.set(entity, name);
149
151
  }
150
152
 
151
- // Collect default tags
153
+ // Collect default tags. The ownership marker is stamped as tags, seeded
154
+ // first so user default tags (and explicit per-resource tags) take precedence
155
+ // on key collisions.
152
156
  const defaultTagEntries: TagEntry[] = [];
157
+ if (ownership) {
158
+ for (const [Key, Value] of Object.entries(ownershipEntries("aws-tag", ownership))) {
159
+ defaultTagEntries.push({ Key, Value });
160
+ }
161
+ }
153
162
  for (const [, entity] of entities) {
154
163
  if (isDefaultTags(entity)) {
155
164
  defaultTagEntries.push(...entity.tags);
@@ -323,7 +332,9 @@ export const awsSerializer: Serializer = {
323
332
  name: "aws",
324
333
  rulePrefix: "WAW",
325
334
 
326
- serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[]): string | SerializerResult {
335
+ serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult {
336
+ const ownership = context?.ownership;
337
+
327
338
  // Check if any entities are child projects (nested stacks)
328
339
  const childProjects = new Map<string, ChildProjectInstance>();
329
340
  let hasChildProjects = false;
@@ -337,7 +348,7 @@ export const awsSerializer: Serializer = {
337
348
 
338
349
  // No nested stacks — use the simple path
339
350
  if (!hasChildProjects) {
340
- const template = serializeToTemplate(entities, outputs);
351
+ const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership);
341
352
  return JSON.stringify(template, null, 2);
342
353
  }
343
354
 
@@ -377,7 +388,7 @@ export const awsSerializer: Serializer = {
377
388
  }
378
389
 
379
390
  // Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
380
- const parentTemplate = serializeToTemplate(entities, outputs, parentParams);
391
+ const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership);
381
392
  const primary = JSON.stringify(parentTemplate, null, 2);
382
393
 
383
394
  return {