@intentius/chant-lexicon-aws 0.0.4 → 0.0.6

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,8 +1,11 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { readdirSync, readFileSync } from "fs";
2
+ import { readdirSync, readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { CFParser } from "./parser";
5
5
  import { CFGenerator } from "./generator";
6
+ import { build } from "@intentius/chant/build";
7
+ import { awsSerializer } from "../serializer";
8
+ import * as awsLexicon from "../index";
6
9
 
7
10
  const parser = new CFParser();
8
11
  const generator = new CFGenerator();
@@ -10,6 +13,15 @@ const generator = new CFGenerator();
10
13
  const roundtripDir = join(import.meta.dir, "../testdata/roundtrip");
11
14
  const samDir = join(import.meta.dir, "../testdata/sam-fixtures");
12
15
 
16
+ /** Extract imported symbols from `import { A, B } from "..."` statements */
17
+ function extractImportedSymbols(code: string): string[] {
18
+ const match = code.match(/import\s*\{([^}]+)\}\s*from\s*/);
19
+ if (!match) return [];
20
+ return match[1].split(",").map((s) => s.trim()).filter(Boolean);
21
+ }
22
+
23
+ const lexiconExports = new Set(Object.keys(awsLexicon));
24
+
13
25
  describe("CF roundtrip fixtures", () => {
14
26
  const fixtures = readdirSync(roundtripDir).filter((f) => f.endsWith(".json"));
15
27
 
@@ -24,32 +36,81 @@ describe("CF roundtrip fixtures", () => {
24
36
  // Verify at least one file was generated
25
37
  expect(files.length).toBeGreaterThanOrEqual(1);
26
38
 
27
- // Verify barrel/main file exists
28
- const hasBarrel = files.some(
29
- (f) => f.path === "main.ts" || f.path === "_.ts",
30
- );
31
- expect(hasBarrel).toBe(true);
39
+ // Verify main file exists
40
+ const hasMain = files.some((f) => f.path === "main.ts");
41
+ expect(hasMain).toBe(true);
32
42
 
33
43
  // Verify resource count: each resource should be represented in generated output
34
44
  const resourceNames = Object.keys(template.Resources ?? {});
35
- const mainFile = files.find((f) => f.path === "main.ts" || f.path === "_.ts");
45
+ const mainFile = files.find((f) => f.path === "main.ts");
36
46
  expect(mainFile).toBeDefined();
37
47
 
38
48
  for (const name of resourceNames) {
39
- const varName = name.charAt(0).toLowerCase() + name.slice(1);
40
- expect(mainFile!.content).toContain(varName);
49
+ expect(mainFile!.content).toContain(name);
41
50
  }
42
51
 
43
52
  // If parameters exist, verify they appear in the output
44
53
  const paramNames = Object.keys(template.Parameters ?? {});
45
54
  for (const name of paramNames) {
46
- const varName = name.charAt(0).toLowerCase() + name.slice(1);
47
- expect(mainFile!.content).toContain(varName);
55
+ expect(mainFile!.content).toContain(name);
56
+ }
57
+
58
+ // Verify all imported symbols actually exist as exports from the lexicon
59
+ const importedSymbols = extractImportedSymbols(mainFile!.content);
60
+ for (const sym of importedSymbols) {
61
+ expect(lexiconExports.has(sym)).toBe(true);
48
62
  }
49
63
  });
50
64
  }
51
65
  });
52
66
 
