@intentius/chant-lexicon-aws 0.37.2 → 0.40.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.
Files changed (95) hide show
  1. package/dist/ambient.d.ts.map +1 -1
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/codegen/generate.d.ts.map +1 -1
  4. package/dist/codegen/naming.d.ts +18 -1
  5. package/dist/codegen/naming.d.ts.map +1 -1
  6. package/dist/composites/alb-shared.d.ts +7 -1
  7. package/dist/composites/alb-shared.d.ts.map +1 -1
  8. package/dist/composites/ec2-instance-role.d.ts +4 -1
  9. package/dist/composites/ec2-instance-role.d.ts.map +1 -1
  10. package/dist/composites/efs-with-access-point.d.ts +5 -1
  11. package/dist/composites/efs-with-access-point.d.ts.map +1 -1
  12. package/dist/composites/fargate-alb.d.ts +14 -1
  13. package/dist/composites/fargate-alb.d.ts.map +1 -1
  14. package/dist/composites/fargate-service.d.ts +19 -8
  15. package/dist/composites/fargate-service.d.ts.map +1 -1
  16. package/dist/composites/index.d.ts +2 -2
  17. package/dist/composites/index.d.ts.map +1 -1
  18. package/dist/composites/lambda-api.d.ts +3 -1
  19. package/dist/composites/lambda-api.d.ts.map +1 -1
  20. package/dist/composites/lambda-dynamodb.d.ts +9 -2
  21. package/dist/composites/lambda-dynamodb.d.ts.map +1 -1
  22. package/dist/composites/lambda-eventbridge.d.ts +9 -2
  23. package/dist/composites/lambda-eventbridge.d.ts.map +1 -1
  24. package/dist/composites/lambda-s3.d.ts +3 -1
  25. package/dist/composites/lambda-s3.d.ts.map +1 -1
  26. package/dist/composites/lambda-sns.d.ts +10 -2
  27. package/dist/composites/lambda-sns.d.ts.map +1 -1
  28. package/dist/composites/lambda-sqs.d.ts +9 -2
  29. package/dist/composites/lambda-sqs.d.ts.map +1 -1
  30. package/dist/composites/microvm-app.d.ts +25 -4
  31. package/dist/composites/microvm-app.d.ts.map +1 -1
  32. package/dist/composites/minimal-vpc.d.ts +10 -1
  33. package/dist/composites/minimal-vpc.d.ts.map +1 -1
  34. package/dist/composites/rds-instance.d.ts +1 -1
  35. package/dist/composites/rds-instance.d.ts.map +1 -1
  36. package/dist/composites/scheduled-lambda.d.ts +15 -3
  37. package/dist/composites/scheduled-lambda.d.ts.map +1 -1
  38. package/dist/composites/solr-fargate-service.d.ts +27 -1
  39. package/dist/composites/solr-fargate-service.d.ts.map +1 -1
  40. package/dist/generated/index.d.ts +57 -49
  41. package/dist/generated/index.d.ts.map +1 -1
  42. package/dist/import/generator.d.ts.map +1 -1
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/integrity.json +4 -4
  46. package/dist/manifest.json +1 -1
  47. package/dist/meta.json +722 -666
  48. package/dist/op/activities/floci.d.ts.map +1 -1
  49. package/dist/plugin.d.ts.map +1 -1
  50. package/dist/reference-catalog.d.ts.map +1 -1
  51. package/dist/spec/pin.d.ts +116 -0
  52. package/dist/spec/pin.d.ts.map +1 -0
  53. package/dist/spec/pinned-types.json +1652 -0
  54. package/dist/types/index.d.ts +679 -555
  55. package/dist/validate.d.ts.map +1 -1
  56. package/package.json +2 -2
  57. package/src/ambient.test.ts +21 -0
  58. package/src/ambient.ts +16 -0
  59. package/src/api/read-client.test.ts +31 -0
  60. package/src/api/read-client.ts +43 -1
  61. package/src/codegen/generate.ts +17 -3
  62. package/src/codegen/naming.ts +31 -2
  63. package/src/composites/alb-shared.ts +1 -1
  64. package/src/composites/ec2-instance-role.ts +1 -1
  65. package/src/composites/efs-with-access-point.ts +1 -1
  66. package/src/composites/fargate-alb.ts +1 -1
  67. package/src/composites/fargate-service.ts +15 -10
  68. package/src/composites/index.ts +2 -2
  69. package/src/composites/lambda-api.ts +3 -2
  70. package/src/composites/lambda-dynamodb.ts +4 -3
  71. package/src/composites/lambda-eventbridge.ts +4 -3
  72. package/src/composites/lambda-s3.ts +3 -2
  73. package/src/composites/lambda-sns.ts +4 -3
  74. package/src/composites/lambda-sqs.ts +4 -3
  75. package/src/composites/microvm-app.test.ts +39 -1
  76. package/src/composites/microvm-app.ts +32 -11
  77. package/src/composites/minimal-vpc.ts +1 -1
  78. package/src/composites/rds-instance.ts +1 -1
  79. package/src/composites/scheduled-lambda.ts +4 -3
  80. package/src/composites/solr-fargate-service.ts +7 -1
  81. package/src/generated/index.d.ts +679 -555
  82. package/src/generated/index.ts +58 -50
  83. package/src/generated/lexicon-aws.json +722 -666
  84. package/src/import/generator.ts +5 -4
  85. package/src/index.ts +2 -2
  86. package/src/lifecycle-integration.test.ts +25 -0
  87. package/src/op/activities/floci.test.ts +2 -2
  88. package/src/op/activities/floci.ts +6 -1
  89. package/src/plugin.ts +4 -0
  90. package/src/reference-catalog.test.ts +28 -0
  91. package/src/reference-catalog.ts +7 -0
  92. package/src/spec/pin.test.ts +248 -0
  93. package/src/spec/pin.ts +247 -0
  94. package/src/spec/pinned-types.json +1652 -0
  95. package/src/validate.ts +5 -0
