@intentius/chant 0.38.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/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.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/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.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/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
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];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadLexiconRegistry, LexiconRegistryMissingError } from "./registry";
|
|
6
|
+
|
|
7
|
+
const REGISTRY = { Bucket: { resourceType: "AWS::S3::Bucket", kind: "resource" } };
|
|
8
|
+
|
|
9
|
+
function pkg(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), "chant-registry-"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("loadLexiconRegistry (#1367)", () => {
|
|
14
|
+
test("reads the dev copy — src/generated, which is what a working checkout has", () => {
|
|
15
|
+
const dir = pkg();
|
|
16
|
+
mkdirSync(join(dir, "src", "generated"), { recursive: true });
|
|
17
|
+
writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify(REGISTRY));
|
|
18
|
+
expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("falls back to dist/meta.json — what an installed package ships", () => {
|
|
22
|
+
const dir = pkg();
|
|
23
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
24
|
+
writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify(REGISTRY));
|
|
25
|
+
expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("prefers the dev copy when both exist", () => {
|
|
29
|
+
const dir = pkg();
|
|
30
|
+
mkdirSync(join(dir, "src", "generated"), { recursive: true });
|
|
31
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
32
|
+
writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify({ Dev: REGISTRY.Bucket }));
|
|
33
|
+
writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify({ Dist: REGISTRY.Bucket }));
|
|
34
|
+
expect(Object.keys(loadLexiconRegistry(dir, "aws"))).toEqual(["Dev"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("throws with the command to run, rather than a module-not-found or an empty map", () => {
|
|
38
|
+
// The whole point. An empty map reads downstream as a lexicon with no
|
|
39
|
+
// resource types, which is how azure's import came to emit
|
|
40
|
+
// `// Unknown resource type: Microsoft.…` and look like a coverage gap.
|
|
41
|
+
const dir = pkg();
|
|
42
|
+
expect(() => loadLexiconRegistry(dir, "azure")).toThrow(LexiconRegistryMissingError);
|
|
43
|
+
expect(() => loadLexiconRegistry(dir, "azure")).toThrow(/npm run --prefix lexicons\/azure prepack/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("names the lexicon that is missing, not a generic one", () => {
|
|
47
|
+
const dir = pkg();
|
|
48
|
+
try {
|
|
49
|
+
loadLexiconRegistry(dir, "gcp");
|
|
50
|
+
throw new Error("should have thrown");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
expect((err as LexiconRegistryMissingError).lexicon).toBe("gcp");
|
|
53
|
+
expect((err as Error).message).toContain("lexicons/gcp");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating a lexicon's generated registry (#1367).
|
|
3
|
+
*
|
|
4
|
+
* The registry — every resource type the lexicon knows, keyed by class name —
|
|
5
|
+
* is written to two places by `npm run --prefix lexicons/<name> prepack`:
|
|
6
|
+
* `src/generated/lexicon-<name>.json` by the generate step, and `dist/meta.json`
|
|
7
|
+
* by the bundle step, byte-identical. Both are gitignored, so a fresh clone has
|
|
8
|
+
* neither until that command runs; CI runs it explicitly before anything else.
|
|
9
|
+
*
|
|
10
|
+
* That is a legitimate state. Being silent about it is not, and the two readers
|
|
11
|
+
* that needed the registry were silent in opposite directions:
|
|
12
|
+
*
|
|
13
|
+
* - AWS's import generator `require`d `dist/meta.json` with no catch, so a
|
|
14
|
+
* fresh clone got `Cannot find module …/lexicons/aws/dist/meta.json` — from
|
|
15
|
+
* `chant import`, and from twelve tests that mention neither modules nor
|
|
16
|
+
* registries.
|
|
17
|
+
* - Azure's caught the failure and continued with an empty map, so the import
|
|
18
|
+
* emitted `// Unknown resource type: Microsoft.Network/virtualNetworks` and
|
|
19
|
+
* its round-trip tests failed as though the registry were incomplete. That is
|
|
20
|
+
* the worse of the two: it looks like a coverage gap in the lexicon.
|
|
21
|
+
*
|
|
22
|
+
* Both now go through here, which tries the dev path first (matching what the
|
|
23
|
+
* azure serializer already did) and, failing both, says what to run.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Thrown when neither copy of the registry exists — carries the fix. */
|
|
29
|
+
export class LexiconRegistryMissingError extends Error {
|
|
30
|
+
constructor(readonly lexicon: string) {
|
|
31
|
+
super(
|
|
32
|
+
`${lexicon} lexicon registry not found — it is a build artifact and this checkout has not built it.\n` +
|
|
33
|
+
`Run: npm run --prefix lexicons/${lexicon} prepack\n` +
|
|
34
|
+
`(generate writes src/generated/lexicon-${lexicon}.json; bundle writes dist/meta.json)`,
|
|
35
|
+
);
|
|
36
|
+
this.name = "LexiconRegistryMissingError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One entry in a lexicon's generated registry. */
|
|
41
|
+
export interface LexiconRegistryEntry {
|
|
42
|
+
resourceType: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
apiVersion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a lexicon's generated registry, dev copy first.
|
|
49
|
+
*
|
|
50
|
+
* `pkgDir` is the lexicon PACKAGE directory (the one holding `src/` and
|
|
51
|
+
* `dist/`) — callers pass an `import.meta.dirname`-derived path, so this stays
|
|
52
|
+
* free of assumptions about where it is imported from. Throws
|
|
53
|
+
* {@link LexiconRegistryMissingError} when neither copy exists, rather than
|
|
54
|
+
* returning an empty map that reads downstream as a lexicon with no resource
|
|
55
|
+
* types at all.
|
|
56
|
+
*/
|
|
57
|
+
export function loadLexiconRegistry(pkgDir: string, lexicon: string): Record<string, LexiconRegistryEntry> {
|
|
58
|
+
for (const candidate of [
|
|
59
|
+
join(pkgDir, "src", "generated", `lexicon-${lexicon}.json`),
|
|
60
|
+
join(pkgDir, "dist", "meta.json"),
|
|
61
|
+
]) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(candidate, "utf-8")) as Record<string, LexiconRegistryEntry>;
|
|
64
|
+
} catch {
|
|
65
|
+
// Try the next candidate; only both failing is an error.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new LexiconRegistryMissingError(lexicon);
|
|
69
|
+
}
|
|
@@ -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,
|
|
@@ -16,7 +16,7 @@ generic `runInterpretDriver` (#556, [`../driver.ts`](./driver.ts)), unchanged.
|
|
|
16
16
|
## Before / after: the ALB/ECS pipeline glue this replaces
|
|
17
17
|
|
|
18
18
|
The component model's whole reason for existing is visible in one concrete
|
|
19
|
-
diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](
|
|
19
|
+
diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
|
|
20
20
|
hand-rolls a `deployService` job that shells out to CloudFormation and greps
|
|
21
21
|
its own infra stack's outputs before it can deploy:
|
|
22
22
|
|
|
@@ -49,7 +49,7 @@ No `describe-stacks`, no `jq`, no shell string-building. `dependsOn:
|
|
|
49
49
|
["shared-alb"]` plus three `stackOutput()` references replace the whole glue
|
|
50
50
|
block; `docker build`/`docker push` become the `docker-build`/`publish-image`
|
|
51
51
|
capabilities the driver dispatches to. [`driver.test.ts`](./driver.test.ts)
|
|
52
|
-
and [`pilots/pilots
|
|
52
|
+
and [`pilots/pilots.test.ts`](./pilots/pilots.test.ts) exercise this
|
|
53
53
|
exact wiring end to end against a mock `CloudExecutor` — `cfn-deploy`
|
|
54
54
|
resolves the `stackOutput` reference itself; no orchestrator code parses a
|
|
55
55
|
CloudFormation output.
|
|
@@ -59,7 +59,7 @@ CloudFormation output.
|
|
|
59
59
|
[`driver.test.ts`](./driver.test.ts)'s `"runs the three pilots through one
|
|
60
60
|
driver instance with zero per-component driver code (sprawl metric)"` test
|
|
61
61
|
(pre-existing from #556/#557, still green) and
|
|
62
|
-
[`pilots
|
|
62
|
+
[`pilots.test.ts`](./pilots/pilots.test.ts)'s first `describe` block
|
|
63
63
|
run Neo4j fan-out, DynamoDB (sticky apply), and ALB/ECS (cross-stack, build)
|
|
64
64
|
through the same `runInterpretDriver` call, dispatching to the real,
|
|
65
65
|
`MockCloudExecutor`-backed capability implementations from #557
|
|
@@ -103,7 +103,7 @@ uses carries the image reference into the apply step.
|
|
|
103
103
|
|
|
104
104
|
### The one new capability: `lambda-deploy`
|
|
105
105
|
|
|
106
|
-
[`
|
|
106
|
+
[`lexicons/aws/src/components/apply.ts`](../../../../lexicons/aws/src/components/apply.ts)'s `createLambdaDeployCapability` is the
|
|
107
107
|
only new leaf this component required, built the same way #557 built the
|
|
108
108
|
other real leaves: typed input/output, an injectable `CloudExecutor`
|
|
109
109
|
(extended with a `lambda` client — [`verbs/cloud-executor.ts`](./verbs/cloud-executor.ts)),
|
|
@@ -121,7 +121,7 @@ No other verb was touched or added. `driver.ts` was not edited.
|
|
|
121
121
|
|
|
122
122
|
[`driver.test.ts`](./driver.test.ts)'s `"runs all four components ... through
|
|
123
123
|
one driver instance with zero per-component driver code (sprawl metric,
|
|
124
|
-
extended)"` test and [`pilots
|
|
124
|
+
extended)"` test and [`pilots.test.ts`](./pilots/pilots.test.ts)'s
|
|
125
125
|
second `describe`-block test run all five components (`shared-alb`,
|
|
126
126
|
`orders-table`, `neo4j-cluster`, `search-service`, `image-processor-lambda`)
|
|
127
127
|
through one `runInterpretDriver` call, dispatching `image-processor-lambda`'s
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { generateComponentDriverSource } from "./driver";
|
|
4
|
+
import { setBuildParams } from "../../params";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* chant #1108 — same re-binding the entity driver does (../../discovery/
|
|
8
|
+
* sandbox/driver.test.ts): a `*.component.ts` file imported in the sandboxed
|
|
9
|
+
* child must see the parent's resolved build-time parameters, not `{}`.
|
|
10
|
+
*/
|
|
11
|
+
describe("generateComponentDriverSource — build-time parameters (#1108)", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
setBuildParams({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("embeds a snapshot of the parent's current params, bound before any component import", () => {
|
|
17
|
+
setBuildParams({ stage: "prod" });
|
|
18
|
+
const source = generateComponentDriverSource({ files: ["proj/svc.component.ts"] });
|
|
19
|
+
|
|
20
|
+
expect(source).toContain("import { setBuildParams } from ");
|
|
21
|
+
const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ stage: "prod" })});`);
|
|
22
|
+
const firstImportAt = source.indexOf(`await import(${JSON.stringify(resolve("proj/svc.component.ts"))})`);
|
|
23
|
+
expect(bindAt).toBeGreaterThan(-1);
|
|
24
|
+
expect(firstImportAt).toBeGreaterThan(-1);
|
|
25
|
+
expect(bindAt).toBeLessThan(firstImportAt);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
+
import { params as currentBuildParams } from "../../params";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* chant #1051 — generates the source of the "driver" module that runs INSIDE
|
|
@@ -29,6 +30,12 @@ const COMPONENTS_DIR = join(HERE, "..");
|
|
|
29
30
|
/** Absolute paths to chant's OWN trusted modules the generated driver imports — resolved relative to THIS file's own location on disk, exactly like `../../discovery/sandbox/driver.ts` does for the entity path (works whether chant runs from the monorepo or a consumer's `node_modules`). */
|
|
30
31
|
const DISCOVER_MODULE = join(COMPONENTS_DIR, "discover.ts");
|
|
31
32
|
const CHILD_ERRORS_MODULE = join(COMPONENTS_DIR, "..", "discovery", "sandbox", "child-errors.ts");
|
|
33
|
+
// chant #1108 — same re-binding the entity driver does (see ../../discovery/
|
|
34
|
+
// sandbox/driver.ts's PARAMS_MODULE doc): the child's copy of the shared
|
|
35
|
+
// `params` object starts empty, so a `*.component.ts` file importing
|
|
36
|
+
// `@intentius/chant/params` would otherwise see `{}` under sandboxed
|
|
37
|
+
// discovery no matter what the parent resolved.
|
|
38
|
+
const PARAMS_MODULE = join(COMPONENTS_DIR, "..", "params.ts");
|
|
32
39
|
|
|
33
40
|
export interface GenerateComponentDriverOptions {
|
|
34
41
|
/** Absolute paths to every discovered `*.component.ts` file for this build. */
|
|
@@ -52,6 +59,11 @@ export function generateComponentDriverSource(options: GenerateComponentDriverOp
|
|
|
52
59
|
const lines: string[] = [
|
|
53
60
|
`import { collectComponents } from ${lit(DISCOVER_MODULE)};`,
|
|
54
61
|
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
62
|
+
`import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
|
|
63
|
+
``,
|
|
64
|
+
// chant #1108 — snapshot of the parent's resolved build-time parameter
|
|
65
|
+
// values (scalars only), bound before any component file is imported.
|
|
66
|
+
`setBuildParams(${lit({ ...currentBuildParams })});`,
|
|
55
67
|
``,
|
|
56
68
|
`function send(payload) {`,
|
|
57
69
|
` if (typeof process.send === "function") process.send(payload);`,
|
package/src/composite.ts
CHANGED
|
@@ -7,14 +7,43 @@ import { setProvenance } from "./provenance";
|
|
|
7
7
|
export const COMPOSITE_MARKER = Symbol.for("chant.composite");
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* A record of named
|
|
10
|
+
* A record of named members produced by a composite factory.
|
|
11
|
+
*
|
|
12
|
+
* What a consumer reads off `instance.members`. Deliberately narrow: every
|
|
13
|
+
* value here is a real Declarable, so `.entityType` and friends resolve without
|
|
14
|
+
* narrowing. What a factory may RETURN is wider — see
|
|
15
|
+
* {@link CompositeFactoryMembers}.
|
|
11
16
|
*/
|
|
12
17
|
export type CompositeMembers = Record<string, Declarable>;
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What a factory is allowed to RETURN — {@link CompositeMembers} plus
|
|
21
|
+
* `undefined`, used only as the generic constraint.
|
|
22
|
+
*
|
|
23
|
+
* A member produced by a conditional spread (`...(cond ? { policy } : {})`) is
|
|
24
|
+
* typed optional, and an optional property is not assignable to a
|
|
25
|
+
* required-value record. Widening the constraint lets such a factory typecheck;
|
|
26
|
+
* widening `CompositeMembers` itself would make every member possibly-undefined
|
|
27
|
+
* for everyone reading `instance.members`, which is a worse trade.
|
|
28
|
+
*
|
|
29
|
+
* The key is absent at runtime rather than present-and-undefined, so nothing
|
|
30
|
+
* reaches the validation in `Composite` below.
|
|
31
|
+
*/
|
|
32
|
+
export type CompositeFactoryMembers =
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
| Record<string, Declarable | CompositeInstance<any> | undefined>
|
|
35
|
+
// A pass-through composite returns another composite's INSTANCE rather than
|
|
36
|
+
// building a record (`return FargateService({...})`). That works at runtime
|
|
37
|
+
// because `members` and `_definition` are defined non-enumerable below
|
|
38
|
+
// precisely so an instance exposes only its member resources — but a type
|
|
39
|
+
// cannot say "non-enumerable", so the instance has to be admitted directly.
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
| CompositeInstance<any>;
|
|
42
|
+
|
|
14
43
|
/**
|
|
15
44
|
* The result of instantiating a composite — contains the marker and expanded members.
|
|
16
45
|
*/
|
|
17
|
-
export interface CompositeInstance<M extends
|
|
46
|
+
export interface CompositeInstance<M extends CompositeFactoryMembers = CompositeMembers> {
|
|
18
47
|
readonly [COMPOSITE_MARKER]: true;
|
|
19
48
|
readonly members: M;
|
|
20
49
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -24,7 +53,7 @@ export interface CompositeInstance<M extends CompositeMembers = CompositeMembers
|
|
|
24
53
|
/**
|
|
25
54
|
* A composite definition: a callable that produces a CompositeInstance.
|
|
26
55
|
*/
|
|
27
|
-
export interface CompositeDefinition<P, M extends
|
|
56
|
+
export interface CompositeDefinition<P, M extends CompositeFactoryMembers = CompositeMembers> {
|
|
28
57
|
(props: P): CompositeInstance<M> & M;
|
|
29
58
|
readonly compositeName: string;
|
|
30
59
|
readonly _id: symbol;
|
|
@@ -78,7 +107,7 @@ export class CompositeRegistry {
|
|
|
78
107
|
* export const storage = SecureStorage({ name: "data" });
|
|
79
108
|
* ```
|
|
80
109
|
*/
|
|
81
|
-
export function Composite<P, M extends
|
|
110
|
+
export function Composite<P, M extends CompositeFactoryMembers = CompositeMembers>(
|
|
82
111
|
factory: (props: P) => M,
|
|
83
112
|
name?: string,
|
|
84
113
|
): CompositeDefinition<P, M> {
|
package/src/deep-observation.ts
CHANGED
|
@@ -56,6 +56,17 @@ export interface DeepResourceObservation {
|
|
|
56
56
|
physicalId?: string;
|
|
57
57
|
/** The normalized live property tree. JSON-safe. */
|
|
58
58
|
properties: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Path → the field manager that owns it, where the substrate records one
|
|
61
|
+
* (#1189). Additive to the v1 envelope: a lexicon with no per-field ownership
|
|
62
|
+
* omits it, and every consumer behaves exactly as before.
|
|
63
|
+
*
|
|
64
|
+
* Only Kubernetes has this — the API server maintains `managedFields`
|
|
65
|
+
* server-side. It is the difference between "this field is foreign" and
|
|
66
|
+
* "`hpa-controller` owns `spec.replicas`", which are the same category and
|
|
67
|
+
* very different answers to an operator.
|
|
68
|
+
*/
|
|
69
|
+
fieldOwners?: Record<string, string>;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/**
|