@intentius/chant 0.19.1 → 0.20.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 (152) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +54 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +12 -5
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/discover.d.ts +62 -7
  30. package/dist/components/discover.d.ts.map +1 -1
  31. package/dist/components/sandbox/driver.d.ts +12 -0
  32. package/dist/components/sandbox/driver.d.ts.map +1 -0
  33. package/dist/components/sandbox/run.d.ts +42 -0
  34. package/dist/components/sandbox/run.d.ts.map +1 -0
  35. package/dist/composite.d.ts +5 -0
  36. package/dist/composite.d.ts.map +1 -1
  37. package/dist/config.d.ts +71 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/discovery/collect.d.ts.map +1 -1
  40. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  41. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  42. package/dist/discovery/entity-wire.d.ts +50 -0
  43. package/dist/discovery/entity-wire.d.ts.map +1 -0
  44. package/dist/discovery/fold-import.d.ts +210 -0
  45. package/dist/discovery/fold-import.d.ts.map +1 -0
  46. package/dist/discovery/index.d.ts +74 -1
  47. package/dist/discovery/index.d.ts.map +1 -1
  48. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  49. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  50. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  51. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  52. package/dist/discovery/sandbox/driver.d.ts +13 -0
  53. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/run.d.ts +69 -0
  55. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  56. package/dist/errors.d.ts +9 -1
  57. package/dist/errors.d.ts.map +1 -1
  58. package/dist/fold/fold.d.ts +226 -0
  59. package/dist/fold/fold.d.ts.map +1 -0
  60. package/dist/fold/subset.d.ts +102 -0
  61. package/dist/fold/subset.d.ts.map +1 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/lexicon-output.d.ts +7 -2
  65. package/dist/lexicon-output.d.ts.map +1 -1
  66. package/dist/lexicon-schema.d.ts +2 -2
  67. package/dist/lexicon-schema.d.ts.map +1 -1
  68. package/dist/lexicon.d.ts +81 -1
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/release-ledger.d.ts +11 -0
  71. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  72. package/dist/lint/component-checks.d.ts +7 -1
  73. package/dist/lint/component-checks.d.ts.map +1 -1
  74. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  75. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  76. package/dist/params.d.ts +60 -0
  77. package/dist/params.d.ts.map +1 -0
  78. package/dist/provenance.d.ts +21 -0
  79. package/dist/provenance.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/build-params.test.ts +144 -0
  82. package/src/build-params.ts +207 -0
  83. package/src/build.test.ts +38 -0
  84. package/src/build.ts +144 -7
  85. package/src/cli/commands/build.test.ts +220 -2
  86. package/src/cli/commands/build.ts +111 -3
  87. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  88. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  89. package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
  90. package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
  91. package/src/cli/commands/check-lexicon.test.ts +34 -0
  92. package/src/cli/commands/check-lexicon.ts +119 -1
  93. package/src/cli/commands/lint.ts +31 -3
  94. package/src/cli/commands/onboard.ts +1 -1
  95. package/src/cli/component-security-boundary.test.ts +170 -0
  96. package/src/cli/handlers/build.ts +24 -3
  97. package/src/cli/handlers/components.ts +9 -2
  98. package/src/cli/handlers/dev.ts +1 -1
  99. package/src/cli/handlers/graph.ts +7 -5
  100. package/src/cli/handlers/lifecycle.ts +2 -2
  101. package/src/cli/handlers/lint.ts +2 -0
  102. package/src/cli/handlers/misc.ts +2 -2
  103. package/src/cli/handlers/run-client.ts +1 -1
  104. package/src/cli/handlers/run.ts +20 -5
  105. package/src/cli/main.test.ts +22 -0
  106. package/src/cli/main.ts +39 -0
  107. package/src/cli/plugins.ts +20 -4
  108. package/src/cli/registry.ts +10 -0
  109. package/src/cli/security-boundary.test.ts +135 -0
  110. package/src/codegen/docs-sections.test.ts +61 -0
  111. package/src/codegen/docs-sections.ts +7 -3
  112. package/src/components/cli-support.ts +22 -10
  113. package/src/components/discover.ts +127 -25
  114. package/src/components/sandbox/driver.ts +114 -0
  115. package/src/components/sandbox/run.test.ts +185 -0
  116. package/src/components/sandbox/run.ts +177 -0
  117. package/src/composite.test.ts +21 -0
  118. package/src/composite.ts +20 -1
  119. package/src/config.ts +81 -0
  120. package/src/discovery/collect.ts +17 -3
  121. package/src/discovery/entity-wire-codec.ts +485 -0
  122. package/src/discovery/entity-wire.test.ts +240 -0
  123. package/src/discovery/entity-wire.ts +67 -0
  124. package/src/discovery/fold-import.test.ts +1026 -0
  125. package/src/discovery/fold-import.ts +1805 -0
  126. package/src/discovery/index.test.ts +191 -1
  127. package/src/discovery/index.ts +233 -1
  128. package/src/discovery/sandbox/bundle.ts +218 -0
  129. package/src/discovery/sandbox/child-errors.ts +65 -0
  130. package/src/discovery/sandbox/driver.ts +147 -0
  131. package/src/discovery/sandbox/run.test.ts +179 -0
  132. package/src/discovery/sandbox/run.ts +196 -0
  133. package/src/errors.ts +9 -1
  134. package/src/fold/fold.test.ts +535 -0
  135. package/src/fold/fold.ts +648 -0
  136. package/src/fold/subset-doc-parity.test.ts +183 -0
  137. package/src/fold/subset.test.ts +241 -0
  138. package/src/fold/subset.ts +302 -0
  139. package/src/index.ts +2 -0
  140. package/src/lexicon-output.ts +7 -2
  141. package/src/lexicon-schema.test.ts +14 -0
  142. package/src/lexicon-schema.ts +3 -1
  143. package/src/lexicon.ts +82 -1
  144. package/src/lifecycle/git.test.ts +10 -5
  145. package/src/lifecycle/release-ledger.test.ts +28 -0
  146. package/src/lifecycle/release-ledger.ts +11 -0
  147. package/src/lint/component-checks.ts +8 -1
  148. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  149. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  150. package/src/params.test.ts +22 -0
  151. package/src/params.ts +66 -0
  152. package/src/provenance.ts +22 -0
