@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.
Files changed (106) hide show
  1. package/dist/apply.d.ts +171 -0
  2. package/dist/apply.d.ts.map +1 -0
  3. package/dist/build.d.ts +21 -0
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  7. package/dist/cli/commands/doctor.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  10. package/dist/cli/handlers/search.d.ts +30 -1
  11. package/dist/cli/handlers/search.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/plugins.d.ts +20 -0
  14. package/dist/cli/plugins.d.ts.map +1 -1
  15. package/dist/codegen/naming.d.ts +48 -1
  16. package/dist/codegen/naming.d.ts.map +1 -1
  17. package/dist/codegen/registry.d.ts +23 -0
  18. package/dist/codegen/registry.d.ts.map +1 -0
  19. package/dist/codegen/validate.d.ts +31 -0
  20. package/dist/codegen/validate.d.ts.map +1 -1
  21. package/dist/components/sandbox/driver.d.ts.map +1 -1
  22. package/dist/composite.d.ts +23 -4
  23. package/dist/composite.d.ts.map +1 -1
  24. package/dist/deep-observation.d.ts +11 -0
  25. package/dist/deep-observation.d.ts.map +1 -1
  26. package/dist/discovery/index.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/kubectl-context.d.ts.map +1 -1
  36. package/dist/lexicon-config.d.ts +61 -0
  37. package/dist/lexicon-config.d.ts.map +1 -0
  38. package/dist/lexicon.d.ts +19 -0
  39. package/dist/lexicon.d.ts.map +1 -1
  40. package/dist/lifecycle/deep-diff.d.ts +11 -0
  41. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  42. package/dist/lifecycle/digest.d.ts.map +1 -1
  43. package/dist/lifecycle/identity.d.ts +52 -0
  44. package/dist/lifecycle/identity.d.ts.map +1 -0
  45. package/dist/lifecycle/observe.d.ts +5 -0
  46. package/dist/lifecycle/observe.d.ts.map +1 -1
  47. package/dist/lifecycle/replay.d.ts.map +1 -1
  48. package/dist/lifecycle/types.d.ts +30 -0
  49. package/dist/lifecycle/types.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/apply.test.ts +169 -0
  54. package/src/apply.ts +249 -0
  55. package/src/build.ts +24 -0
  56. package/src/cli/commands/build.ts +10 -0
  57. package/src/cli/commands/check-lexicon.ts +20 -1
  58. package/src/cli/commands/doctor.test.ts +45 -0
  59. package/src/cli/commands/doctor.ts +40 -0
  60. package/src/cli/handlers/lifecycle.ts +10 -0
  61. package/src/cli/handlers/run-client.ts +3 -1
  62. package/src/cli/handlers/search-kind.test.ts +45 -0
  63. package/src/cli/handlers/search.ts +102 -4
  64. package/src/cli/main.ts +32 -10
  65. package/src/cli/param-flag-scope.test.ts +69 -0
  66. package/src/cli/plugins.test.ts +33 -1
  67. package/src/cli/plugins.ts +55 -0
  68. package/src/codegen/naming.test.ts +129 -0
  69. package/src/codegen/naming.ts +72 -1
  70. package/src/codegen/registry.test.ts +56 -0
  71. package/src/codegen/registry.ts +69 -0
  72. package/src/codegen/validate.test.ts +86 -0
  73. package/src/codegen/validate.ts +74 -0
  74. package/src/components/SPRAWL-VALIDATION.md +5 -5
  75. package/src/components/sandbox/driver.test.ts +27 -0
  76. package/src/components/sandbox/driver.ts +12 -0
  77. package/src/composite.ts +33 -4
  78. package/src/deep-observation.ts +11 -0
  79. package/src/discovery/index.ts +59 -0
  80. package/src/discovery/params-cjs-warning.test.ts +75 -0
  81. package/src/discovery/sandbox/driver.test.ts +34 -0
  82. package/src/discovery/sandbox/driver.ts +19 -0
  83. package/src/graph-declared.test.ts +86 -0
  84. package/src/graph-declared.ts +14 -2
  85. package/src/graph-ir.ts +32 -8
  86. package/src/graph-refs.test.ts +56 -0
  87. package/src/graph-refs.ts +37 -1
  88. package/src/index.ts +1 -0
  89. package/src/kubectl-context.ts +4 -1
  90. package/src/lexicon-config.test.ts +111 -0
  91. package/src/lexicon-config.ts +92 -0
  92. package/src/lexicon.ts +20 -0
  93. package/src/lifecycle/deep-diff.test.ts +48 -1
  94. package/src/lifecycle/deep-diff.ts +16 -0
  95. package/src/lifecycle/digest.test.ts +81 -0
  96. package/src/lifecycle/digest.ts +34 -3
  97. package/src/lifecycle/identity.test.ts +39 -0
  98. package/src/lifecycle/identity.ts +61 -0
  99. package/src/lifecycle/observe.test.ts +75 -1
  100. package/src/lifecycle/observe.ts +28 -2
  101. package/src/lifecycle/replay.test.ts +251 -0
  102. package/src/lifecycle/replay.ts +67 -19
  103. package/src/lifecycle/types.ts +26 -0
  104. package/src/managed-fields.test.ts +50 -0
  105. package/src/managed-fields.ts +25 -6
  106. package/src/meta/peer-deps.test.ts +111 -14
