@intentius/chant 0.18.19 → 0.18.21

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.
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
3
  import { takeSnapshot } from "../../lifecycle/snapshot";
4
- import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
4
+ import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
5
5
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
6
6
  import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
7
7
  import { buildChangeSet, renderChangeSet, gitlabMrReport, type ChangeSet } from "../../lifecycle/change-set";
@@ -29,6 +29,34 @@ function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
29
29
  return resolve(args.src ?? config.sourceDir ?? ".");
30
30
  }
31
31
 
32
+ /** One stack a lifecycle command operates on: its build root and, for a
33
+ * multi-stack project, the deployed CloudFormation stack name it observes
34
+ * against. */
35
+ interface StackTarget {
36
+ /** The deployed stack name to observe (undefined ⇒ single-stack convention:
37
+ * the stack named after the environment). */
38
+ stack?: string;
39
+ /** Build root to synthesize this stack from, scoped so its logical ids match
40
+ * what the stack actually deploys. */
41
+ root: string;
42
+ }
43
+
44
+ /**
45
+ * The stacks a lifecycle command (`snapshot`/`diff`) iterates. A multi-stack
46
+ * project declares them via `stacks` in chant.config (#932) — each built from
47
+ * its own `src` and observed against its own live stack `name`. A single-stack
48
+ * project (no `stacks`) resolves to one target built from `sourceDir`/root and
49
+ * observed as the stack named after the environment (unchanged behavior). An
50
+ * explicit `--src` always wins and forces a single scoped target.
51
+ */
52
+ function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
53
+ if (args.src) return [{ root: resolve(args.src) }];
54
+ if (config.stacks && config.stacks.length > 0) {
55
+ return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src) }));
56
+ }
57
+ return [{ root: resolveBuildRoot(args, config) }];
58
+ }
59
+
32
60
  /**
33
61
  * chant lifecycle snapshot <environment> [lexicon]
34
62
  */
@@ -59,13 +87,6 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
59
87
  : plugins;
60
88
  const targetSerializers = targetPlugins.map((p) => p.serializer);
61
89
 
62
- // Build first to get entity names and build output
63
- const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
64
- if (buildResult.errors.length > 0) {
65
- console.error(formatError({ message: "Build failed — fix errors before taking a snapshot" }));
66
- return 1;
67
- }
68
-
69
90
  const observingPlugins = targetPlugins.filter((p) => p.describeResources || p.listArtifacts);
