@intentius/chant 0.45.0 → 0.46.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 (74) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  5. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  6. package/dist/cli/commands/lint.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +7 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/codegen/lexicon-regen.d.ts +11 -0
  14. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  15. package/dist/codegen/validate.d.ts +10 -0
  16. package/dist/codegen/validate.d.ts.map +1 -1
  17. package/dist/config.d.ts +28 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/env.d.ts +12 -1
  20. package/dist/env.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +182 -2
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/index.d.ts +1 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/teardown.d.ts +130 -0
  26. package/dist/lifecycle/teardown.d.ts.map +1 -0
  27. package/dist/lint/engine.d.ts +6 -2
  28. package/dist/lint/engine.d.ts.map +1 -1
  29. package/dist/lint/rule.d.ts +31 -0
  30. package/dist/lint/rule.d.ts.map +1 -1
  31. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  32. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  33. package/dist/lint/rules/index.d.ts +2 -1
  34. package/dist/lint/rules/index.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +36 -7
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +1 -1
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/testing.d.ts +136 -0
  40. package/dist/testing.d.ts.map +1 -0
  41. package/package.json +6 -1
  42. package/src/cli/commands/build.test.ts +131 -0
  43. package/src/cli/commands/build.ts +20 -0
  44. package/src/cli/commands/check-lexicon.test.ts +45 -1
  45. package/src/cli/commands/check-lexicon.ts +45 -0
  46. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  47. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  48. package/src/cli/commands/lint.ts +19 -6
  49. package/src/cli/handlers/graph.ts +2 -2
  50. package/src/cli/handlers/lifecycle.test.ts +231 -1
  51. package/src/cli/handlers/lifecycle.ts +219 -3
  52. package/src/cli/handlers/search.ts +5 -2
  53. package/src/cli/main.ts +12 -1
  54. package/src/cli/registry.ts +7 -0
  55. package/src/codegen/lexicon-regen.ts +19 -1
  56. package/src/codegen/validate.test.ts +33 -0
  57. package/src/codegen/validate.ts +21 -2
  58. package/src/config.test.ts +40 -0
  59. package/src/config.ts +58 -1
  60. package/src/env.test.ts +35 -1
  61. package/src/env.ts +17 -3
  62. package/src/lexicon.ts +182 -2
  63. package/src/lifecycle/index.ts +1 -0
  64. package/src/lifecycle/teardown.test.ts +537 -0
  65. package/src/lifecycle/teardown.ts +357 -0
  66. package/src/lint/engine.ts +7 -1
  67. package/src/lint/rule.ts +23 -0
  68. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  69. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  70. package/src/lint/rules/index.ts +4 -1
  71. package/src/op/builders.ts +40 -7
  72. package/src/op/index.ts +1 -1
  73. package/src/testing.test.ts +261 -0
  74. package/src/testing.ts +338 -0
