@intentius/chant-lexicon-aws 0.34.1 → 0.37.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The native AWS read transport (#1206).
3
+ *
4
+ * The transport is injected everywhere, so nothing here reaches a network. The
5
+ * XML cases are the ones worth being careful about: the applier's `xmlField`
6
+ * only ever handled flat scalars, and the read path needs `<member>` lists.
7
+ */
8
+ import { describe, test, expect } from "vitest";
9
+ import {
10
+ AwsReadError,
11
+ describeStackOutputs,
12
+ describeStackResources,
13
+ getResource,
14
+ listResources,
15
+ parseResourceDescription,
16
+ serviceUrl,
17
+ xmlLeaves,
18
+ xmlMembers,
19
+ xmlError,
20
+ type AwsReadHttp,
21
+ } from "./read-client";
22
+
23
+ const respond = (text: string, status = 200): ReturnType<AwsReadHttp> => Promise.resolve({ status, text });
24
+
25
+ /** Records every call so a test can assert what went on the wire. */
26
+ function recording(handler: (body: string, headers: Record<string, string>) => ReturnType<AwsReadHttp>) {
27
+ const calls: Array<{ url: string; headers: Record<string, string>; body: string }> = [];
28
+ const http: AwsReadHttp = (url, init) => {
29
+ calls.push({ url, headers: init.headers, body: init.body });
30
+ return handler(init.body, init.headers);
31
+ };
32
+ return { http, calls };
33
+ }
34
+
35
+ const stackXml = `
36
+ <DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>
37
+ <member>
38
+ <LogicalResourceId>dataBucket</LogicalResourceId>
39
+ <ResourceType>AWS::S3::Bucket</ResourceType>
40
+ <PhysicalResourceId>acme-assets</PhysicalResourceId>
41
+ <ResourceStatus>CREATE_COMPLETE</ResourceStatus>
42
+ <Timestamp>2026-01-01T00:00:00Z</Timestamp>
43
+ </member>
44
+ <member>
45
+ <LogicalResourceId>vpc</LogicalResourceId>
46
+ <ResourceType>AWS::EC2::VPC</ResourceType>
47
+ <PhysicalResourceId>vpc-01</PhysicalResourceId>
48
+ <ResourceStatus>CREATE_COMPLETE</ResourceStatus>
49
+ </member>
50
+ </StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`;
51
+
52
+ describe("serviceUrl", () => {
53
+ test("an override wins over the regional host, with exactly one trailing slash", () => {
54
+ expect(serviceUrl("cloudformation", "http://localhost:4566")).toBe("http://localhost:4566/");
55
+ expect(serviceUrl("cloudformation", "http://localhost:4566/")).toBe("http://localhost:4566/");
56
+ });
57
+
58
+ test("without an override it is the real regional host for that service", () => {
59
+ expect(serviceUrl("cloudcontrolapi", undefined, "eu-west-1")).toBe("https://cloudcontrolapi.eu-west-1.amazonaws.com/");
60
+ });
61
+ });
62
+
63
+ describe("the Query XML shapes", () => {
64
+ test("xmlLeaves flattens one fragment and decodes entities", () => {
65
+ expect(xmlLeaves("<A>1</A><B>x &amp; y</B>")).toEqual({ A: "1", B: "x & y" });
66
+ });
67
+
68
+ test("xmlLeaves keeps the first of a repeated tag", () => {
69
+ expect(xmlLeaves("<A>first</A><A>second</A>")).toEqual({ A: "first" });
70
+ });
71
+
72
+ test("xmlMembers reads a member list, which xmlField never could", () => {
73
+ expect(xmlMembers(stackXml, "StackResources")).toHaveLength(2);
74
+ });
75
+
76
+ test("xmlMembers is empty for an absent list rather than throwing", () => {
77
+ expect(xmlMembers("<Response/>", "StackResources")).toEqual([]);
78
+ });
79
+
80
+ test("xmlError finds a Query error document, and nothing in a success body", () => {
81
+ expect(xmlError("<ErrorResponse><Error><Code>ValidationError</Code><Message>nope</Message></Error></ErrorResponse>")).toEqual({
82
+ code: "ValidationError",
83
+ message: "nope",
84
+ });
85
+ expect(xmlError(stackXml)).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe("describeStackResources", () => {
90
+ test("maps members onto the read path's shape, keeping optional fields optional", async () => {
91
+ const { http, calls } = recording(() => respond(stackXml));
92
+ const resources = await describeStackResources("local", { endpoint: "http://localhost:4566", http });
93
+
94
+ expect(resources).toEqual([
95
+ {
96
+ logicalId: "dataBucket",
97
+ type: "AWS::S3::Bucket",
98
+ physicalId: "acme-assets",
99
+ status: "CREATE_COMPLETE",
100
+ timestamp: "2026-01-01T00:00:00Z",
101
+ },
102
+ { logicalId: "vpc", type: "AWS::EC2::VPC", physicalId: "vpc-01", status: "CREATE_COMPLETE" },
103
+ ]);
104
+ expect(calls[0].url).toBe("http://localhost:4566/");
105
+ expect(calls[0].body).toContain("Action=DescribeStackResources");
106
+ expect(calls[0].body).toContain("StackName=local");
107
+ });
108
+
109
+ test("a Query error becomes a typed throw carrying the service's own code", async () => {
110
+ const { http } = recording(() =>
111
+ respond("<ErrorResponse><Error><Code>ValidationError</Code><Message>Stack with id local does not exist</Message></Error></ErrorResponse>", 400),
112
+ );
113
+ await expect(describeStackResources("local", { http })).rejects.toMatchObject({
114
+ name: "AwsReadError",
115
+ code: "ValidationError",
116
+ status: 400,
117
+ });
118
+ });
119
+
120
+ test("a non-2xx with no error document still throws rather than parsing as empty", async () => {
121
+ const { http } = recording(() => respond("<html>502</html>", 502));
122
+ await expect(describeStackResources("local", { http })).rejects.toBeInstanceOf(AwsReadError);
123
+ });
124
+ });
125
+
126
+ describe("describeStackOutputs", () => {
127
+ test("reads the output members as a flat record", async () => {
128
+ const { http } = recording(() =>
129
+ respond(`<DescribeStacksResponse><Outputs>
130
+ <member><OutputKey>VpcId</OutputKey><OutputValue>vpc-01</OutputValue></member>
131
+ <member><OutputKey>Empty</OutputKey></member>
132
+ </Outputs></DescribeStacksResponse>`),
133
+ );
134
+ expect(await describeStackOutputs("local", { http })).toEqual({ VpcId: "vpc-01", Empty: "" });
135
+ });
136
+ });
137
+
138
+ describe("Cloud Control", () => {
139
+ const description = (identifier: string, properties: Record<string, unknown>) => ({
140
+ Identifier: identifier,
141
+ Properties: JSON.stringify(properties),
142
+ });
143
+
144
+ test("getResource unwraps the doubly-encoded model and targets the JSON 1.0 operation", async () => {
145
+ const { http, calls } = recording(() =>
146
+ respond(JSON.stringify({ ResourceDescription: description("acme-assets", { BucketName: "acme-assets" }) })),
147
+ );
148
+ expect(await getResource("AWS::S3::Bucket", "acme-assets", { http })).toEqual({
149
+ identifier: "acme-assets",
150
+ properties: { BucketName: "acme-assets" },
151
+ });
152
+ expect(calls[0].headers["x-amz-target"]).toBe("CloudApiService.GetResource");
153
+ expect(calls[0].headers["content-type"]).toBe("application/x-amz-json-1.0");
154
+ });
155
+
156
+ test("a modelled error becomes a typed throw — UnsupportedOperation is not a miss", async () => {
157
+ const { http } = recording(() =>
158
+ respond(JSON.stringify({ __type: "UnsupportedOperation", message: "Operation GetResource is not supported." }), 400),
159
+ );
160
+ await expect(getResource("AWS::S3::Bucket", "b", { http })).rejects.toMatchObject({
161
+ code: "UnsupportedOperation",
162
+ status: 400,
163
+ });
164
+ });
165
+
166
+ test("a `__type` carrying a shape prefix is reported by its bare name", async () => {
167
+ const { http } = recording(() =>
168
+ respond(JSON.stringify({ __type: "com.amazonaws.cloudapi#ThrottlingException", message: "Rate exceeded" }), 400),
169
+ );
170
+ await expect(getResource("AWS::S3::Bucket", "b", { http })).rejects.toMatchObject({ code: "ThrottlingException" });
171
+ });
172
+
173
+ test("listResources follows NextToken to exhaustion", async () => {
174
+ let page = 0;
175
+ const { http, calls } = recording(() => {
176
+ page += 1;
177
+ return respond(
178
+ JSON.stringify(
179
+ page === 1
180
+ ? { ResourceDescriptions: [description("a", { BucketName: "a" })], NextToken: "more" }
181
+ : { ResourceDescriptions: [description("b", { BucketName: "b" })] },
182
+ ),
183
+ );
184
+ });
185
+ const all = await listResources("AWS::S3::Bucket", { http });
186
+ expect(all.map((r) => r.identifier)).toEqual(["a", "b"]);
187
+ expect(calls).toHaveLength(2);
188
+ expect(calls[1].body).toContain("more");
189
+ });
190
+
191
+ test("an unparseable description is dropped, not returned as an empty resource", async () => {
192
+ const { http } = recording(() =>
193
+ respond(JSON.stringify({ ResourceDescriptions: [{ Identifier: "a", Properties: "{oops" }] })),
194
+ );
195
+ expect(await listResources("AWS::S3::Bucket", { http })).toEqual([]);
196
+ });
197
+
198
+ test("parseResourceDescription refuses anything that is not a model object", () => {
199
+ expect(parseResourceDescription(null)).toBeNull();
200
+ expect(parseResourceDescription({ Identifier: "a" })).toBeNull();
201
+ expect(parseResourceDescription({ Properties: JSON.stringify(["not", "an", "object"]) })).toBeNull();
202
+ });
203
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The native AWS read transport (#1206) — the read half of what
3
+ * `op/activities/aws-apply.ts` already does for writes.
4
+ *
5
+ * Every AWS observer shelled `aws …` once per entity, serially, and parsed
6
+ * stderr to find out what went wrong (#1085). The applier does not: `awsApply`
7
+ * speaks the CloudFormation Query protocol over `fetch`, honours an endpoint
8
+ * override, and is injectable for tests. This module is that same transport
9
+ * pointed at the two APIs the read path needs:
10
+ *
11
+ * - **CloudFormation Query** (form-encoded POST, XML back) — the stack reads
12
+ * `describeResources` and the deep pass both start from.
13
+ * - **Cloud Control** (AWS JSON 1.0, `X-Amz-Target: CloudApiService.*`) — the
14
+ * property-level reads (#1015).
15
+ *
16
+ * Both take the endpoint the same way the applier does, so `chant emulator` /
17
+ * behold `--local` keep working by construction rather than by each observer
18
+ * remembering to inject `--endpoint-url`.
19
+ *
20
+ * ## Signing
21
+ *
22
+ * Requests are unsigned, exactly like `awsApply`'s. That is what makes the
23
+ * emulator-first lanes work with no credential plumbing, and it is the reason
24
+ * this is scoped to the Floci lanes (#1198) rather than announced as a
25
+ * real-cloud read path: real AWS rejects an unsigned request. The CLI path
26
+ * remains available for a signed read until SigV4 lands here.
27
+ */
28
+
29
+ const DEFAULT_REGION = "us-east-1";
30
+ const CFN_API_VERSION = "2010-05-15";
31
+ const CLOUD_CONTROL_TARGET_PREFIX = "CloudApiService";
32
+
33
+ /** Injectable HTTP, mirroring `AwsHttp` in the applier so tests avoid the network. */
34
+ export type AwsReadHttp = (
35
+ url: string,
36
+ init: { headers: Record<string, string>; body: string },
37
+ signal?: AbortSignal,
38
+ ) => Promise<{ status: number; text: string }>;
39
+
40
+ const defaultHttp: AwsReadHttp = async (url, init, signal) => {
41
+ const res = await fetch(url, { method: "POST", headers: init.headers, body: init.body, signal });
42
+ return { status: res.status, text: await res.text() };
43
+ };
44
+
45
+ /** A failed read, carrying enough to classify it without parsing prose. */
46
+ export class AwsReadError extends Error {
47
+ constructor(
48
+ message: string,
49
+ readonly status: number,
50
+ /** The API's own error code (`ValidationError`, `UnsupportedOperation`, …) when it sent one. */
51
+ readonly code?: string,
52
+ ) {
53
+ super(message);
54
+ this.name = "AwsReadError";
55
+ }
56
+ }
57
+
58
+ export interface AwsReadClientOptions {
59
+ /** Endpoint override (Floci `http://localhost:4566`). Omit for real AWS hosts. */
60
+ endpoint?: string;
61
+ /** Region for the real-AWS host and the Query `Version` context. */
62
+ region?: string;
63
+ http?: AwsReadHttp;
64
+ signal?: AbortSignal;
65
+ }
66
+
67
+ /** Service host for `service`, honouring an endpoint override. */
68
+ export function serviceUrl(service: string, endpoint?: string, region = DEFAULT_REGION): string {
69
+ return `${(endpoint ?? `https://${service}.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
70
+ }
71
+
72
+ /* ── CloudFormation Query ─────────────────────────────────────────────────── */
73
+
74
+ /**
75
+ * The `<Tag>text</Tag>` pairs of one XML fragment, as a flat record. Repeated
76
+ * tags keep the first occurrence, which is what a `<member>` body wants —
77
+ * nested lists inside a member are not modelled, because nothing the read path
78
+ * needs from `DescribeStackResources` / `DescribeStacks` is nested that deep.
79
+ */
80
+ export function xmlLeaves(fragment: string): Record<string, string> {
81
+ const out: Record<string, string> = {};
82
+ for (const m of fragment.matchAll(/<([A-Za-z0-9]+)>([^<]*)<\/\1>/g)) {
83
+ const [, tag, value] = m;
84
+ if (tag && !(tag in out)) out[tag] = decodeXmlEntities(value ?? "");
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** The five predefined XML entities. Query responses carry them in ARNs and policy text. */
90
+ function decodeXmlEntities(value: string): string {
91
+ return value
92
+ .replace(/&lt;/g, "<")
93
+ .replace(/&gt;/g, ">")
94
+ .replace(/&quot;/g, '"')
95
+ .replace(/&apos;/g, "'")
96
+ .replace(/&amp;/g, "&");
97
+ }
98
+
99
+ /**
100
+ * Every `<member>…</member>` under `listTag`, each flattened by
101
+ * {@link xmlLeaves}. The Query protocol renders a list as repeated `<member>`
102
+ * elements, and `xmlField` in the applier deliberately does not handle them —
103
+ * it was written for scalar status fields.
104
+ */
105
+ export function xmlMembers(xml: string, listTag: string): Array<Record<string, string>> {
106
+ const list = xml.match(new RegExp(`<${listTag}>([\\s\\S]*?)</${listTag}>`))?.[1];
107
+ if (!list) return [];
108
+ return [...list.matchAll(/<member>([\s\S]*?)<\/member>/g)].map((m) => xmlLeaves(m[1] ?? ""));
109
+ }
110
+
111
+ /** `<Error><Code>…</Code><Message>…</Message></Error>`, when the response carries one. */
112
+ export function xmlError(xml: string): { code?: string; message?: string } | undefined {
113
+ if (!/<Error>/.test(xml)) return undefined;
114
+ const leaves = xmlLeaves(xml.match(/<Error>([\s\S]*?)<\/Error>/)?.[1] ?? "");
115
+ return { code: leaves.Code, message: leaves.Message };
116
+ }
117
+
118
+ /**
119
+ * One CloudFormation Query call. Returns the raw XML; throws
120
+ * {@link AwsReadError} for a non-2xx or an `<Error>` body, so a caller
121
+ * classifies a typed failure instead of matching on stderr.
122
+ */
123
+ export async function cfnQuery(
124
+ action: string,
125
+ params: Record<string, string>,
126
+ options: AwsReadClientOptions = {},
127
+ ): Promise<string> {
128
+ const http = options.http ?? defaultHttp;
129
+ const url = serviceUrl("cloudformation", options.endpoint, options.region);
130
+ const body = new URLSearchParams({ Action: action, Version: CFN_API_VERSION, ...params }).toString();
131
+ const res = await http(url, { headers: { "content-type": "application/x-www-form-urlencoded" }, body }, options.signal);
132
+ const err = xmlError(res.text);
133
+ if (err || res.status >= 400) {
134
+ throw new AwsReadError(
135
+ err?.message ?? `${action} failed with HTTP ${res.status}`,
136
+ res.status,
137
+ err?.code,
138
+ );
139
+ }
140
+ return res.text;
141
+ }
142
+
143
+ /** One stack resource, as `DescribeStackResources` reports it. */
144
+ export interface StackResource {
145
+ logicalId: string;
146
+ type: string;
147
+ physicalId?: string;
148
+ status?: string;
149
+ timestamp?: string;
150
+ }
151
+
152
+ /** `DescribeStackResources` for one stack, mapped off the Query XML. */
153
+ export async function describeStackResources(
154
+ stackName: string,
155
+ options: AwsReadClientOptions = {},
156
+ ): Promise<StackResource[]> {
157
+ const xml = await cfnQuery("DescribeStackResources", { StackName: stackName }, options);
158
+ return xmlMembers(xml, "StackResources").map((m) => ({
159
+ logicalId: m.LogicalResourceId ?? "",
160
+ type: m.ResourceType ?? "",
161
+ ...(m.PhysicalResourceId ? { physicalId: m.PhysicalResourceId } : {}),
162
+ ...(m.ResourceStatus ? { status: m.ResourceStatus } : {}),
163
+ ...(m.Timestamp ? { timestamp: m.Timestamp } : {}),
164
+ }));
165
+ }
166
+
167
+ /** `DescribeStacks` outputs for one stack, as `key → value`. */
168
+ export async function describeStackOutputs(
169
+ stackName: string,
170
+ options: AwsReadClientOptions = {},
171
+ ): Promise<Record<string, string>> {
172
+ const xml = await cfnQuery("DescribeStacks", { StackName: stackName }, options);
173
+ const outputs: Record<string, string> = {};
174
+ for (const m of xmlMembers(xml, "Outputs")) {
175
+ if (m.OutputKey) outputs[m.OutputKey] = m.OutputValue ?? "";
176
+ }
177
+ return outputs;
178
+ }
179
+
180
+ /* ── Cloud Control ────────────────────────────────────────────────────────── */
181
+
182
+ /** One live resource, as Cloud Control describes it. */
183
+ export interface CloudControlDescription {
184
+ identifier: string;
185
+ properties: Record<string, unknown>;
186
+ }
187
+
188
+ /**
189
+ * One Cloud Control call (AWS JSON 1.0). Throws {@link AwsReadError} carrying
190
+ * the service's `__type` so `UnsupportedOperation` — what Floci answers for
191
+ * `GetResource` — stays distinguishable from a credential or a genuine miss.
192
+ */
193
+ async function cloudControl(
194
+ operation: "GetResource" | "ListResources",
195
+ payload: Record<string, unknown>,
196
+ options: AwsReadClientOptions = {},
197
+ ): Promise<Record<string, unknown>> {
198
+ const http = options.http ?? defaultHttp;
199
+ const url = serviceUrl("cloudcontrolapi", options.endpoint, options.region);
200
+ const res = await http(
201
+ url,
202
+ {
203
+ headers: {
204
+ "content-type": "application/x-amz-json-1.0",
205
+ "x-amz-target": `${CLOUD_CONTROL_TARGET_PREFIX}.${operation}`,
206
+ },
207
+ body: JSON.stringify(payload),
208
+ },
209
+ options.signal,
210
+ );
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(res.text);
214
+ } catch {
215
+ throw new AwsReadError(`unparseable ${operation} response`, res.status);
216
+ }
217
+ const body = isRecord(parsed) ? parsed : {};
218
+ const type = typeof body.__type === "string" ? body.__type.split("#").pop() : undefined;
219
+ if (type || res.status >= 400) {
220
+ const message = typeof body.message === "string" ? body.message : `${operation} failed with HTTP ${res.status}`;
221
+ throw new AwsReadError(message, res.status, type);
222
+ }
223
+ return body;
224
+ }
225
+
226
+ function isRecord(value: unknown): value is Record<string, unknown> {
227
+ return typeof value === "object" && value !== null && !Array.isArray(value);
228
+ }
229
+
230
+ /**
231
+ * Cloud Control returns a resource model as a JSON *string* inside the
232
+ * envelope, so every description unwraps twice. Returns null when either level
233
+ * does not parse to an object — an unparseable body is a failed read, not an
234
+ * empty resource.
235
+ */
236
+ export function parseResourceDescription(raw: unknown): CloudControlDescription | null {
237
+ if (!isRecord(raw)) return null;
238
+ const properties = typeof raw.Properties === "string" ? safeParseObject(raw.Properties) : undefined;
239
+ if (!properties) return null;
240
+ return {
241
+ identifier: typeof raw.Identifier === "string" ? raw.Identifier : "",
242
+ properties,
243
+ };
244
+ }
245
+
246
+ function safeParseObject(text: string): Record<string, unknown> | null {
247
+ try {
248
+ const parsed: unknown = JSON.parse(text);
249
+ return isRecord(parsed) ? parsed : null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /** `GetResource` — the full live model for one identifier. */
256
+ export async function getResource(
257
+ typeName: string,
258
+ identifier: string,
259
+ options: AwsReadClientOptions = {},
260
+ ): Promise<CloudControlDescription | null> {
261
+ const body = await cloudControl("GetResource", { TypeName: typeName, Identifier: identifier }, options);
262
+ return parseResourceDescription(body.ResourceDescription);
263
+ }
264
+
265
+ /** `ListResources` — every live resource of one type, paginated to exhaustion. */
266
+ export async function listResources(
267
+ typeName: string,
268
+ options: AwsReadClientOptions = {},
269
+ ): Promise<CloudControlDescription[]> {
270
+ const out: CloudControlDescription[] = [];
271
+ let nextToken: string | undefined;
272
+ do {
273
+ const body = await cloudControl(
274
+ "ListResources",
275
+ { TypeName: typeName, ...(nextToken ? { NextToken: nextToken } : {}) },
276
+ options,
277
+ );
278
+ const descriptions = Array.isArray(body.ResourceDescriptions) ? body.ResourceDescriptions : [];
279
+ for (const d of descriptions) {
280
+ const parsed = parseResourceDescription(d);
281
+ if (parsed) out.push(parsed);
282
+ }
283
+ nextToken = typeof body.NextToken === "string" && body.NextToken.length > 0 ? body.NextToken : undefined;
284
+ } while (nextToken);
285
+ return out;
286
+ }
@@ -110,7 +110,13 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
110
110
  // depends on. The hand-written usage guide with full worked examples
111
111
  // moves to a separate "intrinsics-guide" page below — content
112
112
  // unchanged, just no longer sharing a slug with generated data.
113
- suppressPages: ["pseudo-parameters", "rules"],
113
+ // `rules` is NOT suppressed: the hand-written `lint-rules` page below
114
+ // explains 26 of the lexicon's 50 rules in depth, and the whole WAW032+
115
+ // hardening pass had no entry there at all (#1312). The generated table is
116
+ // the complete, always-current list, so both ship — the overview/reference
117
+ // pairing temporal uses. Duplicating 24 descriptions into the prose page
118
+ // would just create a third copy to drift.
119
+ suppressPages: ["pseudo-parameters"],
114
120
  examplesDir: join(pkgDir, "examples"),
115
121
  extraPages: [
116
122
  {
@@ -330,11 +336,11 @@ Tag values support strings, \`Parameter\` references, and intrinsic functions (\
330
336
  slug: "intrinsics-guide",
331
337
  title: "Intrinsics Guide",
332
338
  description: "Worked examples for every CloudFormation intrinsic function and their chant syntax",
333
- content: `See [Intrinsic Functions](../intrinsics/) for the generated reference table (name, description, output key, whether it's a tagged template, whether \`chant build --fold\` can fold it). This page is the worked-example companion — one \`Fn::\` intrinsic function per section, with real usage.
339
+ content: `See [Intrinsic Functions](../intrinsics/) for the generated reference table (name, description, output key, whether it's a tagged template, whether folding can reduce it). This page is the worked-example companion — one \`Fn::\` intrinsic function per section, with real usage.
334
340
 
335
341
  CloudFormation intrinsic functions are available as imports from the lexicon. They produce the corresponding \`Fn::\` calls in the serialized template.
336
342
 
337
- Only \`Sub\` is a tagged template — the others below are plain function calls. That distinction matters for [\`chant build --fold\`](/chant/concepts/typescript-as-data/#folded-vs-run): a registered intrinsic tagged template is one of the shapes the static folder can reduce with no module execution. A plain function call is not, today — \`chant build --fold\` has no case for a bare call used as a value yet ([#1044](https://github.com/INTENTIUS/chant/issues/1044) tracks changing that, per intrinsic). Using \`Ref\`, \`GetAtt\`, \`If\`, \`Join\`, \`Select\`, \`Split\`, \`Base64\`, or \`GetAZs\` anywhere in a resource's props forces that file back to the normal run path under \`--fold\` for now.
343
+ Only \`Sub\` is a tagged template — the others below are plain function calls. Both forms fold on the [default build path](/chant/concepts/typescript-as-data/#folded-vs-run) ([#1044](https://github.com/INTENTIUS/chant/issues/1044)): every AWS intrinsic on this page is registered with its call form opted in, so using \`Ref\`, \`GetAtt\`, \`If\`, \`Join\`, \`Select\`, \`Split\`, \`Base64\`, or \`GetAZs\` in a resource's props no longer forces that file back to the run path. What folds is the call, not calls in general — the name has to be one this lexicon registered and opted in, imported from this lexicon by the file using it. A same-file resource passed to one (\`Ref(bucket)\` where \`bucket\` is declared in the same file) still falls back, since the intrinsic needs the real constructed resource.
338
344
 
339
345
  Here is a complete example using all intrinsic functions:
340
346
 
@@ -412,7 +418,7 @@ Instantiate and export:
412
418
 
413
419
  During build, composites expand to flat CloudFormation resources: \`healthApiRole\`, \`healthApiFunc\`, \`healthApiPermission\`.
414
420
 
415
- A top-level composite call assigned directly to an export — like \`healthApi\` above — is one of the patterns [\`chant build --fold\`](/chant/concepts/typescript-as-data/#folded-vs-run) can reduce with no module execution (chant #1023). Defining a composite (the \`Composite(...)\` call inside \`lambda-api.ts\` itself) doesn't fold — its factory callback is a function, which is outside the fold subset — and neither does a composite call embedded as a nested value inside another resource's own properties; only a composite call that is itself a file's top-level export (or destructured/re-exported from one) is eligible.
421
+ A top-level composite call assigned directly to an export — like \`healthApi\` above — is one of the patterns [folding](/chant/concepts/typescript-as-data/#folded-vs-run) can reduce with no module execution (chant #1023). Defining a composite (the \`Composite(...)\` call inside \`lambda-api.ts\` itself) doesn't fold — its factory callback is a function, which is outside the fold subset — and neither does a composite call embedded as a nested value inside another resource's own properties; only a composite call that is itself a file's top-level export (or destructured/re-exported from one) is eligible.
416
422
 
417
423
  ## Built-in composites
418
424
 
@@ -645,6 +651,12 @@ Most splitting use cases are better served by other mechanisms:
645
651
  description: "Built-in lint rules and post-synth checks for AWS CloudFormation",
646
652
  content: `The AWS lexicon ships lint rules that run during \`chant lint\` and post-synth checks that validate the serialized CloudFormation output after \`chant build\`.
647
653
 
654
+ This page explains the most commonly hit rules in depth. For the complete list —
655
+ every rule the lexicon registers, generated from the registration itself so it
656
+ cannot fall behind — see [All Rules](../rules/). The security-hardening rules
657
+ (WAW032 onward) also carry remediation guidance and upstream references in the
658
+ [audit rules reference](/chant/lint-rules/audit-rules/).
659
+
648
660
  ## Lint rules
649
661
 
650
662
  Lint rules analyze your TypeScript source code before build.
@@ -1135,7 +1147,12 @@ The lexicon also provides MCP (Model Context Protocol) tools and resources that
1135
1147
  },
1136
1148
  ],
1137
1149
  sidebarExtra: [
1150
+ // buildSidebar declines to link the generated `rules` page when a
1151
+ // `lint-rules` extraPage exists, so the complete table needs an explicit
1152
+ // entry — under a label that distinguishes it from the prose page.
1153
+ { label: "All Rules (generated)", slug: "rules" },
1138
1154
  { label: "Deploying to EKS", slug: "eks-kubernetes" },
1155
+ { label: "Nested Stacks", slug: "nested-stacks" },
1139
1156
  ],
1140
1157
  };
1141
1158