@intentius/chant 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/graph.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/config.d.ts +46 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +153 -17
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  13. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +25 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  18. package/dist/env.d.ts +5 -2
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts +12 -0
  21. package/dist/fold/fold.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +29 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +100 -0
  27. package/dist/kubectl-context.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +31 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/live-diff.d.ts +25 -1
  33. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  34. package/dist/lifecycle/observe.d.ts +4 -2
  35. package/dist/lifecycle/observe.d.ts.map +1 -1
  36. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  37. package/dist/lifecycle/status.d.ts +26 -1
  38. package/dist/lifecycle/status.d.ts.map +1 -1
  39. package/dist/lifecycle/types.d.ts +8 -0
  40. package/dist/lifecycle/types.d.ts.map +1 -1
  41. package/dist/lint/config.d.ts +80 -0
  42. package/dist/lint/config.d.ts.map +1 -1
  43. package/dist/lint/policy.d.ts +8 -2
  44. package/dist/lint/policy.d.ts.map +1 -1
  45. package/dist/lint/post-synth.d.ts +18 -1
  46. package/dist/lint/post-synth.d.ts.map +1 -1
  47. package/dist/live-endpoint.d.ts +92 -0
  48. package/dist/live-endpoint.d.ts.map +1 -0
  49. package/dist/observation.d.ts +123 -0
  50. package/dist/observation.d.ts.map +1 -0
  51. package/dist/stack-output.d.ts +9 -4
  52. package/dist/stack-output.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/commands/build.test.ts +190 -0
  55. package/src/cli/commands/build.ts +34 -2
  56. package/src/cli/commands/lint.ts +17 -25
  57. package/src/cli/handlers/components.test.ts +63 -4
  58. package/src/cli/handlers/components.ts +78 -35
  59. package/src/cli/handlers/graph.test.ts +69 -6
  60. package/src/cli/handlers/graph.ts +61 -27
  61. package/src/cli/handlers/lifecycle.test.ts +285 -6
  62. package/src/cli/handlers/lifecycle.ts +297 -185
  63. package/src/config.test.ts +75 -0
  64. package/src/config.ts +61 -3
  65. package/src/discovery/fold-composite.test.ts +594 -0
  66. package/src/discovery/fold-import.ts +987 -43
  67. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  68. package/src/discovery/sandbox/config-run.ts +3 -0
  69. package/src/discovery/sandbox/config-wire.ts +3 -2
  70. package/src/discovery/sandbox/fork.ts +75 -1
  71. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  72. package/src/discovery/sandbox/policy-run.ts +15 -1
  73. package/src/discovery/sandbox/run.test.ts +85 -1
  74. package/src/discovery/sandbox/run.ts +3 -0
  75. package/src/env.test.ts +12 -0
  76. package/src/env.ts +12 -4
  77. package/src/fold/fold.ts +12 -2
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +68 -12
  80. package/src/index.ts +1 -0
  81. package/src/kubectl-context.test.ts +94 -0
  82. package/src/kubectl-context.ts +207 -0
  83. package/src/lexicon.ts +41 -6
  84. package/src/lifecycle/change-set.test.ts +93 -1
  85. package/src/lifecycle/change-set.ts +65 -13
  86. package/src/lifecycle/live-diff.test.ts +39 -0
  87. package/src/lifecycle/live-diff.ts +51 -5
  88. package/src/lifecycle/observe.test.ts +74 -3
  89. package/src/lifecycle/observe.ts +82 -22
  90. package/src/lifecycle/snapshot.test.ts +39 -1
  91. package/src/lifecycle/snapshot.ts +34 -9
  92. package/src/lifecycle/status.test.ts +89 -8
  93. package/src/lifecycle/status.ts +53 -3
  94. package/src/lifecycle/types.ts +8 -0
  95. package/src/lint/config.test.ts +93 -1
  96. package/src/lint/config.ts +108 -0
  97. package/src/lint/policy.test.ts +90 -0
  98. package/src/lint/policy.ts +17 -5
  99. package/src/lint/post-synth.test.ts +4 -0
  100. package/src/lint/post-synth.ts +30 -1
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +76 -3
  106. package/src/stack-output.ts +59 -26
