@intentius/chant 0.39.0 → 0.41.1

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 (39) hide show
  1. package/dist/apply.d.ts +171 -0
  2. package/dist/apply.d.ts.map +1 -0
  3. package/dist/cli/build-params-cli.d.ts +24 -0
  4. package/dist/cli/build-params-cli.d.ts.map +1 -1
  5. package/dist/cli/commands/doctor.d.ts.map +1 -1
  6. package/dist/cli/handlers/components.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  8. package/dist/codegen/naming.d.ts +48 -1
  9. package/dist/codegen/naming.d.ts.map +1 -1
  10. package/dist/codegen/validate.d.ts +31 -0
  11. package/dist/codegen/validate.d.ts.map +1 -1
  12. package/dist/components/deploy-units.d.ts +49 -0
  13. package/dist/components/deploy-units.d.ts.map +1 -0
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/apply.test.ts +169 -0
  21. package/src/apply.ts +249 -0
  22. package/src/cli/build-params-cli.ts +35 -0
  23. package/src/cli/commands/doctor.test.ts +45 -0
  24. package/src/cli/commands/doctor.ts +40 -0
  25. package/src/cli/handlers/components.ts +29 -23
  26. package/src/cli/handlers/graph.test.ts +51 -1
  27. package/src/cli/handlers/graph.ts +16 -1
  28. package/src/cli/handlers/lifecycle.ts +18 -3
  29. package/src/codegen/naming.test.ts +129 -0
  30. package/src/codegen/naming.ts +72 -1
  31. package/src/codegen/validate.test.ts +86 -0
  32. package/src/codegen/validate.ts +74 -0
  33. package/src/components/deploy-units.test.ts +42 -0
  34. package/src/components/deploy-units.ts +82 -0
  35. package/src/config.test.ts +53 -0
  36. package/src/config.ts +41 -3
  37. package/src/discovery/index.ts +59 -0
  38. package/src/discovery/params-cjs-warning.test.ts +75 -0
  39. package/src/index.ts +1 -0
@@ -85,8 +85,15 @@ vi.mock("../../config", async () => {
85
85
  loadChantConfigUpward: (...a: unknown[]) => loadChantConfigUpwardMock(...a),
86
86
  };
87
87
  });
88
+ const buildMock = vi.fn();
88
89
  vi.mock("../../build", () => ({
89
- build: () => Promise.resolve({ errors: [] }),
90
+ // Forwards its arguments so a test can assert what the live path passed —
91
+ // `buildParams` in particular (#1483), the same reason `discover` above
92
+ // forwards for #1359.
93
+ build: (...a: unknown[]) => {
94
+ buildMock(...a);
95
+ return Promise.resolve({ errors: [] });
96
+ },
90
97
  partitionByLexicon: () => ({}),
91
98
  computeStackGraph: () => ({}),
92
99
  }));