@@ -0,0 +1,1026 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { tryFoldFile, createFoldSession } from "./fold-import";
7
+ import { isDeclarable } from "../declarable";
8
+ import { isCompositeInstance } from "../composite";
9
+ import { isAttrRefLike } from "../utils";
10
+ import { params as sharedParams } from "../params";
11
+ import type { AttrRef } from "../attrref";
12
+ import type { IntrinsicDef } from "../lexicon";
13
+
14
+ const thisDir = dirname(fileURLToPath(import.meta.url));
15
+ /** Absolute path to `packages/core/src/runtime.ts` — the real `createResource`
16
+ * factory, imported by fixture files below exactly as a lexicon package
17
+ * would be imported by real chant source. */
18
+ const runtimePath = resolve(thisDir, "../runtime");
19
+ /** Absolute path to `packages/core/src/composite.ts` — the real `Composite`
20
+ * factory, used to build a genuine composite fixture. */
21
+ const compositePath = resolve(thisDir, "../composite");
22
+ /** Absolute path to `packages/core/src/intrinsic.ts` — the real `Intrinsic`
23
+ * marker/interface every lexicon's own intrinsic classes (AWS's `Sub`,
24
+ * gitlab's `reference`, …) implement. Used below to build a tagged-template
25
+ * intrinsic fixture that is structurally identical to a real lexicon's,
26
+ * not a simplified stand-in. */
27
+ const intrinsicPath = resolve(thisDir, "../intrinsic");
28
+ /** Absolute path to `packages/core/src/params.ts` — chant #1064's build-time-parameters
29
+ * runtime module. A real project imports this as the bare specifier
30
+ * "@intentius/chant/params"; fixture files below use the absolute path instead
31
+ * (same convention as `runtimePath` etc. above), but {@link buildExternals}'s
32
+ * recognition is specifier-shape-agnostic — see fold-import.ts's own comment —
33
+ * so this exercises the identical code path a real bare import would take. */
34
+ const paramsPath = resolve(thisDir, "../params");
35
+
36
+ describe("tryFoldFile", () => {
37
+ let testDir: string;
38
+
39
+ beforeEach(async () => {
40
+ testDir = join(tmpdir(), `chant-fold-import-test-${Date.now()}-${Math.random()}`);
41
+ await mkdir(testDir, { recursive: true });
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await rm(testDir, { recursive: true, force: true });
46
+ });
47
+
48
+ async function writeResourceDefs(): Promise<void> {
49
+ await writeFile(
50
+ join(testDir, "resources.ts"),
51
+ `
52
+ import { createResource } from ${JSON.stringify(runtimePath)};
53
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
54
+ `,
55
+ );
56
+ }
57
+
58
+ test("folds a leaf resource to a real Declarable with zero module execution", async () => {
59
+ await writeResourceDefs();
60
+ const file = join(testDir, "main.ts");
61
+ await writeFile(
62
+ file,
63
+ `
64
+ import { Bucket } from "./resources";
65
+ throw new Error("must never execute — sentinel for #1022 fold verification");
66
+ export const bucket = new Bucket({ name: "my-bucket", versioned: true });
67
+ `,
68
+ );
69
+
70
+ const result = await tryFoldFile(file);
71
+
72
+ expect(result.ok).toBe(true);
73
+ if (!result.ok) return;
74
+ expect(result.entities).toHaveLength(1);
75
+ const [name, entity] = result.entities[0];
76
+ expect(name).toBe("bucket");
77
+ if (!isDeclarable(entity)) throw new Error("expected a Declarable");
78
+ expect(entity.lexicon).toBe("aws");
79
+ expect(entity.entityType).toBe("Test::Bucket");
80
+ expect((entity as unknown as { props: unknown }).props).toEqual({
81
+ name: "my-bucket",
82
+ versioned: true,
83
+ });
84
+ });
85
+
86
+ test("folds multiple leaf resources from one file", async () => {
87
+ await writeResourceDefs();
88
+ const file = join(testDir, "main.ts");
89
+ await writeFile(
90
+ file,
91
+ `
92
+ import { Bucket } from "./resources";
93
+ export const a = new Bucket({ name: "a" });
94
+ export const b = new Bucket({ name: "b" });
95
+ `,
96
+ );
97
+
98
+ const result = await tryFoldFile(file);
99
+
100
+ expect(result.ok).toBe(true);
101
+ if (!result.ok) return;
102
+ expect(result.entities.map(([name]) => name).sort()).toEqual(["a", "b"]);
103
+ });
104
+
105
+ // chant #1023 (epic #1019 Phase 5): a bare composite factory call, resolved
106
+ // through the file's own imports, now folds — the factory is invoked for
107
+ // real with statically-folded props, exactly as `#1022`'s resource
108
+ // constructors already were. This used to be the canonical fallback case;
109
+ // flipping it to fold is the point of #1023.
110
+ test("folds a top-level composite factory call, zero module execution", async () => {
111
+ await writeResourceDefs();
112
+ await writeFile(
113
+ join(testDir, "composites.ts"),
114
+ `
115
+ import { createResource } from ${JSON.stringify(runtimePath)};
116
+ import { Composite } from ${JSON.stringify(compositePath)};
117
+ const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
118
+ export const MyStack = Composite<{ name: string }>((props) => {
119
+ const bucket = new Bucket({ name: props.name });
120
+ return { bucket };
121
+ }, "MyStack");
122
+ `,
123
+ );
124
+ const file = join(testDir, "stack.ts");
125
+ await writeFile(
126
+ file,
127
+ `
128
+ import { MyStack } from "./composites";
129
+ throw new Error("must never execute — sentinel for #1023 fold verification");
130
+ export const stack = MyStack({ name: "composite-bucket" });
131
+ `,
132
+ );
133
+
134
+ const result = await tryFoldFile(file);
135
+
136
+ expect(result.ok).toBe(true);
137
+ if (!result.ok) return;
138
+ expect(result.entities).toHaveLength(1);
139
+ const [name, entity] = result.entities[0];
140
+ expect(name).toBe("stack");
141
+ expect(isCompositeInstance(entity)).toBe(true);
142
+ const bucket = (entity as unknown as { bucket: unknown }).bucket;
143
+ if (!isDeclarable(bucket)) throw new Error("expected the composite's `bucket` member to be a Declarable");
144
+ expect(bucket.entityType).toBe("Test::Bucket");
145
+ expect((bucket as unknown as { props: unknown }).props).toEqual({ name: "composite-bucket" });
146
+ });
147
+
148
+ test("falls back when the composite factory is defined locally (not resolvable via import)", async () => {
149
+ await writeResourceDefs();
150
+ const file = join(testDir, "stack.ts");
151
+ await writeFile(
152
+ file,
153
+ `
154
+ import { Bucket } from "./resources";
155
+ import { Composite } from ${JSON.stringify(compositePath)};
156
+ const LocalStack = Composite<{ name: string }>((props) => {
157
+ const bucket = new Bucket({ name: props.name });
158
+ return { bucket };
159
+ }, "LocalStack");
160
+ export const stack = LocalStack({ name: "composite-bucket" });
161
+ `,
162
+ );
163
+
164
+ const result = await tryFoldFile(file);
165
+
166
+ expect(result.ok).toBe(false);
167
+ if (result.ok) return;
168
+ expect(result.reason).toContain("LocalStack");
169
+ });
170
+
171
+ test("falls back when a prop value is not foldable", async () => {
172
+ await writeResourceDefs();
173
+ const file = join(testDir, "main.ts");
174
+ await writeFile(
175
+ file,
176
+ `
177
+ import { Bucket } from "./resources";
178
+ function computeName(): string { return "dynamic"; }
179
+ export const bucket = new Bucket({ name: computeName() });
180
+ `,
181
+ );
182
+
183
+ const result = await tryFoldFile(file);
184
+
185
+ expect(result.ok).toBe(false);
186
+ if (result.ok) return;
187
+ expect(result.reason).toContain("computeName");
188
+ });
189
+
190
+ test("falls back when the resource constructor is not a resolvable import", async () => {
191
+ const file = join(testDir, "main.ts");
192
+ await writeFile(
193
+ file,
194
+ `
195
+ export const bucket = new Bucket({ name: "my-bucket" });
196
+ `,
197
+ );
198
+
199
+ const result = await tryFoldFile(file);
200
+
201
+ expect(result.ok).toBe(false);
202
+ if (result.ok) return;
203
+ expect(result.reason).toContain("not a resolvable import");
204
+ });
205
+
206
+ test("falls back on `export default`", async () => {
207
+ await writeResourceDefs();
208
+ const file = join(testDir, "main.ts");
209
+ await writeFile(
210
+ file,
211
+ `
212
+ import { Bucket } from "./resources";
213
+ const bucket = new Bucket({ name: "my-bucket" });
214
+ export default bucket;
215
+ `,
216
+ );
217
+
218
+ const result = await tryFoldFile(file);
219
+
220
+ expect(result.ok).toBe(false);
221
+ if (result.ok) return;
222
+ expect(result.reason).toContain("export default");
223
+ });
224
+
225
+ test("falls back when there are no exports at all", async () => {
226
+ const file = join(testDir, "main.ts");
227
+ await writeFile(file, `const helperValue = 5;`);
228
+
229
+ const result = await tryFoldFile(file);
230
+
231
+ expect(result.ok).toBe(false);
232
+ if (result.ok) return;
233
+ expect(result.reason).toBe("no foldable resource exports");
234
+ });
235
+
236
+ // chant #1020: a plain-value-only export now folds too (contributing
237
+ // nothing to `entities` — only Declarable/CompositeInstance land there —
238
+ // but recorded in `exportedValues`). This is intentional, not a relaxed
239
+ // subset: `fold()` already reduced a bare literal trivially; the only
240
+ // change is that `tryFoldFile` now ALSO tries it for a "single" declarator
241
+ // instead of only the composite-call spine. It matters for #1020's own
242
+ // acceptance criterion — a config/params file that exports nothing but
243
+ // plain consts must be safe to fold (0 entities either way), or
244
+ // `planFoldTaint` would needlessly force every file that imports one of
245
+ // its constants back to run too.
246
+ test("a plain-value-only export now folds — zero entities, but the value is recorded for cross-file reference", async () => {
247
+ const file = join(testDir, "main.ts");
248
+ await writeFile(file, `export const helperValue = 5;`);
249
+
250
+ const result = await tryFoldFile(file);
251
+
252
+ expect(result.ok).toBe(true);
253
+ if (!result.ok) return;
254
+ expect(result.entities).toEqual([]);
255
+ expect(result.exportedValues.get("helperValue")).toBe(5);
256
+ });
257
+
258
+ // #1025 differential regression: the constructor's optional second
259
+ // argument (CFN-style resource attributes — DependsOn, Condition,
260
+ // DeletionPolicy, …) was folded by fold.ts's `foldResource` but then
261
+ // dropped on the floor here — only `entry.spec.props` was ever passed to
262
+ // `new ResourceCtor(...)`. Confirms the fix actually reaches the
263
+ // constructed `Declarable`, not just the intermediate `FoldedResource`
264
+ // spec (see fold.test.ts for that narrower unit).
265
+ test("passes the folded second argument through as the constructed entity's attributes", async () => {
266
+ await writeResourceDefs();
267
+ const file = join(testDir, "main.ts");
268
+ await writeFile(
269
+ file,
270
+ `
271
+ import { Bucket } from "./resources";
272
+ export const bucket = new Bucket(
273
+ { name: "my-bucket" },
274
+ { DeletionPolicy: "Retain", DependsOn: "otherResource" },
275
+ );
276
+ `,
277
+ );
278
+
279
+ const result = await tryFoldFile(file);
280
+
281
+ expect(result.ok).toBe(true);
282
+ if (!result.ok) return;
283
+ const [, entity] = result.entities[0];
284
+ expect((entity as unknown as { attributes: unknown }).attributes).toEqual({
285
+ DeletionPolicy: "Retain",
286
+ DependsOn: "otherResource",
287
+ });
288
+ });
289
+ });
290
+
291
+ // chant #1020 (epic #1019) — cross-file resolution: an imported binding now
292
+ // folds by resolving it to its `export const` initializer and folding it in
293
+ // the DEFINING module's own scope, instead of failing the whole importing
294
+ // file on the first identifier it doesn't recognize.
295
+ describe("tryFoldFile — cross-file resolution (chant #1020)", () => {
296
+ let testDir: string;
297
+
298
+ beforeEach(async () => {
299
+ testDir = join(tmpdir(), `chant-fold-import-crossfile-test-${Date.now()}-${Math.random()}`);
300
+ await mkdir(testDir, { recursive: true });
301
+ });
302
+
303
+ afterEach(async () => {
304
+ await rm(testDir, { recursive: true, force: true });
305
+ });
306
+
307
+ async function writeResourceDefs(): Promise<void> {
308
+ await writeFile(
309
+ join(testDir, "resources.ts"),
310
+ `
311
+ import { createResource } from ${JSON.stringify(runtimePath)};
312
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
313
+ export const Vpc = createResource("Test::Vpc", "aws", { vpcId: "VpcId" });
314
+ export const Alb = createResource("Test::Alb", "aws", { albArn: "AlbArn" });
315
+ `,
316
+ );
317
+ }
318
+
319
+ test("an imported plain const folds identically to an inline one", async () => {
320
+ await writeResourceDefs();
321
+ await writeFile(join(testDir, "config.ts"), `export const REGION = "us-east-1";`);
322
+ const file = join(testDir, "main.ts");
323
+ await writeFile(
324
+ file,
325
+ `
326
+ import { Bucket } from "./resources";
327
+ import { REGION } from "./config";
328
+ export const bucket = new Bucket({ name: "b", region: REGION });
329
+ `,
330
+ );
331
+
332
+ const result = await tryFoldFile(file);
333
+
334
+ expect(result.ok).toBe(true);
335
+ if (!result.ok) return;
336
+ const [, entity] = result.entities[0];
337
+ expect((entity as unknown as { props: unknown }).props).toEqual({ name: "b", region: "us-east-1" });
338
+ });
339
+
340
+ test("config.ts's own fold succeeds with zero entities — a plain-const-only file must not be tainted merely for existing", async () => {
341
+ const file = join(testDir, "config.ts");
342
+ await writeFile(file, `export const REGION = "us-east-1";\nexport const RETRIES = 3;`);
343
+
344
+ const result = await tryFoldFile(file);
345
+
346
+ expect(result.ok).toBe(true);
347
+ if (!result.ok) return;
348
+ expect(result.entities).toEqual([]);
349
+ expect(result.exportedValues.get("REGION")).toBe("us-east-1");
350
+ expect(result.exportedValues.get("RETRIES")).toBe(3);
351
+ });
352
+
353
+ test("a cross-file resource attribute reference folds to a real AttrRef", async () => {
354
+ await writeResourceDefs();
355
+ await writeFile(
356
+ join(testDir, "network.ts"),
357
+ `
358
+ import { Vpc } from "./resources";
359
+ export const vpc = new Vpc({ cidr: "10.0.0.0/16" });
360
+ `,
361
+ );
362
+ const file = join(testDir, "alb.ts");
363
+ await writeFile(
364
+ file,
365
+ `
366
+ import { Alb } from "./resources";
367
+ import { vpc } from "./network";
368
+ export const alb = new Alb({ vpcId: vpc.vpcId });
369
+ `,
370
+ );
371
+
372
+ const result = await tryFoldFile(file);
373
+
374
+ expect(result.ok).toBe(true);
375
+ if (!result.ok) return;
376
+ const [, entity] = result.entities[0];
377
+ const vpcId = (entity as unknown as { props: { vpcId: unknown } }).props.vpcId;
378
+ expect(isAttrRefLike(vpcId)).toBe(true);
379
+ });
380
+
381
+ // The hard part (see planFoldTaint's module doc): fold and run must never
382
+ // disagree about object identity. If `alb.ts` reached into `network.ts`
383
+ // and built a SECOND copy of `network.vpc`, that copy would never match
384
+ // discover()'s entities map by identity and serialization would crash, not
385
+ // drift. Asserted with `toBe` — identity, not `toEqual`'s structural check.
386
+ test("a cross-file reference shares the EXACT SAME entity instance as the defining file's own fold — not a duplicate", async () => {
387
+ await writeResourceDefs();
388
+ const networkFile = join(testDir, "network.ts");
389
+ await writeFile(
390
+ networkFile,
391
+ `
392
+ import { Vpc } from "./resources";
393
+ export const vpc = new Vpc({ cidr: "10.0.0.0/16" });
394
+ `,
395
+ );
396
+ const albFile = join(testDir, "alb.ts");
397
+ await writeFile(
398
+ albFile,
399
+ `
400
+ import { Alb } from "./resources";
401
+ import { vpc } from "./network";
402
+ export const alb = new Alb({ vpcId: vpc.vpcId });
403
+ `,
404
+ );
405
+
406
+ // One shared session, exactly as `discover()` gives its own per-file
407
+ // loop — this is what a real build does; two independent `tryFoldFile`
408
+ // calls with no session would each fold `network.ts` on their own and
409
+ // legitimately produce two different (if structurally equal) objects.
410
+ const session = createFoldSession();
411
+ const networkResult = await tryFoldFile(networkFile, [], session);
412
+ const albResult = await tryFoldFile(albFile, [], session);
413
+
414
+ expect(networkResult.ok).toBe(true);
415
+ expect(albResult.ok).toBe(true);
416
+ if (!networkResult.ok || !albResult.ok) return;
417
+
418
+ const [, vpcEntity] = networkResult.entities[0];
419
+ const [, albEntity] = albResult.entities[0];
420
+ const vpcIdRef = (albEntity as unknown as { props: { vpcId: AttrRef } }).props.vpcId;
421
+ expect(vpcIdRef.parent.deref()).toBe(vpcEntity);
422
+ });
423
+
424
+ // A diamond — TWO paths reaching the SAME module (b.ts and c.ts both
425
+ // import hub.ts; neither imports the other) — is the case that silently
426
+ // degrades to duplicated (or, nested a few levels deep, EXPONENTIAL) work
427
+ // without a resolution memo, yet produces no error at all: each path just
428
+ // independently re-resolves the shared module. Distinct from the reference
429
+ // CYCLE tests below, which fail loudly; this one must instead prove the
430
+ // hub was folded exactly once and every referrer shares that one object.
431
+ // An explicit test timeout below turns a reintroduced non-terminating bug
432
+ // into a fast, clearly-labeled failure instead of a hung test run.
433
+ test(
434
+ "a diamond (two independent paths reaching the same module) resolves without re-resolving the shared module",
435
+ async () => {
436
+ await writeResourceDefs();
437
+ await writeFile(
438
+ join(testDir, "hub.ts"),
439
+ `
440
+ import { Vpc } from "./resources";
441
+ export const vpc = new Vpc({ cidr: "10.0.0.0/16" });
442
+ `,
443
+ );
444
+ await writeFile(
445
+ join(testDir, "b.ts"),
446
+ `
447
+ import { Alb } from "./resources";
448
+ import { vpc } from "./hub";
449
+ export const b = new Alb({ vpcId: vpc.vpcId });
450
+ `,
451
+ );
452
+ await writeFile(
453
+ join(testDir, "c.ts"),
454
+ `
455
+ import { Alb } from "./resources";
456
+ import { vpc } from "./hub";
457
+ export const c = new Alb({ vpcId: vpc.vpcId });
458
+ `,
459
+ );
460
+
461
+ const session = createFoldSession();
462
+ const hubResult = await tryFoldFile(join(testDir, "hub.ts"), [], session);
463
+ const bResult = await tryFoldFile(join(testDir, "b.ts"), [], session);
464
+ const cResult = await tryFoldFile(join(testDir, "c.ts"), [], session);
465
+
466
+ expect(hubResult.ok).toBe(true);
467
+ expect(bResult.ok).toBe(true);
468
+ expect(cResult.ok).toBe(true);
469
+ if (!hubResult.ok || !bResult.ok || !cResult.ok) return;
470
+
471
+ const [, hubVpc] = hubResult.entities[0];
472
+ const [, bEntity] = bResult.entities[0];
473
+ const [, cEntity] = cResult.entities[0];
474
+ const bVpcId = (bEntity as unknown as { props: { vpcId: AttrRef } }).props.vpcId;
475
+ const cVpcId = (cEntity as unknown as { props: { vpcId: AttrRef } }).props.vpcId;
476
+ // If the hub were re-resolved once per path (the un-memoized failure
477
+ // mode), each path would construct its OWN, distinct `Vpc` instance —
478
+ // these `toBe` checks would fail (not hang) in that case.
479
+ expect(bVpcId.parent.deref()).toBe(hubVpc);
480
+ expect(cVpcId.parent.deref()).toBe(hubVpc);
481
+ // hub.ts/b.ts/c.ts (3) plus the two shared fixture-support files each
482
+ // of them transitively imports — resources.ts, and (via its own
483
+ // absolute-path import, see `runtimePath`'s doc above) runtime.ts
484
+ // itself — each attempted/cached exactly ONCE too, not once per
485
+ // referrer. The point of this count isn't the literal number 5; it's
486
+ // that it's flat regardless of how many paths (b.ts AND c.ts) lead to
487
+ // the SAME file, which is what a broken/missing memo would violate.
488
+ expect(session.cache.size).toBe(5);
489
+ },
490
+ 5000,
491
+ );
492
+
493
+ // A wider fan-out (many files, all reaching one shared hub, plus a short
494
+ // dependency chain) than the minimal diamond above — cheap enough to stay
495
+ // well under the explicit timeout if resolution is O(files), but would
496
+ // measurably blow up if the hub (or the chain) were re-resolved once per
497
+ // referrer instead of once per session.
498
+ test(
499
+ "a wide fan-out to one shared hub resolves promptly (no combinatorial blowup)",
500
+ async () => {
501
+ await writeResourceDefs();
502
+ await writeFile(
503
+ join(testDir, "hub.ts"),
504
+ `
505
+ import { Vpc } from "./resources";
506
+ export const vpc = new Vpc({ cidr: "10.0.0.0/16" });
507
+ `,
508
+ );
509
+ const FANOUT = 15;
510
+ const files: string[] = [];
511
+ for (let i = 0; i < FANOUT; i++) {
512
+ const f = join(testDir, `leaf${i}.ts`);
513
+ await writeFile(
514
+ f,
515
+ `
516
+ import { Alb } from "./resources";
517
+ import { vpc } from "./hub";
518
+ export const leaf = new Alb({ vpcId: vpc.vpcId });
519
+ `,
520
+ );
521
+ files.push(f);
522
+ }
523
+
524
+ const session = createFoldSession();
525
+ const hubResult = await tryFoldFile(join(testDir, "hub.ts"), [], session);
526
+ expect(hubResult.ok).toBe(true);
527
+ if (!hubResult.ok) return;
528
+ const [, hubVpc] = hubResult.entities[0];
529
+
530
+ for (const f of files) {
531
+ const result = await tryFoldFile(f, [], session);
532
+ expect(result.ok).toBe(true);
533
+ if (!result.ok) continue;
534
+ const [, entity] = result.entities[0];
535
+ const vpcId = (entity as unknown as { props: { vpcId: AttrRef } }).props.vpcId;
536
+ expect(vpcId.parent.deref()).toBe(hubVpc);
537
+ }
538
+
539
+ // hub.ts + the two shared fixture-support files (resources.ts,
540
+ // runtime.ts — see the diamond test above) + FANOUT leaves. Flat in
541
+ // FANOUT's shared dependencies regardless of FANOUT's size is exactly
542
+ // the property a broken memo would violate.
543
+ expect(session.cache.size).toBe(FANOUT + 3);
544
+ },
545
+ 5000,
546
+ );
547
+
548
+ test("a namespace import's member access resolves cross-file (import * as ns)", async () => {
549
+ await writeResourceDefs();
550
+ await writeFile(
551
+ join(testDir, "ecr.ts"),
552
+ `
553
+ import { Bucket } from "./resources";
554
+ export const apiRepo = new Bucket({ name: "api-repo" });
555
+ export const uiRepo = new Bucket({ name: "ui-repo" });
556
+ `,
557
+ );
558
+ const file = join(testDir, "outputs.ts");
559
+ await writeFile(
560
+ file,
561
+ `
562
+ import { Alb } from "./resources";
563
+ import * as ecr from "./ecr";
564
+ export const alb = new Alb({ albArn: ecr.apiRepo.arn });
565
+ `,
566
+ );
567
+
568
+ const result = await tryFoldFile(file);
569
+
570
+ expect(result.ok).toBe(true);
571
+ if (!result.ok) return;
572
+ const [, entity] = result.entities[0];
573
+ const albArn = (entity as unknown as { props: { albArn: unknown } }).props.albArn;
574
+ expect(isAttrRefLike(albArn)).toBe(true);
575
+ });
576
+
577
+ test("a re-export chain (`export { x } from \"./y\"`) resolves cross-file, sharing the same instance", async () => {
578
+ await writeResourceDefs();
579
+ const implFile = join(testDir, "impl.ts");
580
+ await writeFile(
581
+ implFile,
582
+ `
583
+ import { Bucket } from "./resources";
584
+ export const bucket = new Bucket({ name: "impl-bucket" });
585
+ `,
586
+ );
587
+ const barrelFile = join(testDir, "barrel.ts");
588
+ await writeFile(barrelFile, `export { bucket } from "./impl";`);
589
+
590
+ const session = createFoldSession();
591
+ const implResult = await tryFoldFile(implFile, [], session);
592
+ const barrelResult = await tryFoldFile(barrelFile, [], session);
593
+
594
+ expect(implResult.ok).toBe(true);
595
+ expect(barrelResult.ok).toBe(true);
596
+ if (!implResult.ok || !barrelResult.ok) return;
597
+ expect(barrelResult.entities).toHaveLength(1);
598
+ const [name, barrelBucket] = barrelResult.entities[0];
599
+ expect(name).toBe("bucket");
600
+ const [, implBucket] = implResult.entities[0];
601
+ expect(barrelBucket).toBe(implBucket);
602
+ });
603
+
604
+ test("a re-export chain resolves transitively through more than one hop", async () => {
605
+ await writeResourceDefs();
606
+ await writeFile(
607
+ join(testDir, "impl.ts"),
608
+ `
609
+ import { Bucket } from "./resources";
610
+ export const bucket = new Bucket({ name: "impl-bucket" });
611
+ `,
612
+ );
613
+ await writeFile(join(testDir, "mid-barrel.ts"), `export { bucket } from "./impl";`);
614
+ const topBarrel = join(testDir, "top-barrel.ts");
615
+ await writeFile(topBarrel, `export { bucket as sharedBucket } from "./mid-barrel";`);
616
+
617
+ const result = await tryFoldFile(topBarrel);
618
+
619
+ expect(result.ok).toBe(true);
620
+ if (!result.ok) return;
621
+ expect(result.entities).toHaveLength(1);
622
+ const [name, entity] = result.entities[0];
623
+ expect(name).toBe("sharedBucket");
624
+ if (!isDeclarable(entity)) throw new Error("expected a Declarable");
625
+ expect((entity as unknown as { props: unknown }).props).toEqual({ name: "impl-bucket" });
626
+ });
627
+
628
+ // Explicit timeouts throughout this describe's cycle tests: a regression
629
+ // that broke cycle detection (rather than just mis-formatting its message)
630
+ // would hang, not fail — a bounded test timeout turns that into a fast,
631
+ // clearly-labeled failure instead of a stuck test run.
632
+ test(
633
+ "an import cycle produces a located FoldError naming the cycle path",
634
+ async () => {
635
+ await writeFile(
636
+ join(testDir, "a.ts"),
637
+ `
638
+ import { B_VALUE } from "./b";
639
+ export const A_VALUE = "a-" + B_VALUE;
640
+ `,
641
+ );
642
+ await writeFile(
643
+ join(testDir, "b.ts"),
644
+ `
645
+ import { A_VALUE } from "./a";
646
+ export const B_VALUE = "b-" + A_VALUE;
647
+ `,
648
+ );
649
+
650
+ const result = await tryFoldFile(join(testDir, "a.ts"));
651
+
652
+ expect(result.ok).toBe(false);
653
+ if (result.ok) return;
654
+ expect(result.reason).toContain("import cycle");
655
+ expect(result.reason).toContain("a.ts");
656
+ expect(result.reason).toContain("b.ts");
657
+ // Located — "line:col - message", the same `FoldError` formatting as
658
+ // every other fold rejection, not a bare unpositioned string.
659
+ expect(result.reason).toMatch(/\d+:\d+ - /);
660
+ },
661
+ 5000,
662
+ );
663
+
664
+ test(
665
+ "a self-cycle (a file whose own re-export chain reaches back to itself) is also detected",
666
+ async () => {
667
+ await writeFile(join(testDir, "a.ts"), `export { x } from "./b";`);
668
+ const file = join(testDir, "b.ts");
669
+ await writeFile(file, `export { x } from "./a";`);
670
+
671
+ const result = await tryFoldFile(file);
672
+
673
+ expect(result.ok).toBe(false);
674
+ if (result.ok) return;
675
+ expect(result.reason).toContain("import cycle");
676
+ },
677
+ 5000,
678
+ );
679
+
680
+ // A 3-file cycle where the loop closes several hops away from where it
681
+ // started (a -> b -> c -> a), not just a direct pair — proves cycle
682
+ // detection walks the whole `stack`, not just the immediate caller.
683
+ test(
684
+ "a longer cycle (three files) is detected, not just a direct pair",
685
+ async () => {
686
+ await writeFile(join(testDir, "a.ts"), `import { c } from "./c";\nexport const a = "a" + c;`);
687
+ await writeFile(join(testDir, "b.ts"), `import { a } from "./a";\nexport const b = "b" + a;`);
688
+ await writeFile(join(testDir, "c.ts"), `import { b } from "./b";\nexport const c = "c" + b;`);
689
+
690
+ const result = await tryFoldFile(join(testDir, "b.ts"));
691
+
692
+ expect(result.ok).toBe(false);
693
+ if (result.ok) return;
694
+ expect(result.reason).toContain("import cycle");
695
+ },
696
+ 5000,
697
+ );
698
+ });
699
+
700
+ // chant #1039 — the CLI fold path hardcoded `intrinsics` to `[]` at both
701
+ // `fold-import.ts` call sites, so a registered lexicon intrinsic tagged
702
+ // template (e.g. AWS `Sub`\`...\`) never folded in production even though
703
+ // `tryFoldFile` accepted an `intrinsics` parameter. These tests exercise
704
+ // that parameter end-to-end with a REAL tagged-template intrinsic — built on
705
+ // the same `Intrinsic`/`INTRINSIC_MARKER` contract every lexicon's own
706
+ // intrinsic classes implement (see `intrinsicPath` above), not a fabricated
707
+ // shape — rather than the hand-rolled `IntrinsicDef` used by `fold.test.ts`'s
708
+ // narrower unit tests of the reducer alone.
709
+ describe("tryFoldFile — registered intrinsic tagged templates (#1039)", () => {
710
+ let testDir: string;
711
+
712
+ beforeEach(async () => {
713
+ testDir = join(tmpdir(), `chant-fold-import-intrinsic-test-${Date.now()}-${Math.random()}`);
714
+ await mkdir(testDir, { recursive: true });
715
+ });
716
+
717
+ afterEach(async () => {
718
+ await rm(testDir, { recursive: true, force: true });
719
+ });
720
+
721
+ const SUB: IntrinsicDef = { name: "Sub", isTag: true, outputKey: "Test::Sub" };
722
+
723
+ /** A tagged-template intrinsic + pseudo-parameter namespace, shaped exactly like a real lexicon's (AWS's `Sub`/`AWS.StackName`): a class implementing `Intrinsic`, and a factory function invoked as `Sub`\`...\``. */
724
+ async function writeIntrinsicDefs(): Promise<void> {
725
+ await writeFile(
726
+ join(testDir, "intrinsics.ts"),
727
+ `
728
+ import { INTRINSIC_MARKER } from ${JSON.stringify(intrinsicPath)};
729
+
730
+ export class SubIntrinsic {
731
+ constructor(strings, values) {
732
+ this[INTRINSIC_MARKER] = true;
733
+ this.strings = strings;
734
+ this.values = values;
735
+ }
736
+ toJSON() {
737
+ let out = "";
738
+ for (let i = 0; i < this.strings.length; i++) {
739
+ out += this.strings[i];
740
+ if (i < this.values.length) out += String(this.values[i]);
741
+ }
742
+ return { "Test::Sub": out };
743
+ }
744
+ }
745
+
746
+ export function Sub(strings, ...values) {
747
+ return new SubIntrinsic(strings, values);
748
+ }
749
+
750
+ export const NS = { Region: "NS::Region" };
751
+ `,
752
+ );
753
+ }
754
+
755
+ async function writeResourceDefs(): Promise<void> {
756
+ await writeFile(
757
+ join(testDir, "resources.ts"),
758
+ `
759
+ import { createResource } from ${JSON.stringify(runtimePath)};
760
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
761
+ `,
762
+ );
763
+ }
764
+
765
+ test("a registered tag folds end-to-end to the real intrinsic's value — zero module execution", async () => {
766
+ await writeIntrinsicDefs();
767
+ await writeResourceDefs();
768
+ const file = join(testDir, "main.ts");
769
+ await writeFile(
770
+ file,
771
+ `
772
+ import { Bucket } from "./resources";
773
+ import { Sub, NS } from "./intrinsics";
774
+ throw new Error("must never execute — sentinel for #1039 fold verification");
775
+ export const bucket = new Bucket({ name: Sub\`\${NS.Region}-data\` });
776
+ `,
777
+ );
778
+
779
+ const result = await tryFoldFile(file, [SUB]);
780
+
781
+ expect(result.ok).toBe(true);
782
+ if (!result.ok) return;
783
+ expect(result.entities).toHaveLength(1);
784
+ const [name, entity] = result.entities[0];
785
+ expect(name).toBe("bucket");
786
+ if (!isDeclarable(entity)) throw new Error("expected a Declarable");
787
+ // The revived value must be a genuine, live `SubIntrinsic` instance —
788
+ // constructed by actually invoking the real `Sub` tag function through
789
+ // this file's own imports, with "NS::Region" resolved via its `NS`
790
+ // import — not the raw `{ __intrinsic, strings, values }` / `{ __symbol
791
+ // }` envelope fold() produces internally (that envelope is meant to be
792
+ // replayed, not serialized — see fold-import.ts's "Intrinsic revival"
793
+ // section). A serializer calls `.toJSON()` on it exactly like it would
794
+ // for any other lexicon intrinsic (e.g. AWS's real `Sub`).
795
+ const bucketName = (entity as unknown as { props: { name: unknown } }).props.name;
796
+ expect(bucketName).toBeInstanceOf(Object);
797
+ expect((bucketName as { toJSON(): unknown }).toJSON()).toEqual({ "Test::Sub": "NS::Region-data" });
798
+ });
799
+
800
+ test("an unregistered tag still falls back to run when no intrinsics are passed", async () => {
801
+ await writeIntrinsicDefs();
802
+ await writeResourceDefs();
803
+ const file = join(testDir, "main.ts");
804
+ await writeFile(
805
+ file,
806
+ `
807
+ import { Bucket } from "./resources";
808
+ import { Sub, NS } from "./intrinsics";
809
+ export const bucket = new Bucket({ name: Sub\`\${NS.Region}-data\` });
810
+ `,
811
+ );
812
+
813
+ // No second argument — reproduces the exact pre-#1039 production bug
814
+ // (the CLI fold path hardcoded this to `[]`).
815
+ const result = await tryFoldFile(file);
816
+
817
+ expect(result.ok).toBe(false);
818
+ if (result.ok) return;
819
+ expect(result.reason).toContain("unregistered tagged template intrinsic: Sub");
820
+ });
821
+
822
+ test("a same-file resource reference inside a folded intrinsic's interpolation falls back to run rather than folding to the wrong value", async () => {
823
+ await writeIntrinsicDefs();
824
+ await writeResourceDefs();
825
+ const file = join(testDir, "main.ts");
826
+ await writeFile(
827
+ file,
828
+ `
829
+ import { Bucket } from "./resources";
830
+ import { Sub } from "./intrinsics";
831
+ export const a = new Bucket({ name: "a" });
832
+ export const b = new Bucket({ name: Sub\`\${a.arn}-clone\` });
833
+ `,
834
+ );
835
+
836
+ const result = await tryFoldFile(file, [SUB]);
837
+
838
+ // Reviving would need a genuine live `AttrRef` wired to the sibling
839
+ // entity (a WeakRef into an object this file's fold hasn't necessarily
840
+ // constructed yet) — out of scope for #1039, same call as the existing
841
+ // "nested `new Type(...)` as a value" rejection. Falling back to run is
842
+ // always safe; silently folding to the raw envelope would not be.
843
+ expect(result.ok).toBe(false);
844
+ if (result.ok) return;
845
+ expect(result.reason).toContain("not foldable");
846
+ });
847
+ });
848
+
849
+ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
850
+ let testDir: string;
851
+
852
+ beforeEach(async () => {
853
+ testDir = join(tmpdir(), `chant-fold-import-buildparams-test-${Date.now()}-${Math.random()}`);
854
+ await mkdir(testDir, { recursive: true });
855
+ });
856
+
857
+ afterEach(async () => {
858
+ await rm(testDir, { recursive: true, force: true });
859
+ });
860
+
861
+ async function writeResourceDefs(): Promise<void> {
862
+ await writeFile(
863
+ join(testDir, "resources.ts"),
864
+ `
865
+ import { createResource } from ${JSON.stringify(runtimePath)};
866
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
867
+ `,
868
+ );
869
+ }
870
+
871
+ test("a direct params.<name> declarator folds to a LITERAL, not a symbolic node", async () => {
872
+ const file = join(testDir, "main.ts");
873
+ await writeFile(
874
+ file,
875
+ `
876
+ import { params } from ${JSON.stringify(paramsPath)};
877
+ export const tier = params.tier;
878
+ `,
879
+ );
880
+
881
+ const session = createFoldSession([], { tier: "production" });
882
+ const result = await tryFoldFile(file, [], session);
883
+
884
+ expect(result.ok).toBe(true);
885
+ if (!result.ok) return;
886
+ expect(result.exportedValues.get("tier")).toBe("production");
887
+ });
888
+
889
+ test("params.<name> nested inside a resource's props folds through the general reducer", async () => {
890
+ await writeResourceDefs();
891
+ const file = join(testDir, "main.ts");
892
+ await writeFile(
893
+ file,
894
+ `
895
+ import { Bucket } from "./resources";
896
+ import { params } from ${JSON.stringify(paramsPath)};
897
+ export const bucket = new Bucket({ name: params.env });
898
+ `,
899
+ );
900
+
901
+ const session = createFoldSession([], { env: "staging" });
902
+ const result = await tryFoldFile(file, [], session);
903
+
904
+ expect(result.ok).toBe(true);
905
+ if (!result.ok) return;
906
+ expect(result.entities).toHaveLength(1);
907
+ const [, entity] = result.entities[0];
908
+ if (!isDeclarable(entity)) throw new Error("expected a Declarable");
909
+ expect((entity as unknown as { props: { name: unknown } }).props.name).toBe("staging");
910
+ });
911
+
912
+ test("a nullish-coalesced default still folds to a literal (loomster's `params.x ?? \"default\"` pattern)", async () => {
913
+ const file = join(testDir, "main.ts");
914
+ await writeFile(
915
+ file,
916
+ `
917
+ import { params } from ${JSON.stringify(paramsPath)};
918
+ export const project = params.project ?? "loom";
919
+ `,
920
+ );
921
+
922
+ // No "project" key supplied — falls through to the literal default.
923
+ const session = createFoldSession([], {});
924
+ const result = await tryFoldFile(file, [], session);
925
+
926
+ expect(result.ok).toBe(true);
927
+ if (!result.ok) return;
928
+ expect(result.exportedValues.get("project")).toBe("loom");
929
+ });
930
+
931
+ test("an unrelated import also named `params` (not resolving to ../params.ts) is unaffected", async () => {
932
+ await writeFile(
933
+ join(testDir, "local-config.ts"),
934
+ `export const params = { tier: "should-not-be-used" };`,
935
+ );
936
+ const file = join(testDir, "main.ts");
937
+ await writeFile(
938
+ file,
939
+ `
940
+ import { params } from "./local-config";
941
+ export const tier = params.tier;
942
+ `,
943
+ );
944
+
945
+ const session = createFoldSession([], { tier: "production" });
946
+ const result = await tryFoldFile(file, [], session);
947
+
948
+ expect(result.ok).toBe(true);
949
+ if (!result.ok) return;
950
+ // Resolved via the ORDINARY cross-file project resolution, not the
951
+ // build-time-parameters substitution — proves the two never collide.
952
+ expect(result.exportedValues.get("tier")).toBe("should-not-be-used");
953
+ });
954
+
955
+ test("a `params`-named import from an unrelated BARE specifier is left alone (text-match only, never resolved)", async () => {
956
+ // Regression guard: buildExternals must recognize the build-time-params
957
+ // bare specifier by an exact TEXT match, never by resolving an arbitrary
958
+ // bare specifier just because the imported binding happens to be named
959
+ // "params" — resolving every such specifier would reintroduce the exact
960
+ // cold bare-specifier-resolution cost chant#1020 already fixed (up to
961
+ // ~361s for a genuinely new package's first resolution in a process).
962
+ // "left-pad" is never installed in this repo, so if this DID try to
963
+ // resolve it, it would throw (caught) rather than hang — this test
964
+ // mainly documents the invariant; the perf regression itself was only
965
+ // observable via a real corpus/CI run, not a unit test.
966
+ const file = join(testDir, "main.ts");
967
+ await writeFile(
968
+ file,
969
+ `
970
+ import { params } from "left-pad";
971
+ export const tier = params.tier;
972
+ `,
973
+ );
974
+
975
+ const session = createFoldSession([], { tier: "production" });
976
+ const result = await tryFoldFile(file, [], session);
977
+
978
+ // Not substituted from build-time parameters (would be "production" if
979
+ // it were) — falls back to run via the ordinary unresolved-bare-specifier
980
+ // path, exactly as any other unimportable bare specifier would.
981
+ expect(result.ok).toBe(false);
982
+ });
983
+
984
+ test("without a FoldSession buildParams, params.<name> is an unresolved identifier (matches an ordinary bare import)", async () => {
985
+ const file = join(testDir, "main.ts");
986
+ await writeFile(
987
+ file,
988
+ `
989
+ import { params } from ${JSON.stringify(paramsPath)};
990
+ export const tier = params.tier;
991
+ `,
992
+ );
993
+
994
+ // No session passed at all — tryFoldFile creates a private one with no buildParams.
995
+ const result = await tryFoldFile(file);
996
+
997
+ expect(result.ok).toBe(false);
998
+ });
999
+
1000
+ test("real end-to-end parity: the fold path substitutes without ever importing ../params.ts, while a real run-fallback import sees the same values via setBuildParams", async () => {
1001
+ // Simulates what discover() does before either fold or run touches a file.
1002
+ const { setBuildParams } = await import("../params");
1003
+ setBuildParams({ tier: "production-ha" });
1004
+
1005
+ const file = join(testDir, "main.ts");
1006
+ await writeFile(
1007
+ file,
1008
+ `
1009
+ import { params } from ${JSON.stringify(paramsPath)};
1010
+ export const tier = params.tier;
1011
+ `,
1012
+ );
1013
+
1014
+ const session = createFoldSession([], { tier: "production-ha" });
1015
+ const folded = await tryFoldFile(file, [], session);
1016
+ expect(folded.ok).toBe(true);
1017
+ if (folded.ok) expect(folded.exportedValues.get("tier")).toBe("production-ha");
1018
+
1019
+ // A real import of the shared module (what a run-fallback file's own
1020
+ // `import()` would do) observes the identical value — same object,
1021
+ // mutated in place, not re-bound.
1022
+ expect(sharedParams.tier).toBe("production-ha");
1023
+
1024
+ setBuildParams({});
1025
+ });
1026
+ });