@@ -0,0 +1,594 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import * as ts from "typescript";
6
+ import { discover } from "./index";
7
+ import { build } from "../build";
8
+ import { walkValue } from "../serializer-walker";
9
+ import type { Declarable } from "../declarable";
10
+ import type { Serializer } from "../serializer";
11
+ import { foldExecutionCounts, resetFoldExecutionCounts, findFactorySubsetViolation } from "./fold-import";
12
+
13
+ /**
14
+ * chant #1023 (epic #1019 Phase 5) — composite factory INTERPRETATION.
15
+ *
16
+ * `resolveCallExpression` used to have exactly one way to get a composite's
17
+ * value: import the defining module and call the factory, in the CLI's own
18
+ * process. That is the execution chant #1093 documented and #1111 could only
19
+ * fence off by DEMOTING the file to the sandboxed child. Interpretation
20
+ * removes the call for factories whose bodies stay inside the admissible
21
+ * subset — see fold-import.ts's contract block for the five rules.
22
+ *
23
+ * These tests observe the removal directly rather than inferring it, borrowing
24
+ * ./sandbox/fold-boundary.test.ts's discipline: the composite's defining
25
+ * module sets a `globalThis` marker at its top level, so "marker set" is
26
+ * exactly "this module was imported and run here". The inadmissible variant is
27
+ * asserted FIRST, so the probe is proven capable of firing before anything
28
+ * asserts that it doesn't.
29
+ *
30
+ * The defining modules deliberately sit OUTSIDE the discovered source
31
+ * directory. Inside it they would be discovered files in their own right —
32
+ * and a module whose job is to export a factory function never folds, so
33
+ * discovery would import it regardless and the marker would fire for a reason
34
+ * that has nothing to do with the factory call.
35
+ */
36
+
37
+ const thisDir = dirname(fileURLToPath(import.meta.url));
38
+ const repoRoot = resolve(thisDir, "../../../..");
39
+ const compositePath = resolve(thisDir, "../composite");
40
+
41
+ /**
42
+ * The real AWS lexicon — an ACTIVE lexicon of every build below, so its
43
+ * constructors sit on chant #1093's trusted allowlist exactly as they do for a
44
+ * real project, and the `--sandbox` assertions are about interpretation rather
45
+ * than about a stand-in nobody would ship. `Bucket`/`Role` carry genuine `Arn`
46
+ * attributes, so a sibling reference inside a factory body produces a genuine
47
+ * `AttrRef`; `Tag` is a property-kind class, for the nested-construction case.
48
+ */
49
+ const LEXICON = "@intentius/chant-lexicon-aws";
50
+ const LEXICON_NAME = "aws";
51
+
52
+ const MODULE_MARKER = "__chant1023CompositeModuleEvaluated";
53
+
54
+ type MarkerHost = Record<string, boolean | undefined>;
55
+
56
+ function moduleRan(): boolean | undefined {
57
+ return (globalThis as unknown as MarkerHost)[MODULE_MARKER];
58
+ }
59
+
60
+ function clearMarker(): void {
61
+ delete (globalThis as unknown as MarkerHost)[MODULE_MARKER];
62
+ }
63
+
64
+ /** A serializer that renders every entity's real, ref-resolved props — enough to compare a fold build against a run build byte for byte. */
65
+ const specSerializer: Serializer = {
66
+ // Named for the lexicon whose entities it serializes — `build()` selects a
67
+ // serializer by the entities' own `lexicon`, not by an arbitrary label.
68
+ name: LEXICON_NAME,
69
+ rulePrefix: "TEST",
70
+ serialize: (entities) => {
71
+ const names = new Map<Declarable, string>();
72
+ for (const [name, entity] of entities) names.set(entity, name);
73
+ const out: Record<string, unknown> = {};
74
+ for (const [name, entity] of [...entities].sort(([a], [b]) => a.localeCompare(b))) {
75
+ out[name] = {
76
+ type: entity.entityType,
77
+ props: walkValue((entity as unknown as { props: unknown }).props, names, {
78
+ attrRef: (logicalName, attribute) => ({ GetAtt: [logicalName, attribute] }),
79
+ resourceRef: (logicalName) => ({ Ref: logicalName }),
80
+ propertyDeclarable: (e, walk) => walk((e as unknown as { props: unknown }).props),
81
+ }),
82
+ };
83
+ }
84
+ return JSON.stringify(out, null, 2);
85
+ },
86
+ };
87
+
88
+ let seq = 0;
89
+
90
+ describe("composite factory interpretation (chant #1023)", () => {
91
+ let testDir: string;
92
+ let srcDir: string;
93
+
94
+ beforeEach(async () => {
95
+ // Inside the repo (`.cache/` is gitignored), not the system tmpdir, for one
96
+ // reason: the fixtures import the REAL lexicon package above, which only
97
+ // resolves from a directory whose `node_modules` walk reaches this
98
+ // checkout's.
99
+ const dir = join(repoRoot, ".cache", `chant-1023-${process.pid}-${seq++}`);
100
+ await rm(dir, { recursive: true, force: true });
101
+ await mkdir(dir, { recursive: true });
102
+ testDir = await realpath(dir);
103
+ srcDir = join(testDir, "src");
104
+ await mkdir(srcDir, { recursive: true });
105
+ clearMarker();
106
+ resetFoldExecutionCounts();
107
+ });
108
+
109
+ afterEach(async () => {
110
+ clearMarker();
111
+ await rm(testDir, { recursive: true, force: true });
112
+ });
113
+
114
+ /** `composites.ts` — a PROJECT module defining one composite, plus the execution probe. */
115
+ async function writeComposite(body: string, extraExports = ""): Promise<void> {
116
+ await writeFile(
117
+ join(testDir, "composites.ts"),
118
+ `
119
+ import { Bucket, Role, Tag } from ${JSON.stringify(LEXICON)};
120
+ import { Composite } from ${JSON.stringify(compositePath)};
121
+
122
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
123
+
124
+ export const WebApp = Composite(${body}, "WebApp");
125
+ ${extraExports}
126
+ `,
127
+ );
128
+ }
129
+
130
+ async function writeMain(source: string): Promise<void> {
131
+ await writeFile(join(srcDir, "main.ts"), source);
132
+ }
133
+
134
+ const CALL_WEBAPP = `
135
+ import { WebApp } from "../composites";
136
+ export const web = WebApp({ name: "data", tags: [{ Key: "team", Value: "platform" }] });
137
+ `;
138
+
139
+ /**
140
+ * The canonical body: `const`s that construct resources, a live sibling
141
+ * reference, a `??` default, a spread, a nested property construction, a
142
+ * template, and a return of the member record. Everything the contract
143
+ * admits, in one factory.
144
+ */
145
+ const ADMISSIBLE_BODY = `(props) => {
146
+ const bucket = new Bucket({
147
+ BucketName: props.name,
148
+ VersioningConfiguration: props.versioning ?? { Status: "Enabled" },
149
+ Tags: [...(props.tags ?? []), { Key: "managed-by", Value: "chant" }],
150
+ });
151
+ const role = new Role({
152
+ RoleName: \`role-for-\${props.name}\`,
153
+ Description: bucket.Arn,
154
+ Tags: [new Tag({ Key: "owner", Value: props.name })],
155
+ });
156
+ return { bucket, role };
157
+ }`;
158
+
159
+ /** A body one `if` outside the subset — otherwise identical in what it produces. */
160
+ const INADMISSIBLE_BODY = `(props) => {
161
+ if (!props.name) { throw new Error("name required"); }
162
+ const bucket = new Bucket({ BucketName: props.name });
163
+ return { bucket };
164
+ }`;
165
+
166
+ // ───────────────────────────────────────────────────────────────────────
167
+ // The probe fires — an inadmissible factory still invokes, exactly as before.
168
+ // ───────────────────────────────────────────────────────────────────────
169
+
170
+ test("an INADMISSIBLE factory is still invoked in-process (the probe fires)", async () => {
171
+ await writeComposite(INADMISSIBLE_BODY);
172
+ await writeMain(CALL_WEBAPP);
173
+
174
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
175
+
176
+ expect(result.errors).toEqual([]);
177
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
178
+ expect(moduleRan(), "the defining module must have been imported and run here").toBe(true);
179
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 0, projectFactoryInvocations: 1 });
180
+ expect([...result.entities.keys()]).toEqual(["webBucket"]);
181
+ });
182
+
183
+ // ───────────────────────────────────────────────────────────────────────
184
+ // The removal itself.
185
+ // ───────────────────────────────────────────────────────────────────────
186
+
187
+ test("an admissible factory is interpreted — its module is never imported", async () => {
188
+ await writeComposite(ADMISSIBLE_BODY);
189
+ await writeMain(CALL_WEBAPP);
190
+
191
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
192
+
193
+ expect(result.errors).toEqual([]);
194
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
195
+ expect(moduleRan(), "composites.ts must NOT have been imported").toBeUndefined();
196
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 1, projectFactoryInvocations: 0 });
197
+ expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
198
+ });
199
+
200
+ test("a sibling reference inside the body is a LIVE AttrRef on the real instance", async () => {
201
+ await writeComposite(ADMISSIBLE_BODY);
202
+ await writeMain(CALL_WEBAPP);
203
+
204
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
205
+
206
+ const role = result.entities.get("webRole") as unknown as { props: { Description: unknown } };
207
+ const ref = role.props.Description as { getLogicalName?: () => string | undefined; attribute?: string };
208
+ expect(ref.getLogicalName?.()).toBe("webBucket");
209
+ expect(ref.attribute).toBe("Arn");
210
+ });
211
+
212
+ test("interpreted output is byte-identical to run output (the #1023 acceptance differential)", async () => {
213
+ await writeComposite(ADMISSIBLE_BODY);
214
+ await writeMain(CALL_WEBAPP);
215
+
216
+ const runResult = await build(srcDir, [specSerializer], undefined, { fold: false });
217
+ resetFoldExecutionCounts();
218
+ const foldResult = await build(srcDir, [specSerializer], undefined, {
219
+ fold: true,
220
+ lexicons: [LEXICON_NAME],
221
+ });
222
+
223
+ expect(runResult.errors).toEqual([]);
224
+ expect(foldResult.errors).toEqual([]);
225
+ // The comparison only means something if the fold half really interpreted.
226
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 1, projectFactoryInvocations: 0 });
227
+
228
+ const run = String(runResult.outputs.get(LEXICON_NAME));
229
+ const folded = String(foldResult.outputs.get(LEXICON_NAME));
230
+ expect(folded).toBe(run);
231
+ // …and is non-vacuous: the spec carries both members, the `??` default, the
232
+ // spread tags, the nested property construction and the cross-member ref.
233
+ expect(run).toContain("webBucket");
234
+ expect(run).toContain("managed-by");
235
+ expect(run).toContain("Enabled");
236
+ expect(run).toContain("GetAtt");
237
+ });
238
+
239
+ test("provenance is stamped from the source's own composite name", async () => {
240
+ await writeComposite(ADMISSIBLE_BODY);
241
+ await writeMain(CALL_WEBAPP);
242
+
243
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
244
+
245
+ const bucket = result.entities.get("webBucket") as unknown as Record<symbol, unknown>;
246
+ const provenance = bucket[Symbol.for("chant.provenance")] as { composite?: string } | undefined;
247
+ expect(provenance?.composite).toBe("WebApp");
248
+ });
249
+
250
+ test("a concise arrow body (no block) interprets", async () => {
251
+ await writeComposite(`(props) => ({ bucket: new Bucket({ BucketName: props.name }) })`);
252
+ await writeMain(CALL_WEBAPP);
253
+
254
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
255
+
256
+ expect(moduleRan()).toBeUndefined();
257
+ expect(foldExecutionCounts().factoryInterpretations).toBe(1);
258
+ expect([...result.entities.keys()]).toEqual(["webBucket"]);
259
+ });
260
+
261
+ test("a destructured props parameter interprets", async () => {
262
+ await writeComposite(`({ name }) => {
263
+ const bucket = new Bucket({ BucketName: name });
264
+ return { bucket };
265
+ }`);
266
+ await writeMain(CALL_WEBAPP);
267
+
268
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
269
+
270
+ expect(moduleRan()).toBeUndefined();
271
+ expect((result.entities.get("webBucket") as unknown as { props: { BucketName: string } }).props.BucketName).toBe(
272
+ "data",
273
+ );
274
+ });
275
+
276
+ // ───────────────────────────────────────────────────────────────────────
277
+ // Nesting.
278
+ // ───────────────────────────────────────────────────────────────────────
279
+
280
+ test("a nested composite interprets recursively, and neither module is imported", async () => {
281
+ await writeFile(
282
+ join(testDir, "inner.ts"),
283
+ `
284
+ import { Bucket } from ${JSON.stringify(LEXICON)};
285
+ import { Composite } from ${JSON.stringify(compositePath)};
286
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
287
+ export const Storage = Composite((props) => {
288
+ const bucket = new Bucket({ BucketName: props.name });
289
+ return { bucket };
290
+ }, "Storage");
291
+ `,
292
+ );
293
+ await writeFile(
294
+ join(testDir, "outer.ts"),
295
+ `
296
+ import { Role } from ${JSON.stringify(LEXICON)};
297
+ import { Composite } from ${JSON.stringify(compositePath)};
298
+ import { Storage } from "./inner";
299
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
300
+ export const Platform = Composite((props) => {
301
+ const storage = Storage({ name: props.name });
302
+ const role = new Role({ Description: storage.bucket.Arn });
303
+ return { storage, role };
304
+ }, "Platform");
305
+ `,
306
+ );
307
+ await writeMain(`
308
+ import { Platform } from "../outer";
309
+ export const plat = Platform({ name: "data" });
310
+ `);
311
+
312
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
313
+
314
+ expect(result.errors).toEqual([]);
315
+ expect(moduleRan(), "neither composite module may be imported").toBeUndefined();
316
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 2, projectFactoryInvocations: 0 });
317
+ // Nested expansion names members through both prefixes, exactly as the run
318
+ // path's `expandComposite` does.
319
+ expect([...result.entities.keys()].sort()).toEqual(["platRole", "platStorageBucket"]);
320
+ });
321
+
322
+ test("propagate() still merges shared props onto an interpreted instance", async () => {
323
+ await writeComposite(ADMISSIBLE_BODY);
324
+ await writeMain(`
325
+ import { propagate } from ${JSON.stringify(compositePath)};
326
+ import { WebApp } from "../composites";
327
+ export const web = propagate(WebApp({ name: "data" }), { Description: "prod" });
328
+ `);
329
+
330
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
331
+
332
+ expect(moduleRan()).toBeUndefined();
333
+ const bucket = result.entities.get("webBucket") as unknown as { props: { Description?: string } };
334
+ expect(bucket.props.Description).toBe("prod");
335
+ });
336
+
337
+ // ───────────────────────────────────────────────────────────────────────
338
+ // What deliberately still invokes. Each case asserts the FALLBACK is exact:
339
+ // the module ran, the file still folds, the entities are unchanged.
340
+ // ───────────────────────────────────────────────────────────────────────
341
+
342
+ test("a bare call to a plain exported function is not a registered composite (rule 2)", async () => {
343
+ await writeComposite(ADMISSIBLE_BODY, `export function makeApp(props) { return WebApp({ name: props.name }); }`);
344
+ await writeMain(`
345
+ import { makeApp } from "../composites";
346
+ export const web = makeApp({ name: "data" });
347
+ `);
348
+
349
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
350
+
351
+ expect(moduleRan(), "a plain function must still be invoked").toBe(true);
352
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 0, projectFactoryInvocations: 1 });
353
+ expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
354
+ });
355
+
356
+ test("a lexicon-package composite is deliberately not interpreted (rule 1)", async () => {
357
+ // `LambdaApi` is a composite the aws lexicon itself publishes. It folds —
358
+ // by invocation, in-process, which is exactly what #1093's allowlist
359
+ // permits for a package the CLI has already loaded.
360
+ await writeMain(`
361
+ import { LambdaApi } from ${JSON.stringify(LEXICON)};
362
+ export const api = LambdaApi({
363
+ name: "fn",
364
+ runtime: "nodejs20.x",
365
+ handler: "index.handler",
366
+ code: { ZipFile: "exports.handler = async () => ({});" },
367
+ });
368
+ `);
369
+
370
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
371
+
372
+ expect(result.errors).toEqual([]);
373
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
374
+ expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 0, projectFactoryInvocations: 0 });
375
+ expect(foldExecutionCounts().factoryInvocations).toBe(1);
376
+ expect(result.entities.size).toBeGreaterThan(0);
377
+ });
378
+
379
+ test("`Composite` shadowed by a project-local wrapper is not chant's (rule 2)", async () => {
380
+ await writeFile(
381
+ join(testDir, "composites.ts"),
382
+ `
383
+ import { Bucket } from ${JSON.stringify(LEXICON)};
384
+ import { Composite } from "./local-composite";
385
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
386
+ export const WebApp = Composite((props) => {
387
+ const bucket = new Bucket({ BucketName: props.name });
388
+ return { bucket };
389
+ }, "WebApp");
390
+ `,
391
+ );
392
+ await writeFile(
393
+ join(testDir, "local-composite.ts"),
394
+ `
395
+ import { Composite as ChantComposite } from ${JSON.stringify(compositePath)};
396
+ export function Composite(factory, name) { return ChantComposite(factory, name); }
397
+ `,
398
+ );
399
+ await writeMain(CALL_WEBAPP);
400
+
401
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
402
+
403
+ expect(moduleRan()).toBe(true);
404
+ expect(foldExecutionCounts().factoryInterpretations).toBe(0);
405
+ expect([...result.entities.keys()]).toEqual(["webBucket"]);
406
+ });
407
+
408
+ test("a body reference the defining module cannot resolve declines to invocation", async () => {
409
+ // `sharedTags` comes from a sibling that does NOT fold, so the factory's
410
+ // shape is admissible but the name is unresolvable — decline, then invoke.
411
+ await writeFile(
412
+ join(testDir, "unfoldable.ts"),
413
+ `export const sharedTags = [{ Key: "a", Value: "b" }]; export default 1;`,
414
+ );
415
+ await writeFile(
416
+ join(testDir, "composites.ts"),
417
+ `
418
+ import { Bucket } from ${JSON.stringify(LEXICON)};
419
+ import { Composite } from ${JSON.stringify(compositePath)};
420
+ import { sharedTags } from "./unfoldable";
421
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
422
+ export const WebApp = Composite((props) => {
423
+ const bucket = new Bucket({ BucketName: props.name, Tags: sharedTags });
424
+ return { bucket };
425
+ }, "WebApp");
426
+ `,
427
+ );
428
+ await writeMain(CALL_WEBAPP);
429
+
430
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
431
+
432
+ expect(moduleRan(), "an unresolvable body falls back to invoking, not to failing").toBe(true);
433
+ expect(foldExecutionCounts().factoryInterpretations).toBe(0);
434
+ expect((result.entities.get("webBucket") as unknown as { props: { Tags: unknown } }).props.Tags).toEqual([
435
+ { Key: "a", Value: "b" },
436
+ ]);
437
+ });
438
+
439
+ test("a module-level resource shared by every call declines rather than interpreting", async () => {
440
+ await writeFile(
441
+ join(testDir, "composites.ts"),
442
+ `
443
+ import { Bucket, Role } from ${JSON.stringify(LEXICON)};
444
+ import { Composite } from ${JSON.stringify(compositePath)};
445
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
446
+ const sharedBucket = new Bucket({ BucketName: "shared" });
447
+ export const WebApp = Composite((props) => {
448
+ const role = new Role({ Description: sharedBucket.Arn, RoleName: props.name });
449
+ return { role };
450
+ }, "WebApp");
451
+ `,
452
+ );
453
+ await writeMain(CALL_WEBAPP);
454
+
455
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
456
+
457
+ expect(moduleRan(), "a module-level singleton is the run path's to own").toBe(true);
458
+ expect(foldExecutionCounts().factoryInterpretations).toBe(0);
459
+ expect([...result.entities.keys()]).toEqual(["webRole"]);
460
+ });
461
+
462
+ test("a mutually recursive pair terminates instead of interpreting forever", async () => {
463
+ await writeFile(
464
+ join(testDir, "a.ts"),
465
+ `
466
+ import { Bucket } from ${JSON.stringify(LEXICON)};
467
+ import { Composite } from ${JSON.stringify(compositePath)};
468
+ import { B } from "./b";
469
+ export const A = Composite((props) => {
470
+ const inner = B({ name: props.name });
471
+ const bucket = new Bucket({ BucketName: props.name });
472
+ return { inner, bucket };
473
+ }, "A");
474
+ `,
475
+ );
476
+ await writeFile(
477
+ join(testDir, "b.ts"),
478
+ `
479
+ import { Composite } from ${JSON.stringify(compositePath)};
480
+ import { A } from "./a";
481
+ export const B = Composite((props) => {
482
+ const inner = A({ name: props.name });
483
+ return { inner };
484
+ }, "B");
485
+ `,
486
+ );
487
+ await writeMain(`
488
+ import { A } from "../a";
489
+ export const app = A({ name: "data" });
490
+ `);
491
+
492
+ // The only requirement is that it TERMINATES with a decision, either way.
493
+ const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
494
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))).toBeDefined();
495
+ });
496
+
497
+ // ───────────────────────────────────────────────────────────────────────
498
+ // The --sandbox delta this issue exists for.
499
+ // ───────────────────────────────────────────────────────────────────────
500
+
501
+ test("--sandbox FOLDS an interpretable project composite (chant #1093's residual, closed)", async () => {
502
+ await writeComposite(ADMISSIBLE_BODY);
503
+ await writeMain(CALL_WEBAPP);
504
+
505
+ const result = await discover(srcDir, { fold: true, sandbox: true, lexicons: [LEXICON_NAME] });
506
+
507
+ expect(result.errors).toEqual([]);
508
+ expect(
509
+ result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode,
510
+ "before #1023 this file was demoted to the sandboxed child",
511
+ ).toBe("fold");
512
+ expect(moduleRan()).toBeUndefined();
513
+ expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
514
+ });
515
+
516
+ test("--sandbox still demotes an INADMISSIBLE one, with the reason it had before", async () => {
517
+ await writeComposite(INADMISSIBLE_BODY);
518
+ await writeMain(CALL_WEBAPP);
519
+
520
+ const result = await discover(srcDir, { fold: true, sandbox: true, lexicons: [LEXICON_NAME] });
521
+
522
+ expect(result.errors).toEqual([]);
523
+ const main = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
524
+ expect(main?.mode).toBe("run");
525
+ expect(main?.reason).toContain("--sandbox");
526
+ expect(main?.reason).toContain("../composites");
527
+ expect(moduleRan()).toBeUndefined();
528
+ expect([...result.entities.keys()]).toEqual(["webBucket"]);
529
+ });
530
+ });
531
+
532
+ /**
533
+ * The shape half of the contract, on its own — the half a caller with no
534
+ * module graph could evaluate. Kept as a direct table so the enumerated rules
535
+ * in fold-import.ts's contract block have a line-by-line counterpart, rather
536
+ * than being reachable only through a filesystem fixture.
537
+ */
538
+ describe("findFactorySubsetViolation — the shape half of the contract (chant #1023)", () => {
539
+ function factoryOf(source: string): ts.ArrowFunction | ts.FunctionExpression {
540
+ const file = ts.createSourceFile("f.ts", `const F = ${source};`, ts.ScriptTarget.Latest, true);
541
+ const statement = file.statements[0] as ts.VariableStatement;
542
+ return statement.declarationList.declarations[0].initializer as ts.ArrowFunction;
543
+ }
544
+
545
+ const admissible = [
546
+ ["a concise object body", `(props) => ({ b: new Bucket({ name: props.name }) })`],
547
+ ["consts then return", `(props) => { const b = new Bucket({ n: props.n }); return { b }; }`],
548
+ ["a nested construction in a property", `(props) => ({ b: new Bucket({ enc: new Rule({ alg: "AES" }) }) })`],
549
+ ["a construction in an array", `(props) => ({ b: new Bucket({ rules: [new Rule({ alg: props.alg })] }) })`],
550
+ ["a spread and a `??` default", `(props) => ({ b: new Bucket({ t: [...(props.t ?? []), "x"] }) })`],
551
+ ["a ternary", `(props) => ({ b: new Bucket({ n: props.big ? "l" : "s" }) })`],
552
+ ["a template", `(props) => ({ b: new Bucket({ n: \`p-\${props.n}\` }) })`],
553
+ ["a tagged template", `(props) => ({ b: new Bucket({ n: Sub\`p-\${props.n}\` }) })`],
554
+ ["a nested composite call", `(props) => { const i = Inner({ n: props.n }); return { i }; }`],
555
+ ["a destructured props parameter", `({ n }) => ({ b: new Bucket({ n }) })`],
556
+ ["a destructured const", `(props) => { const { a } = props; return { b: new Bucket({ a }) }; }`],
557
+ ["no parameter at all", `() => ({ b: new Bucket({ n: "fixed" }) })`],
558
+ ["a function expression", `function (props) { return { b: new Bucket({ n: props.n }) }; }`],
559
+ ] as const;
560
+
561
+ for (const [label, source] of admissible) {
562
+ test(`admits ${label}`, () => {
563
+ expect(findFactorySubsetViolation(factoryOf(source))).toBeUndefined();
564
+ });
565
+ }
566
+
567
+ const rejected = [
568
+ ["an `if` statement", `(props) => { if (props.n) { return {}; } return {}; }`, "IfStatement"],
569
+ ["a `throw`", `(props) => { throw new Error("x"); const b = 1; return { b }; }`, "ThrowStatement"],
570
+ ["a `for` loop", `(props) => { for (const x of props.n) {} return {}; }`, "ForOfStatement"],
571
+ ["`let`", `(props) => { let b = 1; return { b }; }`, "`let`/`var`"],
572
+ ["a bare expression statement", `(props) => { doThing(); return {}; }`, "ExpressionStatement"],
573
+ ["an early return", `(props) => { return {}; const b = 1; }`, "early `return`"],
574
+ ["a method call", `(props) => ({ b: new Bucket({ t: props.t.map(f) }) })`, "call"],
575
+ ["a member-access constructor", `(props) => ({ b: new ns.Bucket({}) })`, "plain imported constructor"],
576
+ ["a computed key", `(props) => ({ b: new Bucket({ [props.k]: 1 }) })`, "computed"],
577
+ ["a dynamic element access", `(props) => ({ b: new Bucket({ n: props.t[props.i] }) })`, "dynamic element access"],
578
+ ["an unsupported operator", `(props) => ({ b: new Bucket({ n: props.a % 2 }) })`, "unsupported binary"],
579
+ ["an `await`", `(props) => ({ b: new Bucket({ n: await props.n }) })`, "unsupported expression"],
580
+ ["two parameters", `(props, extra) => ({})`, "single props parameter"],
581
+ ["a rest parameter", `(...props) => ({})`, "rest parameter"],
582
+ ["a defaulted parameter", `(props = {}) => ({})`, "defaulted parameter"],
583
+ ["a missing return", `(props) => { const b = new Bucket({}); }`, "must end in `return`"],
584
+ ["an empty body", `(props) => {}`, "no members to interpret"],
585
+ ] as const;
586
+
587
+ for (const [label, source, fragment] of rejected) {
588
+ test(`rejects ${label}`, () => {
589
+ const violation = findFactorySubsetViolation(factoryOf(source));
590
+ expect(violation, `${label} should be rejected`).toBeDefined();
591
+ expect(violation).toContain(fragment);
592
+ });
593
+ }
594
+ });