@@ -1,12 +1,32 @@
1
1
  /**
2
2
  * Collision-free naming strategy for TypeScript class names.
3
3
  *
4
- * 5-phase algorithm:
4
+ * 6-phase algorithm:
5
5
  * 1. Priority names (backward compatibility)
6
+ * 1a. Reserved names — names this lexicon has already published (chant #1459)
6
7
  * 2. Priority aliases (additional short names)
7
8
  * 3. Short names (last segment of type)
8
9
  * 4. Collision resolution (service-prefixed)
9
10
  * 5. Property type aliases (globally unique defs)
11
+ *
12
+ * ## Why phase 1a exists
13
+ *
14
+ * Phases 3 and 4 assign a short name to whoever asks first and service-qualify
15
+ * everyone after. Membership of that contest is the whole input set, so a
16
+ * resource's name was a function of its NEIGHBOURS: removing
17
+ * `AWS::Athena::Session` upstream freed `Session`, and `AWS::Macie::Session`
18
+ * silently changed from `MacieSession` to `Session` — a breaking rename for a
19
+ * resource whose schema had not moved. Adding a resource does the same in
20
+ * reverse: `AWS::QuickSight::Space` appearing renamed `AWS::SageMaker::Space`
21
+ * from `Space` to `SageMakerSpace`.
22
+ *
23
+ * Worse, it is reversible. These are read-only registry types that come and go,
24
+ * so a name could flip back on the next upgrade and break consumers again.
25
+ *
26
+ * Reserved names invert the bias: a name that has already shipped belongs to
27
+ * the type that shipped it, and a newcomer colliding with it gets qualified
28
+ * instead. A published name then changes only when its own type disappears,
29
+ * which is a genuine breaking change rather than an incidental one.
10
30
  */
11
31
 
12
32
  /**
@@ -35,6 +55,40 @@ export interface NamingConfig {
35
55
  shortName: (typeName: string) => string;
36
56
  /** Extract the service name from a type name (e.g. "Vendor::Service::Resource" → "Service"). */
37
57
  serviceName: (typeName: string) => string;
58
+ /**
59
+ * chant #1459 — spec type name → the TypeScript name this lexicon has
60
+ * already published for it, normally read from the committed
61
+ * `surface.snapshot.json` via {@link reservedNamesFromSnapshot}.
62
+ *
63
+ * Claimed before short names are contested, so a shipped name is not taken
64
+ * away from its owner by an unrelated upstream change. Omit for a lexicon
65
+ * with no published surface yet; an entry for a type that is no longer in
66
+ * the input is ignored, so a removed type frees its name for reuse.
67
+ */
68
+ reservedNames?: Record<string, string>;
69
+ }
70
+
71
+ /**
72
+ * The names a lexicon has already published, read from a committed surface
73
+ * snapshot (chant #1459).
74
+ *
75
+ * The snapshot is keyed by TypeScript name with the spec type inside, which is
76
+ * exactly the mapping phase 1a needs, inverted. Only `resource` entries are
77
+ * reserved: property-type names are derived from their owning resource's name
78
+ * (phase 5), so pinning the resource pins them, and reserving them separately
79
+ * would freeze aliases that are meant to follow their parent.
80
+ */
81
+ export function reservedNamesFromSnapshot(
82
+ snapshot: { entries?: Record<string, { kind?: string; resourceType?: string }> } | undefined,
83
+ ): Record<string, string> {
84
+ const reserved: Record<string, string> = {};
85
+ for (const [tsName, entry] of Object.entries(snapshot?.entries ?? {})) {
86
+ if (entry.kind !== "resource" || !entry.resourceType) continue;
87
+ // First writer wins: a snapshot that somehow lists one spec type under two
88
+ // names keeps the earlier, rather than silently preferring iteration order.
89
+ reserved[entry.resourceType] ??= tsName;
90
+ }
91
+ return reserved;
38
92
  }
39
93
 
40
94
  export class NamingStrategy {
@@ -58,6 +112,23 @@ export class NamingStrategy {
58
112
  }
59
113
  }
60
114
 
115
+ // Phase 1a: claim previously-published names (chant #1459).
116
+ //
117
+ // After priority names, which are explicit hand-pinned decisions and still
118
+ // win, and before any short name is contested. A reserved name whose type
119
+ // is gone from the input is simply never reached, so its name is released
120
+ // for whoever legitimately claims it next.
121
+ for (const t of typeNames) {
122
+ if (this.assigned.has(t)) continue;
123
+ const published = config.reservedNames?.[t];
124
+ // `usedNames` guards the case where a priority name already took it —
125
+ // an explicit pin outranks history.
126
+ if (published && !this.usedNames.has(published)) {
127
+ this.assigned.set(t, published);
128
+ this.usedNames.add(published);
129
+ }
130
+ }
131
+
61
132
  // Phase 1b: assign priority aliases
62
133
  for (const t of typeNames) {
63
134
  const extras = config.priorityAliases[t];
@@ -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
+ });
@@ -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`](../../../../../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-e2e.test.ts`](./pilots/pilots-e2e.test.ts) exercise this
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-e2e.test.ts`](./pilots/pilots-e2e.test.ts)'s first `describe` block
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
- [`verbs/apply.ts`](./verbs/apply.ts)'s `createLambdaDeployCapability` is the
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-e2e.test.ts`](./pilots/pilots-e2e.test.ts)'s
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 Declarable members produced by a composite factory.
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 CompositeMembers = CompositeMembers> {
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 CompositeMembers = CompositeMembers> {
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 CompositeMembers = CompositeMembers>(
110
+ export function Composite<P, M extends CompositeFactoryMembers = CompositeMembers>(
82
111
  factory: (props: P) => M,
83
112
  name?: string,
84
113
  ): CompositeDefinition<P, M> {
@@ -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
  /**