67
+ describe("parameters.json build roundtrip", () => {
68
+ test("generated code builds and produces correct CF Parameters", async () => {
69
+ const content = readFileSync(join(roundtripDir, "parameters.json"), "utf-8");
70
+ const source = JSON.parse(content);
71
+
72
+ const ir = parser.parse(content);
73
+ const files = generator.generate(ir);
74
+ const mainFile = files.find((f) => f.path === "main.ts")!;
75
+
76
+ // Write generated code to a temp directory inside the monorepo (so workspace packages resolve)
77
+ const dir = mkdtempSync(join(import.meta.dir, "../../.roundtrip-tmp-"));
78
+ try {
79
+ const srcDir = join(dir, "src");
80
+ mkdirSync(srcDir);
81
+
82
+ // Write generated code as-is (direct imports)
83
+ writeFileSync(join(srcDir, "main.ts"), mainFile.content);
84
+
85
+ // Build and verify
86
+ const result = await build(srcDir, [awsSerializer]);
87
+ expect(result.errors).toHaveLength(0);
88
+
89
+ const template = JSON.parse(result.outputs.get("aws")!);
90
+
91
+ // Verify parameters round-tripped correctly (names preserved as-is)
92
+ for (const [name, param] of Object.entries(source.Parameters ?? {})) {
93
+ const p = param as Record<string, unknown>;
94
+ expect(template.Parameters[name]).toBeDefined();
95
+ expect(template.Parameters[name].Type).toBe(p.Type);
96
+ if (p.Description) {
97
+ expect(template.Parameters[name].Description).toBe(p.Description);
98
+ }
99
+ if (p.Default !== undefined) {
100
+ expect(template.Parameters[name].Default).toBe(p.Default);
101
+ }
102
+ }
103
+
104
+ // Verify resources round-tripped (names preserved as-is)
105
+ for (const name of Object.keys(source.Resources ?? {})) {
106
+ expect(template.Resources[name]).toBeDefined();
107
+ }
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ });
113
+
53
114
  describe("SAM roundtrip fixtures", () => {
54
115
  const fixtures = readdirSync(samDir).filter(
55
116
  (f) => f.endsWith(".yaml") || f.endsWith(".yml"),
@@ -25,6 +25,7 @@ describe("CloudFormation round-trip", () => {
25
25
 
26
26
  expect(files[0].content).toContain("Bucket");
27
27
  expect(files[0].content).toContain('bucketName: "my-bucket"');
28
+ expect(files[0].content).toContain("export const MyBucket");
28
29
  });
29
30
 
30
31
  test("round-trips template with parameters", () => {
@@ -51,8 +52,8 @@ describe("CloudFormation round-trip", () => {
51
52
  const files = generator.generate(ir);
52
53
 
53
54
  expect(files[0].content).toContain("Parameter");
54
- expect(files[0].content).toContain("environment");
55
- expect(files[0].content).toContain("bucketName: environment");
55
+ expect(files[0].content).toContain("Environment");
56
+ expect(files[0].content).toContain("bucketName: Ref(Environment)");
56
57
  });
57
58
 
58
59
  test("round-trips template with Fn::Sub", () => {
@@ -114,7 +115,7 @@ describe("CloudFormation round-trip", () => {
114
115
 
115
116
  expect(files[0].content).toContain("Role");
116
117
  expect(files[0].content).toContain("Function");
117
- expect(files[0].content).toContain("lambdaRole.arn");
118
+ expect(files[0].content).toContain("LambdaRole.arn");
118
119
  });
119
120
 
120
121
  test("round-trips complex nested properties", () => {
@@ -147,8 +148,8 @@ describe("CloudFormation round-trip", () => {
147
148
  const files = generator.generate(ir);
148
149
 
149
150
  expect(files[0].content).toContain("Function");
150
- expect(files[0].content).toContain("environment");
151
- expect(files[0].content).toContain("vpcConfig");
151
+ expect(files[0].content).toContain("environment:");
152
+ expect(files[0].content).toContain("vpcConfig:");
152
153
  });
153
154
 
154
155
  test("round-trips empty template", () => {
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // Parameter
2
+ export { Parameter } from "./parameter";
3
+
1
4
  // Serializer
2
5
  export { awsSerializer } from "./serializer";
3
6
 
@@ -5,11 +8,13 @@ export { awsSerializer } from "./serializer";
5
8
  export { nestedStack, isNestedStackInstance, NestedStackOutputRef, isNestedStackOutputRef, NESTED_STACK_MARKER } from "./nested-stack";
6
9
  export type { NestedStackOptions, NestedStackInstance } from "./nested-stack";
7
10
 
8
- // Re-export core child project and stack output primitives
11
+ // Re-export core child project, stack output, and lexicon output primitives
9
12
  export { isChildProject, CHILD_PROJECT_MARKER } from "@intentius/chant/child-project";
10
13
  export type { ChildProjectInstance } from "@intentius/chant/child-project";
11
14
  export { stackOutput, isStackOutput, STACK_OUTPUT_MARKER } from "@intentius/chant/stack-output";
12
15
  export type { StackOutput } from "@intentius/chant/stack-output";
16
+ export { output, isLexiconOutput } from "@intentius/chant/lexicon-output";
17
+ export type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
18
 
14
19
  // Plugin
15
20
  export { awsPlugin } from "./plugin";
@@ -24,6 +29,7 @@ export {
24
29
  Select,
25
30
  Split,
26
31
  Base64,
32
+ GetAZs,
27
33
  SubIntrinsic,
28
34
  RefIntrinsic,
29
35
  GetAttIntrinsic,
@@ -32,6 +38,7 @@ export {
32
38
  SelectIntrinsic,
33
39
  SplitIntrinsic,
34
40
  Base64Intrinsic,
41
+ GetAZsIntrinsic,
35
42
  } from "./intrinsics";
36
43
 
37
44
  // Pseudo-parameters
package/src/intrinsics.ts CHANGED
@@ -221,3 +221,30 @@ export class Base64Intrinsic implements Intrinsic {
221
221
  export function Base64(value: string | Intrinsic): Base64Intrinsic {
222
222
  return new Base64Intrinsic(value);
223
223
  }
224
+
225
+ /**
226
+ * Fn::GetAZs intrinsic function
227
+ * Returns a list of Availability Zones for a region
228
+ */
229
+ export class GetAZsIntrinsic implements Intrinsic {
230
+ readonly [INTRINSIC_MARKER] = true as const;
231
+ private region: string | Intrinsic;
232
+
233
+ constructor(region: string | Intrinsic = "") {
234
+ this.region = region;
235
+ }
236
+
237
+ toJSON(): { "Fn::GetAZs": unknown } {
238
+ const regionValue = typeof this.region === "string"
239
+ ? this.region
240
+ : (this.region as Intrinsic & { toJSON(): unknown }).toJSON();
241
+ return { "Fn::GetAZs": regionValue };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Create a GetAZs intrinsic
247
+ */
248
+ export function GetAZs(region?: string | Intrinsic): GetAZsIntrinsic {
249
+ return new GetAZsIntrinsic(region);
250
+ }
@@ -0,0 +1,16 @@
1
+ import { DECLARABLE_MARKER, type CoreParameter } from "@intentius/chant/declarable";
2
+
3
+ export class Parameter implements CoreParameter {
4
+ readonly [DECLARABLE_MARKER] = true as const;
5
+ readonly lexicon = "aws";
6
+ readonly entityType = "AWS::CloudFormation::Parameter";
7
+ readonly parameterType: string;
8
+ readonly description?: string;
9
+ readonly defaultValue?: unknown;
10
+
11
+ constructor(type: string, options?: { description?: string; defaultValue?: unknown }) {
12
+ this.parameterType = type;
13
+ this.description = options?.description;
14
+ this.defaultValue = options?.defaultValue;
15
+ }
16
+ }
@@ -36,7 +36,7 @@ describe("awsPlugin", () => {
36
36
 
37
37
  test("returns intrinsics", () => {
38
38
  const intrinsics = awsPlugin.intrinsics!();
39
- expect(intrinsics.length).toBe(8);
39
+ expect(intrinsics.length).toBe(9);
40
40
  const names = intrinsics.map((i) => i.name);
41
41
  expect(names).toContain("Sub");
42
42
  expect(names).toContain("Ref");
package/src/plugin.ts CHANGED
@@ -35,6 +35,7 @@ export const awsPlugin: LexiconPlugin = {
35
35
  { name: "Select", description: "Fn::Select — select value by index" },
36
36
  { name: "Split", description: "Fn::Split — split string by delimiter" },
37
37
  { name: "Base64", description: "Fn::Base64 — encode to Base64" },
38
+ { name: "GetAZs", description: "Fn::GetAZs — list Availability Zones" },
38
39
  ];
39
40
  },
40
41
 
@@ -53,7 +54,6 @@ export const awsPlugin: LexiconPlugin = {
53
54
 
54
55
  initTemplates(): Record<string, string> {
55
56
  return {
56
- "_.ts": `export * from "./config";\n`,
57
57
  "config.ts": `/**
58
58
  * Shared bucket configuration — encryption, versioning, public access
59
59
  */
@@ -92,29 +92,29 @@ export const versioningEnabled = new aws.VersioningConfiguration({
92
92
  * Data bucket — primary storage with encryption and versioning
93
93
  */
94
94
 
95
- import * as aws from "@intentius/chant-lexicon-aws";
96
- import * as _ from "./_";
95
+ import { Bucket, Sub, AWS } from "@intentius/chant-lexicon-aws";
96
+ import { versioningEnabled, bucketEncryption, publicAccessBlock } from "./config";
97
97
 
98
- export const dataBucket = new aws.Bucket({
99
- bucketName: aws.Sub\`\${aws.AWS.StackName}-data\`,
100
- versioningConfiguration: _.versioningEnabled,
101
- bucketEncryption: _.bucketEncryption,
102
- publicAccessBlockConfiguration: _.publicAccessBlock,
98
+ export const dataBucket = new Bucket({
99
+ bucketName: Sub\`\${AWS.StackName}-data\`,
100
+ versioningConfiguration: versioningEnabled,
101
+ bucketEncryption: bucketEncryption,
102
+ publicAccessBlockConfiguration: publicAccessBlock,
103
103
  });
104
104
  `,
105
105
  "logs-bucket.ts": `/**
106
106
  * Logs bucket — log delivery with encryption and versioning
107
107
  */
108
108
 
109
- import * as aws from "@intentius/chant-lexicon-aws";
110
- import * as _ from "./_";
109
+ import { Bucket, Sub, AWS } from "@intentius/chant-lexicon-aws";
110
+ import { versioningEnabled, bucketEncryption, publicAccessBlock } from "./config";
111
111
 
112
- export const logsBucket = new aws.Bucket({
113
- bucketName: aws.Sub\`\${aws.AWS.StackName}-logs\`,
112
+ export const logsBucket = new Bucket({
113
+ bucketName: Sub\`\${AWS.StackName}-logs\`,
114
114
  accessControl: "LogDeliveryWrite",
115
- versioningConfiguration: _.versioningEnabled,
116
- bucketEncryption: _.bucketEncryption,
117
- publicAccessBlockConfiguration: _.publicAccessBlock,
115
+ versioningConfiguration: versioningEnabled,
116
+ bucketEncryption: bucketEncryption,
117
+ publicAccessBlockConfiguration: publicAccessBlock,
118
118
  });
119
119
  `,
120
120
  };
@@ -184,18 +184,9 @@ export const logsBucket = new aws.Bucket({
184
184
 
185
185
  async validate(options?: { verbose?: boolean }): Promise<void> {
186
186
  const { validate } = await import("./validate");
187
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
187
188
  const result = await validate();
188
-
189
- for (const check of result.checks) {
190
- const status = check.ok ? "OK" : "FAIL";
191
- const msg = check.error ? ` — ${check.error}` : "";
192
- console.error(` [${status}] ${check.name}${msg}`);
193
- }
194
-
195
- if (!result.success) {
196
- throw new Error("Validation failed");
197
- }
198
- console.error("All validation checks passed.");
189
+ printValidationResult(result);
199
190
  },
200
191
 
201
192
  async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
@@ -226,34 +217,15 @@ export const logsBucket = new aws.Bucket({
226
217
 
227
218
  async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
228
219
  const { packageLexicon } = await import("./codegen/package");
229
- const { writeFileSync, mkdirSync } = await import("fs");
220
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
230
221
  const { join, dirname } = await import("path");
231
222
  const { fileURLToPath } = await import("url");
232
223
 
233
224
  const { spec, stats } = await packageLexicon({ verbose: options?.verbose, force: options?.force });
234
225
 
235
- // Write manifest and artifacts to dist/
236
226
  const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
237
227
  const distDir = join(pkgDir, "dist");
238
- mkdirSync(join(distDir, "types"), { recursive: true });
239
- mkdirSync(join(distDir, "rules"), { recursive: true });
240
- mkdirSync(join(distDir, "skills"), { recursive: true });
241
-
242
- writeFileSync(join(distDir, "manifest.json"), JSON.stringify(spec.manifest, null, 2));
243
- writeFileSync(join(distDir, "meta.json"), spec.registry);
244
- writeFileSync(join(distDir, "types", "index.d.ts"), spec.typesDTS);
245
-
246
- for (const [name, content] of spec.rules) {
247
- writeFileSync(join(distDir, "rules", name), content);
248
- }
249
- for (const [name, content] of spec.skills) {
250
- writeFileSync(join(distDir, "skills", name), content);
251
- }
252
-
253
- // Write integrity.json if available
254
- if (spec.integrity) {
255
- writeFileSync(join(distDir, "integrity.json"), JSON.stringify(spec.integrity, null, 2));
256
- }
228
+ writeBundleSpec(spec, distDir);
257
229
 
258
230
  console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
259
231
 
@@ -348,7 +320,7 @@ description: AWS CloudFormation best practices and common patterns
348
320
  3. **Use least-privilege IAM** — Avoid \`*\` in IAM policy actions and resources
349
321
  4. **Enable versioning** — Turn on \`VersioningConfiguration\` for data buckets
350
322
  5. **Use Sub for dynamic names** — \`Sub\\\`\\\${AWS::StackName}-suffix\\\`\` for unique naming
351
- 6. **Share config via barrel files** — Put common settings in \`_.ts\` and import as \`* as _\`
323
+ 6. **Share config via direct imports** — Put common settings in a config file and import directly
352
324
  `,
353
325
  triggers: [
354
326
  { type: "file-pattern", value: "**/*.aws.ts" },
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { awsSerializer } from "./serializer";
3
3
  import { AttrRef } from "@intentius/chant/attrref";
4
- import { DECLARABLE_MARKER, type Declarable, type CoreParameter } from "@intentius/chant/declarable";
4
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
5
5
  import { LexiconOutput } from "@intentius/chant/lexicon-output";
6
6
  import { Sub } from "./intrinsics";
7
7
  import { AWS } from "./pseudo";
@@ -10,6 +10,7 @@ import { stackOutput } from "@intentius/chant/stack-output";
10
10
  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
+ import { Parameter } from "./parameter";
13
14
 
14
15
  // Mock S3 Bucket for testing
15
16
  class MockBucket implements Declarable {
@@ -25,22 +26,6 @@ class MockBucket implements Declarable {
25
26
  }
26
27
  }
27
28
 
28
- // Mock Parameter for testing
29
- class MockParameter implements CoreParameter {
30
- readonly [DECLARABLE_MARKER] = true as const;
31
- readonly lexicon = "aws";
32
- readonly entityType = "AWS::CloudFormation::Parameter";
33
- readonly parameterType: string;
34
- readonly description?: string;
35
- readonly defaultValue?: unknown;
36
-
37
- constructor(type: string, options: { description?: string; defaultValue?: unknown } = {}) {
38
- this.parameterType = type;
39
- this.description = options.description;
40
- this.defaultValue = options.defaultValue;
41
- }
42
- }
43
-
44
29
  describe("awsSerializer", () => {
45
30
  test("has correct name", () => {
46
31
  expect(awsSerializer.name).toBe("aws");
@@ -84,7 +69,7 @@ describe("awsSerializer.serialize", () => {
84
69
 
85
70
  test("serializes parameters", () => {
86
71
  const entities = new Map<string, Declarable>();
87
- entities.set("Environment", new MockParameter("String", {
72
+ entities.set("Environment", new Parameter("String", {
88
73
  description: "Environment name",
89
74
  defaultValue: "dev",
90
75
  }));
@@ -139,7 +124,7 @@ describe("awsSerializer.serialize", () => {
139
124
 
140
125
  test("handles resources and parameters together", () => {
141
126
  const entities = new Map<string, Declarable>();
142
- entities.set("Env", new MockParameter("String"));
127
+ entities.set("Env", new Parameter("String"));
143
128
  entities.set("MyBucket", new MockBucket({ bucketName: "bucket" }));
144
129
 
145
130
  const output = awsSerializer.serialize(entities);
package/src/spec/parse.ts CHANGED
@@ -36,7 +36,7 @@ export interface ParsedAttribute {
36
36
 
37
37
  export interface ParsedPropertyType {
38
38
  name: string;
39
- cfnType: string;
39
+ specType: string;
40
40
  properties: ParsedProperty[];
41
41
  }
42
42
 
@@ -130,7 +130,7 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
130
130
  }
131
131
  propertyTypes.push({
132
132
  name: `${shortName}_${defName}`,
133
- cfnType: defName,
133
+ specType: defName,
134
134
  properties: defProps,
135
135
  });
136
136
  }