@intentius/chant 0.19.1 → 0.21.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 (164) 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 +71 -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/codegen/docs-types.d.ts +2 -0
  28. package/dist/codegen/docs-types.d.ts.map +1 -1
  29. package/dist/components/cli-support.d.ts +12 -5
  30. package/dist/components/cli-support.d.ts.map +1 -1
  31. package/dist/components/discover.d.ts +62 -7
  32. package/dist/components/discover.d.ts.map +1 -1
  33. package/dist/components/sandbox/driver.d.ts +12 -0
  34. package/dist/components/sandbox/driver.d.ts.map +1 -0
  35. package/dist/components/sandbox/run.d.ts +42 -0
  36. package/dist/components/sandbox/run.d.ts.map +1 -0
  37. package/dist/composite.d.ts +5 -0
  38. package/dist/composite.d.ts.map +1 -1
  39. package/dist/config.d.ts +71 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/declarable.d.ts +16 -0
  42. package/dist/declarable.d.ts.map +1 -1
  43. package/dist/discovery/collect.d.ts.map +1 -1
  44. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  45. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  46. package/dist/discovery/entity-wire.d.ts +50 -0
  47. package/dist/discovery/entity-wire.d.ts.map +1 -0
  48. package/dist/discovery/fold-import.d.ts +239 -0
  49. package/dist/discovery/fold-import.d.ts.map +1 -0
  50. package/dist/discovery/index.d.ts +74 -1
  51. package/dist/discovery/index.d.ts.map +1 -1
  52. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  53. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  55. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  56. package/dist/discovery/sandbox/driver.d.ts +13 -0
  57. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  58. package/dist/discovery/sandbox/run.d.ts +69 -0
  59. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  60. package/dist/errors.d.ts +9 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/fold/fold.d.ts +299 -0
  63. package/dist/fold/fold.d.ts.map +1 -0
  64. package/dist/fold/foldable-helpers.d.ts +121 -0
  65. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  66. package/dist/fold/subset.d.ts +134 -0
  67. package/dist/fold/subset.d.ts.map +1 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/lexicon-output.d.ts +7 -2
  71. package/dist/lexicon-output.d.ts.map +1 -1
  72. package/dist/lexicon-schema.d.ts +4 -2
  73. package/dist/lexicon-schema.d.ts.map +1 -1
  74. package/dist/lexicon.d.ts +131 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/lifecycle/release-ledger.d.ts +11 -0
  77. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  78. package/dist/lint/component-checks.d.ts +7 -1
  79. package/dist/lint/component-checks.d.ts.map +1 -1
  80. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  81. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  82. package/dist/params.d.ts +60 -0
  83. package/dist/params.d.ts.map +1 -0
  84. package/dist/provenance.d.ts +21 -0
  85. package/dist/provenance.d.ts.map +1 -1
  86. package/dist/runtime.d.ts +10 -1
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/package.json +2 -1
  89. package/src/build-params.test.ts +144 -0
  90. package/src/build-params.ts +207 -0
  91. package/src/build.test.ts +38 -0
  92. package/src/build.ts +144 -7
  93. package/src/cli/commands/build.test.ts +220 -2
  94. package/src/cli/commands/build.ts +111 -3
  95. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  96. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  97. package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
  98. package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
  99. package/src/cli/commands/check-lexicon.test.ts +34 -0
  100. package/src/cli/commands/check-lexicon.ts +137 -1
  101. package/src/cli/commands/lint.ts +31 -3
  102. package/src/cli/commands/onboard.ts +1 -1
  103. package/src/cli/component-security-boundary.test.ts +170 -0
  104. package/src/cli/handlers/build.ts +24 -3
  105. package/src/cli/handlers/components.ts +9 -2
  106. package/src/cli/handlers/dev.ts +1 -1
  107. package/src/cli/handlers/graph.ts +7 -5
  108. package/src/cli/handlers/lifecycle.ts +2 -2
  109. package/src/cli/handlers/lint.ts +2 -0
  110. package/src/cli/handlers/misc.ts +2 -2
  111. package/src/cli/handlers/run-client.ts +1 -1
  112. package/src/cli/handlers/run.ts +20 -5
  113. package/src/cli/main.test.ts +22 -0
  114. package/src/cli/main.ts +39 -0
  115. package/src/cli/plugins.ts +20 -4
  116. package/src/cli/registry.ts +10 -0
  117. package/src/cli/security-boundary.test.ts +135 -0
  118. package/src/codegen/docs-sections.test.ts +67 -0
  119. package/src/codegen/docs-sections.ts +7 -3
  120. package/src/codegen/docs-types.ts +2 -0
  121. package/src/components/cli-support.ts +22 -10
  122. package/src/components/discover.ts +127 -25
  123. package/src/components/sandbox/driver.ts +114 -0
  124. package/src/components/sandbox/run.test.ts +185 -0
  125. package/src/components/sandbox/run.ts +177 -0
  126. package/src/composite.test.ts +21 -0
  127. package/src/composite.ts +20 -1
  128. package/src/config.ts +81 -0
  129. package/src/declarable.ts +20 -0
  130. package/src/discovery/collect.ts +17 -3
  131. package/src/discovery/entity-wire-codec.ts +487 -0
  132. package/src/discovery/entity-wire.test.ts +240 -0
  133. package/src/discovery/entity-wire.ts +67 -0
  134. package/src/discovery/fold-import.test.ts +1598 -0
  135. package/src/discovery/fold-import.ts +1998 -0
  136. package/src/discovery/index.test.ts +191 -1
  137. package/src/discovery/index.ts +242 -1
  138. package/src/discovery/sandbox/bundle.ts +218 -0
  139. package/src/discovery/sandbox/child-errors.ts +65 -0
  140. package/src/discovery/sandbox/driver.ts +147 -0
  141. package/src/discovery/sandbox/run.test.ts +179 -0
  142. package/src/discovery/sandbox/run.ts +196 -0
  143. package/src/errors.ts +9 -1
  144. package/src/fold/fold.test.ts +812 -0
  145. package/src/fold/fold.ts +805 -0
  146. package/src/fold/foldable-helpers.ts +171 -0
  147. package/src/fold/subset-doc-parity.test.ts +210 -0
  148. package/src/fold/subset.test.ts +352 -0
  149. package/src/fold/subset.ts +383 -0
  150. package/src/index.ts +2 -0
  151. package/src/lexicon-output.ts +7 -2
  152. package/src/lexicon-schema.test.ts +57 -0
  153. package/src/lexicon-schema.ts +8 -1
  154. package/src/lexicon.ts +132 -1
  155. package/src/lifecycle/git.test.ts +10 -5
  156. package/src/lifecycle/release-ledger.test.ts +28 -0
  157. package/src/lifecycle/release-ledger.ts +11 -0
  158. package/src/lint/component-checks.ts +8 -1
  159. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  160. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  161. package/src/params.test.ts +22 -0
  162. package/src/params.ts +66 -0
  163. package/src/provenance.ts +22 -0
  164. package/src/runtime.ts +11 -2
