@intentius/chant 0.19.0 → 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 (156) 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/dist/terraform/aws-resources.d.ts.map +1 -1
  81. package/package.json +2 -1
  82. package/src/build-params.test.ts +144 -0
  83. package/src/build-params.ts +207 -0
  84. package/src/build.test.ts +38 -0
  85. package/src/build.ts +144 -7
  86. package/src/cli/commands/build.test.ts +220 -2
  87. package/src/cli/commands/build.ts +111 -3
  88. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  89. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  90. package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
  91. package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
  92. package/src/cli/commands/check-lexicon.test.ts +34 -0
  93. package/src/cli/commands/check-lexicon.ts +119 -1
  94. package/src/cli/commands/lint.ts +31 -3
  95. package/src/cli/commands/onboard.ts +1 -1
  96. package/src/cli/component-security-boundary.test.ts +170 -0
  97. package/src/cli/handlers/build.ts +24 -3
  98. package/src/cli/handlers/components.ts +9 -2
  99. package/src/cli/handlers/dev.ts +1 -1
  100. package/src/cli/handlers/graph.ts +7 -5
  101. package/src/cli/handlers/lifecycle.ts +2 -2
  102. package/src/cli/handlers/lint.ts +2 -0
  103. package/src/cli/handlers/misc.ts +2 -2
  104. package/src/cli/handlers/run-client.ts +1 -1
  105. package/src/cli/handlers/run.ts +20 -5
  106. package/src/cli/main.test.ts +22 -0
  107. package/src/cli/main.ts +39 -0
  108. package/src/cli/plugins.ts +20 -4
  109. package/src/cli/registry.ts +10 -0
  110. package/src/cli/security-boundary.test.ts +135 -0
  111. package/src/codegen/docs-sections.test.ts +61 -0
  112. package/src/codegen/docs-sections.ts +7 -3
  113. package/src/components/cli-support.ts +22 -10
  114. package/src/components/discover.ts +127 -25
  115. package/src/components/sandbox/driver.ts +114 -0
  116. package/src/components/sandbox/run.test.ts +185 -0
  117. package/src/components/sandbox/run.ts +177 -0
  118. package/src/composite.test.ts +21 -0
  119. package/src/composite.ts +20 -1
  120. package/src/config.ts +81 -0
  121. package/src/discovery/collect.ts +17 -3
  122. package/src/discovery/entity-wire-codec.ts +485 -0
  123. package/src/discovery/entity-wire.test.ts +240 -0
  124. package/src/discovery/entity-wire.ts +67 -0
  125. package/src/discovery/fold-import.test.ts +1026 -0
  126. package/src/discovery/fold-import.ts +1805 -0
  127. package/src/discovery/index.test.ts +191 -1
  128. package/src/discovery/index.ts +233 -1
  129. package/src/discovery/sandbox/bundle.ts +218 -0
  130. package/src/discovery/sandbox/child-errors.ts +65 -0
  131. package/src/discovery/sandbox/driver.ts +147 -0
  132. package/src/discovery/sandbox/run.test.ts +179 -0
  133. package/src/discovery/sandbox/run.ts +196 -0
  134. package/src/errors.ts +9 -1
  135. package/src/fold/fold.test.ts +535 -0
  136. package/src/fold/fold.ts +648 -0
  137. package/src/fold/subset-doc-parity.test.ts +183 -0
  138. package/src/fold/subset.test.ts +241 -0
  139. package/src/fold/subset.ts +302 -0
  140. package/src/index.ts +2 -0
  141. package/src/lexicon-output.ts +7 -2
  142. package/src/lexicon-schema.test.ts +14 -0
  143. package/src/lexicon-schema.ts +3 -1
  144. package/src/lexicon.ts +82 -1
  145. package/src/lifecycle/git.test.ts +10 -5
  146. package/src/lifecycle/release-ledger.test.ts +28 -0
  147. package/src/lifecycle/release-ledger.ts +11 -0
  148. package/src/lint/component-checks.ts +8 -1
  149. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  150. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  151. package/src/params.test.ts +22 -0
  152. package/src/params.ts +66 -0
  153. package/src/provenance.ts +22 -0
  154. package/src/terraform/adopt-state.test.ts +1 -1
  155. package/src/terraform/aws-resources.test.ts +11 -4
  156. package/src/terraform/aws-resources.ts +60 -0
@@ -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,63 @@ 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
+ )
170
+ : new Set<string>();
41
171
 
