@intentius/chant-lexicon-aws 0.0.2
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.
- package/README.md +438 -0
- package/package.json +30 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +197 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1206 -0
- package/src/codegen/extensions.ts +171 -0
- package/src/codegen/fallback.ts +33 -0
- package/src/codegen/generate-cli.ts +17 -0
- package/src/codegen/generate-lexicon.ts +98 -0
- package/src/codegen/generate-typescript.ts +257 -0
- package/src/codegen/generate.test.ts +125 -0
- package/src/codegen/generate.ts +226 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.ts +120 -0
- package/src/codegen/package.test.ts +60 -0
- package/src/codegen/package.ts +84 -0
- package/src/codegen/patches.ts +98 -0
- package/src/codegen/rollback.test.ts +80 -0
- package/src/codegen/rollback.ts +20 -0
- package/src/codegen/sam.ts +387 -0
- package/src/codegen/snapshot.test.ts +84 -0
- package/src/codegen/typecheck.test.ts +50 -0
- package/src/codegen/typecheck.ts +4 -0
- package/src/codegen/versions.ts +37 -0
- package/src/coverage.ts +14 -0
- package/src/generated/index.d.ts +160753 -0
- package/src/generated/index.ts +14396 -0
- package/src/generated/lexicon-aws.json +114563 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +181 -0
- package/src/import/generator.ts +349 -0
- package/src/import/parser.test.ts +200 -0
- package/src/import/parser.ts +350 -0
- package/src/import/roundtrip-fixtures.test.ts +78 -0
- package/src/import/roundtrip.test.ts +195 -0
- package/src/index.ts +63 -0
- package/src/integration.test.ts +129 -0
- package/src/intrinsics.test.ts +167 -0
- package/src/intrinsics.ts +223 -0
- package/src/lint/post-synth/cf-refs.ts +91 -0
- package/src/lint/post-synth/cor020.ts +72 -0
- package/src/lint/post-synth/ext001.test.ts +68 -0
- package/src/lint/post-synth/ext001.ts +222 -0
- package/src/lint/post-synth/post-synth.test.ts +280 -0
- package/src/lint/post-synth/waw010.ts +49 -0
- package/src/lint/post-synth/waw011.ts +49 -0
- package/src/lint/post-synth/waw013.ts +45 -0
- package/src/lint/post-synth/waw014.ts +50 -0
- package/src/lint/post-synth/waw015.ts +100 -0
- package/src/lint/rules/hardcoded-region.ts +43 -0
- package/src/lint/rules/iam-wildcard.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/rules.test.ts +175 -0
- package/src/lint/rules/s3-encryption.ts +69 -0
- package/src/lsp/completions.test.ts +72 -0
- package/src/lsp/completions.ts +18 -0
- package/src/lsp/hover.test.ts +53 -0
- package/src/lsp/hover.ts +53 -0
- package/src/nested-stack.test.ts +83 -0
- package/src/nested-stack.ts +125 -0
- package/src/plugin.test.ts +316 -0
- package/src/plugin.ts +514 -0
- package/src/pseudo.test.ts +55 -0
- package/src/pseudo.ts +29 -0
- package/src/serializer.test.ts +507 -0
- package/src/serializer.ts +333 -0
- package/src/spec/fetch.test.ts +27 -0
- package/src/spec/fetch.ts +107 -0
- package/src/spec/parse.test.ts +153 -0
- package/src/spec/parse.ts +202 -0
- package/src/testdata/load-fixtures.ts +17 -0
- package/src/testdata/roundtrip/conditions.json +21 -0
- package/src/testdata/roundtrip/intrinsic-calls.json +31 -0
- package/src/testdata/roundtrip/intrinsics.json +18 -0
- package/src/testdata/roundtrip/multi-resource.json +37 -0
- package/src/testdata/roundtrip/parameters.json +23 -0
- package/src/testdata/roundtrip/simple.json +12 -0
- package/src/testdata/sam-fixtures/api.yaml +14 -0
- package/src/testdata/sam-fixtures/application.yaml +13 -0
- package/src/testdata/sam-fixtures/function.yaml +22 -0
- package/src/testdata/sam-fixtures/graphql-api.yaml +13 -0
- package/src/testdata/sam-fixtures/http-api.yaml +15 -0
- package/src/testdata/sam-fixtures/layer-version.yaml +15 -0
- package/src/testdata/sam-fixtures/multi-type-a.yaml +23 -0
- package/src/testdata/sam-fixtures/multi-type-b.yaml +29 -0
- package/src/testdata/sam-fixtures/simple-table.yaml +12 -0
- package/src/testdata/sam-fixtures/state-machine.yaml +14 -0
- package/src/testdata/schemas/aws-dynamodb-table.json +126 -0
- package/src/testdata/schemas/aws-iam-role.json +85 -0
- package/src/testdata/schemas/aws-lambda-function.json +90 -0
- package/src/testdata/schemas/aws-s3-bucket.json +83 -0
- package/src/testdata/schemas/aws-sns-topic.json +71 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.ts +34 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import { hardcodedRegionRule } from "./hardcoded-region";
|
|
4
|
+
import { s3EncryptionRule } from "./s3-encryption";
|
|
5
|
+
import { iamWildcardRule } from "./iam-wildcard";
|
|
6
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
7
|
+
|
|
8
|
+
function createContext(code: string, fileName = "test.ts"): LintContext {
|
|
9
|
+
const sourceFile = ts.createSourceFile(
|
|
10
|
+
fileName,
|
|
11
|
+
code,
|
|
12
|
+
ts.ScriptTarget.Latest,
|
|
13
|
+
true,
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
sourceFile,
|
|
17
|
+
entities: [],
|
|
18
|
+
filePath: fileName,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("AWS Lint Rules", () => {
|
|
23
|
+
describe("WAW001: Hardcoded Region", () => {
|
|
24
|
+
test("detects hardcoded us-east-1", () => {
|
|
25
|
+
const context = createContext(`const region = "us-east-1";`);
|
|
26
|
+
const diagnostics = hardcodedRegionRule.check(context);
|
|
27
|
+
expect(diagnostics).toHaveLength(1);
|
|
28
|
+
expect(diagnostics[0].ruleId).toBe("WAW001");
|
|
29
|
+
expect(diagnostics[0].message).toContain("us-east-1");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("detects hardcoded eu-west-2", () => {
|
|
33
|
+
const context = createContext(`const region = "eu-west-2";`);
|
|
34
|
+
const diagnostics = hardcodedRegionRule.check(context);
|
|
35
|
+
expect(diagnostics).toHaveLength(1);
|
|
36
|
+
expect(diagnostics[0].message).toContain("eu-west-2");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("detects hardcoded ap-southeast-1", () => {
|
|
40
|
+
const context = createContext(`const region = "ap-southeast-1";`);
|
|
41
|
+
const diagnostics = hardcodedRegionRule.check(context);
|
|
42
|
+
expect(diagnostics).toHaveLength(1);
|
|
43
|
+
expect(diagnostics[0].message).toContain("ap-southeast-1");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ignores non-region strings", () => {
|
|
47
|
+
const context = createContext(`const name = "my-bucket-name";`);
|
|
48
|
+
const diagnostics = hardcodedRegionRule.check(context);
|
|
49
|
+
expect(diagnostics).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("ignores AWS.Region reference", () => {
|
|
53
|
+
const context = createContext(`const region = AWS.Region;`);
|
|
54
|
+
const diagnostics = hardcodedRegionRule.check(context);
|
|
55
|
+
expect(diagnostics).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("has correct metadata", () => {
|
|
59
|
+
expect(hardcodedRegionRule.id).toBe("WAW001");
|
|
60
|
+
expect(hardcodedRegionRule.severity).toBe("warning");
|
|
61
|
+
expect(hardcodedRegionRule.category).toBe("security");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("WAW006: S3 Encryption", () => {
|
|
66
|
+
test("warns on Bucket without encryption", () => {
|
|
67
|
+
const context = createContext(`
|
|
68
|
+
const bucket = new Bucket({ bucketName: "my-bucket" });
|
|
69
|
+
`);
|
|
70
|
+
const diagnostics = s3EncryptionRule.check(context);
|
|
71
|
+
expect(diagnostics).toHaveLength(1);
|
|
72
|
+
expect(diagnostics[0].ruleId).toBe("WAW006");
|
|
73
|
+
expect(diagnostics[0].message).toContain("encryption");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("passes with bucketEncryption property", () => {
|
|
77
|
+
const context = createContext(`
|
|
78
|
+
const bucket = new Bucket({
|
|
79
|
+
bucketName: "my-bucket",
|
|
80
|
+
bucketEncryption: { serverSideEncryptionConfiguration: [] },
|
|
81
|
+
});
|
|
82
|
+
`);
|
|
83
|
+
const diagnostics = s3EncryptionRule.check(context);
|
|
84
|
+
expect(diagnostics).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("passes with encryption property", () => {
|
|
88
|
+
const context = createContext(`
|
|
89
|
+
const bucket = new Bucket({
|
|
90
|
+
bucketName: "my-bucket",
|
|
91
|
+
encryption: "AES256",
|
|
92
|
+
});
|
|
93
|
+
`);
|
|
94
|
+
const diagnostics = s3EncryptionRule.check(context);
|
|
95
|
+
expect(diagnostics).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("ignores non-Bucket constructors", () => {
|
|
99
|
+
const context = createContext(`
|
|
100
|
+
const fn = new Function({ functionName: "my-fn" });
|
|
101
|
+
`);
|
|
102
|
+
const diagnostics = s3EncryptionRule.check(context);
|
|
103
|
+
expect(diagnostics).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("has correct metadata", () => {
|
|
107
|
+
expect(s3EncryptionRule.id).toBe("WAW006");
|
|
108
|
+
expect(s3EncryptionRule.severity).toBe("warning");
|
|
109
|
+
expect(s3EncryptionRule.category).toBe("security");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("WAW009: IAM Wildcard", () => {
|
|
114
|
+
test("warns on Resource: '*'", () => {
|
|
115
|
+
const context = createContext(`
|
|
116
|
+
const policy = {
|
|
117
|
+
Statement: [{
|
|
118
|
+
Effect: "Allow",
|
|
119
|
+
Action: "s3:*",
|
|
120
|
+
Resource: "*",
|
|
121
|
+
}],
|
|
122
|
+
};
|
|
123
|
+
`);
|
|
124
|
+
const diagnostics = iamWildcardRule.check(context);
|
|
125
|
+
expect(diagnostics).toHaveLength(1);
|
|
126
|
+
expect(diagnostics[0].ruleId).toBe("WAW009");
|
|
127
|
+
expect(diagnostics[0].message).toContain("wildcard");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("warns on Resources array with '*'", () => {
|
|
131
|
+
const context = createContext(`
|
|
132
|
+
const policy = {
|
|
133
|
+
Statement: [{
|
|
134
|
+
Effect: "Allow",
|
|
135
|
+
Action: "s3:*",
|
|
136
|
+
Resources: ["*"],
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
`);
|
|
140
|
+
const diagnostics = iamWildcardRule.check(context);
|
|
141
|
+
expect(diagnostics).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("passes with explicit ARN", () => {
|
|
145
|
+
const context = createContext(`
|
|
146
|
+
const policy = {
|
|
147
|
+
Statement: [{
|
|
148
|
+
Effect: "Allow",
|
|
149
|
+
Action: "s3:GetObject",
|
|
150
|
+
Resource: "arn:aws:s3:::my-bucket/*",
|
|
151
|
+
}],
|
|
152
|
+
};
|
|
153
|
+
`);
|
|
154
|
+
const diagnostics = iamWildcardRule.check(context);
|
|
155
|
+
expect(diagnostics).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("ignores unrelated properties", () => {
|
|
159
|
+
const context = createContext(`
|
|
160
|
+
const config = {
|
|
161
|
+
name: "*",
|
|
162
|
+
pattern: "*",
|
|
163
|
+
};
|
|
164
|
+
`);
|
|
165
|
+
const diagnostics = iamWildcardRule.check(context);
|
|
166
|
+
expect(diagnostics).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("has correct metadata", () => {
|
|
170
|
+
expect(iamWildcardRule.id).toBe("WAW009");
|
|
171
|
+
expect(iamWildcardRule.severity).toBe("warning");
|
|
172
|
+
expect(iamWildcardRule.category).toBe("security");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WAW006: S3 Bucket Encryption
|
|
6
|
+
*
|
|
7
|
+
* Detects S3 Bucket creation without encryption configuration.
|
|
8
|
+
* All S3 buckets should have server-side encryption enabled.
|
|
9
|
+
*/
|
|
10
|
+
export const s3EncryptionRule: LintRule = {
|
|
11
|
+
id: "WAW006",
|
|
12
|
+
severity: "warning",
|
|
13
|
+
category: "security",
|
|
14
|
+
|
|
15
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
16
|
+
const { sourceFile } = context;
|
|
17
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
function visit(node: ts.Node): void {
|
|
20
|
+
// Look for `new Bucket(...)` or `new aws.s3.Bucket(...)`
|
|
21
|
+
if (ts.isNewExpression(node)) {
|
|
22
|
+
const expression = node.expression;
|
|
23
|
+
let isBucket = false;
|
|
24
|
+
|
|
25
|
+
// Check for `new Bucket(...)`
|
|
26
|
+
if (ts.isIdentifier(expression) && expression.text === "Bucket") {
|
|
27
|
+
isBucket = true;
|
|
28
|
+
}
|
|
29
|
+
// Check for `new s3.Bucket(...)` or `new aws.s3.Bucket(...)`
|
|
30
|
+
else if (ts.isPropertyAccessExpression(expression)) {
|
|
31
|
+
if (expression.name.text === "Bucket") {
|
|
32
|
+
isBucket = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isBucket && node.arguments && node.arguments.length > 0) {
|
|
37
|
+
const props = node.arguments[0];
|
|
38
|
+
if (ts.isObjectLiteralExpression(props)) {
|
|
39
|
+
const hasEncryption = props.properties.some((prop) => {
|
|
40
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
41
|
+
return prop.name.text === "bucketEncryption" ||
|
|
42
|
+
prop.name.text === "encryption" ||
|
|
43
|
+
prop.name.text === "serverSideEncryptionConfiguration";
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!hasEncryption) {
|
|
49
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
file: sourceFile.fileName,
|
|
52
|
+
line: line + 1,
|
|
53
|
+
column: character + 1,
|
|
54
|
+
ruleId: "WAW006",
|
|
55
|
+
severity: "warning",
|
|
56
|
+
message: "S3 Bucket created without encryption configuration. Enable server-side encryption.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ts.forEachChild(node, visit);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
visit(sourceFile);
|
|
67
|
+
return diagnostics;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { awsCompletions } from "./completions";
|
|
3
|
+
import type { CompletionContext } from "@intentius/chant/lsp/types";
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
|
|
6
|
+
return {
|
|
7
|
+
uri: "file:///test.ts",
|
|
8
|
+
content: "",
|
|
9
|
+
position: { line: 0, character: 0 },
|
|
10
|
+
wordAtCursor: "",
|
|
11
|
+
linePrefix: "",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("awsCompletions", () => {
|
|
17
|
+
test("returns resource completions for `new ` prefix", () => {
|
|
18
|
+
const ctx = makeCtx({
|
|
19
|
+
linePrefix: "const b = new Bucket",
|
|
20
|
+
wordAtCursor: "Bucket",
|
|
21
|
+
content: "const b = new Bucket",
|
|
22
|
+
position: { line: 0, character: 20 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const items = awsCompletions(ctx);
|
|
26
|
+
expect(items.length).toBeGreaterThan(0);
|
|
27
|
+
|
|
28
|
+
const bucketItem = items.find((i) => i.label === "Bucket");
|
|
29
|
+
expect(bucketItem).toBeDefined();
|
|
30
|
+
expect(bucketItem?.kind).toBe("resource");
|
|
31
|
+
expect(bucketItem?.detail).toContain("AWS::S3::Bucket");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("filters resource completions by prefix", () => {
|
|
35
|
+
const ctx = makeCtx({
|
|
36
|
+
linePrefix: "const t = new Table",
|
|
37
|
+
wordAtCursor: "Table",
|
|
38
|
+
content: "const t = new Table",
|
|
39
|
+
position: { line: 0, character: 19 },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const items = awsCompletions(ctx);
|
|
43
|
+
// All items should start with "Table"
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
expect(item.label.toLowerCase().startsWith("table")).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("limits results to 50", () => {
|
|
50
|
+
const ctx = makeCtx({
|
|
51
|
+
linePrefix: "const x = new ",
|
|
52
|
+
wordAtCursor: "",
|
|
53
|
+
content: "const x = new ",
|
|
54
|
+
position: { line: 0, character: 14 },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const items = awsCompletions(ctx);
|
|
58
|
+
expect(items.length).toBeLessThanOrEqual(50);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns empty for non-matching context", () => {
|
|
62
|
+
const ctx = makeCtx({
|
|
63
|
+
linePrefix: "const x = 42",
|
|
64
|
+
wordAtCursor: "42",
|
|
65
|
+
content: "const x = 42",
|
|
66
|
+
position: { line: 0, character: 13 },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const items = awsCompletions(ctx);
|
|
70
|
+
expect(items.length).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
2
|
+
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
3
|
+
|
|
4
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
5
|
+
|
|
6
|
+
function getIndex(): LexiconIndex {
|
|
7
|
+
if (cachedIndex) return cachedIndex;
|
|
8
|
+
const data = require("../generated/lexicon-aws.json") as Record<string, LexiconEntry>;
|
|
9
|
+
cachedIndex = new LexiconIndex(data);
|
|
10
|
+
return cachedIndex;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provide AWS completions based on context.
|
|
15
|
+
*/
|
|
16
|
+
export function awsCompletions(ctx: CompletionContext): CompletionItem[] {
|
|
17
|
+
return lexiconCompletions(ctx, getIndex(), "AWS CloudFormation resource");
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { awsHover } from "./hover";
|
|
3
|
+
import type { HoverContext } from "@intentius/chant/lsp/types";
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides: Partial<HoverContext>): HoverContext {
|
|
6
|
+
return {
|
|
7
|
+
uri: "file:///test.ts",
|
|
8
|
+
content: "",
|
|
9
|
+
position: { line: 0, character: 0 },
|
|
10
|
+
word: "",
|
|
11
|
+
lineText: "",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("awsHover", () => {
|
|
17
|
+
test("returns hover info for Bucket", () => {
|
|
18
|
+
const ctx = makeCtx({ word: "Bucket" });
|
|
19
|
+
const info = awsHover(ctx);
|
|
20
|
+
|
|
21
|
+
expect(info).toBeDefined();
|
|
22
|
+
expect(info!.contents).toContain("Bucket");
|
|
23
|
+
expect(info!.contents).toContain("AWS::S3::Bucket");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("shows attributes for resource types", () => {
|
|
27
|
+
const ctx = makeCtx({ word: "Bucket" });
|
|
28
|
+
const info = awsHover(ctx);
|
|
29
|
+
|
|
30
|
+
expect(info).toBeDefined();
|
|
31
|
+
expect(info!.contents).toContain("Attributes");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns undefined for unknown word", () => {
|
|
35
|
+
const ctx = makeCtx({ word: "NotARealResource12345" });
|
|
36
|
+
const info = awsHover(ctx);
|
|
37
|
+
expect(info).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns undefined for empty word", () => {
|
|
41
|
+
const ctx = makeCtx({ word: "" });
|
|
42
|
+
const info = awsHover(ctx);
|
|
43
|
+
expect(info).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns info for Table resource", () => {
|
|
47
|
+
const ctx = makeCtx({ word: "Table" });
|
|
48
|
+
const info = awsHover(ctx);
|
|
49
|
+
|
|
50
|
+
expect(info).toBeDefined();
|
|
51
|
+
expect(info!.contents).toContain("AWS::DynamoDB::Table");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/lsp/hover.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
2
|
+
import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
3
|
+
|
|
4
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
5
|
+
|
|
6
|
+
function getIndex(): LexiconIndex {
|
|
7
|
+
if (cachedIndex) return cachedIndex;
|
|
8
|
+
const data = require("../generated/lexicon-aws.json") as Record<string, LexiconEntry>;
|
|
9
|
+
cachedIndex = new LexiconIndex(data);
|
|
10
|
+
return cachedIndex;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provide hover information for AWS resource types and properties.
|
|
15
|
+
*/
|
|
16
|
+
export function awsHover(ctx: HoverContext): HoverInfo | undefined {
|
|
17
|
+
return lexiconHover(ctx, getIndex(), resourceHover);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resourceHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
|
|
21
|
+
if (entry.kind !== "resource") return undefined;
|
|
22
|
+
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
|
|
25
|
+
lines.push(`**${className}**`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`CloudFormation type: \`${entry.resourceType}\``);
|
|
28
|
+
|
|
29
|
+
if (entry.attrs && Object.keys(entry.attrs).length > 0) {
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push("**Attributes:**");
|
|
32
|
+
for (const [key, value] of Object.entries(entry.attrs)) {
|
|
33
|
+
lines.push(`- \`${key}\` → \`${value}\``);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (entry.primaryIdentifier && entry.primaryIdentifier.length > 0) {
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push(`**Primary identifier:** ${entry.primaryIdentifier.map((p) => `\`${p}\``).join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (entry.createOnly && entry.createOnly.length > 0) {
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(`**Create-only:** ${entry.createOnly.map((p) => `\`${p}\``).join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry.writeOnly && entry.writeOnly.length > 0) {
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push(`**Write-only:** ${entry.writeOnly.map((p) => `\`${p}\``).join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { contents: lines.join("\n") };
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { nestedStack, isNestedStackInstance, NestedStackOutputRef, NESTED_STACK_MARKER } from "./nested-stack";
|
|
3
|
+
import { DECLARABLE_MARKER, isDeclarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { CHILD_PROJECT_MARKER, isChildProject } from "@intentius/chant/child-project";
|
|
5
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
6
|
+
|
|
7
|
+
describe("nestedStack", () => {
|
|
8
|
+
test("creates a nested stack with correct markers", () => {
|
|
9
|
+
const stack = nestedStack("network", "/path/to/network");
|
|
10
|
+
|
|
11
|
+
expect(isNestedStackInstance(stack)).toBe(true);
|
|
12
|
+
expect(isChildProject(stack)).toBe(true);
|
|
13
|
+
expect(isDeclarable(stack)).toBe(true);
|
|
14
|
+
expect((stack as any)[NESTED_STACK_MARKER]).toBe(true);
|
|
15
|
+
expect((stack as any)[CHILD_PROJECT_MARKER]).toBe(true);
|
|
16
|
+
expect((stack as any)[DECLARABLE_MARKER]).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("has correct metadata", () => {
|
|
20
|
+
const stack = nestedStack("network", "/path/to/network");
|
|
21
|
+
|
|
22
|
+
expect(stack.lexicon).toBe("aws");
|
|
23
|
+
expect(stack.entityType).toBe("AWS::CloudFormation::Stack");
|
|
24
|
+
expect(stack.kind).toBe("resource");
|
|
25
|
+
expect(stack.logicalName).toBe("network");
|
|
26
|
+
expect(stack.projectPath).toBe("/path/to/network");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("outputs proxy returns NestedStackOutputRef", () => {
|
|
30
|
+
const stack = nestedStack("network", "/path/to/network");
|
|
31
|
+
|
|
32
|
+
const ref = stack.outputs.vpcId;
|
|
33
|
+
expect(ref).toBeInstanceOf(NestedStackOutputRef);
|
|
34
|
+
expect((ref as NestedStackOutputRef).stackName).toBe("network");
|
|
35
|
+
expect((ref as NestedStackOutputRef).outputName).toBe("vpcId");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("accepts options with parameters", () => {
|
|
39
|
+
const stack = nestedStack("network", "/path/to/network", {
|
|
40
|
+
parameters: { Environment: "prod" },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(stack.options).toEqual({ parameters: { Environment: "prod" } });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("defaults options to empty object", () => {
|
|
47
|
+
const stack = nestedStack("network", "/path/to/network");
|
|
48
|
+
expect(stack.options).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("NestedStackOutputRef", () => {
|
|
53
|
+
test("stores stackName and outputName", () => {
|
|
54
|
+
const ref = new NestedStackOutputRef("network", "vpcId");
|
|
55
|
+
expect(ref.stackName).toBe("network");
|
|
56
|
+
expect(ref.outputName).toBe("vpcId");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("has INTRINSIC_MARKER", () => {
|
|
60
|
+
const ref = new NestedStackOutputRef("network", "vpcId");
|
|
61
|
+
expect((ref as any)[INTRINSIC_MARKER]).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("serializes to Fn::GetAtt", () => {
|
|
65
|
+
const ref = new NestedStackOutputRef("network", "vpcId");
|
|
66
|
+
expect(ref.toJSON()).toEqual({
|
|
67
|
+
"Fn::GetAtt": ["network", "Outputs.vpcId"],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("isNestedStackInstance", () => {
|
|
73
|
+
test("returns true for nested stack", () => {
|
|
74
|
+
const stack = nestedStack("test", "/path");
|
|
75
|
+
expect(isNestedStackInstance(stack)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns false for plain object", () => {
|
|
79
|
+
expect(isNestedStackInstance({})).toBe(false);
|
|
80
|
+
expect(isNestedStackInstance(null)).toBe(false);
|
|
81
|
+
expect(isNestedStackInstance("string")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS NestedStack — references a child project directory that builds
|
|
3
|
+
* to an independent CloudFormation template. The parent emits
|
|
4
|
+
* AWS::CloudFormation::Stack pointing at the child template.
|
|
5
|
+
*
|
|
6
|
+
* Cross-stack references are explicit: the child declares `stackOutput()`
|
|
7
|
+
* exports, and the parent reads them via `nestedStack().outputs.name`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CHILD_PROJECT_MARKER, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
11
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
12
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Marker symbol for nested stack identification.
|
|
16
|
+
*/
|
|
17
|
+
export const NESTED_STACK_MARKER = Symbol.for("chant.aws.nestedStack");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options for configuring a nested stack.
|
|
21
|
+
*/
|
|
22
|
+
export interface NestedStackOptions {
|
|
23
|
+
/** Explicit CloudFormation Parameters to pass to the child stack */
|
|
24
|
+
parameters?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A reference to an output from a nested stack.
|
|
29
|
+
* Serializes to `{ "Fn::GetAtt": [stackLogicalName, "Outputs.OutputName"] }`.
|
|
30
|
+
*/
|
|
31
|
+
export class NestedStackOutputRef {
|
|
32
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
33
|
+
readonly stackName: string;
|
|
34
|
+
readonly outputName: string;
|
|
35
|
+
|
|
36
|
+
constructor(stackName: string, outputName: string) {
|
|
37
|
+
this.stackName = stackName;
|
|
38
|
+
this.outputName = outputName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toJSON(): { "Fn::GetAtt": [string, string] } {
|
|
42
|
+
return {
|
|
43
|
+
"Fn::GetAtt": [this.stackName, `Outputs.${this.outputName}`],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Type guard for NestedStackOutputRef.
|
|
50
|
+
*/
|
|
51
|
+
export function isNestedStackOutputRef(value: unknown): value is NestedStackOutputRef {
|
|
52
|
+
return value instanceof NestedStackOutputRef;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extended ChildProjectInstance with AWS-specific marker.
|
|
57
|
+
*/
|
|
58
|
+
export interface NestedStackInstance extends ChildProjectInstance {
|
|
59
|
+
readonly [NESTED_STACK_MARKER]: true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Type guard for NestedStackInstance.
|
|
64
|
+
*/
|
|
65
|
+
export function isNestedStackInstance(value: unknown): value is NestedStackInstance {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === "object" &&
|
|
68
|
+
value !== null &&
|
|
69
|
+
NESTED_STACK_MARKER in value &&
|
|
70
|
+
(value as Record<symbol, unknown>)[NESTED_STACK_MARKER] === true
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a nested stack that references a child project directory.
|
|
76
|
+
*
|
|
77
|
+
* The child directory must contain its own `_.ts` barrel and resource files.
|
|
78
|
+
* It can be built independently with `chant build`. Cross-stack outputs
|
|
79
|
+
* are declared in the child via `stackOutput()`.
|
|
80
|
+
*
|
|
81
|
+
* @param name - Logical name for the nested stack resource
|
|
82
|
+
* @param projectPath - Absolute path to the child project directory
|
|
83
|
+
* @param options - Optional parameters to pass to the child stack
|
|
84
|
+
* @returns A ChildProjectInstance with an `outputs` proxy for cross-stack refs
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const network = _.nestedStack("network", import.meta.dir + "/network", {
|
|
89
|
+
* parameters: { Environment: "prod" },
|
|
90
|
+
* });
|
|
91
|
+
*
|
|
92
|
+
* export const handler = new _.Function({
|
|
93
|
+
* vpcConfig: {
|
|
94
|
+
* subnetIds: [network.outputs.subnetId], // cross-stack ref
|
|
95
|
+
* },
|
|
96
|
+
* });
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function nestedStack(
|
|
100
|
+
name: string,
|
|
101
|
+
projectPath: string,
|
|
102
|
+
options?: NestedStackOptions,
|
|
103
|
+
): NestedStackInstance {
|
|
104
|
+
const outputsProxy = new Proxy({} as Record<string, NestedStackOutputRef>, {
|
|
105
|
+
get(_, prop: string) {
|
|
106
|
+
if (typeof prop === "symbol") return undefined;
|
|
107
|
+
return new NestedStackOutputRef(name, prop);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const instance = {
|
|
112
|
+
[CHILD_PROJECT_MARKER]: true,
|
|
113
|
+
[DECLARABLE_MARKER]: true,
|
|
114
|
+
[NESTED_STACK_MARKER]: true,
|
|
115
|
+
lexicon: "aws",
|
|
116
|
+
entityType: "AWS::CloudFormation::Stack",
|
|
117
|
+
kind: "resource" as const,
|
|
118
|
+
projectPath,
|
|
119
|
+
logicalName: name,
|
|
120
|
+
outputs: outputsProxy,
|
|
121
|
+
options: options ?? {},
|
|
122
|
+
} as NestedStackInstance;
|
|
123
|
+
|
|
124
|
+
return instance;
|
|
125
|
+
}
|