@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/dist/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/index.ts +1 -0
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
|
+
});
|
package/src/codegen/naming.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Collision-free naming strategy for TypeScript class names.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
+
});
|