42
172
  for (const file of files) {
173
+ if (options?.fold) {
174
+ const folded = foldAttempts.get(file)!;
175
+ if (folded.ok && !taintedFiles.has(file)) {
176
+ const exportsObj: Record<string, unknown> = {};
177
+ for (const [name, value] of folded.entities) exportsObj[name] = value;
178
+ modules.push({ file, exports: exportsObj });
179
+ foldDecisions.push({ file, mode: "fold", resourceCount: folded.entities.length });
180
+ continue;
181
+ }
182
+ const reason = !folded.ok
183
+ ? folded.reason
184
+ : `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`;
185
+ foldDecisions.push({ file, mode: "run", reason });
186
+ }
187
+
188
+ if (options?.sandbox) {
189
+ sandboxFiles.push(file);
190
+ continue;
191
+ }
192
+
43
193
  try {
44
194
  const exports = await importModule(file);
45
195
  modules.push({ file, exports });
@@ -88,9 +238,90 @@ export async function discover(path: string): Promise<DiscoveryResult> {
88
238
  dependencies: new Map(),
89
239
  sourceFiles,
90
240
  errors,
241
+ foldDecisions,
91
242
  };
92
243
  }
93
244
 
245
+ // chant #1045 Phase 2 — run the queued sandbox files together, isolated, in
246
+ // one child process, and merge its already-named, already-ref-resolved
247
+ // entities in. The child performs its OWN collectEntities/resolveAttrRefs
248
+ // over just this subset (see ./sandbox/run.ts) rather than sharing live
249
+ // objects with the fold-only `entities` map above — a real object can't
250
+ // cross a process boundary, and `planFoldTaint` already guarantees no
251
+ // folded file is ever referenced by a run-fallback one (or vice versa), so
252
+ // the two subsets never share cross-references. A name collision between
253
+ // them is therefore always a genuine duplicate (never the same object
254
+ // legitimately re-exported), reported exactly like collectEntities' own
255
+ // same-key check above rather than silently overwritten. (One narrower gap
256
+ // than the single unified collectEntities call this replaces: cross-
257
+ // directory stack-prefix disambiguation, #932, is computed separately for
258
+ // each subset, so a bare-name collision that spans a folded directory and a
259
+ // run-fallback directory won't be disambiguated the same way a single
260
+ // combined call would. Not hit by any corpus entry today.)
261
+ if (options?.sandbox && sandboxFiles.length > 0) {
262
+ try {
263
+ // Dynamic, not static — `./sandbox/run` imports `esbuild`, a large CJS
264
+ // package with its own module-scope filesystem access (the same class
265
+ // of thing `entity-wire.ts`'s split from `entity-wire-codec.ts` avoids
266
+ // for `typescript`). `discover()` is re-exported from the package root
267
+ // (`@intentius/chant`), and project source commonly imports that root
268
+ // — a STATIC top-level import here would make `esbuild` transitively
269
+ // reachable, and therefore BUNDLED AND EAGERLY EVALUATED, by every
270
+ // project file the sandboxed child imports that happens to import
271
+ // chant itself. A dynamic import is only ever actually reached here,
272
+ // at runtime, when a caller opts into `sandbox: true` — never from
273
+ // inside a bundled project file (project code never calls `discover()`
274
+ // itself), so esbuild only ever bundles it as inert, un-evaluated code
275
+ // when it's pulled in transitively.
276
+ const { runFallbackFilesSandboxed } = await import("./sandbox/run");
277
+ const sandboxResult = await runFallbackFilesSandboxed(sandboxFiles, path);
278
+ errors.push(...sandboxResult.errors);
279
+ for (const [name, entity] of sandboxResult.entities) {
280
+ if (entities.has(name)) {
281
+ const { DiscoveryError: DiscoveryErrorClass } = await import("../errors");
282
+ const file = sandboxResult.provenanceByName[name] ?? path;
283
+ errors.push(new DiscoveryErrorClass(file, `Duplicate export name "${name}" found`, "resolution"));
284
+ continue;
285
+ }
286
+ entities.set(name, entity);
287
+ }
288
+
289
+ // Re-order the merged map to match original file discovery order.
290
+ // Without this, every fold entity (inserted above via the parent's own
291
+ // collectEntities call) sorts before every sandboxed entity (appended
292
+ // by the loop just above) — a fold-block-then-run-block order, not the
293
+ // per-file INTERLEAVED order a single unified collectEntities call
294
+ // over `files` would produce. Several downstream consumers iterate the
295
+ // entities map directly and are order-sensitive (e.g. a serializer's
296
+ // auto-detected cross-lexicon `Parameters`), so this isn't cosmetic —
297
+ // uncorrected, it's real, if harmless (still a valid template),
298
+ // byte-level drift. Ordered by each entity's OWN provenance
299
+ // (`../provenance.ts`) rather than by which loop produced it: a fold
300
+ // entity's provenance is read directly (a live object, still in this
301
+ // process); a sandboxed entity's provenance didn't survive the wire —
302
+ // `encodeEntitySet` deliberately drops build metadata, not declared
303
+ // configuration — so `sandboxResult.provenanceByName` (a side channel
304
+ // the child computes for exactly this, see ./sandbox/run.ts) is used
305
+ // instead. `Array.prototype.sort` is stable (guaranteed since ES2019),
306
+ // so entities from the same file (already correctly ordered by
307
+ // whichever collectEntities call produced them) keep their relative
308
+ // order — only which FILE's block comes first changes.
309
+ const fileOrder = new Map(files.map((file, i) => [file, i]));
310
+ const withIndex = [...entities.entries()].map(([name, entity]) => {
311
+ const sourceFile = getProvenance(entity)?.sourceFile ?? sandboxResult.provenanceByName[name];
312
+ const index = sourceFile !== undefined ? fileOrder.get(sourceFile) : undefined;
313
+ return { name, entity, index: index ?? Number.MAX_SAFE_INTEGER };
314
+ });
315
+ withIndex.sort((a, b) => a.index - b.index);
316
+ entities = new Map(withIndex.map(({ name, entity }) => [name, entity]));
317
+ } catch (error) {
318
+ const { DiscoveryError: DiscoveryErrorClass } = await import("../errors");
319
+ errors.push(
320
+ new DiscoveryErrorClass(path, error instanceof Error ? error.message : String(error), "resolution"),
321
+ );
322
+ }
323
+ }
324
+
94
325
  // Step 4: Resolve AttrRefs
95
326
  try {
96
327
  resolveAttrRefs(entities);
@@ -128,5 +359,6 @@ export async function discover(path: string): Promise<DiscoveryResult> {
128
359
  dependencies,
129
360
  sourceFiles,
130
361
  errors,
362
+ foldDecisions,
131
363
  };
132
364
  }