@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
@@ -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
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { discoverComponentsSandboxed } from "./run";
7
+
8
+ /**
9
+ * chant #1051 — proves the actual isolation properties for sandboxed
10
+ * component discovery (not just that it returns the right components),
11
+ * mirroring `../../discovery/sandbox/run.test.ts`'s coverage for the
12
+ * lexicon-resource run-fallback path (chant #1045 Phase 2): a hostile
13
+ * `*.component.ts` file cannot read outside the project directory, write
14
+ * anywhere, spawn a process, or read the ambient environment, and a
15
+ * permission denial names the file and the operation rather than leaking a
16
+ * raw `ERR_ACCESS_DENIED`.
17
+ *
18
+ * Fixtures are written to a fresh tmpdir per test, never into the source
19
+ * tree.
20
+ */
21
+ describe("discoverComponentsSandboxed — isolation", () => {
22
+ let testDir: string;
23
+
24
+ beforeEach(async () => {
25
+ testDir = await realpath(
26
+ await (async () => {
27
+ const dir = join(tmpdir(), `chant-component-sandbox-run-test-${Date.now()}-${Math.random()}`);
28
+ await mkdir(dir, { recursive: true });
29
+ return dir;
30
+ })(),
31
+ );
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await rm(testDir, { recursive: true, force: true });
36
+ });
37
+
38
+ test("denies filesystem reads outside the project directory, naming the file and the operation", async () => {
39
+ const file = join(testDir, "evil.component.ts");
40
+ await writeFile(
41
+ file,
42
+ `
43
+ import { readFileSync } from "node:fs";
44
+ readFileSync("/etc/hosts", "utf-8");
45
+ export const evil = { name: "evil", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };
46
+ `,
47
+ );
48
+
49
+ const result = await discoverComponentsSandboxed([file], testDir);
50
+
51
+ expect(result.components.size).toBe(0);
52
+ expect(result.errors).toHaveLength(1);
53
+ expect(result.errors[0].file).toBe(file);
54
+ expect(result.errors[0].type).toBe("permission");
55
+ expect(result.errors[0].message).toMatch(/FileSystemRead/);
56
+ expect(result.errors[0].message).toContain(file);
57
+ });
58
+
59
+ test("denies filesystem writes anywhere", async () => {
60
+ const file = join(testDir, "evil.component.ts");
61
+ const targetPath = join(testDir, "..", "escaped.txt");
62
+ await writeFile(
63
+ file,
64
+ `
65
+ import { writeFileSync } from "node:fs";
66
+ writeFileSync(${JSON.stringify(targetPath)}, "pwned");
67
+ export const evil = { name: "evil", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };
68
+ `,
69
+ );
70
+
71
+ const result = await discoverComponentsSandboxed([file], testDir);
72
+
73
+ expect(result.errors).toHaveLength(1);
74
+ expect(result.errors[0].type).toBe("permission");
75
+ expect(result.errors[0].message).toMatch(/FileSystemWrite/);
76
+ expect(existsSync(targetPath)).toBe(false);
77
+ });
78
+
79
+ test("denies spawning a child process", async () => {
80
+ const file = join(testDir, "evil.component.ts");
81
+ const markerPath = join(testDir, "spawned.txt");
82
+ await writeFile(
83
+ file,
84
+ `
85
+ import { execSync } from "node:child_process";
86
+ execSync(${JSON.stringify(`touch ${markerPath}`)});
87
+ export const evil = { name: "evil", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };
88
+ `,
89
+ );
90
+
91
+ const result = await discoverComponentsSandboxed([file], testDir);
92
+
93
+ expect(result.errors).toHaveLength(1);
94
+ expect(result.errors[0].type).toBe("permission");
95
+ expect(result.errors[0].message).toMatch(/ChildProcess/);
96
+ expect(existsSync(markerPath)).toBe(false);
97
+ });
98
+
99
+ test("scrubs the ambient environment — a real secret set on the parent's process.env is invisible to project source", async () => {
100
+ const file = join(testDir, "evil.component.ts");
101
+ await writeFile(
102
+ file,
103
+ `
104
+ const seen = process.env.CHANT_SANDBOX_TEST_SECRET;
105
+ if (seen !== undefined) {
106
+ throw new Error("ambient env leaked: " + seen);
107
+ }
108
+ export const evil = {
109
+ name: "evil",
110
+ dependsOn: [],
111
+ deploy: [{ phase: "Apply", steps: [{ kind: "shell", envKeyCount: Object.keys(process.env).length }] }],
112
+ };
113
+ `,
114
+ );
115
+
116
+ const previous = process.env.CHANT_SANDBOX_TEST_SECRET;
117
+ process.env.CHANT_SANDBOX_TEST_SECRET = "super-secret-should-not-cross-the-boundary";
118
+ try {
119
+ const result = await discoverComponentsSandboxed([file], testDir);
120
+ expect(result.errors).toEqual([]);
121
+ expect(result.components.has("evil")).toBe(true);
122
+ } finally {
123
+ if (previous === undefined) delete process.env.CHANT_SANDBOX_TEST_SECRET;
124
+ else process.env.CHANT_SANDBOX_TEST_SECRET = previous;
125
+ }
126
+ });
127
+
128
+ test("the same component object re-exported under two bindings in one file is NOT a duplicate — the identity check runs on live objects, before any serialization", async () => {
129
+ // `collectComponents`'s dedupe check (`../discover.ts`) compares
130
+ // candidates with `existing.component !== value` — real object identity,
131
+ // not deep equality. That distinction only matters if the check runs
132
+ // over the LIVE, still-in-memory export values: had this instead run
133
+ // over a JSON-round-tripped copy (e.g. serialize each file's exports in
134
+ // the child, decode in the parent, THEN dedupe), `JSON.parse` never
135
+ // returns the same reference twice, so even this legitimate case — one
136
+ // object, two export bindings — would wrongly report a duplicate. This
137
+ // proves the sandboxed path preserves the unsandboxed behavior: the
138
+ // dedupe collector (`collectComponents`) runs bundled INSIDE the child,
139
+ // over the driver's own live `modules` array, before the one
140
+ // `JSON.stringify` at the very end (`./driver.ts`).
141
+ const file = join(testDir, "aliased.component.ts");
142
+ await writeFile(
143
+ file,
144
+ `
145
+ const base = {
146
+ name: "search-service",
147
+ dependsOn: [],
148
+ deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }],
149
+ };
150
+ export const searchService = base;
151
+ export const searchServiceAlias = base;
152
+ `,
153
+ );
154
+
155
+ const result = await discoverComponentsSandboxed([file], testDir);
156
+
157
+ expect(result.errors).toEqual([]);
158
+ expect(result.components.size).toBe(1);
159
+ expect(result.components.has("search-service")).toBe(true);
160
+ });
161
+
162
+ test("a genuine duplicate — two DIFFERENT objects declaring the same component name — is still reported", async () => {
163
+ await writeFile(
164
+ join(testDir, "one.component.ts"),
165
+ `
166
+ export const svc = { name: "dup", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };
167
+ `,
168
+ );
169
+ await writeFile(
170
+ join(testDir, "two.component.ts"),
171
+ `
172
+ export const svcAgain = { name: "dup", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };
173
+ `,
174
+ );
175
+
176
+ const result = await discoverComponentsSandboxed(
177
+ [join(testDir, "one.component.ts"), join(testDir, "two.component.ts")],
178
+ testDir,
179
+ );
180
+
181
+ expect(result.errors.length).toBeGreaterThan(0);
182
+ expect(result.errors[0].type).toBe("resolution");
183
+ expect(result.errors[0].message).toMatch(/Duplicate component name "dup"/);
184
+ });
185
+ });
@@ -0,0 +1,177 @@
1
+ import { fork } from "node:child_process";
2
+ import { realpathSync, rmSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { bundleDriver } from "../../discovery/sandbox/bundle";
5
+ import { classifyChildError } from "../../discovery/sandbox/child-errors";
6
+ import { DiscoveryError, type DiscoveryErrorType } from "../../errors";
7
+ import type { Component } from "../component";
8
+ import type { DiscoveredComponent } from "../discover";
9
+ import { generateComponentDriverSource } from "./driver";
10
+
11
+ /**
12
+ * chant #1051 — imports every discovered `*.component.ts` file for a build
13
+ * TOGETHER, as one bundled module graph, inside one sandboxed child process,
14
+ * and returns the same shape `discoverComponents`'s own in-process import
15
+ * loop would have produced: named components plus any errors.
16
+ *
17
+ * Isolation mechanics are identical to `../../discovery/sandbox/run.ts`'s
18
+ * `runFallbackFilesSandboxed` (chant #1045 Phase 2 — see that module's doc
19
+ * for the full write-up, verified on Node v24.13.1): `--permission
20
+ * --allow-fs-read=<bundle dir>,<project dir>[,<trusted external package
21
+ * dirs>]`, a spawn-time-scrubbed `env`, and a bounded wait before the child
22
+ * is treated as hung. `bundleDriver` (`../../discovery/sandbox/bundle.ts`) is
23
+ * reused as-is — it is generic over what the driver does, so the same
24
+ * esbuild bundling (and the same `typescript`-stays-external carve-out) that
25
+ * serves the entity path serves this one too.
26
+ */
27
+
28
+ export interface ComponentSandboxRunResult {
29
+ /** Discovered components from this sandboxed import, keyed by `component.name` — functionally indistinguishable from what `discoverComponents`'s in-process path would have produced for this same file set. */
30
+ components: Map<string, DiscoveredComponent>;
31
+ /** Import/collection/permission errors, already chant-shaped (`../../discovery/sandbox/child-errors.ts` — a permission denial names the file and the operation, never a raw `ERR_ACCESS_DENIED`). */
32
+ errors: DiscoveryError[];
33
+ /** esbuild bundling wall-clock time. */
34
+ bundleMs: number;
35
+ /** Bundle size in bytes. */
36
+ bundleBytes: number;
37
+ }
38
+
39
+ interface ChildComponentEntry {
40
+ name: string;
41
+ component: Component;
42
+ exportName: string;
43
+ filePath: string;
44
+ }
45
+
46
+ interface ChildResponse {
47
+ components: ChildComponentEntry[];
48
+ errors: Array<{ file: string; message: string; type: DiscoveryErrorType }>;
49
+ fatal?: boolean;
50
+ }
51
+
52
+ /** How long to wait for the sandboxed child to report back before treating it as hung and killing it. Generous, matching the entity path's own budget. */
53
+ const CHILD_TIMEOUT_MS = 120_000;
54
+
55
+ function isChildResponse(value: unknown): value is ChildResponse {
56
+ return typeof value === "object" && value !== null && "components" in value && "errors" in value;
57
+ }
58
+
59
+ /**
60
+ * Import `files` (every discovered `*.component.ts` file) together, isolated,
61
+ * in one sandboxed child process, and collect their `Component`-shaped
62
+ * exports — including the duplicate-name check, which runs INSIDE the child
63
+ * over live objects before anything is serialized (`../discover.ts`'s
64
+ * `collectComponents`, bundled into the driver).
65
+ *
66
+ * @param files - Absolute paths to every discovered `*.component.ts` file.
67
+ * @param buildRoot - The directory `discoverComponents` was pointed at — used
68
+ * to compute the project-directory read allowance, mirroring `../../
69
+ * discovery/sandbox/run.ts`.
70
+ */
71
+ export async function discoverComponentsSandboxed(
72
+ files: readonly string[],
73
+ buildRoot: string,
74
+ ): Promise<ComponentSandboxRunResult> {
75
+ if (files.length === 0) {
76
+ return { components: new Map(), errors: [], bundleMs: 0, bundleBytes: 0 };
77
+ }
78
+
79
+ const driverSource = generateComponentDriverSource({ files });
80
+ const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
81
+
82
+ try {
83
+ let projectRealpath: string;
84
+ try {
85
+ projectRealpath = realpathSync(resolve(buildRoot));
86
+ } catch {
87
+ projectRealpath = resolve(buildRoot);
88
+ }
89
+
90
+ const response = await runChildProcess(bundlePath, bundleDir, projectRealpath, externalReadPaths);
91
+
92
+ const errors = (response.errors ?? []).map((e) => new DiscoveryError(e.file, e.message, e.type));
93
+ if (response.fatal) {
94
+ return { components: new Map(), errors, bundleMs: durationMs, bundleBytes: bytes };
95
+ }
96
+
97
+ const components = new Map<string, DiscoveredComponent>();
98
+ for (const entry of response.components ?? []) {
99
+ components.set(entry.name, {
100
+ component: entry.component,
101
+ exportName: entry.exportName,
102
+ filePath: entry.filePath,
103
+ });
104
+ }
105
+ return { components, errors, bundleMs: durationMs, bundleBytes: bytes };
106
+ } catch (err) {
107
+ return {
108
+ components: new Map(),
109
+ errors: [classifyChildError("", err, "import")],
110
+ bundleMs: durationMs,
111
+ bundleBytes: bytes,
112
+ };
113
+ } finally {
114
+ rmSync(bundleDir, { recursive: true, force: true });
115
+ }
116
+ }
117
+
118
+ /** Fork the bundle under `--permission`, with a scrubbed environment, and resolve with its one IPC message (or reject on crash/timeout/fork error). Mirrors `../../discovery/sandbox/run.ts`'s `runChildProcess`. */
119
+ function runChildProcess(
120
+ bundlePath: string,
121
+ bundleDir: string,
122
+ projectRealpath: string,
123
+ externalReadPaths: readonly string[],
124
+ ): Promise<ChildResponse> {
125
+ return new Promise((resolvePromise, reject) => {
126
+ const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
127
+ (p) => `--allow-fs-read=${p}`,
128
+ );
129
+ const child = fork(bundlePath, [], {
130
+ execArgv: ["--permission", ...readAllowances],
131
+ // chant #1051 — Node's Permission Model does not gate `process.env`;
132
+ // scrubbing it here is the only way to keep the ambient environment out
133
+ // of untrusted project source's reach, exactly as the entity path does.
134
+ env: { PATH: process.env.PATH ?? "" },
135
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
136
+ });
137
+
138
+ let settled = false;
139
+ let stderrBuf = "";
140
+
141
+ const timeout = setTimeout(() => {
142
+ if (settled) return;
143
+ settled = true;
144
+ child.kill();
145
+ reject(new Error(`sandboxed component discovery timed out after ${CHILD_TIMEOUT_MS}ms`));
146
+ }, CHILD_TIMEOUT_MS);
147
+
148
+ child.stderr?.on("data", (chunk: Buffer) => {
149
+ stderrBuf += chunk.toString();
150
+ });
151
+
152
+ child.on("message", (msg: unknown) => {
153
+ if (settled || !isChildResponse(msg)) return;
154
+ settled = true;
155
+ clearTimeout(timeout);
156
+ resolvePromise(msg);
157
+ });
158
+
159
+ child.on("error", (err) => {
160
+ if (settled) return;
161
+ settled = true;
162
+ clearTimeout(timeout);
163
+ reject(err);
164
+ });
165
+
166
+ child.on("exit", (code, signal) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ clearTimeout(timeout);
170
+ reject(
171
+ new Error(
172
+ `sandboxed component discovery child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
173
+ ),
174
+ );
175
+ });
176
+ });
177
+ }