@intentius/chant 0.39.0 → 0.41.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.
package/src/apply.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The apply contract (#1446) — what a lexicon's applier is allowed to mean.
3
+ *
4
+ * The peer of `./observation.ts`, and it exists for the same reason on the other
5
+ * side of the lifecycle.
6
+ *
7
+ * The read path had two ways to return nothing for a declared entity and no way
8
+ * to tell them apart — "the provider says it is absent" versus "I never looked"
9
+ * — and the second was classifying as `create`. #1089 made that a tri-state and
10
+ * #1201 built a conformance suite so a lexicon could not pass by returning a
11
+ * well-shaped result that still proposed a create.
12
+ *
13
+ * The write path had the identical hole. An applier could skip a resource and
14
+ * report nothing:
15
+ *
16
+ * const mapper = MAPPERS[r.kind];
17
+ * if (!mapper) {
18
+ * console.log(`skip: no mapper for kind ${r.kind}`);
19
+ * continue; // never appears in `applied`, or anywhere
20
+ * }
21
+ *
22
+ * A caller receiving `{ applied: [...] }` could not distinguish a complete apply
23
+ * from one that dropped half the manifest, and `ApplyOp` had nothing to gate on.
24
+ * A `console.log` on stdout is not a signal in a result, the same way a warn on
25
+ * stderr was not a signal in a change set (#1447 was the concrete bug; #1457
26
+ * found the same shape in two more appliers).
27
+ *
28
+ * The contract is a tri-state, per resource in the plan:
29
+ *
30
+ * - **APPLIED** — the provider was called and converged. Carries the action
31
+ * (`created` / `updated` / `unchanged`) and the physical id when resolved.
32
+ * - **PRUNED** — owned, no longer declared, deleted.
33
+ * - **NOT-ATTEMPTED** — carrying a total {@link NotAttemptedReason}. Never
34
+ * silent, never inferred from absence.
35
+ *
36
+ * The three are **disjoint and total**: every resource the applier was given
37
+ * appears in exactly one. That is the assertion the conformance suite exists to
38
+ * make, and the one the gcp skip failed.
39
+ *
40
+ * Compatibility: an applier may keep returning its own shape. The envelope is
41
+ * discriminated by its literal `apply: "v1"` field, the same mechanism
42
+ * `observation: "v1"` uses, so an un-migrated applier normalizes to "everything
43
+ * I was handed, I attempted" and nothing breaks. Reporting NOT-ATTEMPTED
44
+ * requires the envelope — which is the point: you cannot claim the guarantee
45
+ * without adopting the shape that can express its absence.
46
+ */
47
+
48
+ /**
49
+ * Why a resource in the plan was not attempted. Total: an applier that skips a
50
+ * resource must pick one of these, and consumers may switch exhaustively.
51
+ *
52
+ * - `unsupported-kind` — the lexicon has no mapper/writer for this type. The
53
+ * resource is declared and simply was not written.
54
+ * - `no-credentials` — no usable credentials/authorization for the target.
55
+ * - `no-binding` — the environment resolves to no concrete target (no cluster
56
+ * context, no subscription, no resource group, no endpoint).
57
+ * - `dependency-failed` — an upstream resource in the same run failed, so this
58
+ * one was never reached. Distinct from a failure of its own.
59
+ * - `filtered` — reached but deliberately withheld by a caller-requested scope.
60
+ * - `not-prunable` — an owned orphan of a kind the applier cannot enumerate or
61
+ * delete. The prune-side shape: not "there was nothing to prune" but "I could
62
+ * not look for anything to prune here".
63
+ */
64
+ export type NotAttemptedReason =
65
+ | "unsupported-kind"
66
+ | "no-credentials"
67
+ | "no-binding"
68
+ | "dependency-failed"
69
+ | "filtered"
70
+ | "not-prunable";
71
+
72
+ /** Every legal {@link NotAttemptedReason}, for validation and conformance checks. */
73
+ export const NOT_ATTEMPTED_REASONS: readonly NotAttemptedReason[] = [
74
+ "unsupported-kind",
75
+ "no-credentials",
76
+ "no-binding",
77
+ "dependency-failed",
78
+ "filtered",
79
+ "not-prunable",
80
+ ];
81
+
82
+ /** True when `value` is a legal {@link NotAttemptedReason}. */
83
+ export function isNotAttemptedReason(value: unknown): value is NotAttemptedReason {
84
+ return typeof value === "string" && (NOT_ATTEMPTED_REASONS as readonly string[]).includes(value);
85
+ }
86
+
87
+ /** What an apply did to a resource that it did reach. */
88
+ export type AppliedAction = "created" | "updated" | "unchanged";
89
+
90
+ /** Every legal {@link AppliedAction}. */
91
+ export const APPLIED_ACTIONS: readonly AppliedAction[] = ["created", "updated", "unchanged"];
92
+
93
+ /** How a resource is named across appliers whose native vocabularies differ. */
94
+ export interface ApplyRef {
95
+ /**
96
+ * The provider's own type name — a CNRM `kind`, an ARM `type`, a
97
+ * CloudFormation resource type, a Fly entity class. Kept verbatim rather than
98
+ * normalized into a chant vocabulary, so an applier stays true to its target.
99
+ */
100
+ kind: string;
101
+ /** The resource's name within its scope. */
102
+ name: string;
103
+ }
104
+
105
+ /** APPLIED — the provider was called and the resource converged. */
106
+ export interface AppliedResource extends ApplyRef {
107
+ action: AppliedAction;
108
+ /** Provider-assigned identifier, when the response carried one. */
109
+ physicalId?: string;
110
+ }
111
+
112
+ /** PRUNED — owned, no longer declared, deleted. */
113
+ export interface PrunedResource extends ApplyRef {
114
+ /**
115
+ * False when the delete was a no-op because the resource was already gone.
116
+ * Still PRUNED: the applier looked, decided, and acted.
117
+ */
118
+ deleted: boolean;
119
+ }
120
+
121
+ /** NOT-ATTEMPTED — no provider call was made, and why. */
122
+ export interface NotAttemptedResource extends ApplyRef {
123
+ reason: NotAttemptedReason;
124
+ /** Detail for the operator: the status and body, the missing binding, the unsupported kind. */
125
+ detail?: string;
126
+ }
127
+
128
+ /**
129
+ * The apply envelope. Explicitly versioned: `apply: "v1"`, which discriminates
130
+ * it from an applier's own return shape.
131
+ */
132
+ export interface ApplyResult {
133
+ /** Discriminant + wire version. */
134
+ readonly apply: "v1";
135
+ applied: AppliedResource[];
136
+ pruned?: PrunedResource[];
137
+ /** Omit or leave empty when everything in the plan was attempted. */
138
+ notAttempted?: NotAttemptedResource[];
139
+ }
140
+
141
+ /** Normalized form every consumer works with. All three arrays always present. */
142
+ export interface NormalizedApply {
143
+ applied: AppliedResource[];
144
+ pruned: PrunedResource[];
145
+ notAttempted: NotAttemptedResource[];
146
+ }
147
+
148
+ /** True when `value` is the versioned {@link ApplyResult} envelope. */
149
+ export function isApplyResult(value: unknown): value is ApplyResult {
150
+ return typeof value === "object" && value !== null && (value as { apply?: unknown }).apply === "v1";
151
+ }
152
+
153
+ /**
154
+ * Build an {@link ApplyResult}. Appliers use this rather than writing the
155
+ * discriminant by hand.
156
+ */
157
+ export function applyResult(
158
+ applied: AppliedResource[],
159
+ pruned?: PrunedResource[],
160
+ notAttempted?: NotAttemptedResource[],
161
+ ): ApplyResult {
162
+ return {
163
+ apply: "v1",
164
+ applied,
165
+ ...(pruned && pruned.length > 0 ? { pruned } : {}),
166
+ ...(notAttempted && notAttempted.length > 0 ? { notAttempted } : {}),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Normalize an applier's return value.
172
+ *
173
+ * A non-envelope value normalizes to "everything I was handed, I attempted" —
174
+ * an empty `notAttempted`. That is the compatibility path, and it is also
175
+ * exactly the claim an un-migrated applier is implicitly making, so nothing is
176
+ * invented on its behalf.
177
+ *
178
+ * `undefined` normalizes to three empty arrays. An applier that means "I applied
179
+ * nothing because I could not" must say so with {@link notAttemptedAll} rather
180
+ * than returning nothing, for the same reason `unobservedAll` exists on the read
181
+ * side.
182
+ */
183
+ export function normalizeApply(value: ApplyResult | Partial<NormalizedApply> | undefined): NormalizedApply {
184
+ if (!value) return { applied: [], pruned: [], notAttempted: [] };
185
+ return {
186
+ applied: value.applied ?? [],
187
+ pruned: value.pruned ?? [],
188
+ notAttempted: value.notAttempted ?? [],
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Mark every named resource NOT-ATTEMPTED with one reason — the whole-run
194
+ * failure case (no credentials, no binding, the transport never came up). Core
195
+ * applies this when an applier throws, so a thrown apply degrades to an honest
196
+ * "did not attempt" per resource instead of an empty result that reads as a
197
+ * successful no-op.
198
+ */
199
+ export function notAttemptedAll(
200
+ refs: Iterable<ApplyRef>,
201
+ reason: NotAttemptedReason,
202
+ detail?: string,
203
+ ): NotAttemptedResource[] {
204
+ return [...refs].map((ref) => ({ ...ref, reason, ...(detail ? { detail } : {}) }));
205
+ }
206
+
207
+ /** A resource's identity within one apply, for disjointness checks. */
208
+ export function applyRefKey(ref: ApplyRef): string {
209
+ return `${ref.kind}/${ref.name}`;
210
+ }
211
+
212
+ /**
213
+ * Every resource that appears in more than one bucket. Empty is the contract;
214
+ * a non-empty result means the applier both wrote and skipped the same
215
+ * resource, which is not a state that can be true.
216
+ *
217
+ * Pruned is checked against the other two but not against itself: an applier
218
+ * may legitimately apply a resource and prune a different, same-named one only
219
+ * if their kinds differ, and {@link applyRefKey} already separates those.
220
+ */
221
+ export function overlappingRefs(result: NormalizedApply): string[] {
222
+ const seen = new Map<string, Set<string>>();
223
+ const note = (bucket: string, refs: ApplyRef[]): void => {
224
+ for (const ref of refs) {
225
+ const key = applyRefKey(ref);
226
+ (seen.get(key) ?? seen.set(key, new Set()).get(key)!).add(bucket);
227
+ }
228
+ };
229
+ note("applied", result.applied);
230
+ note("pruned", result.pruned);
231
+ note("notAttempted", result.notAttempted);
232
+ return [...seen.entries()].filter(([, buckets]) => buckets.size > 1).map(([key]) => key);
233
+ }
234
+
235
+ /**
236
+ * Resources in `plan` that the result accounts for in no bucket — the silent
237
+ * drop this contract exists to make impossible.
238
+ *
239
+ * The conformance suite's central assertion. Before #1447, gcp's applier failed
240
+ * exactly this: an unmapped kind was in the plan, in no bucket, and the result
241
+ * looked complete.
242
+ */
243
+ export function unaccountedRefs(plan: Iterable<ApplyRef>, result: NormalizedApply): string[] {
244
+ const accounted = new Set<string>();
245
+ for (const bucket of [result.applied, result.pruned, result.notAttempted]) {
246
+ for (const ref of bucket) accounted.add(applyRefKey(ref));
247
+ }
248
+ return [...plan].map(applyRefKey).filter((key) => !accounted.has(key));
249
+ }
@@ -351,3 +351,48 @@ describe("doctorCommand", () => {
351
351
  });
352
352
  });
353
353
  });
354
+
355
+ /**
356
+ * #1421 — the sibling of the `tsconfig-paths` check. Both are project settings
357
+ * that silently break runtime module resolution; this one makes build
358
+ * parameters read as empty in project source on the run path, so declarations
359
+ * conditioned on them take their default branch while chant reports success.
360
+ */
361
+ describe("package-type-module (#1421)", () => {
362
+ const withPkg = async (pkg: object | undefined, assert: (check: { status: string; message?: string } | undefined) => void): Promise<void> => {
363
+ await withTestDir(async (testDir) => {
364
+ if (pkg) writeFileSync(join(testDir, "package.json"), JSON.stringify(pkg));
365
+ const report = await doctorCommand(testDir);
366
+ assert(report.checks.find((c) => c.name === "package-type-module"));
367
+ });
368
+ };
369
+
370
+ test('passes for "type": "module"', async () => {
371
+ await withPkg({ name: "p", type: "module" }, (check) => {
372
+ expect(check?.status).toBe("pass");
373
+ });
374
+ });
375
+
376
+ test('warns for "type": "commonjs", saying what breaks', async () => {
377
+ await withPkg({ name: "p", type: "commonjs" }, (check) => {
378
+ expect(check?.status).toBe("warn");
379
+ expect(check?.message).toContain('"type": "commonjs"');
380
+ expect(check?.message).toContain("build parameters");
381
+ expect(check?.message).toContain('Set "type": "module"');
382
+ });
383
+ });
384
+
385
+ // The easier one to miss: no `type` field is CommonJS too.
386
+ test("warns when no type field is declared at all", async () => {
387
+ await withPkg({ name: "p" }, (check) => {
388
+ expect(check?.status).toBe("warn");
389
+ expect(check?.message).toContain("no `type` field");
390
+ });
391
+ });
392
+
393
+ test("says nothing when there is no package.json to judge", async () => {
394
+ await withPkg(undefined, (check) => {
395
+ expect(check).toBeUndefined();
396
+ });
397
+ });
398
+ });
@@ -169,6 +169,46 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
169
169
  }
