@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.
- package/dist/build-params.d.ts +108 -0
- package/dist/build-params.d.ts.map +1 -0
- package/dist/build.d.ts +79 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +31 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts +71 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +27 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +16 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +10 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +2 -0
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +12 -5
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts +62 -7
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts +12 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -0
- package/dist/components/sandbox/run.d.ts +42 -0
- package/dist/components/sandbox/run.d.ts.map +1 -0
- package/dist/composite.d.ts +5 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/config.d.ts +71 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/declarable.d.ts +16 -0
- package/dist/declarable.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +166 -0
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
- package/dist/discovery/entity-wire.d.ts +50 -0
- package/dist/discovery/entity-wire.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +239 -0
- package/dist/discovery/fold-import.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +74 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.d.ts +18 -0
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
- package/dist/discovery/sandbox/child-errors.d.ts +15 -0
- package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +13 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +69 -0
- package/dist/discovery/sandbox/run.d.ts.map +1 -0
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +299 -0
- package/dist/fold/fold.d.ts.map +1 -0
- package/dist/fold/foldable-helpers.d.ts +121 -0
- package/dist/fold/foldable-helpers.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +134 -0
- package/dist/fold/subset.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +7 -2
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +4 -2
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +131 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +11 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +7 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
- package/dist/params.d.ts +60 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/provenance.d.ts +21 -0
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime.d.ts +10 -1
- package/dist/runtime.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/build-params.test.ts +144 -0
- package/src/build-params.ts +207 -0
- package/src/build.test.ts +38 -0
- package/src/build.ts +144 -7
- package/src/cli/commands/build.test.ts +220 -2
- package/src/cli/commands/build.ts +111 -3
- package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
- package/src/cli/commands/check-lexicon-examples.ts +103 -0
- package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
- package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
- package/src/cli/commands/check-lexicon.test.ts +34 -0
- package/src/cli/commands/check-lexicon.ts +137 -1
- package/src/cli/commands/lint.ts +31 -3
- package/src/cli/commands/onboard.ts +1 -1
- package/src/cli/component-security-boundary.test.ts +170 -0
- package/src/cli/handlers/build.ts +24 -3
- package/src/cli/handlers/components.ts +9 -2
- package/src/cli/handlers/dev.ts +1 -1
- package/src/cli/handlers/graph.ts +7 -5
- package/src/cli/handlers/lifecycle.ts +2 -2
- package/src/cli/handlers/lint.ts +2 -0
- package/src/cli/handlers/misc.ts +2 -2
- package/src/cli/handlers/run-client.ts +1 -1
- package/src/cli/handlers/run.ts +20 -5
- package/src/cli/main.test.ts +22 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +20 -4
- package/src/cli/registry.ts +10 -0
- package/src/cli/security-boundary.test.ts +135 -0
- package/src/codegen/docs-sections.test.ts +67 -0
- package/src/codegen/docs-sections.ts +7 -3
- package/src/codegen/docs-types.ts +2 -0
- package/src/components/cli-support.ts +22 -10
- package/src/components/discover.ts +127 -25
- package/src/components/sandbox/driver.ts +114 -0
- package/src/components/sandbox/run.test.ts +185 -0
- package/src/components/sandbox/run.ts +177 -0
- package/src/composite.test.ts +21 -0
- package/src/composite.ts +20 -1
- package/src/config.ts +81 -0
- package/src/declarable.ts +20 -0
- package/src/discovery/collect.ts +17 -3
- package/src/discovery/entity-wire-codec.ts +487 -0
- package/src/discovery/entity-wire.test.ts +240 -0
- package/src/discovery/entity-wire.ts +67 -0
- package/src/discovery/fold-import.test.ts +1598 -0
- package/src/discovery/fold-import.ts +1998 -0
- package/src/discovery/index.test.ts +191 -1
- package/src/discovery/index.ts +242 -1
- package/src/discovery/sandbox/bundle.ts +218 -0
- package/src/discovery/sandbox/child-errors.ts +65 -0
- package/src/discovery/sandbox/driver.ts +147 -0
- package/src/discovery/sandbox/run.test.ts +179 -0
- package/src/discovery/sandbox/run.ts +196 -0
- package/src/errors.ts +9 -1
- package/src/fold/fold.test.ts +812 -0
- package/src/fold/fold.ts +805 -0
- package/src/fold/foldable-helpers.ts +171 -0
- package/src/fold/subset-doc-parity.test.ts +210 -0
- package/src/fold/subset.test.ts +352 -0
- package/src/fold/subset.ts +383 -0
- package/src/index.ts +2 -0
- package/src/lexicon-output.ts +7 -2
- package/src/lexicon-schema.test.ts +57 -0
- package/src/lexicon-schema.ts +8 -1
- package/src/lexicon.ts +132 -1
- package/src/lifecycle/git.test.ts +10 -5
- package/src/lifecycle/release-ledger.test.ts +28 -0
- package/src/lifecycle/release-ledger.ts +11 -0
- package/src/lint/component-checks.ts +8 -1
- package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
- package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
- package/src/params.test.ts +22 -0
- package/src/params.ts +66 -0
- package/src/provenance.ts +22 -0
- 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
|
+
}
|
package/src/composite.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*/
|
package/src/discovery/collect.ts
CHANGED
|
@@ -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.
|
|
51
|
-
*
|
|
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
|
-
|
|
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 } });
|