@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,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
+ }
@@ -181,6 +181,27 @@ describe("expandComposite", () => {
181
181
  expect(expanded.get("sBucket")).toBe(bucket);
182
182
  });
183
183
 
184
+ test("is idempotent — repeated expansion does not duplicate shared array props (#1032)", () => {
185
+ const member = new MockResource({ type: "Bucket", tags: [{ key: "app", value: "x" }] });
186
+ const Comp = Composite<{}>(() => ({ bucket: member as unknown as Declarable }));
187
+ const instance = propagate(
188
+ Comp({}) as never,
189
+ { tags: [{ key: "env", value: "prod" }] },
190
+ );
191
+
192
+ const first = expandComposite("s", instance);
193
+ const firstTags = (first.get("sBucket") as unknown as MockResource).props.tags as unknown[];
194
+ // Expand the SAME singleton instance again (what building one tree twice in a
195
+ // process does) — must not re-merge onto the already-merged props.
196
+ const second = expandComposite("s", instance);
197
+ const secondTags = (second.get("sBucket") as unknown as MockResource).props.tags as unknown[];
198
+
199
+ // shared [env] + member [app] = 2 tags, both times — not 3 on the second pass.
200
+ expect(firstTags).toHaveLength(2);
201
+ expect(secondTags).toHaveLength(2);
202
+ expect(secondTags).toEqual(firstTags);
203
+ });
204
+
184
205
  test("handles empty composite", () => {
185
206
  const Empty = Composite<{}>(() => ({} as Record<string, Declarable>));
186
207
  const expanded = expandComposite("e", Empty({}));
package/src/composite.ts CHANGED
@@ -150,7 +150,20 @@ export function expandComposite(
150
150
  if (shared) {
151
151
  for (const entity of result.values()) {
152
152
  if ("props" in entity) {
153
- const existing = entity.props as Record<string, unknown>;
153
+ // Merge shared props onto each member's ORIGINAL props, not its current
154
+ // props. Members are module-level singletons, so without stashing the
155
+ // original a second expansion (e.g. building the same tree twice in one
156
+ // process) would re-merge shared arrays like tags onto the already-merged
157
+ // value, duplicating them on every rebuild (#1032). Stashing the original
158
+ // once makes expansion idempotent: same input -> same output, every time.
159
+ const store = entity as unknown as Record<symbol, unknown>;
160
+ let existing = store[ORIGINAL_PROPS] as Record<string, unknown> | undefined;
161
+ if (existing === undefined) {
162
+ existing = entity.props as Record<string, unknown>;
163
+ Object.defineProperty(entity, ORIGINAL_PROPS, {
164
+ value: existing, enumerable: false, configurable: true,
165
+ });
166
+ }
154
167
  const merged: Record<string, unknown> = {};
155
168
  for (const [k, v] of Object.entries(shared)) {
156
169
  if (v !== undefined) {
@@ -216,6 +229,12 @@ export function withDefaults<P, M extends CompositeMembers, D extends Partial<P>
216
229
  */
217
230
  export const SHARED_PROPS = Symbol.for("chant.composite.shared");
218
231
 
232
+ /**
233
+ * Symbol key stashing a member's original (pre-merge) props, so expandComposite()
234
+ * is idempotent across repeated expansions of the same singleton instance (#1032).
235
+ */
236
+ export const ORIGINAL_PROPS = Symbol.for("chant.composite.origProps");
237
+
219
238
  /**
220
239
  * Attaches shared properties to a composite instance.
221
240
  * During expandComposite(), shared props are merged into every member's props.
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import type { OwnershipMarker } from "./ownership";
6
6
  import { DEFAULT_SBOM_FORMAT, type SbomFormat } from "./components/verbs/sbom-generator";
7
7
  import type { Severity } from "./components/verbs/vuln-scan";
8
8
  import type { VulnPolicy } from "./components/verbs/vuln-gate";
9
+ import type { BuildParamsConfig } from "./build-params";
9
10
 
10
11
  /**
11
12
  * Zod schema for ChantConfig validation.
@@ -21,6 +22,20 @@ export const ChantConfigSchema = z.object({
21
22
  env: z.string().min(1).optional(),
22
23
  enabled: z.boolean().optional(),
23
24
  }).optional(),
25
+ build: z.object({
26
+ fold: z.boolean().optional(),
27
+ }).optional(),
28
+ buildParams: z.record(
29
+ z.string(),
30
+ z.object({
31
+ type: z.enum(["string", "number", "boolean"]),
32
+ default: z.union([z.string(), z.number(), z.boolean()]).optional(),
33
+ enum: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(),
34
+ env: z.string().min(1).optional(),
35
+ required: z.boolean().optional(),
36
+ description: z.string().optional(),
37
+ }),
38
+ ).optional(),
24
39
  release: z.object({
25
40
  autoRecord: z.boolean().optional(),
26
41
  }).optional(),
@@ -117,6 +132,45 @@ export interface ChantConfig {
117
132
  enabled?: boolean;
118
133
  };
119
134
 
135
+ /**
136
+ * `chant build` behavior toggles (#1022, epic #1019).
137
+ */
138
+ build?: {
139
+ /**
140
+ * Opt-in: fold source modules statically instead of importing/running
141
+ * them, falling back to run per-file for anything the folder can't
142
+ * represent (composite factory calls, non-`new` exports, …). Default
143
+ * `false`. The `--fold` CLI flag overrides this per-invocation (a flag
144
+ * of `true` always wins; the flag cannot force fold *off* when this is
145
+ * `true`). See {@link resolveFoldEnabled}.
146
+ */
147
+ fold?: boolean;
148
+
149
+ /**
150
+ * chant #1045 Phase 2 — opt-in: run-fallback source files (or, when
151
+ * `fold` above isn't set, every file) execute together, isolated, in one
152
+ * sandboxed child process instead of in-process. Default `false`. The
153
+ * `--sandbox` CLI flag overrides this per-invocation (a flag of `true`
154
+ * always wins; the flag cannot force sandboxing *off* when this is
155
+ * `true`). See {@link resolveSandboxEnabled}.
156
+ */
157
+ sandbox?: boolean;
158
+ };
159
+
160
+ /**
161
+ * Build-time parameters (#1064) — values supplied to `chant build` (a
162
+ * `--param name=value` flag, a `--params-file` JSON file, or a declared
163
+ * `env` var mapping) and bound to `params.<name>` (`@intentius/chant/params`)
164
+ * for source to reference, instead of reading `process.env` at module
165
+ * scope. Distinct from the deploy-time `Parameter` class
166
+ * (`lexicons/aws/src/parameter.ts`, a CloudFormation `Parameters:` entry
167
+ * that resolves at stack deploy) — a build-time parameter resolves before
168
+ * the template is even synthesized, so it can change which resources are
169
+ * produced at all (e.g. a tier selecting `light` vs `production`). See
170
+ * {@link resolveBuildParams} in `./build-params.ts`.
171
+ */
172
+ buildParams?: BuildParamsConfig;
173
+
120
174
  /**
121
175
  * Release-ledger recording behavior (#597, epic #551). Auto-emitting a
122
176
  * release record on a successful `chant run --components <name> --env
@@ -265,6 +319,33 @@ export function resolveAutoReleaseDisabled(config: ChantConfig, cliFlag?: boolea
265
319
  return config.release?.autoRecord === false;
266
320
  }
267
321
 
322
+ /**
323
+ * Whether `chant build` should use the fold path (#1022, epic #1019)
324
+ * instead of running each source module. Opt-in: off unless the CLI's
325
+ * `--fold` flag was passed (`cliFlag`) or the project config sets
326
+ * `build.fold: true` — the flag always wins for that one invocation,
327
+ * regardless of config.
328
+ */
329
+ export function resolveFoldEnabled(config: ChantConfig, cliFlag?: boolean): boolean {
330
+ if (cliFlag) return true;
331
+ return config.build?.fold === true;
332
+ }
333
+
334
+ /**
335
+ * Whether `chant build` should run its run-fallback files (or, without
336
+ * `--fold`, every file) in an isolated sandboxed child process rather than
337
+ * in-process (chant #1045 Phase 2). Opt-in: off unless the CLI's `--sandbox`
338
+ * flag was passed (`cliFlag`) or the project config sets `build.sandbox:
339
+ * true` — the flag always wins for that one invocation, regardless of
340
+ * config. Independent of {@link resolveFoldEnabled}: sandboxing without
341
+ * folding isolates every discovered file; sandboxing with folding isolates
342
+ * only the per-file run-fallback remainder.
343
+ */
344
+ export function resolveSandboxEnabled(config: ChantConfig, cliFlag?: boolean): boolean {
345
+ if (cliFlag) return true;
346
+ return config.build?.sandbox === true;
347
+ }
348
+
268
349
  /**
269
350
  * Resolve which SBOM format a `generate-sbom` step should request (#606),
270
351
  * given the project's `chant.config.ts` `sbom.format` and an optional
package/src/declarable.ts CHANGED
@@ -13,6 +13,26 @@ export interface Declarable {
13
13
  readonly [DECLARABLE_MARKER]: true;
14
14
  }
15
15
 
16
+ /**
17
+ * A `Declarable` that carries a resource payload — the `props`/`attributes`
18
+ * fields that most lexicon serializers read to produce output. Not every
19
+ * `Declarable` has one (outputs and parameters genuinely don't), so this is
20
+ * kept as a sub-interface rather than widening the base type. See chant #1049.
21
+ */
22
+ export interface ResourceDeclarable extends Declarable {
23
+ readonly props: unknown;
24
+ readonly attributes?: unknown;
25
+ }
26
+
27
+ /**
28
+ * Type guard for `ResourceDeclarable` — replaces the ad-hoc `"props" in x`
29
+ * checks that were previously repeated (with an `as unknown as` cast) at every
30
+ * call site that reads `props`/`attributes` off a `Declarable`.
31
+ */
32
+ export function isResourceDeclarable(value: Declarable): value is ResourceDeclarable {
33
+ return "props" in value;
34
+ }
35
+
16
36
  /**
17
37
  * Core parameter type for lexicon-agnostic parameters
18
38
  */
@@ -47,8 +47,21 @@ interface PendingEntry {
47
47
  /**
48
48
  * Flatten every module's exports into the ordered list of entities they
49
49
  * contribute — declarables directly, arrays element-by-element (indexed names),
50
- * composite instances expanded into members, and LexiconOutputs. Order is
51
- * preserved so downstream serializers emit resources in a stable order.
50
+ * composite instances expanded into members, and LexiconOutputs. `modules`
51
+ * order (one discovered file after another) is preserved so downstream
52
+ * serializers emit resources in a stable, file-discovery-order sequence.
53
+ *
54
+ * Within one module, exports are visited in ascending name order rather than
55
+ * `Object.entries()`'s own — a real ECMAScript Module namespace object
56
+ * already enumerates its (non-default) string keys this way per spec
57
+ * (`[[OwnPropertyKeys]]`, sorted), regardless of source declaration order;
58
+ * sorting here just makes that the case EXPLICITLY, so this doesn't quietly
59
+ * depend on whichever loader imported the file preserving (or not) that spec
60
+ * behavior — chant #1045 Phase 2 found `vite-node` (vitest's own in-process
61
+ * transform) does NOT sort, unlike plain Node, which made comparing an
62
+ * in-process build against a real-subprocess one (its differential's whole
63
+ * point) spuriously "drift" on multi-export-per-file modules whenever the
64
+ * in-process side ran under vitest.
52
65
  */
53
66
  function enumerateEntries(
54
67
  modules: Array<{ file: string; exports: Record<string, unknown> }>,
@@ -56,7 +69,8 @@ function enumerateEntries(
56
69
  const entries: PendingEntry[] = [];
57
70
 
58
71
  for (const { file, exports } of modules) {
59
- for (const [rawName, value] of Object.entries(exports)) {
72
+ const sortedExports = Object.entries(exports).sort(([a], [b]) => a.localeCompare(b));
73
+ for (const [rawName, value] of sortedExports) {
60
74
  const name = exportKey(rawName, file);
61
75
  if (isDeclarable(value)) {
62
76
  entries.push({ bareKey: name, value, file, provenance: { sourceFile: file } });