@@ -136,6 +143,7 @@ describe("runGraph", () => {
136
143
  // Default: no components — the single-stack --live path most tests exercise.
137
144
  discoverComponentsMock.mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
138
145
  observeMock.mockReset();
146
+ buildMock.mockReset();
139
147
  loadPluginsMock.mockReset();
140
148
  resolveLexMock.mockReset();
141
149
  loadChantConfigMock.mockReset();
@@ -480,6 +488,48 @@ describe("runGraph", () => {
480
488
  });
481
489
 
482
490
  describe("live graph (--live)", () => {
491
+ // #1483 — the live path builds the source to learn which entities to
492
+ // observe, and did so on default parameters while the declared overlay
493
+ // resolved the caller's. For a project whose parameters choose *which
494
+ // resources exist* — a tier, a size, a profile — that observed one estate
495
+ // and compared it against another, so every resource the real parameter
496
+ // declares read as absent and every resource the default declares read as
497
+ // pending. A confidently wrong overlay, not an empty one.
498
+ describe("build-time parameters reach the observed build (#1483)", () => {
499
+ const liveWithTier = async (param?: string[]) => {
500
+ loadChantConfigUpwardMock.mockResolvedValue({
501
+ config: { buildParams: { tier: { type: "string", enum: ["light", "prod"], default: "light" } } },
502
+ });
503
+ resolveLexMock.mockResolvedValue(["aws"]);
504
+ loadPluginsMock.mockResolvedValue([
505
+ { name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
506
+ ]);
507
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
508
+ return runGraph({
509
+ args: makeArgs({ format: "ir", live: true, env: "prod", ...(param ? { param } : {}) }),
510
+ plugins: [],
511
+ serializers: [],
512
+ });
513
+ };
514
+
515
+ const paramsOf = (call: unknown[]) =>
516
+ (call[3] as { buildParams?: Array<{ name: string; value: unknown }> } | undefined)?.buildParams;
517
+
518
+ test("--param reaches the build the observation is scoped from", async () => {
519
+ expect(await liveWithTier(["tier=prod"])).toBe(0);
520
+ expect(paramsOf(buildMock.mock.calls[0]!)).toContainEqual(
521
+ expect.objectContaining({ name: "tier", value: "prod" }),
522
+ );
523
+ });
524
+
525
+ test("a declared default reaches it too, rather than nothing at all", async () => {
526
+ expect(await liveWithTier()).toBe(0);
527
+ expect(paramsOf(buildMock.mock.calls[0]!)).toContainEqual(
528
+ expect.objectContaining({ name: "tier", value: "light" }),
529
+ );
530
+ });
531
+ });
532
+
483
533
  // Regression: `graph` is not `requiresPlugins`, so `ctx.plugins` is empty. The
484
534
  // live path must load the project's plugins itself — otherwise it wrongly
485
535
  // reports "No lexicons implement describeResources" and observes nothing.
@@ -144,7 +144,22 @@ async function runGraphLive(
144
144
 
145
145
  // Build to get each lexicon's entity names + output (the scope
146
146
  // describeResources needs), mirroring `chant lifecycle snapshot`.
147
- const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer));
147
+ //
148
+ // With the same build params the source graph resolves (#1483). Without
149
+ // them this build ran on defaults while the declared overlay below ran on
150
+ // the caller's, so a project whose parameters choose *which resources
151
+ // exist* — a tier, a size, a profile — observed one estate and compared it
152
+ // against another. Every resource the real parameter declares read as
153
+ // absent and every resource the default declares read as pending, which is
154
+ // a confidently wrong overlay rather than an empty one.
155
+ const liveBuildParams = await graphBuildParams(ctx, projectPath);
156
+ if (!liveBuildParams) return 1;
157
+ const buildResult = await build(
158
+ resolve(args.src ?? config.sourceDir ?? "."),
159
+ plugins.map((p) => p.serializer),
160
+ undefined,
161
+ { buildParams: liveBuildParams },
162
+ );
148
163
  if (buildResult.errors.length > 0) {
149
164
  console.error(formatError({ message: "Build failed — fix errors before graphing live state" }));
150
165
  return 1;
@@ -1,4 +1,5 @@
1
1
  import { resolve } from "node:path";
2
+ import { commandBuildParams } from "../build-params-cli";
2
3
  import { build } from "../../build";
3
4
  import { takeSnapshot } from "../../lifecycle/snapshot";
4
5
  import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
@@ -101,6 +102,10 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
101
102
  // Validate environment against config
102
103
  const projectPath = resolve(".");
103
104
  const { config } = await loadChantConfig(projectPath);
105
+ // This invocation's parameters, so the declared side of the comparison is the estate
106
+ // the caller asked for rather than the parameter defaults (#1483).
107
+ const declaredParams = await commandBuildParams(config.buildParams, args);
108
+ if (!declaredParams) return 1;
104
109
  const declaredEnvNames = environmentNames(config.environments);
105
110
  if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
106
111
  console.error(formatError({
@@ -146,7 +151,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
146
151
  const built: Array<{ target: (typeof targets)[number]; buildResult: Awaited<ReturnType<typeof build>> }> = [];
147
152
  for (const target of targets) {
148
153
  const label = target.stack ? `stack "${target.stack}"` : "project";
149
- const buildResult = await build(target.root, targetSerializers);
154
+ const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
150
155
  if (buildResult.errors.length > 0) {
151
156
  console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
152
157
  anyHardError = true;
@@ -326,6 +331,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
326
331
 
327
332
  // Fetch previous snapshots once (all stacks share the orphan branch).
328
333
  const { config } = await loadChantConfig(resolve("."));
334
+ // This invocation's parameters, so the declared side of the comparison is the estate
335
+ // the caller asked for rather than the parameter defaults (#1483).
336
+ const declaredParams = await commandBuildParams(config.buildParams, args);
337
+ if (!declaredParams) return 1;
329
338
  await fetchLifecycle();
330
339
 
331
340
  // One target per stack (single-stack projects: exactly one), each built from
@@ -355,7 +364,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
355
364
 
356
365
  try {
357
366
  for (const target of targets) {
358
- const buildResult = await build(target.root, targetSerializers);
367
+ const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
359
368
  if (buildResult.errors.length > 0) {
360
369
  const label = target.stack ? `stack "${target.stack}"` : "project";
361
370
  console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
@@ -1021,7 +1030,13 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1021
1030
  : serializers;
1022
1031
 
1023
1032
  const { config } = await loadChantConfig(resolve("."));
1024
- const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
1033
+ // This invocation's parameters, so the declared side of the comparison is the estate
1034
+ // the caller asked for rather than the parameter defaults (#1483).
1035
+ const declaredParams = await commandBuildParams(config.buildParams, args);
1036
+ if (!declaredParams) return 1;
1037
+ const buildResult = await build(resolveBuildRoot(args, config), targetSerializers, undefined, {
1038
+ buildParams: declaredParams,
1039
+ });
1025
1040
  if (buildResult.errors.length > 0) {
1026
1041
  console.error(formatError({ message: "Build failed — fix errors before planning" }));
1027
1042
  return 1;
@@ -0,0 +1,129 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { NamingStrategy, reservedNamesFromSnapshot, type NamingConfig, type NamingInput } from "./naming";
3
+
4
+ /**
5
+ * chant #1459 — a published TypeScript name belongs to the type that published
6
+ * it, so an unrelated upstream addition or removal cannot rename a resource
7
+ * whose own schema never moved.
8
+ */
9
+
10
+ const config: NamingConfig = {
11
+ priorityNames: {},
12
+ priorityAliases: {},
13
+ priorityPropertyAliases: {},
14
+ serviceAbbreviations: {},
15
+ shortName: (t) => t.split("::").pop() ?? t,
16
+ serviceName: (t) => t.split("::")[1] ?? "",
17
+ };
18
+
19
+ function name(types: string[], over: Partial<NamingConfig> = {}): Map<string, string | undefined> {
20
+ const inputs: NamingInput[] = types.map((typeName) => ({ typeName, propertyTypes: [] }));
21
+ const strategy = new NamingStrategy(inputs, { ...config, ...over });
22
+ return new Map(types.map((t) => [t, strategy.resolve(t)]));
23
+ }
24
+
25
+ describe("reservedNamesFromSnapshot", () => {
26
+ test("inverts the snapshot into spec type → published name", () => {
27
+ expect(
28
+ reservedNamesFromSnapshot({
29
+ entries: { MacieSession: { kind: "resource", resourceType: "AWS::Macie::Session" } },
30
+ }),
31
+ ).toEqual({ "AWS::Macie::Session": "MacieSession" });
32
+ });
33
+
34
+ test("reserves resources only, not property types", () => {
35
+ // Property names derive from their owning resource (phase 5), so pinning
36
+ // the resource pins them; reserving them separately would freeze aliases
37
+ // that are meant to follow their parent.
38
+ const reserved = reservedNamesFromSnapshot({
39
+ entries: {
40
+ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket" },
41
+ Bucket_Rule: { kind: "property", resourceType: "AWS::S3::Bucket.Rule" },
42
+ },
43
+ });
44
+ expect(reserved).toEqual({ "AWS::S3::Bucket": "Bucket" });
45
+ });
46
+
47
+ test("ignores entries with no resourceType, and a missing snapshot", () => {
48
+ expect(reservedNamesFromSnapshot({ entries: { Broken: { kind: "resource" } } })).toEqual({});
49
+ expect(reservedNamesFromSnapshot(undefined)).toEqual({});
50
+ expect(reservedNamesFromSnapshot({})).toEqual({});
51
+ });
52
+
53
+ test("first writer wins when one spec type is listed under two names", () => {
54
+ const reserved = reservedNamesFromSnapshot({
55
+ entries: {
56
+ Alpha: { kind: "resource", resourceType: "AWS::X::Y" },
57
+ Beta: { kind: "resource", resourceType: "AWS::X::Y" },
58
+ },
59
+ });
60
+ expect(reserved).toEqual({ "AWS::X::Y": "Alpha" });
61
+ });
62
+ });
63
+
64
+ describe("reserved names survive their neighbours changing", () => {
65
+ test("a competitor disappearing does not hand its short name over", () => {
66
+ // The real case: AWS::Athena::Session and AWS::SSM::Session were removed
67
+ // upstream, which silently renamed AWS::Macie::Session from MacieSession
68
+ // to Session — breaking a resource AWS had not touched.
69
+ const withCompetitor = name(["AWS::Macie::Session", "AWS::Athena::Session"]);
70
+ expect(withCompetitor.get("AWS::Macie::Session")).toBe("MacieSession");
71
+
72
+ const afterRemoval = name(["AWS::Macie::Session"], {
73
+ reservedNames: { "AWS::Macie::Session": "MacieSession" },
74
+ });
75
+ expect(afterRemoval.get("AWS::Macie::Session")).toBe("MacieSession");
76
+ });
77
+
78
+ test("without reservation, the same removal renames it — the bug", () => {
79
+ const afterRemoval = name(["AWS::Macie::Session"]);
80
+ expect(afterRemoval.get("AWS::Macie::Session")).toBe("Session");
81
+ });
82
+
83
+ test("a newcomer colliding gets qualified instead of displacing the incumbent", () => {
84
+ // AWS::QuickSight::Space appearing renamed AWS::SageMaker::Space from
85
+ // Space to SageMakerSpace. The newcomer should absorb the qualification.
86
+ const names = name(["AWS::SageMaker::Space", "AWS::QuickSight::Space"], {
87
+ reservedNames: { "AWS::SageMaker::Space": "Space" },
88
+ });
89
+ expect(names.get("AWS::SageMaker::Space")).toBe("Space");
90
+ expect(names.get("AWS::QuickSight::Space")).toBe("QuickSightSpace");
91
+ });
92
+
93
+ test("order of the input does not decide the outcome", () => {
94
+ const reservedNames = { "AWS::SageMaker::Space": "Space" };
95
+ const forward = name(["AWS::SageMaker::Space", "AWS::QuickSight::Space"], { reservedNames });
96
+ const reverse = name(["AWS::QuickSight::Space", "AWS::SageMaker::Space"], { reservedNames });
97
+ expect(forward.get("AWS::SageMaker::Space")).toBe(reverse.get("AWS::SageMaker::Space"));
98
+ expect(forward.get("AWS::QuickSight::Space")).toBe(reverse.get("AWS::QuickSight::Space"));
99
+ });
100
+ });
101
+
102
+ describe("reservation boundaries", () => {
103
+ test("an explicit priority name still outranks history", () => {
104
+ const names = name(["AWS::Macie::Session"], {
105
+ priorityNames: { "AWS::Macie::Session": "PinnedName" },
106
+ reservedNames: { "AWS::Macie::Session": "MacieSession" },
107
+ });
108
+ expect(names.get("AWS::Macie::Session")).toBe("PinnedName");
109
+ });
110
+
111
+ test("a reservation for a type no longer in the input releases the name", () => {
112
+ // AWS::CodeArtifact::Package was genuinely removed, so Package is free for
113
+ // whoever legitimately claims it next.
114
+ const names = name(["AWS::Panorama::Package"], {
115
+ reservedNames: { "AWS::CodeArtifact::Package": "Package" },
116
+ });
117
+ expect(names.get("AWS::Panorama::Package")).toBe("Package");
118
+ });
119
+
120
+ test("a brand-new resource with no reservation is unaffected", () => {
121
+ const names = name(["AWS::MSK::Channel"], { reservedNames: { "AWS::Macie::Session": "MacieSession" } });
122
+ expect(names.get("AWS::MSK::Channel")).toBe("Channel");
123
+ });
124
+
125
+ test("no reservations at all reproduces the previous behaviour exactly", () => {
126
+ const types = ["AWS::Macie::Session", "AWS::Athena::Session", "AWS::MSK::Channel"];
127
+ expect(name(types, { reservedNames: {} })).toEqual(name(types));
128
+ });
129
+ });
@@ -1,12 +1,32 @@
1
1
  /**
2
2
  * Collision-free naming strategy for TypeScript class names.
3
3
  *
4
- * 5-phase algorithm:
4
+ * 6-phase algorithm:
5
5
  * 1. Priority names (backward compatibility)
6
+ * 1a. Reserved names — names this lexicon has already published (chant #1459)
6
7
  * 2. Priority aliases (additional short names)
7
8
  * 3. Short names (last segment of type)
8
9
  * 4. Collision resolution (service-prefixed)
9
10
  * 5. Property type aliases (globally unique defs)
11
+ *
12
+ * ## Why phase 1a exists
13
+ *
14
+ * Phases 3 and 4 assign a short name to whoever asks first and service-qualify
15
+ * everyone after. Membership of that contest is the whole input set, so a
16
+ * resource's name was a function of its NEIGHBOURS: removing
17
+ * `AWS::Athena::Session` upstream freed `Session`, and `AWS::Macie::Session`
18
+ * silently changed from `MacieSession` to `Session` — a breaking rename for a
19
+ * resource whose schema had not moved. Adding a resource does the same in
20
+ * reverse: `AWS::QuickSight::Space` appearing renamed `AWS::SageMaker::Space`
21
+ * from `Space` to `SageMakerSpace`.
22
+ *
23
+ * Worse, it is reversible. These are read-only registry types that come and go,
24
+ * so a name could flip back on the next upgrade and break consumers again.
25
+ *
26
+ * Reserved names invert the bias: a name that has already shipped belongs to
27
+ * the type that shipped it, and a newcomer colliding with it gets qualified
28
+ * instead. A published name then changes only when its own type disappears,
29
+ * which is a genuine breaking change rather than an incidental one.
10
30
  */
11
31
 
12
32
  /**
@@ -35,6 +55,40 @@ export interface NamingConfig {
35
55
  shortName: (typeName: string) => string;
36
56
  /** Extract the service name from a type name (e.g. "Vendor::Service::Resource" → "Service"). */
37
57
  serviceName: (typeName: string) => string;
58
+ /**
59
+ * chant #1459 — spec type name → the TypeScript name this lexicon has
60
+ * already published for it, normally read from the committed
61
+ * `surface.snapshot.json` via {@link reservedNamesFromSnapshot}.
62
+ *
63
+ * Claimed before short names are contested, so a shipped name is not taken
64
+ * away from its owner by an unrelated upstream change. Omit for a lexicon
65
+ * with no published surface yet; an entry for a type that is no longer in
66
+ * the input is ignored, so a removed type frees its name for reuse.
67
+ */
68
+ reservedNames?: Record<string, string>;
69
+ }
70
+
71
+ /**
72
+ * The names a lexicon has already published, read from a committed surface
73
+ * snapshot (chant #1459).
74
+ *
75
+ * The snapshot is keyed by TypeScript name with the spec type inside, which is
76
+ * exactly the mapping phase 1a needs, inverted. Only `resource` entries are
77
+ * reserved: property-type names are derived from their owning resource's name
78
+ * (phase 5), so pinning the resource pins them, and reserving them separately
79
+ * would freeze aliases that are meant to follow their parent.
80
+ */
81
+ export function reservedNamesFromSnapshot(
82
+ snapshot: { entries?: Record<string, { kind?: string; resourceType?: string }> } | undefined,
83
+ ): Record<string, string> {
84
+ const reserved: Record<string, string> = {};
85
+ for (const [tsName, entry] of Object.entries(snapshot?.entries ?? {})) {
86
+ if (entry.kind !== "resource" || !entry.resourceType) continue;
87
+ // First writer wins: a snapshot that somehow lists one spec type under two
88
+ // names keeps the earlier, rather than silently preferring iteration order.
89
+ reserved[entry.resourceType] ??= tsName;
90
+ }
91
+ return reserved;
38
92
  }
39
93
 
40
94
  export class NamingStrategy {
@@ -58,6 +112,23 @@ export class NamingStrategy {
58
112
  }
59
113
  }
60
114
 
115
+ // Phase 1a: claim previously-published names (chant #1459).
116
+ //
117
+ // After priority names, which are explicit hand-pinned decisions and still
118
+ // win, and before any short name is contested. A reserved name whose type
119
+ // is gone from the input is simply never reached, so its name is released
120
+ // for whoever legitimately claims it next.
121
+ for (const t of typeNames) {
122
+ if (this.assigned.has(t)) continue;
123
+ const published = config.reservedNames?.[t];
124
+ // `usedNames` guards the case where a priority name already took it —
125
+ // an explicit pin outranks history.
126
+ if (published && !this.usedNames.has(published)) {
127
+ this.assigned.set(t, published);
128
+ this.usedNames.add(published);
129
+ }
130
+ }
131
+
61
132
  // Phase 1b: assign priority aliases
62
133
  for (const t of typeNames) {
63
134
  const extras = config.priorityAliases[t];
@@ -84,3 +84,89 @@ describe("validateLexiconArtifacts", () => {
84
84
  rmSync(dir, { recursive: true, force: true });
85
85
  });
86
86
  });
87
+
88
+ /**
89
+ * chant #1473 — the release gate. `prepack` regenerates from an upstream that
90
+ * moves, so what must match the reviewed baseline is the API that comes out,
91
+ * not the archive that went in.
92
+ */
93
+ describe("surface snapshot gate (#1473)", () => {
94
+ const LEXICON = JSON.stringify({
95
+ Bucket: { resourceType: "AWS::S3::Bucket", kind: "resource", lexicon: "aws" },
96
+ });
97
+ const DTS = "export declare class Bucket {}\n";
98
+
99
+ function fixture(opts: { snapshot?: string } = {}): string {
100
+ const dir = makeTempDir();
101
+ const genDir = join(dir, "src", "generated");
102
+ mkdirSync(genDir, { recursive: true });
103
+ writeFileSync(join(genDir, "lexicon-test.json"), LEXICON);
104
+ writeFileSync(join(genDir, "index.d.ts"), DTS);
105
+ if (opts.snapshot !== undefined) writeFileSync(join(dir, "surface.snapshot.json"), opts.snapshot);
106
+ return dir;
107
+ }
108
+
109
+ const run = (basePath: string, checkSurfaceSnapshot: boolean, armed = true) =>
110
+ validateLexiconArtifacts({
111
+ lexiconJsonFilename: "lexicon-test.json",
112
+ requiredNames: [],
113
+ basePath,
114
+ checkSurfaceSnapshot,
115
+ env: armed ? { CHANT_RELEASE_GATE: "1" } : {},
116
+ });
117
+
118
+ /** The snapshot a matching build would have produced. */
119
+ async function matchingSnapshot(): Promise<string> {
120
+ const { extractSurface, serializeSnapshot } = await import("./surface-snapshot");
121
+ return serializeSnapshot(extractSurface(LEXICON, DTS));
122
+ }
123
+
124
+ test("passes when the generated API matches the snapshot", async () => {
125
+ const result = await run(fixture({ snapshot: await matchingSnapshot() }), true);
126
+ const check = result.checks.find((c) => c.name === "surface-matches-snapshot");
127
+ expect(check?.ok).toBe(true);
128
+ });
129
+
130
+ test("fails when the generated API differs, and says how to accept it", async () => {
131
+ const stale = JSON.stringify({
132
+ schemaVersion: 1,
133
+ generatedAt: "2026-01-01T00:00:00.000Z",
134
+ entries: { Queue: { kind: "resource", resourceType: "AWS::SQS::Queue", attrs: [], props: [] } },
135
+ });
136
+ const result = await run(fixture({ snapshot: stale }), true);
137
+ const check = result.checks.find((c) => c.name === "surface-matches-snapshot");
138
+ expect(check?.ok).toBe(false);
139
+ expect(check?.error).toContain("--update-snapshot");
140
+ expect(result.success).toBe(false);
141
+ });
142
+
143
+ test("is off unless the lexicon opts in", async () => {
144
+ // k8s and azure are adrift from their own baselines (#1475); switching
145
+ // this on globally would block their releases.
146
+ const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
147
+ const result = await run(fixture({ snapshot: stale }), false);
148
+ expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
149
+ });
150
+
151
+ test("does not run outside a release, even for a lexicon that opted in", async () => {
152
+ // `validate` runs on every PR. Upstream can move the surface at any time,
153
+ // so a hard check here would turn unrelated PRs red — the same trap the
154
+ // spec pin fell into. Drift between releases is the upgrade job's business.
155
+ const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
156
+ const result = await run(fixture({ snapshot: stale }), true, false);
157
+ expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
158
+ expect(result.success).toBe(true);
159
+ });
160
+
161
+ test("is skipped for a lexicon with no committed snapshot", async () => {
162
+ // A new lexicon before its first baseline must still be able to build.
163
+ const result = await run(fixture(), true);
164
+ expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
165
+ });
166
+
167
+ test("an unreadable snapshot fails rather than passing silently", async () => {
168
+ const result = await run(fixture({ snapshot: "{ not json" }), true);
169
+ const check = result.checks.find((c) => c.name === "surface-matches-snapshot");
170
+ expect(check?.ok).toBe(false);
171
+ });
172
+ });
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { computeCoverage, checkThresholds, type CoverageThresholds } from "./coverage";
11
+ import { extractSurface, diffSurface, parseSnapshot, formatDelta } from "./surface-snapshot";
11
12
 
12
13
  export interface ValidateCheck {
13
14
  name: string;
@@ -20,6 +21,12 @@ export interface ValidateResult {
20
21
  checks: ValidateCheck[];
21
22
  }
22
23
 
24
+ /**
25
+ * Set by the publish workflow to arm the release-time surface gate
26
+ * (chant #1473). Absent in ordinary CI, where upstream drift is expected.
27
+ */
28
+ export const RELEASE_GATE_ENV = "CHANT_RELEASE_GATE";
29
+
23
30
  export interface LexiconValidationConfig {
24
31
  /** Filename of the lexicon JSON (e.g. "lexicon-mydom.json") */
25
32
  lexiconJsonFilename: string;
@@ -35,6 +42,32 @@ export interface LexiconValidationConfig {
35
42
  requiredNamesMatchSubstring?: boolean;
36
43
  /** Base path of the lexicon package */
37
44
  basePath: string;
45
+ /**
46
+ * chant #1473 — this lexicon's release is gated on the generated API
47
+ * matching the committed `surface.snapshot.json`.
48
+ *
49
+ * Two conditions, both required. The lexicon opts in here, AND
50
+ * {@link RELEASE_GATE_ENV} is set — which the publish workflow does and
51
+ * ordinary CI does not.
52
+ *
53
+ * The env half is not caution, it is correctness. `validate` runs on every
54
+ * PR, and the upstream a lexicon generates from can move at any time: the
55
+ * CloudFormation archive republishes schemas several times a day, and some
56
+ * of those edits do change the surface. A hard surface check on every PR
57
+ * would turn any unrelated change red the moment upstream moved, which is
58
+ * the same trap the spec pin fell into one level down. Drift between
59
+ * releases is expected and is what the scheduled lexicon-upgrade job exists
60
+ * to report (#1423).
61
+ *
62
+ * What must never happen is *publishing* a surface nobody reviewed. That is
63
+ * a release-time property, so it is checked at release time.
64
+ *
65
+ * Opt-in per lexicon because k8s and azure are currently adrift from their
66
+ * own baselines (393 and 483 entries, #1475).
67
+ */
68
+ checkSurfaceSnapshot?: boolean;
69
+ /** Environment to read {@link RELEASE_GATE_ENV} from. Defaults to `process.env`; overridden in tests. */
70
+ env?: NodeJS.ProcessEnv;
38
71
  /** Path to the generated directory (defaults to basePath/src/generated) */
39
72
  generatedDir?: string;
40
73
  /** Coverage thresholds (optional) */
@@ -148,6 +181,47 @@ export async function validateLexiconArtifacts(config: LexiconValidationConfig):
148
181
  }
149
182
  }
150
183
 
184
+ // Check: the generated API matches the reviewed one (chant #1473).
185
+ //
186
+ // This is the gate that makes a release trustworthy. `prepack` regenerates
187
+ // from upstream, and for aws that upstream republishes schemas several times
188
+ // a day, so the input can differ from the one whose delta a human accepted.
189
+ // What must not differ is the API that ships. Comparing the just-generated
190
+ // artifacts against the committed `surface.snapshot.json` says exactly that,
191
+ // and says nothing about byte churn that changed no declaration.
192
+ //
193
+ // Runs on the artifacts already on disk — no second generation — and is
194
+ // skipped for a lexicon with no committed snapshot, which is the case for a
195
+ // new lexicon before its first baseline.
196
+ const snapshotPath = join(config.basePath, "surface.snapshot.json");
197
+ const releaseGate = config.checkSurfaceSnapshot && (config.env ?? process.env)[RELEASE_GATE_ENV] === "1";
198
+ if (releaseGate && lexiconData && existsSync(snapshotPath) && existsSync(dtsPath)) {
199
+ try {
200
+ const fresh = extractSurface(readFileSync(lexiconPath, "utf-8"), readFileSync(dtsPath, "utf-8"));
201
+ const delta = diffSurface(parseSnapshot(readFileSync(snapshotPath, "utf-8")), fresh);
202
+ const moved = delta.added.length + delta.removed.length + delta.changed.length;
203
+ checks.push(
204
+ moved === 0
205
+ ? { name: "surface-matches-snapshot", ok: true }
206
+ : {
207
+ name: "surface-matches-snapshot",
208
+ ok: false,
209
+ error:
210
+ `The generated API differs from the reviewed surface.snapshot.json ` +
211
+ `(${delta.added.length} added, ${delta.removed.length} removed, ${delta.changed.length} changed). ` +
212
+ `Accept it deliberately with \`chant dev surface-diff <lexicon> --update-snapshot --bump\`, ` +
213
+ `never as a side effect of a release.\n${formatDelta(delta)}`,
214
+ },
215
+ );
216
+ } catch (err) {
217
+ checks.push({
218
+ name: "surface-matches-snapshot",
219
+ ok: false,
220
+ error: `Failed to compare against surface.snapshot.json: ${err instanceof Error ? err.message : String(err)}`,
221
+ });
222
+ }
223
+ }
224
+
151
225
  return {
152
226
  success: checks.every((c) => c.ok),
153
227
  checks,
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { deployUnits } from "./deploy-units";
3
+ import type { Phase } from "./component";
4
+
5
+ const phase = (name: string, steps: Phase["steps"]): Phase => ({ phase: name, steps });
6
+
7
+ describe("deployUnits (#1495 piece 1)", () => {
8
+ test("resolves a cfn-deploy step to its stack, keyed to the aws observer", () => {
9
+ const deploy = [phase("Apply", [{ kind: "cfn-deploy", stack: "cc-canonical", template: "t.json" }])];
10
+ expect(deployUnits(deploy)).toEqual([{ unit: "cc-canonical", lexicon: "aws" }]);
11
+ });
12
+
13
+ test("resolves kubectl-apply and helm-upgrade units once their steps exist", () => {
14
+ const deploy = [
15
+ phase("Apply", [
16
+ { kind: "kubectl-apply", stack: "kubemicrovm-ops" },
17
+ { kind: "helm-upgrade", release: "operator" },
18
+ ]),
19
+ ];
20
+ expect(deployUnits(deploy)).toEqual([
21
+ { unit: "kubemicrovm-ops", lexicon: "k8s" },
22
+ { unit: "operator", lexicon: "helm" },
23
+ ]);
24
+ });
25
+
26
+ test("walks nested phases, dedupes per lexicon, and skips unitless steps", () => {
27
+ const deploy = [
28
+ phase("Outer", [
29
+ phase("Inner", [{ kind: "cfn-deploy", stack: "web" }]) as never,
30
+ { kind: "cfn-deploy", stack: "web" },
31
+ { kind: "shell", cmd: "echo", reason: "no capability yet" },
32
+ { kind: "cfn-deploy" }, // no stack named — contributes nothing
33
+ ]),
34
+ ];
35
+ expect(deployUnits(deploy)).toEqual([{ unit: "web", lexicon: "aws" }]);
36
+ });
37
+
38
+ test("an unlisted kind contributes no unit — the registry is the rule", () => {
39
+ const deploy = [phase("Apply", [{ kind: "gcloud-deploy", stack: "x" }])];
40
+ expect(deployUnits(deploy)).toEqual([]);
41
+ });
42
+ });