@intentius/chant 0.18.20 → 0.18.22

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,9 +1,9 @@
1
- import { basename } from "node:path";
1
+ import { basename, dirname, relative, resolve } from "node:path";
2
2
  import { isDeclarable, type Declarable } from "../declarable";
3
3
  import { isCompositeInstance, expandComposite } from "../composite";
4
4
  import { isLexiconOutput } from "../lexicon-output";
5
5
  import { DiscoveryError } from "../errors";
6
- import { setProvenance } from "../provenance";
6
+ import { setProvenance, type EntityProvenance } from "../provenance";
7
7
 
8
8
  /**
9
9
  * The entity key for an export. `export default` is per-module — the `Op` pattern
@@ -18,84 +18,177 @@ function exportKey(rawName: string, file: string): string {
18
18
  }
19
19
 
20
20
  /**
21
- * Collects all declarable entities from imported modules.
22
- * CompositeInstance exports are expanded into individual entities
23
- * with `{exportName}_{memberName}` naming.
24
- * LexiconOutput exports are also collected so that build() can
25
- * extract them and pass them to the serializer.
26
- *
27
- * @param modules - Array of module records with their exports
28
- * @returns Map of export name to Declarable entity
29
- * @throws {DiscoveryError} with type "resolution" if duplicate export names are found
21
+ * A stable, CloudFormation-valid prefix identifying the stack directory a file
22
+ * belongs to, relative to the build root. Used to disambiguate an entity name
23
+ * that legitimately repeats across sibling stack directories (see
24
+ * {@link collectEntities}). Derived from the path *relative to the build root*
25
+ * (not the absolute path) so the resulting key — and therefore any build digest
26
+ * that hashes it — is portable across machines and checkouts. Punctuation
27
+ * (`/`, `-`, `.`) is dropped and each segment PascalCased, keeping the key
28
+ * within CloudFormation's `^[A-Za-z0-9]+$` logical-id grammar.
30
29
  */
