@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
@@ -0,0 +1,135 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, writeFile, rm, symlink, realpath } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, dirname, resolve } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ /**
10
+ * chant #1045 Phase 2 (post-merge finding) — regression test for a real
11
+ * bypass: `resolveProjectLexicons` (`./plugins.ts`) used to fall back to a
12
+ * full, unsandboxed `discover()` call whenever a project's `chant.config.ts`
13
+ * didn't list `lexicons` explicitly — importing and RUNNING every source
14
+ * file in the CLI's own process, with no `--fold`/`--sandbox` involved at
15
+ * all, before the caller's own (possibly folded, possibly sandboxed)
16
+ * discovery ever ran. `resolveProjectLexicons` sits underneath nearly every
17
+ * CLI command (`build`, `lint`, `doctor`, `import`, `graph`, the MCP server),
18
+ * so this reached far past `chant build --fold`.
19
+ *
20
+ * This is deliberately an end-to-end test of the real CLI entry point
21
+ * (`main.ts`, spawned as a genuine child process — the same way `bin/chant`
22
+ * invokes it), NOT a call to `build()`/`buildCommand()` directly: calling
23
+ * either of those skips the exact code path the bug lived in
24
+ * (`main.ts`'s `loadPluginsOrExit` → `resolveProjectLexicons`), which is
25
+ * precisely how the corpus differential and Phase 2's other tests — all of
26
+ * which call `build()` — passed while this stayed open.
27
+ *
28
+ * Fixture lives in a fresh tmpdir per test, never in the source tree.
29
+ */
30
+
31
+ const thisDir = dirname(fileURLToPath(import.meta.url));
32
+ const mainTsPath = resolve(thisDir, "main.ts");
33
+ const repoRoot = resolve(thisDir, "../../../..");
34
+
35
+ describe("CLI end-to-end — resolveProjectLexicons must not execute project source", () => {
36
+ let testDir: string;
37
+ let exfilMarkerPath: string;
38
+
39
+ beforeEach(async () => {
40
+ testDir = await realpath(
41
+ await (async () => {
42
+ const dir = join(tmpdir(), `chant-security-boundary-test-${Date.now()}-${Math.random()}`);
43
+ await mkdir(join(dir, "src"), { recursive: true });
44
+ return dir;
45
+ })(),
46
+ );
47
+ exfilMarkerPath = join(testDir, "exfil-happened.txt");
48
+
49
+ // A real `node_modules/@intentius/chant-lexicon-k8s` symlink — same shape
50
+ // this monorepo's own workspace linking produces — so the fixture's bare
51
+ // `from "@intentius/chant-lexicon-k8s"` import resolves exactly the way a
52
+ // real installed project's would, and `detectLexicons`'s text scan (which
53
+ // matches that literal specifier) has something genuine to find.
54
+ await mkdir(join(testDir, "node_modules", "@intentius"), { recursive: true });
55
+ await symlink(
56
+ join(repoRoot, "lexicons", "k8s"),
57
+ join(testDir, "node_modules", "@intentius", "chant-lexicon-k8s"),
58
+ "dir",
59
+ );
60
+
61
+ // No `lexicons` field — the exact condition that sends
62
+ // `resolveProjectLexicons` down the detection fallback.
63
+ await writeFile(join(testDir, "chant.config.ts"), "export default {};\n");
64
+
65
+ // A hostile source file: reads a file outside the project and the
66
+ // ambient environment at module top level (before its one Declarable
67
+ // export), and — if it got to run — writes a marker file so the test can
68
+ // tell "executed" apart from "folded" without depending on stdout
69
+ // capture alone.
70
+ await writeFile(
71
+ join(testDir, "src", "infra.ts"),
72
+ [
73
+ 'import { readFileSync, writeFileSync } from "node:fs";',
74
+ 'import { Namespace } from "@intentius/chant-lexicon-k8s";',
75
+ "",
76
+ "const stolen = readFileSync(\"/etc/hosts\", \"utf-8\");",
77
+ `writeFileSync(${JSON.stringify(exfilMarkerPath)}, "exfil: " + stolen.slice(0, 20) + " env=" + Object.keys(process.env).length);`,
78
+ 'console.error(">>> EXFIL " + stolen.slice(0, 20) + " env=" + Object.keys(process.env).length);',
79
+ "",
80
+ 'export const ns = new Namespace({ metadata: { name: "x" } });',
81
+ "",
82
+ ].join("\n"),
83
+ );
84
+ });
85
+
86
+ afterEach(async () => {
87
+ await rm(testDir, { recursive: true, force: true });
88
+ });
89
+
90
+ /** Spawn the real CLI (`main.ts`, exactly as `bin/chant` invokes it via `npx tsx`) and capture its output. */
91
+ function runCli(args: string[]): { status: number | null; stdout: string; stderr: string } {
92
+ const result = spawnSync("npx", ["tsx", mainTsPath, ...args], {
93
+ cwd: repoRoot,
94
+ encoding: "utf-8",
95
+ timeout: 30_000,
96
+ env: { ...process.env, CHANT_SECRET_FOR_TEST: "should-never-leak" },
97
+ });
98
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
99
+ }
100
+
101
+ test("`chant build --fold` on a project with no `lexicons` config does not execute the hostile file", () => {
102
+ const { status, stdout, stderr } = runCli(["build", testDir, "--fold"]);
103
+
104
+ expect(stderr).not.toMatch(/EXFIL/);
105
+ expect(stdout).not.toMatch(/EXFIL/);
106
+ expect(existsSync(exfilMarkerPath), "hostile file wrote its marker — it executed").toBe(false);
107
+
108
+ // The build still has to actually work — this isn't proving safety by
109
+ // making the build fail outright.
110
+ expect(status).toBe(0);
111
+ expect(stdout).toContain("kind: Namespace");
112
+
113
+ // The fold decision for the hostile file should say what's now true:
114
+ // this file really did fold with zero module execution.
115
+ expect(stderr).toMatch(/\[fold:fold\] src\/infra\.ts/);
116
+ });
117
+
118
+ test("`chant build --fold --sandbox` also does not execute the hostile file (both layers closed)", () => {
119
+ const { status, stdout, stderr } = runCli(["build", testDir, "--fold", "--sandbox"]);
120
+
121
+ expect(stderr).not.toMatch(/EXFIL/);
122
+ expect(stdout).not.toMatch(/EXFIL/);
123
+ expect(existsSync(exfilMarkerPath)).toBe(false);
124
+ expect(status).toBe(0);
125
+ expect(stdout).toContain("kind: Namespace");
126
+ });
127
+
128
+ test("`chant lint` (which also resolves lexicons this way) does not execute the hostile file", () => {
129
+ const { stdout, stderr } = runCli(["lint", testDir]);
130
+
131
+ expect(stderr).not.toMatch(/EXFIL/);
132
+ expect(stdout).not.toMatch(/EXFIL/);
133
+ expect(existsSync(exfilMarkerPath), "hostile file wrote its marker — it executed").toBe(false);
134
+ });
135
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { generateIntrinsics } from "./docs-sections";
3
+ import { intrinsicFolds } from "../lexicon";
4
+ import type { DocsConfig, ManifestJSON } from "./docs-types";
5
+
6
+ /**
7
+ * chant #1062 (epic #1019) — the per-lexicon intrinsic foldability matrix.
8
+ * `generateIntrinsics`'s "Folds?" column must come from {@link intrinsicFolds}
9
+ * (`../lexicon.ts`), the SAME predicate `fold()` uses to decide whether a
10
+ * registered tag actually folds — not a second `isTag`-shaped guess that
11
+ * could silently disagree with it. This test locks in that the column
12
+ * exists, has the right value per intrinsic, and tracks `intrinsicFolds`
13
+ * rather than restating its logic.
14
+ */
15
+
16
+ const config: DocsConfig = {
17
+ name: "test",
18
+ displayName: "Test",
19
+ description: "A test lexicon",
20
+ distDir: "/tmp/does-not-matter",
21
+ outDir: "/tmp/does-not-matter",
22
+ };
23
+
24
+ function manifestWith(intrinsics: ManifestJSON["intrinsics"]): ManifestJSON {
25
+ return { name: "test", version: "0.0.0", intrinsics };
26
+ }
27
+
28
+ describe("generateIntrinsics — Folds? column", () => {
29
+ test("a registered tagged-template intrinsic folds", () => {
30
+ const page = generateIntrinsics(config, manifestWith([{ name: "Sub", isTag: true }]));
31
+ expect(page).toContain("| Function | Description | Output Key | Tag? | Folds? |");
32
+ expect(page).toContain("| `Sub` | — | `Sub` | Yes | Yes |");
33
+ });
34
+
35
+ test("a plain-call intrinsic its lexicon never opted in does not fold", () => {
36
+ const page = generateIntrinsics(config, manifestWith([{ name: "Ref", isTag: false }]));
37
+ expect(page).toContain("| `Ref` | — | `Ref` | No | No |");
38
+ });
39
+
40
+ test("a plain-call intrinsic opted into call-form folding does fold (chant #1044)", () => {
41
+ const page = generateIntrinsics(config, manifestWith([{ name: "Ref", isTag: false, foldsAsCall: true }]));
42
+ expect(page).toContain("| `Ref` | — | `Ref` | No | Yes |");
43
+ });
44
+
45
+ test("an intrinsic with isTag omitted does not fold (same as isTag: false)", () => {
46
+ const page = generateIntrinsics(config, manifestWith([{ name: "reference", outputKey: "!reference" }]));
47
+ expect(page).toContain("| `reference` | — | `!reference` | No | No |");
48
+ });
49
+
50
+ test("Folds? always matches intrinsicFolds() for every row, never a restated copy", () => {
51
+ const intrinsics: NonNullable<ManifestJSON["intrinsics"]> = [
52
+ { name: "Sub", isTag: true },
53
+ { name: "Ref" },
54
+ { name: "GetAtt", isTag: false },
55
+ { name: "Join", isTag: false, foldsAsCall: true },
56
+ ];
57
+ const page = generateIntrinsics(config, manifestWith(intrinsics));
58
+ for (const fn of intrinsics) {
59
+ const expected = intrinsicFolds(fn) ? "Yes" : "No";
60
+ const row = page.split("\n").find((line) => line.startsWith(`| \`${fn.name}\` |`));
61
+ expect(row, `no row rendered for ${fn.name}`).toBeDefined();
62
+ const cells = (row as string).split("|").map((c) => c.trim());
63
+ // | `name` | description | outputKey | Tag? | Folds? | → ["", "`name`", desc, key, tag, folds, ""]
64
+ expect(cells[5]).toBe(expected);
65
+ }
66
+ });
67
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { escapeMdx } from "./docs-file-markers";
8
+ import { intrinsicFolds } from "../lexicon";
8
9
  import type { DocsConfig, ManifestJSON, MetaEntry, RuleMeta } from "./docs-types";
9
10
 
10
11
  export function generateOverview(
@@ -94,14 +95,17 @@ export function generateIntrinsics(
94
95
  "",
95
96
  `The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
96
97
  "",
97
- "| Function | Description | Output Key | Tag? |",
98
- "|----------|-------------|------------|------|",
98
+ `**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [\`chant build --fold\`](/chant/concepts/typescript-as-data/#folded-vs-run) can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
99
+ "",
100
+ "| Function | Description | Output Key | Tag? | Folds? |",
101
+ "|----------|-------------|------------|------|--------|",
99
102
  ];
100
103
 
101
104
  for (const fn of intrinsics) {
102
105
  const tag = fn.isTag ? "Yes" : "No";
106
+ const folds = intrinsicFolds(fn) ? "Yes" : "No";
103
107
  lines.push(
104
- `| \`${fn.name}\` | ${escapeMdx(fn.description ?? "—")} | \`${fn.outputKey ?? fn.name}\` | ${tag} |`,
108
+ `| \`${fn.name}\` | ${escapeMdx(fn.description ?? "—")} | \`${fn.outputKey ?? fn.name}\` | ${tag} | ${folds} |`,
105
109
  );
106
110
  }
107
111
 
@@ -55,6 +55,8 @@ export interface ManifestJSON {
55
55
  description?: string;
56
56
  outputKey?: string;
57
57
  isTag?: boolean;
58
+ /** chant #1044 — the call-form fold opt-in, as published in the lexicon's manifest. Absent in any manifest built before #1044, which reads the same as "not opted in". */
59
+ foldsAsCall?: boolean;
58
60
  }>;
59
61
  pseudoParameters?: Record<string, string>;
60
62
  }
@@ -61,8 +61,8 @@ export interface ListComponentsResult {
61
61
  }
62
62
 
63
63
  /** Discover components under `path` and shape them for `chant list --components`. */
64
- export async function listComponents(path: string): Promise<ListComponentsResult> {
65
- const result = await discoverComponents(path);
64
+ export async function listComponents(path: string, sandbox?: boolean): Promise<ListComponentsResult> {
65
+ const result = await discoverComponents(path, { sandbox });
66
66
  if (result.errors.length > 0) {
67
67
  return { success: false, components: [], errors: result.errors.map((e) => e.message) };
68
68
  }
@@ -100,8 +100,8 @@ export interface DescribeComponentResult {
100
100
  }
101
101
 
102
102
  /** Find and project one named component, for `chant describe <name> --components`. */
103
- export async function describeComponent(path: string, name: string): Promise<DescribeComponentResult> {
104
- const result = await discoverComponents(path);
103
+ export async function describeComponent(path: string, name: string, sandbox?: boolean): Promise<DescribeComponentResult> {
104
+ const result = await discoverComponents(path, { sandbox });
105
105
  if (result.errors.length > 0) {
106
106
  return { success: false, component: name, output: result.errors.map((e) => e.message).join("\n") };
107
107
  }
@@ -139,8 +139,8 @@ export interface ComponentGraphResult {
139
139
  }
140
140
 
141
141
  /** Compute the components' dependency graph under `path`, for `chant graph --components`. */
142
- export async function computeComponentGraph(path: string): Promise<ComponentGraphResult> {
143
- const result = await discoverComponents(path);
142
+ export async function computeComponentGraph(path: string, sandbox?: boolean): Promise<ComponentGraphResult> {
143
+ const result = await discoverComponents(path, { sandbox });
144
144
  if (result.errors.length > 0) {
145
145
  return { success: false, order: [], waves: [], edges: [], error: result.errors.map((e) => e.message).join("\n") };
146
146
  }
@@ -220,6 +220,7 @@ export async function generateComponentsPipeline(
220
220
  path: string,
221
221
  lexicon: GenerateLexicon,
222
222
  options?: ComponentPipelineOptions,
223
+ sandbox?: boolean,
223
224
  ): Promise<GenerateComponentsResult> {
224
225
  const plugin = await loadLexiconPlugin(lexicon);
225
226
  if (!plugin?.generateComponentPipeline) {
@@ -229,7 +230,7 @@ export async function generateComponentsPipeline(
229
230
  };
230
231
  }
231
232
 
232
- const result = await discoverComponents(path);
233
+ const result = await discoverComponents(path, { sandbox });
233
234
  if (result.errors.length > 0) {
234
235
  return { success: false, error: result.errors.map((e) => e.message).join("\n") };
235
236
  }
@@ -295,6 +296,13 @@ export function findComponentGate(component: DriverComponent): { signalName: str
295
296
  export interface RunComponentsOptions {
296
297
  /** Target environment name, threaded into every capability's `DeployContext.env` (default: "local"). */
297
298
  env?: string;
299
+ /**
300
+ * chant #1051 — opt-in: discover `*.component.ts` files in a sandboxed
301
+ * child process (`chant run --components <name|all> --sandbox`) instead of
302
+ * in the CLI's own process. See `discoverComponents`'s `sandbox` option
303
+ * (../discover.ts).
304
+ */
305
+ sandbox?: boolean;
298
306
  /** Additional capability plugin package names to load on top of the built-in starter set (see `buildCapabilityRegistry`). */
299
307
  capabilityPlugins?: string[];
300
308
  /**
@@ -392,8 +400,12 @@ export interface ResolvedComponentTargets {
392
400
  * without also inheriting the local executor's pre-flight gate rejection
393
401
  * (gates are exactly what the durable path exists to support).
394
402
  */
395
- export async function resolveComponentTargets(path: string, selector: string): Promise<ResolvedComponentTargets> {
396
- const result = await discoverComponents(path);
403
+ export async function resolveComponentTargets(
404
+ path: string,
405
+ selector: string,
406
+ sandbox?: boolean,
407
+ ): Promise<ResolvedComponentTargets> {
408
+ const result = await discoverComponents(path, { sandbox });
397
409
  if (result.errors.length > 0) {
398
410
  return { success: false, targets: [], error: result.errors.map((e) => e.message).join("\n") };
399
411
  }
@@ -421,7 +433,7 @@ export async function runComponents(
421
433
  selector: string,
422
434
  options: RunComponentsOptions = {},
423
435
  ): Promise<RunComponentsResult> {
424
- const resolved = await resolveComponentTargets(path, selector);
436
+ const resolved = await resolveComponentTargets(path, selector, options.sandbox);
425
437
  if (!resolved.success) {
426
438
  return { success: false, selected: [], error: resolved.error };
427
439
  }
@@ -27,6 +27,17 @@
27
27
  * components the same way it can for `*.op.ts` files, and so discovery does
28
28
  * not have to import (and risk side-effecting) every `.ts` file in the
29
29
  * project looking for stray `Component`-shaped objects.
30
+ *
31
+ * chant #1051 — `discoverComponents` unconditionally `await import()`s every
32
+ * discovered file in the CLI's own process, the same exposure #1045 Phase 2
33
+ * closed for lexicon-resource discovery (`../discovery/index.ts`'s
34
+ * `discover({ sandbox: true })`). `{ sandbox: true }` here runs that import
35
+ * (and the duplicate-name collection below) together, isolated, in one
36
+ * sandboxed child process instead — see `./sandbox/driver.ts`/`./sandbox/
37
+ * run.ts`. Materially simpler than the entity path: a `Component` is plain
38
+ * JSON (no `AttrRef` cross-references), so the child can hand back its result
39
+ * with a bare `JSON.stringify` — no entity-wire codec needed (`projectToJson`,
40
+ * `./component.ts`, already proves the plain-JSON round-trip).
30
41
  */
31
42
 
32
43
  import { readdir } from "node:fs/promises";
@@ -52,6 +63,26 @@ export interface ComponentDiscoveryResult {
52
63
  errors: DiscoveryError[];
53
64
  }
54
65
 
66
+ /** Options for {@link discoverComponents}. */
67
+ export interface ComponentDiscoveryOptions {
68
+ /**
69
+ * chant #1051 — opt-in: import every discovered `*.component.ts` file
70
+ * together, isolated, in one sandboxed child process (`./sandbox/run.ts`)
71
+ * instead of in this (the CLI's own) process. Mirrors `discover({ sandbox:
72
+ * true })` (`../discovery/index.ts`, chant #1045 Phase 2) for the parallel
73
+ * `*.component.ts` convention. Default `false` — behavior, including
74
+ * performance (no bundling, no child process, no IPC), is unchanged unless
75
+ * requested.
76
+ */
77
+ sandbox?: boolean;
78
+ }
79
+
80
+ /** One already-imported `*.component.ts` module — the input to {@link collectComponents}. */
81
+ export interface ImportedComponentModule {
82
+ file: string;
83
+ exports: Record<string, unknown>;
84
+ }
85
+
55
86
  /**
56
87
  * Recursively find every `*.component.ts` file under `path`, the same
57
88
  * child-project boundary rule `findInfraFiles` (`../discovery/files.ts`)
@@ -106,36 +137,35 @@ async function findComponentFiles(path: string): Promise<string[]> {
106
137
  }
107
138
 
108
139
  /**
109
- * Discover every `Component` declared under `path`: scan for `*.component.ts`
110
- * files, import each, and collect every export whose value satisfies
111
- * `isComponent` (any export name not just `default` — so a file may
112
- * declare several related components, matching how a resource file can
113
- * export several `Declarable`s). Duplicate `component.name` across files (or
114
- * within one file) is a discovery error, matching `collectEntities`'s
115
- * duplicate-export handling and `discoverOps`'s duplicate-Op-name handling
116
- * except the identity compared is the schema-level `name` field, since two
117
- * different export bindings could otherwise declare the same component name
118
- * and silently collide at deploy time.
140
+ * Collect every `Component`-shaped export from already-imported `modules`,
141
+ * enforcing the duplicate-`component.name` rule (across files, or within one
142
+ * file): a discovery error, matching `collectEntities`'s duplicate-export
143
+ * handling and `discoverOps`'s duplicate-Op-name handling except the
144
+ * identity compared is the schema-level `name` field, since two different
145
+ * export bindings could otherwise declare the same component name and
146
+ * silently collide at deploy time. `existing.component !== value` object
147
+ * identity, not deep equality is what lets the SAME component re-exported
148
+ * under several bindings pass through without tripping the duplicate check.
149
+ *
150
+ * Split out from {@link discoverComponents} (#1051) precisely because that
151
+ * identity check has to run on the LIVE, still-in-memory export values —
152
+ * identity does not survive a process boundary. That means this function
153
+ * must run wherever `modules` were actually imported: in this process for
154
+ * the default (unsandboxed) path below, or inside the sandboxed child for
155
+ * `{ sandbox: true }` (`./sandbox/driver.ts`, which bundles and calls this
156
+ * same function), mirroring how `collectEntities` runs inside `../discovery/
157
+ * sandbox/driver.ts` for the lexicon-resource run-fallback set (chant #1045
158
+ * Phase 2).
119
159
  */
120
- export async function discoverComponents(path: string): Promise<ComponentDiscoveryResult> {
160
+ export function collectComponents(modules: readonly ImportedComponentModule[]): {
161
+ components: Map<string, DiscoveredComponent>;
162
+ errors: DiscoveryError[];
163
+ } {
121
164
  const errors: DiscoveryError[] = [];
122
- const sourceFiles = await findComponentFiles(path);
123
165
  const components = new Map<string, DiscoveredComponent>();
124
166
  const nameToFile = new Map<string, string>();
125
167
 
126
- for (const filePath of sourceFiles) {
127
- let exports: Record<string, unknown>;
128
- try {
129
- // Resolve to an absolute file:// URL before importing. A relative
130
- // `filePath` (e.g. from `chant build --components --generate`, which passes
131
- // a bare "." path) would otherwise be treated by `import()` as a bare
132
- // package specifier and fail with "Cannot find package 'x.component.ts'".
133
- exports = await import(pathToFileURL(resolve(filePath)).href);
134
- } catch (err) {
135
- errors.push(new DiscoveryError(filePath, err instanceof Error ? err.message : String(err), "import"));
136
- continue;
137
- }
138
-
168
+ for (const { file: filePath, exports } of modules) {
139
169
  for (const [exportName, value] of Object.entries(exports)) {
140
170
  if (!isComponent(value)) continue;
141
171
 
@@ -163,5 +193,77 @@ export async function discoverComponents(path: string): Promise<ComponentDiscove
163
193
  }
164
194
  }
165
195
 
196
+ return { components, errors };
197
+ }
198
+
199
+ /**
200
+ * Import every already-discovered `*.component.ts` file in THIS process (the
201
+ * unsandboxed default) and collect their `Component`-shaped exports.
202
+ */
203
+ async function importAndCollectComponents(
204
+ sourceFiles: readonly string[],
205
+ ): Promise<{ components: Map<string, DiscoveredComponent>; errors: DiscoveryError[] }> {
206
+ const modules: ImportedComponentModule[] = [];
207
+ const importErrors: DiscoveryError[] = [];
208
+
209
+ for (const filePath of sourceFiles) {
210
+ try {
211
+ // Resolve to an absolute file:// URL before importing. A relative
212
+ // `filePath` (e.g. from `chant build --components --generate`, which passes
213
+ // a bare "." path) would otherwise be treated by `import()` as a bare
214
+ // package specifier and fail with "Cannot find package 'x.component.ts'".
215
+ const exports = await import(pathToFileURL(resolve(filePath)).href);
216
+ modules.push({ file: filePath, exports });
217
+ } catch (err) {
218
+ importErrors.push(new DiscoveryError(filePath, err instanceof Error ? err.message : String(err), "import"));
219
+ }
220
+ }
221
+
222
+ const { components, errors: collectErrors } = collectComponents(modules);
223
+ return { components, errors: [...importErrors, ...collectErrors] };
224
+ }
225
+
226
+ /**
227
+ * Discover every `Component` declared under `path`: scan for `*.component.ts`
228
+ * files, import each, and collect every export whose value satisfies
229
+ * `isComponent` (any export name — not just `default` — so a file may
230
+ * declare several related components, matching how a resource file can
231
+ * export several `Declarable`s). See {@link collectComponents} for the
232
+ * duplicate-name rule.
233
+ *
234
+ * `{ sandbox: true }` (#1051) imports every file together, isolated, in one
235
+ * sandboxed child process instead (`./sandbox/run.ts`) — the same isolation
236
+ * `discover({ sandbox: true })` (`../discovery/index.ts`) gives lexicon
237
+ * resources, chant #1045 Phase 2.
238
+ */
239
+ export async function discoverComponents(
240
+ path: string,
241
+ options?: ComponentDiscoveryOptions,
242
+ ): Promise<ComponentDiscoveryResult> {
243
+ const sourceFiles = await findComponentFiles(path);
244
+
245
+ if (options?.sandbox && sourceFiles.length > 0) {
246
+ // Dynamic, not static — for the same reason `../discovery/index.ts`
247
+ // dynamically imports its own `./sandbox/run`: that module (transitively)
248
+ // imports `esbuild`, a large package with its own module-scope filesystem
249
+ // access, and this module is reachable from `@intentius/chant`'s package
250
+ // root, which project source commonly imports. A static import here would
251
+ // make `esbuild` bundled and eagerly evaluated by any build of a project
252
+ // that itself imports chant. A dynamic import is only ever actually
253
+ // reached here, at runtime, when a caller opts into `sandbox: true`.
254
+ try {
255
+ const { discoverComponentsSandboxed } = await import("./sandbox/run");
256
+ const result = await discoverComponentsSandboxed(sourceFiles, path);
257
+ return { components: result.components, sourceFiles, errors: result.errors };
258
+ } catch (err) {
259
+ return {
260
+ components: new Map(),
261
+ sourceFiles,
262
+ errors: [new DiscoveryError(path, err instanceof Error ? err.message : String(err), "resolution")],
263
+ };
264
+ }
265
+ }
266
+
267
+ const { components, errors } = await importAndCollectComponents(sourceFiles);
166
268
  return { components, sourceFiles, errors };
167
269
  }
@@ -0,0 +1,114 @@
1
+ import { join, resolve } from "node:path";
2
+
3
+ /**
4
+ * chant #1051 — generates the source of the "driver" module that runs INSIDE
5
+ * the sandboxed child for component discovery. Mirrors `../../discovery/
6
+ * sandbox/driver.ts`'s shape (chant #1045 Phase 2) but is materially simpler:
7
+ * a `Component` is plain JSON (no `AttrRef` cross-references to resolve), so
8
+ * there is no equivalent of `resolveAttrRefs`/`encodeEntitySet` here — the
9
+ * driver hands back its collected components with a bare, structural
10
+ * `JSON.stringify` over the IPC channel (see `./run.ts`'s `send`).
11
+ *
12
+ * Like the entity driver, every file is imported via a LITERAL string
13
+ * specifier dynamic `import()` — not a variable — so esbuild's bundler can
14
+ * trace and inline it into ONE self-contained module graph (see `../../
15
+ * discovery/sandbox/driver.ts`'s doc for why that's verified to lower to a
16
+ * lazily-invoked local module initializer, not a runtime resolution). That
17
+ * single module graph is what lets `collectComponents`'s duplicate-name check
18
+ * (`../discover.ts`) run over real, live object identity — "the same
19
+ * component re-exported under two bindings" only detects correctly when both
20
+ * bindings really do resolve to the same in-memory object, which requires
21
+ * every `*.component.ts` file for one discovery call to share one module
22
+ * cache inside a single child (see `../discover.ts`'s `collectComponents` doc
23
+ * for the full identity-correctness argument).
24
+ */
25
+
26
+ const HERE = import.meta.dirname;
27
+ const COMPONENTS_DIR = join(HERE, "..");
28
+
29
+ /** Absolute paths to chant's OWN trusted modules the generated driver imports — resolved relative to THIS file's own location on disk, exactly like `../../discovery/sandbox/driver.ts` does for the entity path (works whether chant runs from the monorepo or a consumer's `node_modules`). */
30
+ const DISCOVER_MODULE = join(COMPONENTS_DIR, "discover.ts");
31
+ const CHILD_ERRORS_MODULE = join(COMPONENTS_DIR, "..", "discovery", "sandbox", "child-errors.ts");
32
+
33
+ export interface GenerateComponentDriverOptions {
34
+ /** Absolute paths to every discovered `*.component.ts` file for this build. */
35
+ files: readonly string[];
36
+ }
37
+
38
+ /** Escape a value for embedding as a JS/TS source-level literal. */
39
+ function lit(value: unknown): string {
40
+ return JSON.stringify(value);
41
+ }
42
+
43
+ /**
44
+ * Generate the component driver module's full TypeScript source. Written to
45
+ * a tmp file and bundled (`../../discovery/sandbox/bundle.ts`'s `bundleDriver`
46
+ * — generic, reused as-is) before being handed to a sandboxed child — never
47
+ * executed directly by the parent process.
48
+ */
49
+ export function generateComponentDriverSource(options: GenerateComponentDriverOptions): string {
50
+ const { files } = options;
51
+
52
+ const lines: string[] = [
53
+ `import { collectComponents } from ${lit(DISCOVER_MODULE)};`,
54
+ `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
55
+ ``,
56
+ `function send(payload) {`,
57
+ ` if (typeof process.send === "function") process.send(payload);`,
58
+ ` else console.log(JSON.stringify(payload));`,
59
+ `}`,
60
+ ``,
61
+ `async function main() {`,
62
+ ` const modules = [];`,
63
+ ` const errors = [];`,
64
+ ];
65
+
66
+ for (const file of files) {
67
+ // `file` (as discovered by `findComponentFiles`, `../discover.ts`) may be
68
+ // relative to whatever cwd-relative `path` `discoverComponents` was
69
+ // called with — `chant list --components examples/foo` never resolves
70
+ // its argument before discovery, matching the unsandboxed import loop's
71
+ // own tolerance for that (`pathToFileURL(resolve(filePath)).href`, see
72
+ // `../discover.ts`'s `importAndCollectComponents`). A bare relative
73
+ // string hits Node's *bare-specifier* (package) resolution instead of
74
+ // being treated as a path, so only the import target — never the `file`
75
+ // key `collectComponents` dedupes and reports by — is resolved to an
76
+ // absolute filesystem path here, in THIS (parent) process, before being
77
+ // embedded as a literal in the bundled child (mirrors `../../discovery/
78
+ // sandbox/driver.ts`'s own literal-specifier `import()`s, which are
79
+ // likewise plain absolute paths, not `file://` URLs — kept that way
80
+ // rather than converting to a URL since esbuild's bundler is only
81
+ // verified to trace/inline a literal filesystem-path specifier).
82
+ const absoluteFile = resolve(file);
83
+ lines.push(
84
+ ` try {`,
85
+ ` const mod = await import(${lit(absoluteFile)});`,
86
+ ` modules.push({ file: ${lit(file)}, exports: mod });`,
87
+ ` } catch (err) {`,
88
+ ` errors.push(classifyChildError(${lit(file)}, err).toJSON());`,
89
+ ` }`,
90
+ );
91
+ }
92
+
93
+ lines.push(
94
+ ``,
95
+ ` const collected = collectComponents(modules);`,
96
+ ` for (const err of collected.errors) errors.push(err.toJSON());`,
97
+ ``,
98
+ // Plain JSON: no entity-wire codec needed (see this module's doc + chant
99
+ // #1051's issue — `projectToJson`, ../component.ts, already proves the
100
+ // bare JSON.parse(JSON.stringify()) round-trip for a Component).
101
+ ` const components = [];`,
102
+ ` for (const [name, discovered] of collected.components) {`,
103
+ ` components.push({ name, component: discovered.component, exportName: discovered.exportName, filePath: discovered.filePath });`,
104
+ ` }`,
105
+ ` send({ components, errors });`,
106
+ `}`,
107
+ ``,
108
+ `main().catch((err) => {`,
109
+ ` send({ components: [], errors: [classifyChildError("", err).toJSON()], fatal: true });`,
110
+ `});`,
111
+ );
112
+
113
+ return lines.join("\n");
114
+ }