@@ -1,8 +1,9 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
2
  import { discover } from "./index";
3
3
  import { mkdir, writeFile, rm } from "node:fs/promises";
4
- import { join } from "node:path";
4
+ import { join, dirname, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { DECLARABLE_MARKER } from "../declarable";
7
8
 
8
9
  describe("discover", () => {
@@ -270,3 +271,192 @@ describe("discover", () => {
270
271
  expect(deps2?.size).toBe(0);
271
272
  });
272
273
  });
274
+
275
+ describe("discover — fold mode (#1022, epic #1019)", () => {
276
+ let testDir: string;
277
+ const thisDir = dirname(fileURLToPath(import.meta.url));
278
+ const runtimePath = resolve(thisDir, "../runtime");
279
+ const compositePath = resolve(thisDir, "../composite");
280
+
281
+ beforeEach(async () => {
282
+ testDir = join(tmpdir(), `chant-discover-fold-test-${Date.now()}-${Math.random()}`);
283
+ await mkdir(testDir, { recursive: true });
284
+ });
285
+
286
+ afterEach(async () => {
287
+ await rm(testDir, { recursive: true, force: true });
288
+ });
289
+
290
+ test("default (flag omitted) leaves foldDecisions empty and behavior unchanged", async () => {
291
+ await writeFile(
292
+ join(testDir, "app.ts"),
293
+ `
294
+ export const entity = {
295
+ entityType: "Entity",
296
+ [Symbol.for("chant.declarable")]: true,
297
+ };
298
+ `,
299
+ );
300
+
301
+ const result = await discover(testDir);
302
+
303
+ expect(result.entities.size).toBe(1);
304
+ expect(result.foldDecisions).toEqual([]);
305
+ });
306
+
307
+ test("folds a leaf-only module with zero execution (throw sentinel never fires)", async () => {
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
+ `,
314
+ );
315
+ await writeFile(
316
+ join(testDir, "main.ts"),
317
+ `
318
+ import { Bucket } from "./resources";
319
+ throw new Error("must never execute — sentinel for #1022 fold verification");
320
+ export const bucket = new Bucket({ name: "my-bucket" });
321
+ `,
322
+ );
323
+
324
+ // Sanity check: running this directory the normal way really does fire
325
+ // the sentinel (a collected import error), proving fold isn't just
326
+ // accidentally skipping a no-op file.
327
+ const ran = await discover(testDir);
328
+ expect(ran.errors.some((e) => e.message.includes("must never execute"))).toBe(true);
329
+
330
+ const result = await discover(testDir, { fold: true });
331
+
332
+ expect(result.errors).toEqual([]);
333
+ expect(result.entities.size).toBe(1);
334
+ expect(result.entities.has("bucket")).toBe(true);
335
+ const bucket = result.entities.get("bucket")!;
336
+ expect((bucket as unknown as { lexicon: string }).lexicon).toBe("aws");
337
+ expect((bucket as unknown as { entityType: string }).entityType).toBe("Test::Bucket");
338
+
339
+ expect(result.foldDecisions).toHaveLength(2);
340
+ const mainDecision = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
341
+ expect(mainDecision?.mode).toBe("fold");
342
+ expect(mainDecision?.resourceCount).toBe(1);
343
+ });
344
+
345
+ // chant #1023 (epic #1019 Phase 5): a bare composite factory call now
346
+ // folds too — the factory is resolved through the file's imports and
347
+ // invoked for real with statically-folded props, exactly as a resource
348
+ // constructor already was (#1022). Fold and run must still agree
349
+ // byte-for-byte on the expanded entities (this is the unit-level version
350
+ // of the #1025 fold-vs-run differential).
351
+ test("a module instantiating a composite folds too; output is unchanged", async () => {
352
+ await writeFile(
353
+ join(testDir, "composites.ts"),
354
+ `
355
+ import { createResource } from ${JSON.stringify(runtimePath)};
356
+ import { Composite } from ${JSON.stringify(compositePath)};
357
+ const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
358
+ export const MyStack = Composite<{ name: string }>((props) => {
359
+ const bucket = new Bucket({ name: props.name });
360
+ return { bucket };
361
+ }, "MyStack");
362
+ `,
363
+ );
364
+ await writeFile(
365
+ join(testDir, "stack.ts"),
366
+ `
367
+ import { MyStack } from "./composites";
368
+ export const stack = MyStack({ name: "composite-bucket" });
369
+ `,
370
+ );
371
+
372
+ const withoutFold = await discover(testDir);
373
+ const withFold = await discover(testDir, { fold: true });
374
+
375
+ expect(withFold.errors).toEqual([]);
376
+ expect(withFold.entities.size).toBe(withoutFold.entities.size);
377
+ expect([...withFold.entities.keys()].sort()).toEqual([...withoutFold.entities.keys()].sort());
378
+ expect(withFold.entities.has("stackBucket")).toBe(true);
379
+ const folded = withFold.entities.get("stackBucket")! as unknown as { props: { name: string } };
380
+ const run = withoutFold.entities.get("stackBucket")! as unknown as { props: { name: string } };
381
+ expect(folded.props).toEqual(run.props);
382
+
383
+ const stackDecision = withFold.foldDecisions.find((d) => d.file.endsWith("stack.ts"));
384
+ expect(stackDecision?.mode).toBe("fold");
385
+ expect(stackDecision?.resourceCount).toBe(1);
386
+ });
387
+
388
+ test("a composite factory defined locally (not resolvable via import) still falls back to run; output is unchanged", async () => {
389
+ await writeFile(
390
+ join(testDir, "stack.ts"),
391
+ `
392
+ import { createResource } from ${JSON.stringify(runtimePath)};
393
+ import { Composite } from ${JSON.stringify(compositePath)};
394
+ const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
395
+ const LocalStack = Composite<{ name: string }>((props) => {
396
+ const bucket = new Bucket({ name: props.name });
397
+ return { bucket };
398
+ }, "LocalStack");
399
+ export const stack = LocalStack({ name: "composite-bucket" });
400
+ `,
401
+ );
402
+
403
+ const withoutFold = await discover(testDir);
404
+ const withFold = await discover(testDir, { fold: true });
405
+
406
+ expect(withFold.errors).toEqual([]);
407
+ expect(withFold.entities.size).toBe(withoutFold.entities.size);
408
+ expect([...withFold.entities.keys()].sort()).toEqual([...withoutFold.entities.keys()].sort());
409
+ expect(withFold.entities.has("stackBucket")).toBe(true);
410
+ const folded = withFold.entities.get("stackBucket")! as unknown as { props: { name: string } };
411
+ const run = withoutFold.entities.get("stackBucket")! as unknown as { props: { name: string } };
412
+ expect(folded.props).toEqual(run.props);
413
+
414
+ const stackDecision = withFold.foldDecisions.find((d) => d.file.endsWith("stack.ts"));
415
+ expect(stackDecision?.mode).toBe("run");
416
+ expect(stackDecision?.reason).toContain("LocalStack");
417
+ });
418
+
419
+ // chant #1020 — the full `discover()` pipeline, not just `tryFoldFile` in
420
+ // isolation: two files, one importing a resource attribute from the
421
+ // other, both fold, and `resolveAttrRefs` (the same identity-matching pass
422
+ // the run path always used) must assign the cross-file AttrRef the
423
+ // correct logical name with ZERO special-casing — proof the shared
424
+ // `FoldSession` this module wires into every top-level `tryFoldFile` call
425
+ // really does give `alb.ts` and `network.ts` the same `vpc` object.
426
+ test("cross-file resource reference resolves end-to-end: both files fold and the AttrRef gets the right logical name", async () => {
427
+ await writeFile(
428
+ join(testDir, "resources.ts"),
429
+ `
430
+ import { createResource } from ${JSON.stringify(runtimePath)};
431
+ export const Vpc = createResource("Test::Vpc", "aws", { vpcId: "VpcId" });
432
+ export const Alb = createResource("Test::Alb", "aws", { albArn: "AlbArn" });
433
+ `,
434
+ );
435
+ await writeFile(
436
+ join(testDir, "network.ts"),
437
+ `
438
+ import { Vpc } from "./resources";
439
+ export const vpc = new Vpc({ cidr: "10.0.0.0/16" });
440
+ `,
441
+ );
442
+ await writeFile(
443
+ join(testDir, "alb.ts"),
444
+ `
445
+ import { Alb } from "./resources";
446
+ import { vpc } from "./network";
447
+ export const alb = new Alb({ vpcId: vpc.vpcId });
448
+ `,
449
+ );
450
+
451
+ const result = await discover(testDir, { fold: true });
452
+
453
+ expect(result.errors).toEqual([]);
454
+ expect(result.foldDecisions.every((d) => d.mode === "fold")).toBe(true);
455
+ expect(result.entities.has("vpc")).toBe(true);
456
+ expect(result.entities.has("alb")).toBe(true);
457
+
458
+ const alb = result.entities.get("alb")! as unknown as { props: { vpcId: { getLogicalName(): string | undefined; attribute: string } } };
459
+ expect(alb.props.vpcId.getLogicalName()).toBe("vpc");
460
+ expect(alb.props.vpcId.attribute).toBe("VpcId");
461
+ });
462
+ });
@@ -1,10 +1,86 @@
1
1
  import type { Declarable } from "../declarable";
2
2
  import type { DiscoveryError } from "../errors";
3
+ import type { IntrinsicDef } from "../lexicon";
3
4
  import { findInfraFiles } from "./files";
4
5
  import { importModule } from "./import";
5
6
  import { collectEntities } from "./collect";
6
7
  import { resolveAttrRefs } from "./resolve";
7
8
  import { buildDependencyGraph } from "./graph";
9
+ import { tryFoldFile, planFoldTaint, createFoldSession } from "./fold-import";
10
+ import { getProvenance } from "../provenance";
11
+ import type { BuildParamProvenance } from "../provenance";
12
+ import { buildParamValues } from "../build-params";
13
+ import { setBuildParams } from "../params";
14
+
15
+ /**
16
+ * Per-file fold-vs-run outcome (chant #1022, epic #1019), populated only
17
+ * when {@link DiscoveryOptions.fold} was requested. Lets a caller (`chant
18
+ * build --fold`) report which files skipped module execution and which
19
+ * fell back to the run path, and why.
20
+ */
21
+ export interface FoldDecision {
22
+ /** Absolute path of the source file this decision covers. */
23
+ file: string;
24
+ /** "fold" — folded statically, zero execution. "run" — imported/executed as before. */
25
+ mode: "fold" | "run";
26
+ /** Why the file fell back to run. Present only when `mode === "run"` and fold was requested. */
27
+ reason?: string;
28
+ /** Number of entities the fold produced. Present only when `mode === "fold"`. */
29
+ resourceCount?: number;
30
+ }
31
+
32
+ /**
33
+ * Optional inputs to {@link discover}.
34
+ */
35
+ export interface DiscoveryOptions {
36
+ /**
37
+ * chant #1022/#1023 (epic #1019) — opt-in: for each source file, try to
38
+ * fold it to `Declarable`/`CompositeInstance` entities statically (no
39
+ * module execution) before falling back to importing/running it. Anything
40
+ * the folder can't represent (a non-`new`, non-composite-call export, a
41
+ * cross-file-only reference, …) falls back per-file — see
42
+ * {@link planFoldTaint} for the one case where a file that WOULD fold in
43
+ * isolation is still forced back to run, to keep fold and run from ever
44
+ * disagreeing about a shared entity's identity. Default `false` — behavior
45
+ * is unchanged unless requested.
46
+ */
47
+ fold?: boolean;
48
+
49
+ /**
50
+ * chant #1039 — lexicon-registered intrinsic tags (e.g. AWS's `Sub`) to
51
+ * recognize while folding. Threaded down to `tryFoldFile`/the static
52
+ * folder so a registered tagged template folds instead of falling back to
53
+ * run. Only meaningful when {@link fold} is set; ignored otherwise.
54
+ * Default: none (an intrinsic-using file still folds up to that point,
55
+ * then falls back to run at the unregistered tag).
56
+ */
57
+ intrinsics?: IntrinsicDef[];
58
+
59
+ /**
60
+ * chant #1045 Phase 2 — opt-in: whatever would otherwise reach the
61
+ * in-process `importModule` step (every file, when {@link fold} isn't set;
62
+ * only the per-file run-fallback remainder, when it is) instead runs
63
+ * together, isolated, in one sandboxed child process — see
64
+ * `./sandbox/run.ts`. Folded files are unaffected: fold already executes
65
+ * zero of a file's own top-level code, so it stays in this process exactly
66
+ * as it does today. Default `false` — behavior, including performance
67
+ * (no bundling, no child process, no IPC), is unchanged unless requested.
68
+ */
69
+ sandbox?: boolean;
70
+
71
+ /**
72
+ * chant #1064 — this build's resolved build-time parameter values (see
73
+ * ../build-params.ts's `resolveBuildParams`, driven by the CLI's
74
+ * `--param`/`--params-file`/declared `env` mapping/`chant.config.ts`
75
+ * defaults). Populated into `../params.ts`'s shared `params` object
76
+ * (`setBuildParams`, below) before any project file is imported or folded,
77
+ * and threaded into the fold session so a `params.<name>` reference
78
+ * resolves to a literal instead of an unresolved identifier. Default: none
79
+ * — `params` stays empty, matching a project that declares no
80
+ * `buildParams` at all.
81
+ */
82
+ buildParams?: BuildParamProvenance[];
83
+ }
8
84
 
9
85
  /**
10
86
  * Result of the discovery process
@@ -18,6 +94,11 @@ export interface DiscoveryResult {
18
94
  sourceFiles: string[];
19
95
  /** Array of errors encountered during discovery */
20
96
  errors: DiscoveryError[];
97
+ /**
98
+ * Per-file fold-vs-run decisions (#1022). Empty unless
99
+ * {@link DiscoveryOptions.fold} was set.
100
+ */
101
+ foldDecisions: FoldDecision[];
21
102
  }
22
103
 
23
104
  /**
@@ -26,11 +107,25 @@ export interface DiscoveryResult {
26
107
  * a dependency graph.
27
108
  *
28
109
  * @param path - The directory path to discover entities in
110
+ * @param options - Optional discovery behavior, e.g. {@link DiscoveryOptions.fold}
29
111
  * @returns DiscoveryResult with entities, dependencies, sourceFiles, and errors
30
112
  */
31
- export async function discover(path: string): Promise<DiscoveryResult> {
113
+ export async function discover(path: string, options?: DiscoveryOptions): Promise<DiscoveryResult> {
32
114
  const errors: DiscoveryError[] = [];
33
115
  const sourceFiles: string[] = [];
116
+ const foldDecisions: FoldDecision[] = [];
117
+
118
+ // chant #1064 — populate the shared build-time-parameters object BEFORE any
119
+ // project file is imported or folded, so both paths observe the identical
120
+ // values: the fold path substitutes them directly (see
121
+ // fold-import.ts's `buildExternals`), and a run(-fallback) file that
122
+ // imports "@intentius/chant/params" for real sees them too, since
123
+ // `setBuildParams` mutates the shared object in place rather than
124
+ // rebinding it. Unconditional (not just when `buildParams` is set) so a
125
+ // stale value from a PRIOR `discover()` call in the same process (tests,
126
+ // `--watch`) never leaks into a build that supplied none.
127
+ const buildParamValuesMap = buildParamValues(options?.buildParams ?? []);
128
+ setBuildParams(buildParamValuesMap);
34
129
 
35
130
  // Step 1: Scan for TypeScript files
36
131
  const files = await findInfraFiles(path);
@@ -38,8 +133,72 @@ export async function discover(path: string): Promise<DiscoveryResult> {
38
133
 
39
134
  // Step 2: Import all modules
40
135
  const modules: Array<{ file: string; exports: Record<string, unknown> }> = [];
136
+ // chant #1045 Phase 2 — files that would reach the in-process importModule()
137
+ // call below are instead queued here when options.sandbox is set, and run
138
+ // together afterward in one isolated child (see the merge step after
139
+ // collectEntities).
140
+ const sandboxFiles: string[] = [];
141
+
142
+ // Fold path (#1022/#1023): try the static folder on EVERY file first,
143
+ // independently, before committing any decision. A file that folds
144
+ // successfully in isolation must still be forced back to run if some
145
+ // OTHER file that itself falls back to run imports it (directly or
146
+ // transitively) — otherwise the run-fallback file's real re-import
147
+ // produces a SECOND, non-identical copy of the same entities this file
148
+ // already folded, and cross-file AttrRefs between them can never resolve
149
+ // (see {@link planFoldTaint}'s doc for the full story). This is only
150
+ // knowable after every file's fold has been attempted, hence the two
151
+ // passes instead of committing per-file as they're visited.
152
+ const foldAttempts = new Map<string, Awaited<ReturnType<typeof tryFoldFile>>>();
153
+ // chant #1020 — ONE session, shared by every top-level `tryFoldFile` call
154
+ // below AND by any cross-file reference one file's fold attempt makes into
155
+ // another (see fold-import.ts's `FoldSession` doc): this is what guarantees
156
+ // a project file imported by several others is folded exactly once, so
157
+ // every referrer shares the identical constructed Declarable/
158
+ // CompositeInstance objects rather than each building its own copy.
159
+ const foldSession = options?.fold ? createFoldSession(options.intrinsics, buildParamValuesMap) : undefined;
160
+ if (options?.fold) {
161
+ for (const file of files) {
162
+ foldAttempts.set(file, await tryFoldFile(file, options.intrinsics, foldSession));
163
+ }
164
+ }
165
+ const taintedFiles = options?.fold
166
+ ? await planFoldTaint(
167
+ files,
168
+ new Map(files.map((file) => [file, foldAttempts.get(file)?.ok === true])),
169
+ // chant #1044 — which files' OBJECTS each successful fold captured,
170
+ // so a file forced back to run also invalidates the folds that
171
+ // already hold its instances (see planFoldTaint's doc).
172
+ new Map(
173
+ files.flatMap((file) => {
174
+ const attempt = foldAttempts.get(file);
175
+ return attempt?.ok === true ? [[file, attempt.liveSources] as const] : [];
176
+ }),
177
+ ),
178
+ )
179
+ : new Set<string>();
41
180
 
42
181
  for (const file of files) {
182
+ if (options?.fold) {
183
+ const folded = foldAttempts.get(file)!;
184
+ if (folded.ok && !taintedFiles.has(file)) {
185
+ const exportsObj: Record<string, unknown> = {};
186
+ for (const [name, value] of folded.entities) exportsObj[name] = value;
187
+ modules.push({ file, exports: exportsObj });
188
+ foldDecisions.push({ file, mode: "fold", resourceCount: folded.entities.length });
189
+ continue;
190
+ }
191
+ const reason = !folded.ok
192
+ ? folded.reason
193
+ : `would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance`;
194
+ foldDecisions.push({ file, mode: "run", reason });
195
+ }
196
+
197
+ if (options?.sandbox) {
198
+ sandboxFiles.push(file);
199
+ continue;
200
+ }
201
+
43
202
  try {
44
203
  const exports = await importModule(file);
45
204
  modules.push({ file, exports });
@@ -88,9 +247,90 @@ export async function discover(path: string): Promise<DiscoveryResult> {
88
247
  dependencies: new Map(),
89
248
  sourceFiles,
90
249
  errors,
250
+ foldDecisions,
91
251
  };
92
252
  }
93
253
 
254
+ // chant #1045 Phase 2 — run the queued sandbox files together, isolated, in
255
+ // one child process, and merge its already-named, already-ref-resolved
256
+ // entities in. The child performs its OWN collectEntities/resolveAttrRefs
257
+ // over just this subset (see ./sandbox/run.ts) rather than sharing live
258
+ // objects with the fold-only `entities` map above — a real object can't
259
+ // cross a process boundary, and `planFoldTaint` already guarantees no
260
+ // folded file is ever referenced by a run-fallback one (or vice versa), so
261
+ // the two subsets never share cross-references. A name collision between
262
+ // them is therefore always a genuine duplicate (never the same object
263
+ // legitimately re-exported), reported exactly like collectEntities' own
264
+ // same-key check above rather than silently overwritten. (One narrower gap
265
+ // than the single unified collectEntities call this replaces: cross-
266
+ // directory stack-prefix disambiguation, #932, is computed separately for
267
+ // each subset, so a bare-name collision that spans a folded directory and a
268
+ // run-fallback directory won't be disambiguated the same way a single
269
+ // combined call would. Not hit by any corpus entry today.)
270
+ if (options?.sandbox && sandboxFiles.length > 0) {
271
+ try {
272
+ // Dynamic, not static — `./sandbox/run` imports `esbuild`, a large CJS
273
+ // package with its own module-scope filesystem access (the same class
274
+ // of thing `entity-wire.ts`'s split from `entity-wire-codec.ts` avoids
275
+ // for `typescript`). `discover()` is re-exported from the package root
276
+ // (`@intentius/chant`), and project source commonly imports that root
277
+ // — a STATIC top-level import here would make `esbuild` transitively
278
+ // reachable, and therefore BUNDLED AND EAGERLY EVALUATED, by every
279
+ // project file the sandboxed child imports that happens to import
280
+ // chant itself. A dynamic import is only ever actually reached here,
281
+ // at runtime, when a caller opts into `sandbox: true` — never from
282
+ // inside a bundled project file (project code never calls `discover()`
283
+ // itself), so esbuild only ever bundles it as inert, un-evaluated code
284
+ // when it's pulled in transitively.
285
+ const { runFallbackFilesSandboxed } = await import("./sandbox/run");
286
+ const sandboxResult = await runFallbackFilesSandboxed(sandboxFiles, path);
287
+ errors.push(...sandboxResult.errors);
288
+ for (const [name, entity] of sandboxResult.entities) {
289
+ if (entities.has(name)) {
290
+ const { DiscoveryError: DiscoveryErrorClass } = await import("../errors");
291
+ const file = sandboxResult.provenanceByName[name] ?? path;
292
+ errors.push(new DiscoveryErrorClass(file, `Duplicate export name "${name}" found`, "resolution"));
293
+ continue;
294
+ }
295
+ entities.set(name, entity);
296
+ }
297
+
298
+ // Re-order the merged map to match original file discovery order.
299
+ // Without this, every fold entity (inserted above via the parent's own
300
+ // collectEntities call) sorts before every sandboxed entity (appended
301
+ // by the loop just above) — a fold-block-then-run-block order, not the
302
+ // per-file INTERLEAVED order a single unified collectEntities call
303
+ // over `files` would produce. Several downstream consumers iterate the
304
+ // entities map directly and are order-sensitive (e.g. a serializer's
305
+ // auto-detected cross-lexicon `Parameters`), so this isn't cosmetic —
306
+ // uncorrected, it's real, if harmless (still a valid template),
307
+ // byte-level drift. Ordered by each entity's OWN provenance
308
+ // (`../provenance.ts`) rather than by which loop produced it: a fold
309
+ // entity's provenance is read directly (a live object, still in this
310
+ // process); a sandboxed entity's provenance didn't survive the wire —
311
+ // `encodeEntitySet` deliberately drops build metadata, not declared
312
+ // configuration — so `sandboxResult.provenanceByName` (a side channel
313
+ // the child computes for exactly this, see ./sandbox/run.ts) is used
314
+ // instead. `Array.prototype.sort` is stable (guaranteed since ES2019),
315
+ // so entities from the same file (already correctly ordered by
316
+ // whichever collectEntities call produced them) keep their relative
317
+ // order — only which FILE's block comes first changes.
318
+ const fileOrder = new Map(files.map((file, i) => [file, i]));
319
+ const withIndex = [...entities.entries()].map(([name, entity]) => {
320
+ const sourceFile = getProvenance(entity)?.sourceFile ?? sandboxResult.provenanceByName[name];
321
+ const index = sourceFile !== undefined ? fileOrder.get(sourceFile) : undefined;
322
+ return { name, entity, index: index ?? Number.MAX_SAFE_INTEGER };
323
+ });
324
+ withIndex.sort((a, b) => a.index - b.index);
325
+ entities = new Map(withIndex.map(({ name, entity }) => [name, entity]));
326
+ } catch (error) {
327
+ const { DiscoveryError: DiscoveryErrorClass } = await import("../errors");
328
+ errors.push(
329
+ new DiscoveryErrorClass(path, error instanceof Error ? error.message : String(error), "resolution"),
330
+ );
331
+ }
332
+ }
333
+
94
334
  // Step 4: Resolve AttrRefs
95
335
  try {
96
336
  resolveAttrRefs(entities);
@@ -128,5 +368,6 @@ export async function discover(path: string): Promise<DiscoveryResult> {
128
368
  dependencies,
129
369
  sourceFiles,
130
370
  errors,
371
+ foldDecisions,
131
372
  };
132
373
  }