70
91
  if (observingPlugins.length === 0) {
71
92
  console.error(formatError({
@@ -75,35 +96,55 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
75
96
  return 1;
76
97
  }
77
98
 
78
- let result;
79
- try {
80
- result = await takeSnapshot(environment, observingPlugins, buildResult);
81
- } catch (err) {
82
- if (err instanceof StaleLifecycleBranchError) {
83
- console.error(formatError({
84
- message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
85
- hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
86
- }));
87
- return 1;
99
+ // One target per stack (single-stack projects: exactly one). Each stack builds
100
+ // from its own scoped root — so its logical ids match what it deploys — and
101
+ // snapshots against its own live stack name (#932).
102
+ const targets = resolveStackTargets(args, config);
103
+ let anySnapshotSaved = false;
104
+ let anyHardError = false;
105
+
106
+ for (const target of targets) {
107
+ const label = target.stack ? `stack "${target.stack}"` : "project";
108
+ const buildResult = await build(target.root, targetSerializers);
109
+ if (buildResult.errors.length > 0) {
110
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
111
+ anyHardError = true;
112
+ continue;
88
113
  }
89
- throw err;
90
- }
91
114
 
92
- for (const w of result.warnings) {
93
- console.error(formatWarning({ message: w }));
94
- }
95
- for (const e of result.errors) {
96
- console.error(formatError({ message: e }));
97
- }
115
+ let result;
116
+ try {
117
+ result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
118
+ } catch (err) {
119
+ if (err instanceof StaleLifecycleBranchError) {
120
+ console.error(formatError({
121
+ message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
122
+ hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
123
+ }));
124
+ return 1;
125
+ }
126
+ throw err;
127
+ }
128
+
129
+ for (const w of result.warnings) {
130
+ console.error(formatWarning({ message: w }));
131
+ }
132
+ for (const e of result.errors) {
133
+ console.error(formatError({ message: e }));
134
+ }
98
135
 
99
- if (result.snapshots.length > 0) {
100
- const counts = result.snapshots
101
- .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
102
- .join(" ");
103
- console.error(formatSuccess(`Snapshot saved to chant/lifecycle (${counts})`));
136
+ if (result.snapshots.length > 0) {
137
+ anySnapshotSaved = true;
138
+ const prefix = target.stack ? `${target.stack}: ` : "";
139
+ const counts = result.snapshots
140
+ .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
141
+ .join(" ");
142
+ console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
143
+ }
144
+ if (result.errors.length > 0) anyHardError = true;
104
145
  }
105
146
 
106
- return result.errors.length > 0 && result.snapshots.length === 0 ? 1 : 0;
147
+ return anyHardError && !anySnapshotSaved ? 1 : 0;
107
148
  }
108
149
 
109
150
  /**
@@ -209,26 +250,68 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
209
250
  ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
210
251
  : serializers;
211
252
 
212
- // Build to get current state (from the configured source root, not necessarily ".")
253
+ // Fetch previous snapshots once (all stacks share the orphan branch).
213
254
  const { config } = await loadChantConfig(resolve("."));
214
- const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
215
- if (buildResult.errors.length > 0) {
216
- console.error(formatError({ message: "Build failed — fix errors before diffing" }));
217
- return 1;
218
- }
219
-
220
- // Fetch and read previous snapshot
221
255
  await fetchLifecycle();
222
256
 
223
- const lexicons = lexiconFilter
224
- ? [lexiconFilter]
225
- : Array.from(buildResult.manifest.lexicons);
257
+ // One target per stack (single-stack projects: exactly one), each built from
258
+ // its own scoped root and diffed against its own live stack name (#932).
259
+ const targets = resolveStackTargets(args, config);
260
+ const json = !!args.json;
261
+ const perStackJson: Record<string, unknown> = {};
262
+ let combinedLexiconsJson: Record<string, unknown> | undefined;
263
+ let totalDrift = 0;
264
+ let totalChecked = 0;
265
+ let anyBuildError = false;
266
+
267
+ for (const target of targets) {
268
+ const buildResult = await build(target.root, targetSerializers);
269
+ if (buildResult.errors.length > 0) {
270
+ const label = target.stack ? `stack "${target.stack}"` : "project";
271
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
272
+ anyBuildError = true;
273
+ continue;
274
+ }
275
+
276
+ const lexicons = lexiconFilter
277
+ ? [lexiconFilter]
278
+ : Array.from(buildResult.manifest.lexicons);
279
+
280
+ if (args.live) {
281
+ const r = await runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json, stack: target.stack });
282
+ totalDrift += r.totalDrift;
283
+ totalChecked += r.totalLexiconsChecked;
284
+ if (json) {
285
+ if (target.stack) perStackJson[target.stack] = r.byLexicon;
286
+ else combinedLexiconsJson = r.byLexicon;
287
+ }
288
+ } else {
289
+ await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
290
+ }
291
+ }
226
292
 
227
293
  if (args.live) {
228
- return runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json: !!args.json });
294
+ if (json) {
295
+ // Single-stack keeps the original `{ environment, lexicons }` shape
296
+ // (behold's inspect diff, #852); multi-stack nests under `stacks`.
297
+ console.log(
298
+ JSON.stringify(
299
+ combinedLexiconsJson !== undefined
300
+ ? { environment, lexicons: combinedLexiconsJson }
301
+ : { environment, stacks: perStackJson },
302
+ ),
303
+ );
304
+ } else if (totalChecked === 0) {
305
+ console.error(formatWarning({
306
+ message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
307
+ }));
308
+ return 1;
309
+ } else if (totalDrift === 0) {
310
+ console.error(formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`));
311
+ }
229
312
  }
230
313
 
231
- return runLifecycleDiffDigest({ environment, lexicons, buildResult });
314
+ return anyBuildError ? 1 : 0;
232
315
  }
233
316
 
234
317
  interface BetweenDiffArgs {
@@ -300,13 +383,16 @@ interface DigestDiffArgs {
300
383
  environment: string;
301
384
  lexicons: string[];
302
385
  buildResult: BuildResult;
386
+ /** Deployed stack name for a multi-stack project (#932); scopes the snapshot read. */
387
+ stack?: string;
303
388
  }
304
389
 
305
390
  async function runLifecycleDiffDigest(args: DigestDiffArgs): Promise<number> {
306
391
  const currentDigest = computeBuildDigest(args.buildResult);
392
+ if (args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
307
393
 
308
394
  for (const lexicon of args.lexicons) {
309
- const content = await readSnapshot(args.environment, lexicon);
395
+ const content = await readSnapshot(args.environment, snapshotStorageKey(lexicon, args.stack));
310
396
  let previousDigest = undefined;
311
397
  if (content) {
312
398
  const snapshot: LifecycleSnapshot = JSON.parse(content);
@@ -343,17 +429,28 @@ interface LiveDiffArgs {
343
429
  buildResult: BuildResult;
344
430
  /** Emit machine-readable JSON on stdout instead of the human report (#852). */
345
431
  json: boolean;
432
+ /** Deployed stack name for a multi-stack project (#932); scopes the live
433
+ * observation (which CloudFormation stack to query) and the snapshot read. */
434
+ stack?: string;
346
435
  }
347
436
 
348
- async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
349
- let totalDrift = 0;
350
- let totalLexiconsChecked = 0;
351
- // Collected per-lexicon results, emitted as one JSON object when --json is set
352
- // (parsed by programmatic consumers, e.g. behold's inspect diff — #852).
353
- const byLexicon: Record<
437
+ interface LiveDiffOutcome {
438
+ byLexicon: Record<
354
439
  string,
355
440
  { resources?: LiveDiffResult; observed?: Record<string, ResourceMetadata>; artifacts?: LiveArtifactDiffResult }
356
- > = {};
441
+ >;
442
+ totalDrift: number;
443
+ totalLexiconsChecked: number;
444
+ }
445
+
446
+ /** Diff current build vs live cloud for one stack. Renders the human report
447
+ * inline; returns per-lexicon results so the caller emits the aggregate `--json`
448
+ * once (single-stack keeps the original shape; multi-stack nests under `stacks`). */
449
+ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome> {
450
+ let totalDrift = 0;
451
+ let totalLexiconsChecked = 0;
452
+ const byLexicon: LiveDiffOutcome["byLexicon"] = {};
453
+ if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
357
454
 
358
455
  for (const lexiconName of args.lexicons) {
359
456
  const plugin = args.plugins.find((p) => p.name === lexiconName);
@@ -391,7 +488,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
391
488
 
392
489
  // Read previous snapshot once; both flows pull what they need.
393
490
  let prevSnapshot: LifecycleSnapshot | undefined;
394
- const content = await readSnapshot(args.environment, lexiconName);
491
+ const content = await readSnapshot(args.environment, snapshotStorageKey(lexiconName, args.stack));
395
492
  if (content) prevSnapshot = JSON.parse(content);
396
493
 
397
494
  let lexiconChecked = false;
@@ -405,6 +502,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
405
502
  buildOutput,
406
503
  entityNames: Array.from(declared),
407
504
  entities,
505
+ stack: args.stack,
408
506
  });
409
507
  } catch (err) {
410
508
  console.error(formatError({
@@ -427,7 +525,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
427
525
  if (plugin.listArtifacts) {
428
526
  let observedNow: Record<string, ArtifactMetadata>;
429
527
  try {
430
- observedNow = await plugin.listArtifacts({ environment: args.environment, entities });
528
+ observedNow = await plugin.listArtifacts({ environment: args.environment, entities, stack: args.stack });
431
529
  } catch (err) {
432
530
  console.error(formatError({
433
531
  message: `${lexiconName}: listArtifacts failed — ${err instanceof Error ? err.message : String(err)}`,
@@ -445,23 +543,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
445
543
  if (lexiconChecked) totalLexiconsChecked++;
446
544
  }
447
545
 
448
- if (totalLexiconsChecked === 0) {
449
- console.error(formatWarning({
450
- message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
451
- }));
452
- return 1;
453
- }
454
-
455
- if (args.json) {
456
- console.log(JSON.stringify({ environment: args.environment, lexicons: byLexicon }));
457
- return 0;
458
- }
459
-
460
- if (totalDrift === 0) {
461
- console.error(formatSuccess(`No drift detected across ${totalLexiconsChecked} lexicon(s)`));
462
- }
463
-
464
- return 0;
546
+ return { byLexicon, totalDrift, totalLexiconsChecked };
465
547
  }
466
548
 
467
549
  function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
@@ -160,7 +160,7 @@ export async function computeComponentGraph(path: string): Promise<ComponentGrap
160
160
  }
161
161
  }
162
162
 
163
- /** A generate-mode target lexicon name — any lexicon whose plugin implements `generateComponentPipeline` (currently gitlab; github/forgejo as they add it). */
163
+ /** A generate-mode target lexicon name — any lexicon whose plugin implements `generateComponentPipeline` (gitlab, github, and forgejo today). */
164
164
  export type GenerateLexicon = string;
165
165
 
166
166
  /** Result of `chant build --components --generate <lexicon>`. */
@@ -213,7 +213,7 @@ export async function generateComponentsPipeline(
213
213
  if (!plugin?.generateComponentPipeline) {
214
214
  return {
215
215
  success: false,
216
- error: `Lexicon "${lexicon}" does not support generate mode (no generateComponentPipeline). GitLab is supported today; GitHub/Forgejo as they add it.`,
216
+ error: `Lexicon "${lexicon}" does not support generate mode (no generateComponentPipeline). GitLab, GitHub, and Forgejo are supported today.`,
217
217
  };
218
218
  }
219
219
 
package/src/config.ts CHANGED
@@ -82,6 +82,23 @@ export interface ChantConfig {
82
82
  */
83
83
  sourceDir?: string;
84
84
 
85
+ /**
86
+ * Multi-stack projects: the independently-deployed CloudFormation stacks this
87
+ * project comprises, each built from its own source directory. When set,
88
+ * lifecycle commands (`snapshot`/`diff`) iterate every stack — building each
89
+ * `src` scoped (so its logical ids match what that stack actually deploys) and
90
+ * observing it against its own live stack `name` — instead of assuming one
91
+ * stack per environment. Leave unset for a single-stack project (the default:
92
+ * one build from `sourceDir`/root, observed as the stack named after the
93
+ * environment). See {@link resolveStackTargets}.
94
+ */
95
+ stacks?: Array<{
96
+ /** The deployed CloudFormation stack name (what `cfn-deploy` targets). */
97
+ name: string;
98
+ /** Source directory to build for this stack, relative to the project root. */
99
+ src: string;
100
+ }>;
101
+
85
102
  /** Lint configuration (rules, extends, overrides, plugins) */
86
103
  lint?: LintConfig;
87
104
 
@@ -280,6 +280,97 @@ describe("collectEntities", () => {
280
280
  });
281
281
  });
282
282
 
283
+ // #932 — a multi-stack project (independently-deployed sibling stacks under one
284
+ // root) legitimately reuses conventional cross-stack Parameter names across
285
+ // siblings. The unscoped whole-project build must namespace by directory instead
286
+ // of throwing, while a per-stack scoped build keeps the raw (deployed) names.
287
+ describe("collectEntities — cross-directory namespaces (#932)", () => {
288
+ test("the same bare name in two different directories is disambiguated by a stack prefix, not thrown", () => {
289
+ const agentsBucket = createMockEntity("param");
290
+ const backendBucket = createMockEntity("param");
291
+
292
+ const result = collectEntities(
293
+ [
294
+ { file: "src/loom-agents/params.ts", exports: { pArtifactBucket: agentsBucket } },
295
+ { file: "src/loom-backend/params.ts", exports: { pArtifactBucket: backendBucket } },
296
+ ],
297
+ "src",
298
+ );
299
+
300
+ expect(result.size).toBe(2);
301
+ expect(result.get("LoomAgentspArtifactBucket")).toBe(agentsBucket);
302
+ expect(result.get("LoomBackendpArtifactBucket")).toBe(backendBucket);
303
+ // The raw name is not used when it collides across directories.
304
+ expect(result.has("pArtifactBucket")).toBe(false);
305
+ // Disambiguated keys stay within CloudFormation's logical-id grammar.
306
+ for (const key of result.keys()) expect(key).toMatch(/^[A-Za-z0-9]+$/);
307
+ });
308
+
309
+ test("a genuine same-directory duplicate still throws", async () => {
310
+ const a = createMockEntity("param");
311
+ const b = createMockEntity("param");
312
+
313
+ await expectToThrow(
314
+ () =>
315
+ collectEntities(
316
+ [
317
+ { file: "src/loom-backend/params.ts", exports: { pArtifactBucket: a } },
318
+ { file: "src/loom-backend/more.ts", exports: { pArtifactBucket: b } },
319
+ ],
320
+ "src",
321
+ ),
322
+ DiscoveryError,
323
+ (error) => {
324
+ expect(error.type).toBe("resolution");
325
+ expect(error.message).toBe('Duplicate export name "pArtifactBucket" found');
326
+ },
327
+ );
328
+ });
329
+
330
+ test("the same object re-exported from two directories stays one raw-named entity", () => {
331
+ const shared = createMockEntity("param");
332
+
333
+ const result = collectEntities(
334
+ [
335
+ { file: "src/a/x.ts", exports: { shared } },
336
+ { file: "src/b/y.ts", exports: { shared } },
337
+ ],
338
+ "src",
339
+ );
340
+
341
+ expect(result.size).toBe(1);
342
+ expect(result.get("shared")).toBe(shared);
343
+ });
344
+
345
+ test("non-colliding names in different directories keep their raw names (single-stack subdirs unaffected)", () => {
346
+ const vpc = createMockEntity("resource");
347
+ const app = createMockEntity("resource");
348
+
349
+ const result = collectEntities(
350
+ [
351
+ { file: "src/network/vpc.ts", exports: { vpc } },
352
+ { file: "src/compute/app.ts", exports: { app } },
353
+ ],
354
+ "src",
355
+ );
356
+
357
+ expect(result.size).toBe(2);
358
+ expect(result.get("vpc")).toBe(vpc);
359
+ expect(result.get("app")).toBe(app);
360
+ });
361
+
362
+ test("a scoped per-stack build (buildRoot == the stack dir) keeps raw names — deploy fidelity", () => {
363
+ const bucket = createMockEntity("param");
364
+
365
+ const result = collectEntities(
366
+ [{ file: "src/loom-backend/params.ts", exports: { pArtifactBucket: bucket } }],
367
+ "src/loom-backend",
368
+ );
369
+
370
+ expect(result.get("pArtifactBucket")).toBe(bucket);
371
+ });
372
+ });
373
+
283
374
  describe("collectEntities with composites", () => {
284
375
  beforeEach(() => {
285
376
  CompositeRegistry.clear();