170
170
  }
171
171
 
172
+ // Check 8b: package.json declares "type": "module" (#1421)
173
+ //
174
+ // Sibling of the tsconfig-paths check above, and filed for the same reason:
175
+ // both are project settings that silently break runtime module resolution.
176
+ //
177
+ // chant's core is ESM. When the project is CJS — `"type": "commonjs"`, or no
178
+ // `type` field at all — tsx loads project source through the CommonJS
179
+ // transform, so the project's `require` of `params.ts` and core's `import` of
180
+ // it produce two separate module records. `setBuildParams` mutates one object
181
+ // in place; project source reads the other, and sees `{}`.
182
+ //
183
+ // The result is a silent wrong answer, not a failure: chant prints
184
+ // `[param] tier = "prod" (cli)` and then emits the graph for the default. It
185
+ // hits `chant graph` (always the run path) and `chant build --no-fold`; plain
186
+ // `chant build` escapes only because folding substitutes parameters
187
+ // statically and never reads the shared object.
188
+ const projectPkgPath = join(projectPath, "package.json");
189
+ if (existsSync(projectPkgPath)) {
190
+ try {
191
+ const pkg = JSON.parse(readFileSync(projectPkgPath, "utf-8")) as { type?: string };
192
+ if (pkg.type === "module") {
193
+ checks.push({ name: "package-type-module", status: "pass" });
194
+ } else {
195
+ const found = pkg.type ? `"type": "${pkg.type}"` : "no `type` field";
196
+ checks.push({
197
+ name: "package-type-module",
198
+ status: "warn",
199
+ message:
200
+ `package.json has ${found} — chant is ESM, and a CommonJS project reads build ` +
201
+ `parameters as empty on the run path (\`chant graph\`, \`chant build --no-fold\`). ` +
202
+ `Declarations conditioned on \`params.<name>\` silently take their default branch. ` +
203
+ `Set "type": "module".`,
204
+ });
205
+ }
206
+ } catch (e) {
207
+ debug("project package.json parse failed:", e);
208
+ checks.push({ name: "package-type-module", status: "warn", message: "Could not parse package.json" });
209
+ }
210
+ }
211
+
172
212
  // Check 9: .mcp.json exists and has chant entry
173
213
  const mcpPath = join(projectPath, ".mcp.json");
174
214
  if (!existsSync(mcpPath)) {
@@ -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
+ });