@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.
- package/dist/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/cli/build-params-cli.d.ts +24 -0
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.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/components/deploy-units.d.ts +49 -0
- package/dist/components/deploy-units.d.ts.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.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/build-params-cli.ts +35 -0
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/components.ts +29 -23
- package/src/cli/handlers/graph.test.ts +51 -1
- package/src/cli/handlers/graph.ts +16 -1
- package/src/cli/handlers/lifecycle.ts +18 -3
- 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/components/deploy-units.test.ts +42 -0
- package/src/components/deploy-units.ts +82 -0
- package/src/config.test.ts +53 -0
- package/src/config.ts +41 -3
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
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
|
+
});
|
package/src/codegen/validate.ts
CHANGED
|
@@ -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
|
+
});
|