31
- export function collectEntities(
32
- modules: Array<{ file: string; exports: Record<string, unknown> }>
33
- ): Map<string, Declarable> {
34
- const entities = new Map<string, Declarable>();
30
+ function stackPrefix(file: string, buildRoot: string | undefined): string {
31
+ const dir = dirname(file);
32
+ const rel = buildRoot ? relative(resolve(buildRoot), resolve(dir)) : dir;
33
+ const segments = rel.split(/[^A-Za-z0-9]+/).filter((s) => s.length > 0);
34
+ return segments.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
35
+ }
36
+
37
+ /** One entity to be placed into the map, produced by {@link enumerateEntries}. */
38
+ interface PendingEntry {
39
+ /** The un-disambiguated map key this entity would take (export name, indexed
40
+ * array name, or composite-expanded member name). */
41
+ bareKey: string;
42
+ value: Declarable;
43
+ file: string;
44
+ provenance: EntityProvenance;
45
+ }
46
+
47
+ /**
48
+ * Flatten every module's exports into the ordered list of entities they
49
+ * contribute — declarables directly, arrays element-by-element (indexed names),
50
+ * composite instances expanded into members, and LexiconOutputs. Order is
51
+ * preserved so downstream serializers emit resources in a stable order.
52
+ */
53
+ function enumerateEntries(
54
+ modules: Array<{ file: string; exports: Record<string, unknown> }>,
55
+ ): PendingEntry[] {
56
+ const entries: PendingEntry[] = [];
35
57
 
36
58
  for (const { file, exports } of modules) {
37
59
  for (const [rawName, value] of Object.entries(exports)) {
38
60
  const name = exportKey(rawName, file);
39
61
  if (isDeclarable(value)) {
40
- if (entities.has(name)) {
41
- // Same object re-exported from multiple files (e.g. re-exports from multiple files) is fine
42
- if (entities.get(name) !== value) {
43
- throw new DiscoveryError(
44
- file,
45
- `Duplicate export name "${name}" found`,
46
- "resolution"
47
- );
48
- }
49
- } else {
50
- setProvenance(value, { sourceFile: file });
51
- entities.set(name, value);
52
- }
62
+ entries.push({ bareKey: name, value, file, provenance: { sourceFile: file } });
53
63
  } else if (Array.isArray(value)) {
54
- // Arrays of Declarables or CompositeInstances — each element gets an indexed name: exportName_0, ...
55
64
  for (let i = 0; i < value.length; i++) {
56
65
  const item = value[i];
57
66
  if (isDeclarable(item)) {
58
- const indexedName = `${name}_${i}`;
59
- if (entities.has(indexedName) && entities.get(indexedName) !== item) {
60
- throw new DiscoveryError(file, `Duplicate entity name "${indexedName}"`, "resolution");
61
- }
62
- setProvenance(item, { sourceFile: file });
63
- entities.set(indexedName, item);
67
+ entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file } });
64
68
  } else if (isCompositeInstance(item)) {
65
69
  const indexedName = `${name}_${i}`;
66
- const expanded = expandComposite(indexedName, item);
67
- for (const [expandedName, entity] of expanded) {
68
- if (entities.has(expandedName)) {
69
- throw new DiscoveryError(
70
- file,
71
- `Duplicate entity name "${expandedName}" from composite expansion of "${indexedName}"`,
72
- "resolution",
73
- );
74
- }
75
- setProvenance(entity, { sourceFile: file, compositeInstance: indexedName });
76
- entities.set(expandedName, entity);
70
+ for (const [expandedName, entity] of expandComposite(indexedName, item)) {
71
+ entries.push({
72
+ bareKey: expandedName,
73
+ value: entity,
74
+ file,
75
+ provenance: { sourceFile: file, compositeInstance: indexedName },
76
+ });
77
77
  }
78
78
  }
79
79
  }
80
80
  } else if (isCompositeInstance(value)) {
81
- const expanded = expandComposite(name, value);
82
- for (const [expandedName, entity] of expanded) {
83
- if (entities.has(expandedName)) {
84
- throw new DiscoveryError(
85
- file,
86
- `Duplicate entity name "${expandedName}" from composite expansion of "${name}"`,
87
- "resolution",
88
- );
89
- }
90
- setProvenance(entity, { sourceFile: file, compositeInstance: name });
91
- entities.set(expandedName, entity);
81
+ for (const [expandedName, entity] of expandComposite(name, value)) {
82
+ entries.push({
83
+ bareKey: expandedName,
84
+ value: entity,
85
+ file,
86
+ provenance: { sourceFile: file, compositeInstance: name },
87
+ });
92
88
  }
93
89
  } else if (isLexiconOutput(value)) {
94
- // LexiconOutput is not a Declarable but build() expects to find them
95
- // in the entities map so it can collect and pass them to serializers
96
- entities.set(name, value as unknown as Declarable);
90
+ // LexiconOutput is not a Declarable but build() expects to find them in
91
+ // the entities map so it can collect and pass them to serializers.
92
+ entries.push({ bareKey: name, value: value as unknown as Declarable, file, provenance: { sourceFile: file } });
93
+ }
94
+ }
95
+ }
96
+
97
+ return entries;
98
+ }
99
+
100
+ /**
101
+ * Collects all declarable entities from imported modules.
102
+ * CompositeInstance exports are expanded into individual entities
103
+ * with `{exportName}_{memberName}` naming.
104
+ * LexiconOutput exports are also collected so that build() can
105
+ * extract them and pass them to the serializer.
106
+ *
107
+ * A bare entity name must be unique *within a single stack directory*, not
108
+ * across the whole project. A multi-stack project (independently-deployed
109
+ * sibling stacks under one root — e.g. `src/loom-backend/`, `src/loom-agents/`)
110
+ * legitimately reuses conventional cross-stack `Parameter` names like
111
+ * `pArtifactBucket`/`pImageUri` across siblings, because each directory is its
112
+ * own CloudFormation template where that name is the real, deployed logical id.
113
+ * When the same bare name is declared (as distinct objects) in two different
114
+ * directories, each is disambiguated by a stack prefix derived from its
115
+ * directory ({@link stackPrefix}) rather than throwing — so an unscoped
116
+ * whole-project build / `chant lifecycle snapshot|diff` no longer collides
117
+ * (#932). A per-stack scoped build (`chant build <dir>`) has a single directory
118
+ * relative to its root, so nothing is prefixed and the deployed logical ids are
119
+ * unchanged. A genuine *same-directory* duplicate is still an error.
120
+ *
121
+ * @param modules - Array of module records with their exports
122
+ * @param buildRoot - The build root the discovery ran against; stack prefixes
123
+ * are derived relative to it so keys stay portable across machines.
124
+ * @returns Map of export name to Declarable entity
125
+ * @throws {DiscoveryError} with type "resolution" if a name is duplicated within one directory
126
+ */
127
+ export function collectEntities(
128
+ modules: Array<{ file: string; exports: Record<string, unknown> }>,
129
+ buildRoot?: string,
130
+ ): Map<string, Declarable> {
131
+ const entries = enumerateEntries(modules);
132
+
133
+ // Which bare keys collide across more than one directory (as distinct
134
+ // objects)? Those — and only those — get a stack prefix. A bare key that
135
+ // resolves to a single object (even one re-exported from several files) keeps
136
+ // its raw name, so single-stack projects are unaffected.
137
+ const dirsByKey = new Map<string, Map<string, Set<Declarable>>>();
138
+ for (const { bareKey, value, file } of entries) {
139
+ const dir = dirname(file);
140
+ let byDir = dirsByKey.get(bareKey);
141
+ if (!byDir) {
142
+ byDir = new Map();
143
+ dirsByKey.set(bareKey, byDir);
144
+ }
145
+ let objs = byDir.get(dir);
146
+ if (!objs) {
147
+ objs = new Set();
148
+ byDir.set(dir, objs);
149
+ }
150
+ objs.add(value);
151
+ }
152
+ const crossDirKeys = new Set<string>();
153
+ for (const [bareKey, byDir] of dirsByKey) {
154
+ const dirsWithObjects = [...byDir.values()].filter((objs) => objs.size > 0).length;
155
+ const distinctObjects = new Set<Declarable>();
156
+ for (const objs of byDir.values()) for (const o of objs) distinctObjects.add(o);
157
+ if (dirsWithObjects > 1 && distinctObjects.size > 1) crossDirKeys.add(bareKey);
158
+ }
159
+
160
+ const entities = new Map<string, Declarable>();
161
+ // Per bare key, the object already claimed for each directory — a second,
162
+ // *different* object in the same directory is a genuine duplicate.
163
+ const claimedByDir = new Map<string, Map<string, Declarable>>();
164
+
165
+ for (const { bareKey, value, file, provenance } of entries) {
166
+ const dir = dirname(file);
167
+ let perDir = claimedByDir.get(bareKey);
168
+ if (!perDir) {
169
+ perDir = new Map();
170
+ claimedByDir.set(bareKey, perDir);
171
+ }
172
+ const claimed = perDir.get(dir);
173
+ if (claimed !== undefined && claimed !== value) {
174
+ // Same name, same directory, different object → a real collision.
175
+ throw new DiscoveryError(file, `Duplicate export name "${bareKey}" found`, "resolution");
176
+ }
177
+ perDir.set(dir, value);
178
+
179
+ const key = crossDirKeys.has(bareKey) ? `${stackPrefix(file, buildRoot)}${bareKey}` : bareKey;
180
+ const existing = entities.get(key);
181
+ if (existing !== undefined) {
182
+ // Same object re-exported (possibly from multiple files) is fine; a
183
+ // different object landing on the same disambiguated key would only
184
+ // happen if two directories produced an identical stack prefix.
185
+ if (existing !== value) {
186
+ throw new DiscoveryError(file, `Duplicate export name "${bareKey}" found`, "resolution");
97
187
  }
188
+ continue;
98
189
  }
190
+ setProvenance(value, provenance);
191
+ entities.set(key, value);
99
192
  }
100
193
 
101
194
  return entities;
@@ -67,7 +67,7 @@ export async function discover(path: string): Promise<DiscoveryResult> {
67
67
  let entities = new Map<string, Declarable>();
68
68
 
69
69
  try {
70
- entities = collectEntities(modules);
70
+ entities = collectEntities(modules, path);
71
71
  } catch (error) {
72
72
  // Collect resolution errors
73
73
  if (error instanceof Error && error.name === "DiscoveryError") {
package/src/lexicon.ts CHANGED
@@ -372,6 +372,13 @@ export interface LexiconPlugin {
372
372
  buildOutput: string;
373
373
  entityNames: string[];
374
374
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
375
+ /**
376
+ * The deployed stack name to observe, for a multi-stack project where the
377
+ * stack is not named after the environment (see `stacks` in {@link
378
+ * ChantConfig}). When omitted, an implementation keeps its single-stack
379
+ * convention (AWS: the stack named after `environment`).
380
+ */
381
+ stack?: string;
375
382
  /**
376
383
  * Restrict the result to chant-owned resources (those carrying the
377
384
  * ownership marker, #119). Where a lexicon has no durable marker channel,
@@ -399,7 +406,7 @@ export interface LexiconPlugin {
399
406
  * thin to carry references (e.g. AWS CloudFormation, where it's sourced from
400
407
  * the fuller `exportResources` config).
401
408
  */
402
- enrichLiveAttrs?(options: { environment: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
409
+ enrichLiveAttrs?(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
403
410
 
404
411
  /**
405
412
  * List runtime artifacts in the given environment. Opt-in.
@@ -418,6 +425,9 @@ export interface LexiconPlugin {
418
425
  listArtifacts?(options: {
419
426
  environment: string;
420
427
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
428
+ /** The deployed stack to scope enumeration to, for a multi-stack project
429
+ * (see `stacks` in {@link ChantConfig}). Omitted for single-stack projects. */
430
+ stack?: string;
421
431
  }): Promise<Record<string, ArtifactMetadata>>;
422
432
 
423
433
  // Live export (cloud → code)
@@ -444,6 +454,11 @@ export interface LexiconPlugin {
444
454
  */
445
455
  exportResources?(options: {
446
456
  environment: string;
457
+ /** The deployed stack to export from, for a multi-stack project (see
458
+ * `stacks` in {@link ChantConfig}, #932). When omitted, an implementation
459
+ * keeps its single-stack convention (AWS: the stack named after
460
+ * `environment`). */
461
+ stack?: string;
447
462
  selector?: ResourceSelector;
448
463
  owned?: boolean;
449
464
  verbatim?: boolean;
@@ -141,6 +141,19 @@ export async function readBlobFromPath(
141
141
  return result.stdout;
142
142
  }
143
143
 
144
+ /**
145
+ * Storage key for a snapshot on the orphan branch. Single-stack projects key by
146
+ * lexicon (`<env>/<lexicon>.json`, unchanged). A multi-stack project (see
147
+ * `stacks` in ChantConfig, #932) folds the stack in as `<stack>__<lexicon>`
148
+ * (`<env>/<stack>__<lexicon>.json`) so sibling stacks that deploy the same
149
+ * lexicon don't overwrite each other's snapshots. The `__` separator can't
150
+ * collide with a lexicon name (lexicons are single tokens) and round-trips
151
+ * through `readSnapshot`/`readEnvironmentSnapshots` unchanged.
152
+ */
153
+ export function snapshotStorageKey(lexicon: string, stack?: string): string {
154
+ return stack ? `${stack}__${lexicon}` : lexicon;
155
+ }
156
+
144
157
  /**
145
158
  * Write a state snapshot JSON to the orphan branch.
146
159
  *
@@ -8,6 +8,7 @@ const pushLifecycleMock = vi.fn();
8
8
 
9
9
  vi.mock("./git", () => ({
10
10
  writeSnapshot: (...args: unknown[]) => writeSnapshotMock(...args),
11
+ snapshotStorageKey: (lexicon: string, stack?: string) => (stack ? `${stack}__${lexicon}` : lexicon),
11
12
  getHeadCommit: () => getHeadCommitMock(),
12
13
  pushLifecycle: () => pushLifecycleMock(),
13
14
  }));
@@ -57,6 +58,29 @@ describe("takeSnapshot", () => {
57
58
  });
58
59
  expect(writeSnapshotMock).toHaveBeenCalledTimes(1);
59
60
  expect(pushLifecycleMock).toHaveBeenCalledTimes(1);
61
+ // Single-stack: written under the bare lexicon key, snapshot carries no stack.
62
+ expect(writeSnapshotMock.mock.calls[0][1]).toBe("aws");
63
+ expect(result.snapshots[0].stack).toBeUndefined();
64
+ });
65
+
66
+ // #932 — a multi-stack project observes each stack against its own live stack
67
+ // and stores its snapshot under a stack-scoped key so siblings don't overwrite.
68
+ test("stack option: observes the named stack and stores under a stack-scoped key", async () => {
69
+ let observedStack: string | undefined = "unset";
70
+ const plugin = createMockPlugin({
71
+ name: "aws",
72
+ describeResources: async (options: { stack?: string }) => {
73
+ observedStack = options.stack;
74
+ return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
75
+ },
76
+ });
77
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { stack: "loom-backend" });
78
+ // describeResources was told which live stack to query.
79
+ expect(observedStack).toBe("loom-backend");
80
+ // The snapshot records its stack …
81
+ expect(result.snapshots[0].stack).toBe("loom-backend");
82
+ // … and is stored under `<stack>__<lexicon>`, not the bare lexicon key.
83
+ expect(writeSnapshotMock.mock.calls[0][1]).toBe("loom-backend__aws");
60
84
  });
61
85
 
62
86
  test("plugin without describeResources is skipped", async () => {
@@ -7,7 +7,7 @@ import type { BuildResult } from "../build";
7
7
  import type { SerializerResult } from "../serializer";
8
8
  import type { LifecycleSnapshot } from "./types";
9
9
  import { computeBuildDigest } from "./digest";
10
- import { writeSnapshot, getHeadCommit, pushLifecycle } from "./git";
10
+ import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
11
11
  import { sortedJsonReplacer } from "../utils";
12
12
 
13
13
  /** Patterns in attribute names that suggest sensitive data. */
@@ -84,8 +84,9 @@ export async function takeSnapshot(
84
84
  environment: string,
85
85
  plugins: ObservationLexicon[],
86
86
  buildResult: BuildResult,
87
- opts?: { cwd?: string },
87
+ opts?: { cwd?: string; stack?: string },
88
88
  ): Promise<TakeSnapshotResult> {
89
+ const stack = opts?.stack;
89
90
  const warnings: string[] = [];
90
91
  const errors: string[] = [];
91
92
  const snapshots: LifecycleSnapshot[] = [];
@@ -131,6 +132,7 @@ export async function takeSnapshot(
131
132
  buildOutput,
132
133
  entityNames,
133
134
  entities,
135
+ stack,
134
136
  });
135
137
  const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
136
138
  warnings.push(...validationWarnings);
@@ -141,7 +143,7 @@ export async function takeSnapshot(
141
143
  }
142
144
 
143
145
  if (plugin.listArtifacts) {
144
- const raw = await plugin.listArtifacts({ environment, entities });
146
+ const raw = await plugin.listArtifacts({ environment, entities, stack });
145
147
  const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
146
148
  warnings.push(...validationWarnings);
147
149
  if (dropped.length > 0) {
@@ -158,6 +160,7 @@ export async function takeSnapshot(
158
160
  const snapshot: LifecycleSnapshot = {
159
161
  lexicon: plugin.name,
160
162
  environment,
163
+ ...(stack ? { stack } : {}),
161
164
  commit: headCommit,
162
165
  timestamp,
163
166
  resources,
@@ -179,7 +182,7 @@ export async function takeSnapshot(
179
182
  const json = JSON.stringify(snapshot, sortedJsonReplacer, 2);
180
183
  commitSha = await writeSnapshot(
181
184
  snapshot.environment,
182
- snapshot.lexicon,
185
+ snapshotStorageKey(snapshot.lexicon, snapshot.stack),
183
186
  json,
184
187
  opts,
185
188
  );
@@ -8,6 +8,10 @@ export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
8
8
  export interface LifecycleSnapshot {
9
9
  lexicon: string;
10
10
  environment: string;
11
+ /** Deployed stack name, for a multi-stack project (see `stacks` in
12
+ * ChantConfig). Absent for single-stack projects. Snapshots are stored per
13
+ * `<env>/<stack>/<lexicon>` when set, so sibling stacks don't overwrite. */
14
+ stack?: string;
11
15
  /** Main branch commit this corresponds to */
12
16
  commit: string;
13
17
  /** ISO timestamp when the snapshot was taken */