@@ -1,3 +1,4 @@
1
+ import { loadLexiconRegistry } from "@intentius/chant/codegen/registry";
1
2
  import { createRequire } from "module";
2
3
  import type { TemplateIR, ResourceIR, ParameterIR } from "@intentius/chant/import/parser";
3
4
  const require = createRequire(import.meta.url);
@@ -14,10 +15,10 @@ export class CFGenerator implements TypeScriptGenerator {
14
15
  private allClassNames: Set<string>;
15
16
 
16
17
  constructor() {
17
- // Build reverse lookup from dist/meta.json: resourceType → className
18
- const metaPath = join(import.meta.dirname, "../../dist/meta.json");
19
- const meta: Record<string, { resourceType: string; kind: string }> =
20
- require(metaPath);
18
+ // Reverse lookup from the generated registry: resourceType → className.
19
+ // Loaded through core so a missing registry says what to run rather than
20
+ // "Cannot find module .../dist/meta.json" (#1367).
21
+ const meta = loadLexiconRegistry(join(import.meta.dirname, "../.."), "aws");
21
22
  this.typeToClass = new Map();
22
23
  this.allClassNames = new Set();
23
24
  for (const [className, entry] of Object.entries(meta)) {
package/src/index.ts CHANGED
@@ -112,7 +112,7 @@ export {
112
112
  EfsWithAccessPoint,
113
113
  Ec2InstanceRole, MinimalVpc,
114
114
  SolrFargateService,
115
- MicrovmApp,
115
+ MicrovmApp, MICROVM_LIMITS,
116
116
  AgentCoreAgent,
117
117
  } from "./composites/index";
118
118
  export type {
@@ -122,7 +122,7 @@ export type {
122
122
  EfsWithAccessPointProps,
123
123
  Ec2InstanceRoleProps, MinimalVpcProps,
124
124
  SolrFargateServiceProps,
125
- MicrovmAppProps, MicrovmAppResult, MicrovmAppBuildConnectorProps,
125
+ MicrovmAppProps, MicrovmAppResult, MicrovmAppBuildConnectorProps, MicrovmMemoryMiB,
126
126
  AgentCoreAgentProps, AgentCoreAgentResult,
127
127
  } from "./composites/index";
128
128
 
@@ -239,6 +239,7 @@ describe("aws lifecycle integration (#163)", () => {
239
239
  // The shared conformance suite (#1089).
240
240
  describeObservationConformance({
241
241
  lexicon: "aws",
242
+ ownershipChannel: awsPlugin.ownershipChannel,
242
243
  scenarios: [
243
244
  {
244
245
  name: "a stack read that fails on credentials",
@@ -286,5 +287,29 @@ describeObservationConformance({
286
287
  });
287
288
  },
288
289
  },
290
+ {
291
+ // aws declares a marker channel on the deep read and on live export, but
292
+ // not here: describe-stack-resources returns no tags, so the filter has
293
+ // nothing to filter on. The suite holds it to that — an `owned` verdict
294
+ // from this path would be a claim the transport cannot support (#1348).
295
+ name: "an owned read on a path with no marker channel",
296
+ declared: ["MyBucket"],
297
+ expectPresent: ["MyBucket"],
298
+ owned: true,
299
+ run: () => {
300
+ stubCfn((action) =>
301
+ action === "DescribeStackResources"
302
+ ? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
303
+ : { text: stackOutputsXml() },
304
+ );
305
+ return awsPlugin.describeResources!({
306
+ environment: "prod",
307
+ buildOutput: "",
308
+ entityNames: ["MyBucket"],
309
+ entities: new Map(),
310
+ owned: true,
311
+ });
312
+ },
313
+ },
289
314
  ],
290
315
  });
@@ -13,7 +13,7 @@ import {
13
13
  describe("flociRunCommand (#704)", () => {
14
14
  test("minimal — defaults name/port/image, no docker socket", () => {
15
15
  expect(flociRunCommand({})).toBe(
16
- "docker run -d --rm --name chant-floci -p 4566:4566 floci/floci:latest",
16
+ "docker run -d --rm --name chant-floci -p 4566:4566 floci/floci:1.5.34",
17
17
  );
18
18
  });
19
19
 
@@ -60,7 +60,7 @@ describe("flociHealthUrl / flociEnv (#704)", () => {
60
60
  describe("FLOCI_SPEC / FLOCI_EMULATOR — the aws plugin's emulator capability (#920)", () => {
61
61
  test("spec pins the Floci container identity the shared lifecycle boots", () => {
62
62
  expect(FLOCI_SPEC.name).toBe("chant-floci");
63
- expect(FLOCI_SPEC.image).toBe("floci/floci:latest");
63
+ expect(FLOCI_SPEC.image).toBe("floci/floci:1.5.34");
64
64
  expect(FLOCI_SPEC.containerPort).toBe(4566);
65
65
  expect(FLOCI_SPEC.healthPath).toBe("/_localstack/health");
66
66
  });
@@ -2,7 +2,11 @@ import { emulatorLifecycle, type EmulatorSpec, type EmulatorCapability } from "@
2
2
 
3
3
  const DEFAULT_NAME = "chant-floci";
4
4
  const DEFAULT_PORT = 4566;
5
- const DEFAULT_IMAGE = "floci/floci:latest";
5
+ // Pinned, not `:latest` (#1345). An image that moves underneath a passing
6
+ // local suite is the drift a pin exists to stop, and the freshness check in
7
+ // core reports how far behind this is rather than bumping it — the tag moves
8
+ // when a consuming test needs the newer emulator (#808).
9
+ const DEFAULT_IMAGE = "floci/floci:1.5.34";
6
10
  const DEFAULT_REGION = "us-east-1";
7
11
  const DEFAULT_READY_SERVICE = "cloudformation";
8
12
  const DOCKER_SOCK = ["-v", "/var/run/docker.sock:/var/run/docker.sock"] as const;
@@ -55,6 +59,7 @@ const flociSpecFor = (readyService: string): EmulatorSpec => ({
55
59
  containerPort: DEFAULT_PORT,
56
60
  healthPath: "/_localstack/health",
57
61
  ready: (body) => isFlociReady(body, readyService),
62
+ upstream: { repo: "floci-io/floci" },
58
63
  });
59
64
 
60
65
  /** The Floci emulator spec — the aws plugin's `emulator` capability (#920). */
package/src/plugin.ts CHANGED
@@ -35,6 +35,7 @@ import { CFGenerator } from "./import/generator";
35
35
  import { parseStackTemplate } from "./import/live-export";
36
36
  import { awsCompletions } from "./lsp/completions";
37
37
  import { awsHover } from "./lsp/hover";
38
+ import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
38
39
 
39
40
  /** Re-exported from ./stack-errors so the long-standing import path (and its
40
41
  * tests) keep working now that the deep reader shares the classifier. */
@@ -51,6 +52,9 @@ let warnedOwnership = false;
51
52
 
52
53
  export const awsPlugin: LexiconPlugin = {
53
54
  name: "aws",
55
+ // The thin read is sourced from describe-stack-resources, which returns no
56
+ // tags — so `owned` on that path can only answer `unknown`, and does (#1348).
57
+ ownershipChannel: { keys: AWS_TAG_OWNERSHIP_KEYS, reads: ["observeResourcesDeep", "exportResources"] },
54
58
  serializer: awsSerializer,
55
59
  // Local emulator (#920): Floci + the AWS env that redirects the SDK / observe.
56
60
  emulator: FLOCI_EMULATOR,
@@ -72,3 +72,31 @@ describe("awsReferenceCatalog — golden 3-tier VPC", () => {
72
72
  expect(groups.privA).toEqual(expect.arrayContaining(["svc", "rds"]));
73
73
  });
74
74
  });
75
+
76
+ describe("an instance is in a VPC (#1432 follow-up)", () => {
77
+ // Subnet, security group and route table all had an "in VPC" rule. The
78
+ // instance did not, so the one kind every estate question is about reached
79
+ // its VPC only through its subnet — two hops, which a one-hop `<-` cannot
80
+ // cross. `describe-instances` returns VpcId directly; nothing had to be
81
+ // inferred.
82
+ const nodes = [
83
+ { id: "vpc-1", kind: "AWS::EC2::VPC", lexicon: "aws", physicalId: "vpc-1", attrs: { VpcId: "vpc-1" } },
84
+ { id: "vpc-2", kind: "AWS::EC2::VPC", lexicon: "aws", physicalId: "vpc-2", attrs: { VpcId: "vpc-2" } },
85
+ { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", physicalId: "i-1", attrs: { VpcId: "vpc-1", SubnetId: "sub-1" } },
86
+ ];
87
+
88
+ it("reaches its VPC in one hop", () => {
89
+ const { containmentEdges } = reconstructEdges(nodes, awsReferenceCatalog);
90
+ expect(containmentEdges).toContainEqual({ from: "web", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
91
+ });
92
+
93
+ it("leaves an empty VPC reached by nothing — the question being asked", () => {
94
+ const { containmentEdges } = reconstructEdges(nodes, awsReferenceCatalog);
95
+ expect(containmentEdges.some((e) => e.to === "vpc-2")).toBe(false);
96
+ });
97
+
98
+ it("does not draw it — the VPC stays a boundary", () => {
99
+ const { edges } = reconstructEdges(nodes, awsReferenceCatalog);
100
+ expect(edges.some((e) => e.to === "vpc-1")).toBe(false);
101
+ });
102
+ });
@@ -35,6 +35,13 @@ export const awsReferenceCatalog: ReferenceCatalog = {
35
35
  { from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
36
36
  { from: "AWS::EC2::RouteTable", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
37
37
  { from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
38
+ // An instance is in a VPC, and `describe-instances` says so directly. The
39
+ // other three kinds that sit in a VPC — subnet, security group, route table
40
+ // — all had this rule; the instance did not, so the estate's own topology
41
+ // was missing its most-asked-about member. Nothing derived it either: the
42
+ // instance reached its VPC only through its subnet, two hops, which a
43
+ // one-hop `<-` cannot cross.
44
+ { from: "AWS::EC2::Instance", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
38
45
  { from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
39
46
  { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
40
47
  { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
@@ -0,0 +1,248 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ AWS_SPEC_PIN,
4
+ PINNED_TYPE_NAMES,
5
+ ACCEPT_ENV,
6
+ specContentDigest,
7
+ specDrift,
8
+ driftMessage,
9
+ assertPinnedSpec,
10
+ } from "./pin";
11
+
12
+ const schema = (typeName: string, extra = "") =>
13
+ Buffer.from(JSON.stringify({ typeName, description: extra }));
14
+
15
+ const archive = (...names: string[]): Map<string, Buffer> =>
16
+ new Map(names.map((n) => [n, schema(n)]));
17
+
18
+ const pinFor = (schemas: Map<string, Buffer>) => ({
19
+ digest: specContentDigest(schemas),
20
+ resources: schemas.size,
21
+ accepted: "2026-01-01",
22
+ });
23
+
24
+ describe("specContentDigest (#1390)", () => {
25
+ test("is stable across map insertion order", () => {
26
+ // The zip's file order is not something to depend on.
27
+ const a = archive("AWS::A::One", "AWS::B::Two");
28
+ const b = new Map([...a.entries()].reverse());
29
+ expect(specContentDigest(a)).toBe(specContentDigest(b));
30
+ });
31
+
32
+ test("changes when a schema's bytes change", () => {
33
+ const before = archive("AWS::A::One");
34
+ const after = new Map([["AWS::A::One", schema("AWS::A::One", "now documented")]]);
35
+ expect(specContentDigest(after)).not.toBe(specContentDigest(before));
36
+ });
37
+
38
+ test("changes when a type is added", () => {
39
+ expect(specContentDigest(archive("AWS::A::One", "AWS::B::Two"))).not.toBe(
40
+ specContentDigest(archive("AWS::A::One")),
41
+ );
42
+ });
43
+
44
+ test("changes when a type is removed", () => {
45
+ expect(specContentDigest(archive("AWS::A::One"))).not.toBe(
46
+ specContentDigest(archive("AWS::A::One", "AWS::B::Two")),
47
+ );
48
+ });
49
+
50
+ test("an empty archive still digests", () => {
51
+ expect(specContentDigest(new Map())).toMatch(/^sha256:[0-9a-f]{64}$/);
52
+ });
53
+ });
54
+
55
+ describe("specDrift", () => {
56
+ const pinned = archive("AWS::A::One", "AWS::B::Two");
57
+ const pin = pinFor(pinned);
58
+ const names = new Set(pinned.keys());
59
+
60
+ test("null when the content matches", () => {
61
+ expect(specDrift(pinned, names, pin)).toBeNull();
62
+ });
63
+
64
+ test("names what was added", () => {
65
+ const drifted = archive("AWS::A::One", "AWS::B::Two", "AWS::C::Three");
66
+ expect(specDrift(drifted, names, pin)?.added).toEqual(["AWS::C::Three"]);
67
+ });
68
+
69
+ test("names what was removed", () => {
70
+ expect(specDrift(archive("AWS::A::One"), names, pin)?.removed).toEqual(["AWS::B::Two"]);
71
+ });
72
+
73
+ test("a changed schema drifts with no added or removed types", () => {
74
+ const changed = new Map(pinned);
75
+ changed.set("AWS::A::One", schema("AWS::A::One", "changed"));
76
+ const drift = specDrift(changed, names, pin);
77
+ expect(drift).not.toBeNull();
78
+ expect(drift!.added).toEqual([]);
79
+ expect(drift!.removed).toEqual([]);
80
+ expect(drift!.resources).toBe(pin.resources);
81
+ });
82
+
83
+ test("without a name set it still reports the count", () => {
84
+ const drift = specDrift(archive("AWS::A::One"), undefined, pin);
85
+ expect(drift?.resources).toBe(1);
86
+ expect(drift?.added).toEqual([]);
87
+ });
88
+ });
89
+
90
+ describe("driftMessage", () => {
91
+ const pinned = archive("AWS::A::One", "AWS::B::Two");
92
+ const pin = pinFor(pinned);
93
+ const names = new Set(pinned.keys());
94
+
95
+ test("states the count delta, not just that a digest differs", () => {
96
+ const drift = specDrift(archive("AWS::A::One", "AWS::B::Two", "AWS::C::Three"), names, pin)!;
97
+ expect(driftMessage(drift, pin)).toContain("+1 against the pin");
98
+ });
99
+
100
+ test("says so when only content changed", () => {
101
+ const changed = new Map(pinned);
102
+ changed.set("AWS::A::One", schema("AWS::A::One", "changed"));
103
+ expect(driftMessage(specDrift(changed, names, pin)!, pin)).toContain("unchanged in count");
104
+ });
105
+
106
+ test("prints the block to paste into the pin", () => {
107
+ const drift = specDrift(archive("AWS::A::One"), names, pin)!;
108
+ const message = driftMessage(drift, pin);
109
+ expect(message).toContain(`digest: "${drift.digest}"`);
110
+ expect(message).toContain("resources: 1,");
111
+ });
112
+
113
+ test("truncates a long list rather than printing hundreds of names", () => {
114
+ const many = archive("AWS::A::One", "AWS::B::Two", ...Array.from({ length: 9 }, (_, i) => `AWS::N::T${i}`));
115
+ expect(driftMessage(specDrift(many, names, pin)!, pin)).toContain("+4 more");
116
+ });
117
+ });
118
+
119
+ describe("assertPinnedSpec", () => {
120
+ const pinned = archive("AWS::A::One");
121
+ const pin = pinFor(pinned);
122
+ const names = new Set(pinned.keys());
123
+
124
+ test("passes when the archive matches", () => {
125
+ expect(() => assertPinnedSpec(pinned, { pin, pinnedNames: names, env: {} })).not.toThrow();
126
+ });
127
+
128
+ test("refuses a drifted archive — generation does not proceed", () => {
129
+ expect(() => assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), { pin, pinnedNames: names, env: {} }))
130
+ .toThrow(/upstream CloudFormation schema has moved/);
131
+ });
132
+
133
+ test("the accept env proceeds and reports instead", () => {
134
+ const warnings: string[] = [];
135
+ expect(() =>
136
+ assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), {
137
+ pin,
138
+ pinnedNames: names,
139
+ env: { [ACCEPT_ENV]: "1" },
140
+ warn: (m) => warnings.push(m),
141
+ }),
142
+ ).not.toThrow();
143
+ expect(warnings[0]).toContain("AWS::B::Two");
144
+ });
145
+ });
146
+
147
+ describe("the committed pin", () => {
148
+ test("records as many type names as it claims resources", () => {
149
+ // A pin bumped without regenerating the name list would make every future
150
+ // drift message wrong about what was added.
151
+ expect(PINNED_TYPE_NAMES.size).toBe(AWS_SPEC_PIN.resources);
152
+ });
153
+
154
+ test("is a sha256 and an ISO date", () => {
155
+ expect(AWS_SPEC_PIN.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
156
+ expect(AWS_SPEC_PIN.accepted).toMatch(/^\d{4}-\d{2}-\d{2}$/);
157
+ });
158
+
159
+ test("holds real CloudFormation type names", () => {
160
+ expect(PINNED_TYPE_NAMES.has("AWS::S3::Bucket")).toBe(true);
161
+ expect(PINNED_TYPE_NAMES.has("AWS::IAM::Role")).toBe(true);
162
+ });
163
+ });
164
+
165
+ /**
166
+ * chant #1473 — byte churn is a warning, a moved resource set is still a
167
+ * refusal. The aws lexicon became unpublishable because upstream republishes
168
+ * schemas several times a day and any digest mismatch was fatal.
169
+ */
170
+ describe("byte churn vs a moved resource set (#1473)", () => {
171
+ const pinned = archive("AWS::S3::Bucket", "AWS::IAM::Role");
172
+ const pin = pinFor(pinned);
173
+ const names = new Set(pinned.keys());
174
+
175
+ function capture(schemas: Map<string, Buffer>) {
176
+ const warnings: string[] = [];
177
+ let threw: Error | undefined;
178
+ try {
179
+ assertPinnedSpec(schemas, { pin, pinnedNames: names, env: {}, warn: (m) => warnings.push(m) });
180
+ } catch (err) {
181
+ threw = err as Error;
182
+ }
183
+ return { warnings, threw };
184
+ }
185
+
186
+ test("same type set, different bytes — warns and proceeds", () => {
187
+ // AWS editing a description in place. Three digests were observed in one
188
+ // day this way, all with an unchanged resource count.
189
+ const edited = new Map(pinned);
190
+ edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
191
+
192
+ const { warnings, threw } = capture(edited);
193
+ expect(threw).toBeUndefined();
194
+ expect(warnings).toHaveLength(1);
195
+ expect(warnings[0]).toContain("resource set is unchanged");
196
+ expect(warnings[0]).toContain("surface.snapshot.json");
197
+ });
198
+
199
+ test("a type removed — still refuses", () => {
200
+ const { threw } = capture(archive("AWS::S3::Bucket"));
201
+ expect(threw?.message).toContain("Generation refuses");
202
+ expect(threw?.message).toContain("removed");
203
+ });
204
+
205
+ test("a type added — still refuses", () => {
206
+ const { threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
207
+ expect(threw?.message).toContain("Generation refuses");
208
+ expect(threw?.message).toContain("added");
209
+ });
210
+
211
+ test("an unchanged archive neither warns nor throws", () => {
212
+ const { warnings, threw } = capture(new Map(pinned));
213
+ expect(threw).toBeUndefined();
214
+ expect(warnings).toEqual([]);
215
+ });
216
+
217
+ test("with no previous type set to compare, a mismatch stays fatal", () => {
218
+ // `specDrift` reports empty added/removed when it cannot compare, which
219
+ // would otherwise read as "no type moved" and downgrade every mismatch.
220
+ const edited = new Map(pinned);
221
+ edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
222
+
223
+ expect(() =>
224
+ assertPinnedSpec(edited, { pin, pinnedNames: new Set(), env: {}, warn: () => {} }),
225
+ ).toThrow(/Generation refuses/);
226
+ });
227
+
228
+ test("the accept env still short-circuits both cases", () => {
229
+ const warnings: string[] = [];
230
+ assertPinnedSpec(archive("AWS::S3::Bucket"), {
231
+ pin,
232
+ pinnedNames: names,
233
+ env: { [ACCEPT_ENV]: "1" },
234
+ warn: (m) => warnings.push(m),
235
+ });
236
+ expect(warnings[0]).toContain(ACCEPT_ENV);
237
+ });
238
+
239
+ test("the non-fatal message still prints a pastable pin block", () => {
240
+ const edited = new Map(pinned);
241
+ edited.set("AWS::IAM::Role", schema("AWS::IAM::Role", "reworded"));
242
+ const { warnings } = capture(edited);
243
+ expect(warnings[0]).toContain(' digest: "sha256:');
244
+ expect(warnings[0]).toContain(" resources: 2,");
245
+ // The one-off escape hatch is meaningless when nothing is being refused.
246
+ expect(warnings[0]).not.toContain(ACCEPT_ENV);
247
+ });
248
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Content pin for the CloudFormation Registry schema (#1390).
3
+ *
4
+ * cfn-lint is pinned to a git tag (`PINNED_VERSIONS.cfnLint`). The registry
5
+ * schema could not be: it is a single "latest" artifact with no version in the
6
+ * path, republished constantly —
7
+ *
8
+ * $ curl -sI .../CloudformationSchema.zip
9
+ * Last-Modified: Mon, 03 Aug 2026 01:29:20 GMT
10
+ *
11
+ * — so `npm run --prefix lexicons/aws prepack`, which CI runs on every build and
12
+ * any local codegen or docs task triggers, resolved to whatever CloudFormation
13
+ * shipped that morning. During the #1312 docs work a docs-only branch twice
14
+ * picked up a resource-count move with nothing in the commit explaining it. The
15
+ * count was only the visible symptom; the same regeneration rewrites generated
16
+ * types and the resource registry.
17
+ *
18
+ * Since there is no version to pin, the pin is over content. Specifically over
19
+ * the **extracted schemas**, not the zip: AWS repackaging the archive changes
20
+ * its `ETag` and its bytes while the schemas are identical, and a pin that fires
21
+ * on that would be noise. Sorted `typeName` → `sha256(schema)`, hashed in order,
22
+ * moves exactly when a schema does.
23
+ *
24
+ * Advisory is not enough here, unlike the emulator image pins (#808). An
25
+ * emulator that drifts fails a test you can see; a spec that drifts silently
26
+ * rewrites committed artifacts in a branch about something else. So a mismatch
27
+ * refuses, and accepting is a deliberate act that lands as its own commit.
28
+ */
29
+
30
+ import { createHash } from "crypto";
31
+ import pinnedTypes from "./pinned-types.json" with { type: "json" };
32
+
33
+ /**
34
+ * The resource types the pinned archive contained.
35
+ *
36
+ * Committed beside the digest so accepting a new spec is reviewable as a diff:
37
+ * the PR that moves the pin shows exactly which types AWS added or removed,
38
+ * rather than one opaque hash replacing another. The generated artifacts cannot
39
+ * play that role — `src/generated/` is not committed.
40
+ */
41
+ export const PINNED_TYPE_NAMES: ReadonlySet<string> = new Set(pinnedTypes as string[]);
42
+
43
+ export interface SpecPin {
44
+ /** `sha256:…` over the sorted typeName → schema content. */
45
+ readonly digest: string;
46
+ /** How many resource types the pinned archive contained. */
47
+ readonly resources: number;
48
+ /** ISO date the pin was accepted, so a diff reads as a decision. */
49
+ readonly accepted: string;
50
+ }
51
+
52
+ /**
53
+ * The accepted upstream spec.
54
+ *
55
+ * To move it: run generation, read the refusal, confirm the delta is one you
56
+ * want, and paste the printed pin here in its own commit.
57
+ */
58
+ export const AWS_SPEC_PIN: SpecPin = {
59
+ digest: "sha256:f2a0f4f2c0685116fa928a01d889b2aa2a03e4f02cd3257b124cbb184f7e533b",
60
+ resources: 1650,
61
+ accepted: "2026-08-04",
62
+ };
63
+
64
+ /** Env var that accepts whatever upstream currently serves, printing the new pin. */
65
+ export const ACCEPT_ENV = "CHANT_ACCEPT_AWS_SPEC";
66
+
67
+ /**
68
+ * Digest the extracted schemas. Stable against repackaging; changes when any
69
+ * schema's bytes change, or when a type is added or removed.
70
+ */
71
+ export function specContentDigest(schemas: ReadonlyMap<string, Buffer>): string {
72
+ const hash = createHash("sha256");
73
+ for (const typeName of [...schemas.keys()].sort()) {
74
+ hash.update(typeName);
75
+ hash.update(createHash("sha256").update(schemas.get(typeName)!).digest());
76
+ }
77
+ return `sha256:${hash.digest("hex")}`;
78
+ }
79
+
80
+ /** What moved between the pinned archive and the one just fetched. */
81
+ export interface SpecDrift {
82
+ digest: string;
83
+ resources: number;
84
+ added: string[];
85
+ removed: string[];
86
+ }
87
+
88
+ /** Compare a freshly fetched archive against a pin. `null` when it matches. */
89
+ export function specDrift(
90
+ schemas: ReadonlyMap<string, Buffer>,
91
+ pinnedNames: ReadonlySet<string> | undefined,
92
+ pin: SpecPin = AWS_SPEC_PIN,
93
+ ): SpecDrift | null {
94
+ const digest = specContentDigest(schemas);
95
+ if (digest === pin.digest) return null;
96
+
97
+ const names = new Set(schemas.keys());
98
+ return {
99
+ digest,
100
+ resources: names.size,
101
+ // Type names are only known when a caller supplies the previous set;
102
+ // without it the count delta still tells a reader the shape of the change.
103
+ added: pinnedNames ? [...names].filter((n) => !pinnedNames.has(n)).sort() : [],
104
+ removed: pinnedNames ? [...pinnedNames].filter((n) => !names.has(n)).sort() : [],
105
+ };
106
+ }
107
+
108
+ /** The refusal a mismatch produces, or the acceptance notice under {@link ACCEPT_ENV}. */
109
+ export function driftMessage(
110
+ drift: SpecDrift,
111
+ pin: SpecPin = AWS_SPEC_PIN,
112
+ options: { fatal?: boolean } = {},
113
+ ): string {
114
+ const fatal = options.fatal ?? true;
115
+ const delta = drift.resources - pin.resources;
116
+ const countLine =
117
+ delta === 0
118
+ ? `${drift.resources} resource types, unchanged in count`
119
+ : `${drift.resources} resource types, ${delta > 0 ? "+" : ""}${delta} against the pin`;
120
+
121
+ const lines = [
122
+ "The upstream CloudFormation schema has moved since the pinned one.",
123
+ "",
124
+ ` pinned ${pin.digest} (${pin.resources} resources, accepted ${pin.accepted})`,
125
+ ` upstream ${drift.digest} (${countLine})`,
126
+ ];
127
+
128
+ if (drift.added.length > 0) {
129
+ lines.push(` added ${drift.added.slice(0, 5).join(", ")}${drift.added.length > 5 ? ` (+${drift.added.length - 5} more)` : ""}`);
130
+ }
131
+ if (drift.removed.length > 0) {
132
+ lines.push(` removed ${drift.removed.slice(0, 5).join(", ")}${drift.removed.length > 5 ? ` (+${drift.removed.length - 5} more)` : ""}`);
133
+ }
134
+
135
+ if (fatal) {
136
+ lines.push(
137
+ "",
138
+ "Generation refuses rather than regenerating against a spec nobody chose:",
139
+ "a docs or codegen task on an unrelated branch would otherwise rewrite the",
140
+ "generated types and resource registry with no commit saying why.",
141
+ );
142
+ } else {
143
+ // chant #1473 — the type set is identical, so this is upstream editing
144
+ // schemas in place, not a spec swap. Generation continues; whether it
145
+ // changed anything visible is decided by the surface snapshot.
146
+ lines.push(
147
+ "",
148
+ "The resource set is unchanged, so this is upstream editing schemas in",
149
+ "place rather than a different spec. Generation continues — whether it",
150
+ "changed the published API is decided by surface.snapshot.json, which is",
151
+ "checked against the generated artifacts before anything is packed.",
152
+ );
153
+ }
154
+
155
+ lines.push(
156
+ "",
157
+ "To refresh the pin, confirm the delta is one you want and update",
158
+ "lexicons/aws/src/spec/pin.ts, in its own commit:",
159
+ "",
160
+ ` digest: "${drift.digest}",`,
161
+ ` resources: ${drift.resources},`,
162
+ ` accepted: "<today>",`,
163
+ );
164
+
165
+ if (fatal) {
166
+ lines.push("", `Or re-run with ${ACCEPT_ENV}=1 to proceed this once and print the same block.`);
167
+ }
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ /**
173
+ * Refuse a fetched archive whose RESOURCE SET does not match the pin.
174
+ *
175
+ * Under {@link ACCEPT_ENV} it warns with the same detail instead, so the
176
+ * accept-then-paste loop is one command rather than two.
177
+ *
178
+ * ## Why byte drift alone is a warning (chant #1473)
179
+ *
180
+ * This originally threw on any digest mismatch, which made the aws lexicon
181
+ * unpublishable. `prepack` runs `generate`, `generate` fetches from upstream,
182
+ * and CloudFormation republishes individual schemas several times a day: three
183
+ * distinct digests were observed on 2026-08-03 alone, all with an unchanged
184
+ * count of 1650, and two fetches two hours apart differed in 4 of 1650 files.
185
+ * `chant-v0.39.0` published 13 packages and failed on aws for exactly this,
186
+ * with a pin ~18 hours old.
187
+ *
188
+ * A pin that goes stale by itself within hours cannot gate a release: it does
189
+ * not distinguish "someone regenerated against a spec nobody chose" from
190
+ * "AWS edited a description this afternoon".
191
+ *
192
+ * So the two cases are separated:
193
+ *
194
+ * - **The resource set moved** (a type added or removed) — still a refusal.
195
+ * That always changes the published API, and it is the case #1390 was filed
196
+ * about.
197
+ * - **Only bytes moved**, with an identical type set — a warning. Whether it
198
+ * matters is decided downstream by the surface gate in core's
199
+ * `validateLexiconArtifacts`, which compares the generated API against the
200
+ * committed `surface.snapshot.json`. That gate is exact: a byte change that
201
+ * alters the emitted surface fails the build, and one that does not is
202
+ * correctly ignored.
203
+ *
204
+ * The guarantee is unchanged in substance — nothing ships whose surface was
205
+ * not reviewed — and it is now enforced against the thing that actually
206
+ * matters rather than against an archive that is not stable enough to pin.
207
+ */
208
+ export function assertPinnedSpec(
209
+ schemas: ReadonlyMap<string, Buffer>,
210
+ options: {
211
+ pin?: SpecPin;
212
+ /** Defaults to {@link PINNED_TYPE_NAMES}; overridden in tests. */
213
+ pinnedNames?: ReadonlySet<string>;
214
+ env?: NodeJS.ProcessEnv;
215
+ warn?: (message: string) => void;
216
+ } = {},
217
+ ): void {
218
+ const pin = options.pin ?? AWS_SPEC_PIN;
219
+ const pinnedNames = options.pinnedNames ?? PINNED_TYPE_NAMES;
220
+ const drift = specDrift(schemas, pinnedNames, pin);
221
+ if (!drift) return;
222
+
223
+ const env = options.env ?? process.env;
224
+ const warn = options.warn ?? ((m: string) => console.error(m));
225
+
226
+ if (env[ACCEPT_ENV]) {
227
+ warn(driftMessage(drift, pin));
228
+ return;
229
+ }
230
+
231
+ // chant #1473 — an identical type set with different bytes is upstream
232
+ // churn, not an unchosen spec. Reported so a stale pin stays visible, but
233
+ // not fatal; the surface gate decides whether it changed anything.
234
+ //
235
+ // Guarded on actually HAVING a previous type set: `specDrift` reports empty
236
+ // added/removed when it has nothing to compare against, which would
237
+ // otherwise read as "no type moved" and downgrade every mismatch.
238
+ const typeSetKnown = pinnedNames !== undefined && pinnedNames.size > 0;
239
+ const typeSetMoved = drift.added.length > 0 || drift.removed.length > 0;
240
+
241
+ if (typeSetKnown && !typeSetMoved) {
242
+ warn(driftMessage(drift, pin, { fatal: false }));
243
+ return;
244
+ }
245
+
246
+ throw new Error(driftMessage(drift, pin));
247
+ }