package/src/testing.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * `@intentius/chant/testing` (#1224) — the live-stack test harness.
3
+ *
4
+ * A vitest suite deploys a real instance of its project once, asserts against
5
+ * it, and tears it down — emulator-locally on a dev machine, real-cloud in CI,
6
+ * with the same suite text. One call composes machinery that already exists:
7
+ *
8
+ * - **build** — the same programmatic path `chant build` takes: config
9
+ * resolution (`buildParams` + the ownership marker) mirrored from the CLI,
10
+ * then `build()` over the project's own serializers.
11
+ * - **apply** — an additive `nativeApply` per built lexicon output, run
12
+ * in-process through the local Op executor (`runOpLocally`). `deleteMode`
13
+ * is `"never"`: a test deploy creates and updates, nothing else.
14
+ * - **destroy** — #1222's marker-scoped teardown (`executeTeardown`), called
15
+ * in-process for exactly this suite's environment. Stateless by design:
16
+ * a crashed suite's environment is recovered by calling destroy again (or
17
+ * `chant lifecycle teardown <env> --yes`).
18
+ *
19
+ * ## Isolation
20
+ *
21
+ * Every deploy targets its own environment: `test-<suite>-<nonce>` by default
22
+ * ({@link testEnvName}), so parallel CI jobs never collide. The environment
23
+ * is the teardown key — everything the deploy stamps carries the ownership
24
+ * marker `{ stack, env }`, and `destroy()` selects on that identity and
25
+ * nothing else. A project that declares `environments` must legalize the
26
+ * dynamic names with a pattern entry (#1221):
27
+ *
28
+ * ```ts
29
+ * environments: ["dev", "prod", { name: "test-*", endpoint: "http://localhost:4566" }]
30
+ * ```
31
+ *
32
+ * ## Emulators
33
+ *
34
+ * The harness is emulator-aware the same way `--live` reads are (#1166): the
35
+ * target environment's declared `endpoint` is injected into each lexicon's
36
+ * ambient endpoint variable (`AWS_ENDPOINT_URL`, …) for the apply and the
37
+ * teardown — unless the variable is already set. Ambient always wins, so the
38
+ * env vars `chant emulator up --json` reports (#920) take precedence when
39
+ * exported, and a CI job pointing the identical suite at a real account just
40
+ * leaves both unset.
41
+ */
42
+
43
+ import { basename, join, resolve } from "node:path";
44
+ import { mkdtempSync, writeFileSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { build } from "./build";
47
+ import type { Declarable } from "./declarable";
48
+ import type { SerializerResult } from "./serializer";
49
+ import {
50
+ loadChantConfigUpward,
51
+ resolveOwnershipStack,
52
+ type ChantConfig,
53
+ } from "./config";
54
+ import { resolveBuildParams, type BuildParamValue } from "./build-params";
55
+ import { ENV_VAR, unknownEnvError } from "./env";
56
+ import { applyLiveEndpoint } from "./live-endpoint";
57
+ import { loadPlugins, resolveProjectLexicons, collectBuildRootContributors } from "./cli/plugins";
58
+ import type { LexiconPlugin } from "./lexicon";
59
+ import type { OwnershipMarker } from "./ownership";
60
+ import { runOpLocally } from "./op/local-executor";
61
+ import { loadActivities, loadProfiles, type ActivityFn, type ActivityProfile } from "./op/activity-registry";
62
+ import type { OpConfig, ActivityStep } from "./op/types";
63
+ import { executeTeardown, type TeardownReport } from "./lifecycle/teardown";
64
+
65
+ /**
66
+ * Which `nativeApply` target deploys each lexicon's built output. Only these
67
+ * lexicons have a native apply mechanism; an output from any other lexicon
68
+ * (helm values, a CI pipeline file, …) is returned to the caller but not
69
+ * applied — deploying it is not what a live-stack test means by "deploy".
70
+ */
71
+ const APPLY_TARGETS: Record<string, string> = {
72
+ aws: "cloudformation",
73
+ k8s: "kubectl",
74
+ azure: "arm",
75
+ gcp: "gcp",
76
+ fly: "fly",
77
+ };
78
+
79
+ export interface DeployStackOptions {
80
+ /** The chant project directory (where the infra source lives). */
81
+ dir: string;
82
+ /**
83
+ * Explicit environment name. Overrides the default `test-<suite>-<nonce>`
84
+ * derivation — for a shared long-lived test environment, or a test that
85
+ * needs a deterministic name. Must be legal for the project's declared
86
+ * `environments`, exactly as `--env` would be.
87
+ */
88
+ env?: string;
89
+ /**
90
+ * Suite name folded into the default environment name. Defaults to the
91
+ * project directory's basename.
92
+ */
93
+ suite?: string;
94
+ /** Build parameters, exactly as `--param name=value` flags would supply them. */
95
+ params?: Record<string, BuildParamValue>;
96
+ /**
97
+ * Loaded lexicon plugins. Defaults to loading the project's own declared/
98
+ * detected lexicons — pass this only to substitute test doubles.
99
+ */
100
+ plugins?: LexiconPlugin[];
101
+ /** Activity implementations for the apply. Defaults to the real registry. */
102
+ activities?: Map<string, ActivityFn>;
103
+ /** Activity profiles (timeouts/retries). Defaults to the lexicon-declared table. */
104
+ profiles?: Record<string, ActivityProfile>;
105
+ /**
106
+ * Extra lexicon-name → `nativeApply` target entries, merged over the
107
+ * built-in map (aws → cloudformation, k8s → kubectl, azure → arm,
108
+ * gcp → gcp, fly → fly). A seam for tests and out-of-tree lexicons.
109
+ */
110
+ applyTargets?: Record<string, string>;
111
+ }
112
+
113
+ /** The handle a suite holds between `beforeAll` and `afterAll`. */
114
+ export interface DeployedStack {
115
+ /** The built outputs, keyed by lexicon — what was deployed. */
116
+ outputs: Map<string, string | SerializerResult>;
117
+ /** The discovered entities, keyed by name. */
118
+ entities: Map<string, Declarable>;
119
+ /** The environment this deploy targeted — the teardown key. */
120
+ env: string;
121
+ /**
122
+ * Tear down everything carrying this suite's marker `{ stack, env }`.
123
+ * Throws {@link TeardownIncompleteError} when any candidate failed to
124
+ * delete or the plan had holes — an environment that cannot be called
125
+ * clean is a test failure, never a silent leak. Safe to call again:
126
+ * teardown is stateless, and a second call over a clean environment
127
+ * plans nothing.
128
+ */
129
+ destroy(): Promise<TeardownReport>;
130
+ }
131
+
132
+ /**
133
+ * Thrown by {@link DeployedStack.destroy} when the environment cannot be
134
+ * called clean: candidates still `failed` after the retry pass, or the plan
135
+ * had holes (#1089 — parts of the estate could not be read, so "nothing
136
+ * left" would be a claim about what was readable, not about the environment).
137
+ * Carries the full report for diagnosis.
138
+ */
139
+ export class TeardownIncompleteError extends Error {
140
+ constructor(public readonly report: TeardownReport) {
141
+ const failed = report.outcomes.filter((o) => o.outcome === "failed");
142
+ const parts: string[] = [];
143
+ if (failed.length > 0) {
144
+ parts.push(
145
+ `${failed.length} candidate(s) failed to delete: ` +
146
+ failed.map((o) => `${o.lexicon}/${o.name}${o.detail ? ` (${o.detail})` : ""}`).join(", "),
147
+ );
148
+ }
149
+ if (report.plan.holes.length > 0) {
150
+ parts.push(
151
+ `${report.plan.holes.length} hole(s) — resources chant may own but could not read: ` +
152
+ report.plan.holes.map((h) => `${h.lexicon}/${h.name} (${h.reason})`).join(", "),
153
+ );
154
+ }
155
+ super(`teardown of "${report.environment}" is incomplete — ${parts.join("; ")}`);
156
+ this.name = "TeardownIncompleteError";
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The default environment name for one suite run: `test-<suite>-<nonce>`.
162
+ * The `test-` prefix is what a project's `"test-*"` pattern entry (#1221)
163
+ * legalizes; the nonce keeps parallel CI jobs of the same suite apart.
164
+ */
165
+ export function testEnvName(suite: string): string {
166
+ const slug =
167
+ suite
168
+ .toLowerCase()
169
+ .replace(/[^a-z0-9]+/g, "-")
170
+ .replace(/^-+|-+$/g, "")
171
+ .slice(0, 24)
172
+ .replace(/-+$/, "") || "suite";
173
+ const nonce = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
174
+ return `test-${slug}-${nonce}`;
175
+ }
176
+
177
+ /** JSON output starts with a brace/bracket; everything else is written as YAML. */
178
+ function outputExtension(content: string): string {
179
+ const head = content.trimStart();
180
+ return head.startsWith("{") || head.startsWith("[") ? ".json" : ".yaml";
181
+ }
182
+
183
+ /**
184
+ * Write each lexicon's built output to a scratch directory and return the
185
+ * primary file path per lexicon. Secondary files (a multi-file serializer
186
+ * result) land beside the primary under their own names.
187
+ */
188
+ function writeOutputs(outputs: Map<string, string | SerializerResult>, env: string): Map<string, string> {
189
+ const dir = mkdtempSync(join(tmpdir(), `chant-testing-${env}-`));
190
+ const paths = new Map<string, string>();
191
+ for (const [lexicon, output] of outputs) {
192
+ const primary = typeof output === "string" ? output : output.primary;
193
+ const path = join(dir, `${lexicon}${outputExtension(primary)}`);
194
+ writeFileSync(path, primary);
195
+ if (typeof output !== "string" && output.files) {
196
+ for (const [name, content] of Object.entries(output.files)) {
197
+ writeFileSync(join(dir, basename(name)), content);
198
+ }
199
+ }
200
+ paths.set(lexicon, path);
201
+ }
202
+ return paths;
203
+ }
204
+
205
+ /**
206
+ * Deploy a live instance of the project in `dir` for one test suite.
207
+ *
208
+ * Build (the CLI's own config resolution: `buildParams`, ownership marker,
209
+ * build roots) + additive apply (one `nativeApply` per built output with a
210
+ * native mechanism, via the local Op executor). Returns the handle the suite
211
+ * holds: outputs, entities, the environment name, and `destroy`.
212
+ *
213
+ * The ownership marker stamped on every resource is `{ stack: ownership.stack,
214
+ * env: <this deploy's environment> }` — the marker env always follows the
215
+ * suite environment, whatever `ownership.env` in config says, because the
216
+ * marker is the only thing `destroy()` selects on. A project with no
217
+ * `ownership.stack` is refused up front: a deploy that stamps nothing is a
218
+ * deploy nothing can sweep.
219
+ *
220
+ * Failures are thrown, never returned: build errors, unresolved parameters,
221
+ * an illegal environment name, and a failed apply (the local executor's
222
+ * `OpRunFailure`) all reject the returned promise.
223
+ */
224
+ export async function deployStack(options: DeployStackOptions): Promise<DeployedStack> {
225
+ const dir = resolve(options.dir);
226
+ const { config, configPath } = await loadChantConfigUpward(dir);
227
+
228
+ const env = options.env ?? testEnvName(options.suite ?? basename(dir));
229
+ const envError = unknownEnvError(env, config.environments);
230
+ if (envError) {
231
+ throw new Error(
232
+ `${envError} The harness derives per-run environment names — declare a "test-*" pattern entry ` +
233
+ `in chant.config.ts's environments to legalize them (#1221).`,
234
+ );
235
+ }
236
+
237
+ const stack = resolveOwnershipStack(config);
238
+ if (stack === undefined) {
239
+ throw new Error(
240
+ `${dir}: this project declares no ownership.stack — the harness's destroy() is marker-scoped ` +
241
+ `and would have nothing to select on. Set ownership: { stack: "<name>" } in chant.config.ts.`,
242
+ );
243
+ }
244
+ const marker: OwnershipMarker = { stack, env };
245
+
246
+ const plugins = options.plugins ?? (await loadPlugins(await resolveProjectLexicons(dir)));
247
+
248
+ // Build parameters, resolved as `chant build --param ...` resolves them.
249
+ // A project whose config maps `env` to a build parameter gets this deploy's
250
+ // environment automatically, unless the caller passed one explicitly.
251
+ const cli: Record<string, string> = {};
252
+ for (const [name, value] of Object.entries(options.params ?? {})) cli[name] = String(value);
253
+ if (config.buildParams && "env" in config.buildParams && !("env" in cli)) cli.env = env;
254
+ const resolution = resolveBuildParams(config.buildParams, { cli, env: process.env });
255
+ if (resolution.errors.length > 0) {
256
+ throw new Error(`${dir}: build parameters did not resolve —\n ${resolution.errors.join("\n ")}`);
257
+ }
258
+
259
+ const projectRoot = configPath ? resolve(configPath, "..") : dir;
260
+ const configRecord = config as unknown as Record<string, unknown>;
261
+
262
+ // Env-aware source branches on env() (#505) during discovery — scope the
263
+ // variable to the build and restore whatever the shell had.
264
+ const priorEnv = process.env[ENV_VAR];
265
+ let result;
266
+ try {
267
+ process.env[ENV_VAR] = env;
268
+ result = await build(dir, plugins.map((p) => p.serializer), undefined, {
269
+ ownership: marker,
270
+ config: configRecord,
271
+ lexicons: plugins.map((p) => p.name),
272
+ intrinsics: plugins.flatMap((p) => p.intrinsics?.() ?? []),
273
+ buildParams: resolution.provenance,
274
+ buildRoots: collectBuildRootContributors(plugins, configRecord, projectRoot),
275
+ });
276
+ } finally {
277
+ if (priorEnv === undefined) delete process.env[ENV_VAR];
278
+ else process.env[ENV_VAR] = priorEnv;
279
+ }
280
+ if (result.errors.length > 0) {
281
+ const messages = result.errors.map((e) => e.message);
282
+ throw new Error(`${dir}: build failed —\n ${messages.join("\n ")}`);
283
+ }
284
+
285
+ // One additive apply step per built output with a native mechanism.
286
+ const targets = { ...APPLY_TARGETS, ...options.applyTargets };
287
+ const paths = writeOutputs(result.outputs, env);
288
+ const steps: ActivityStep[] = [];
289
+ for (const [lexicon, path] of paths) {
290
+ const target = targets[lexicon];
291
+ if (!target) continue;
292
+ steps.push({
293
+ kind: "activity",
294
+ fn: "nativeApply",
295
+ args: { target, env, output: path, deleteMode: "never" },
296
+ profile: "longInfra",
297
+ });
298
+ }
299
+ if (steps.length === 0) {
300
+ throw new Error(
301
+ `${dir}: nothing to deploy — no built output has a native apply target ` +
302
+ `(built: ${[...result.outputs.keys()].join(", ") || "none"}; targets exist for: ${Object.keys(targets).join(", ")}).`,
303
+ );
304
+ }
305
+ const op: OpConfig = {
306
+ name: `deploy-${env}`,
307
+ overview: `test harness deploy of ${dir} into ${env}`,
308
+ phases: [{ name: "Deploy", steps }],
309
+ };
310
+
311
+ const activities = options.activities ?? (await loadActivities(plugins.map((p) => p.name)));
312
+ const profiles = options.profiles ?? (await loadProfiles());
313
+
314
+ // The environment's declared endpoint (#1166) applies to the deploy the way
315
+ // it applies to a --live read — and ambient always wins, so exported
316
+ // `chant emulator up --json` vars take precedence.
317
+ const applied = applyLiveEndpoint(config.environments, env, plugins);
318
+ try {
319
+ await runOpLocally(op, activities, profiles);
320
+ } finally {
321
+ applied.restore();
322
+ }
323
+
324
+ const destroy = async (): Promise<TeardownReport> => {
325
+ const endpointForTeardown = applyLiveEndpoint(config.environments, env, plugins);
326
+ let report: TeardownReport;
327
+ try {
328
+ report = await executeTeardown({ environment: env, stack, plugins });
329
+ } finally {
330
+ endpointForTeardown.restore();
331
+ }
332
+ const failed = report.outcomes.some((o) => o.outcome === "failed");
333
+ if (failed || report.plan.holes.length > 0) throw new TeardownIncompleteError(report);
334
+ return report;
335
+ };
336
+
337
+ return { outputs: result.outputs, entities: result.entities, env, destroy };
338
+ }