@intentius/chant 0.27.0 → 0.29.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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +100 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- package/dist/lexicon.d.ts +31 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +190 -0
- package/src/cli/commands/build.ts +34 -2
- package/src/cli/commands/lint.ts +17 -25
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- package/src/discovery/sandbox/config-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.test.ts +94 -0
- package/src/kubectl-context.ts +207 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +34 -9
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +76 -3
- package/src/stack-output.ts +59 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -236,4 +236,58 @@ describe("chant.config.ts evaluation under --sandbox (chant #1113)", () => {
|
|
|
236
236
|
);
|
|
237
237
|
expect(marker()).toBeUndefined();
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* chant #1148 — `chant.config.ts`'s own `console.log`/`console.error` used
|
|
242
|
+
* to go nowhere under `--sandbox`. Same relay as the run-fallback child
|
|
243
|
+
* (`./run.test.ts`), just this child's own prefix.
|
|
244
|
+
*/
|
|
245
|
+
describe("console output forwarding (chant #1148)", () => {
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
vi.restoreAllMocks();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
afterEach(() => {
|
|
251
|
+
vi.restoreAllMocks();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/** Spies on `process.stderr.write` and returns the lines captured so far — same shape as `../../cli/handlers/emulator.test.ts`'s `stdout()`/`stderr()` helpers. */
|
|
255
|
+
function captureStderr(): string[] {
|
|
256
|
+
const lines: string[] = [];
|
|
257
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
258
|
+
lines.push(String(chunk));
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// See ./run.test.ts's identical helper doc: the IPC "message" the awaited
|
|
265
|
+
// call resolves on and the child's stdout/stderr pipe data are
|
|
266
|
+
// independent channels, so polling briefly avoids a flaky assertion.
|
|
267
|
+
async function waitFor(lines: string[], matcher: RegExp, timeoutMs = 5000): Promise<void> {
|
|
268
|
+
const start = Date.now();
|
|
269
|
+
while (Date.now() - start < timeoutMs) {
|
|
270
|
+
if (matcher.test(lines.join(""))) return;
|
|
271
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`stderr never matched ${matcher}. Captured so far:\n${lines.join("")}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
test("the config's own console.log/console.error are forwarded, prefixed with [sandbox:config]", async () => {
|
|
277
|
+
const stderr = captureStderr();
|
|
278
|
+
await writeFile(
|
|
279
|
+
join(testDir, "chant.config.ts"),
|
|
280
|
+
`console.log("hello from config stdout");\n` +
|
|
281
|
+
`console.error("hello from config stderr");\n` +
|
|
282
|
+
`export default { lexicons: ["aws"] };\n`,
|
|
283
|
+
);
|
|
284
|
+
armSandboxConfigEvaluation();
|
|
285
|
+
|
|
286
|
+
const { config } = await loadChantConfig(testDir);
|
|
287
|
+
|
|
288
|
+
expect(config.lexicons).toEqual(["aws"]);
|
|
289
|
+
await waitFor(stderr, /^\[sandbox:config\] hello from config stdout$/m);
|
|
290
|
+
await waitFor(stderr, /^\[sandbox:config\] hello from config stderr$/m);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
239
293
|
});
|
|
@@ -108,6 +108,9 @@ export async function evaluateConfigSandboxed(
|
|
|
108
108
|
env,
|
|
109
109
|
timeoutMs: CONFIG_CHILD_TIMEOUT_MS,
|
|
110
110
|
label: `sandboxed evaluation of ${configPath}`,
|
|
111
|
+
// chant #1148 — the config's own console.log/error no longer goes
|
|
112
|
+
// nowhere; see `./fork.ts`'s `outputPrefix` doc.
|
|
113
|
+
outputPrefix: "[sandbox:config]",
|
|
111
114
|
},
|
|
112
115
|
isConfigChildResponse,
|
|
113
116
|
);
|
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
* ## What `ChantConfig` legally holds
|
|
28
28
|
*
|
|
29
29
|
* Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
|
|
30
|
-
* (`lexicons`, `capabilities
|
|
31
|
-
* plain
|
|
30
|
+
* (`lexicons`, `capabilities`), strings (`sourceDir`), an array of strings or
|
|
31
|
+
* plain `{ name, endpoint }` objects (`environments`, #1166), nested plain
|
|
32
|
+
* objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
|
|
32
33
|
* `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
|
|
33
34
|
* plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
|
|
34
35
|
* are a severity string or a `[severity, options]` tuple, and whose `plugins`
|
|
@@ -11,6 +11,11 @@ import { fork } from "node:child_process";
|
|
|
11
11
|
* one function is the cheapest way to make "same profile" a fact rather than
|
|
12
12
|
* a claim.
|
|
13
13
|
*
|
|
14
|
+
* chant #1148 — this is also the one place chant forwards a sandboxed
|
|
15
|
+
* child's own stdout/stderr, so `./run.ts`, `./config-run.ts` and
|
|
16
|
+
* `./policy-run.ts` cannot drift on whether project output vanishes. See
|
|
17
|
+
* {@link SandboxForkOptions.outputPrefix}.
|
|
18
|
+
*
|
|
14
19
|
* Isolation mechanics (verified on Node v24.13.1 — see the chant#1045 PR
|
|
15
20
|
* description for the full write-up):
|
|
16
21
|
* - `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted
|
|
@@ -45,6 +50,26 @@ export interface SandboxForkOptions {
|
|
|
45
50
|
timeoutMs: number;
|
|
46
51
|
/** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
|
|
47
52
|
label: string;
|
|
53
|
+
/**
|
|
54
|
+
* chant #1148 — prepended to every line the child writes on EITHER stdout
|
|
55
|
+
* or stderr before it is relayed, line-buffered, to this process's own
|
|
56
|
+
* stderr (e.g. `"[sandbox:run]"`, `"[sandbox:config]"`,
|
|
57
|
+
* `"[policy:org.ts]"`).
|
|
58
|
+
*
|
|
59
|
+
* A sandboxed child's `console.log`/`console.error` used to go nowhere: its
|
|
60
|
+
* stdout was piped but never read, and its stderr was captured only into
|
|
61
|
+
* {@link stderrBuf}'s error-message use, never surfaced on a successful
|
|
62
|
+
* run. Diagnostics crossing as data (the whole point of the boundary) is
|
|
63
|
+
* not the same thing as incidental output being silently dropped — chant's
|
|
64
|
+
* stance is that nothing the project prints vanishes, sandboxed or not.
|
|
65
|
+
*
|
|
66
|
+
* This is forwarding, not a second capture: {@link stderrBuf} still
|
|
67
|
+
* accumulates the child's raw stderr for `classifyChildError`/the
|
|
68
|
+
* exited-before-reporting message exactly as before. Both read the same
|
|
69
|
+
* `data` events; one buffers for classification, this one relays for a
|
|
70
|
+
* human to see.
|
|
71
|
+
*/
|
|
72
|
+
outputPrefix: string;
|
|
48
73
|
/**
|
|
49
74
|
* chant #1131 — an optional payload sent to the child over the SAME IPC
|
|
50
75
|
* channel its response comes back on, immediately after the fork.
|
|
@@ -65,6 +90,36 @@ export interface SandboxForkOptions {
|
|
|
65
90
|
send?: Record<string, unknown>;
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
/**
|
|
94
|
+
* chant #1148 — buffers arbitrary chunks and calls `emit` once per complete
|
|
95
|
+
* line, never on a chunk boundary that happens to split a line in two (a
|
|
96
|
+
* pipe makes no promise that one `write()` on the child's side arrives as one
|
|
97
|
+
* `data` event on ours). `flush()` emits whatever partial line never got a
|
|
98
|
+
* trailing newline, so the last unterminated write doesn't silently vanish
|
|
99
|
+
* when the stream ends — the same no-dropping stance this whole feature
|
|
100
|
+
* exists for.
|
|
101
|
+
*/
|
|
102
|
+
function lineBuffered(emit: (line: string) => void) {
|
|
103
|
+
let pending = "";
|
|
104
|
+
return {
|
|
105
|
+
push(chunk: Buffer | string): void {
|
|
106
|
+
pending += chunk.toString();
|
|
107
|
+
let newlineAt = pending.indexOf("\n");
|
|
108
|
+
while (newlineAt !== -1) {
|
|
109
|
+
emit(pending.slice(0, newlineAt));
|
|
110
|
+
pending = pending.slice(newlineAt + 1);
|
|
111
|
+
newlineAt = pending.indexOf("\n");
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
flush(): void {
|
|
115
|
+
if (pending.length > 0) {
|
|
116
|
+
emit(pending);
|
|
117
|
+
pending = "";
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
68
123
|
/**
|
|
69
124
|
* Fork `bundlePath` under `--permission` with a scrubbed environment, and
|
|
70
125
|
* resolve with the first IPC message that satisfies `isResponse` (or reject
|
|
@@ -74,7 +129,8 @@ export function forkSandboxed<T>(
|
|
|
74
129
|
options: SandboxForkOptions,
|
|
75
130
|
isResponse: (value: unknown) => value is T,
|
|
76
131
|
): Promise<T> {
|
|
77
|
-
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } =
|
|
132
|
+
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send, outputPrefix } =
|
|
133
|
+
options;
|
|
78
134
|
|
|
79
135
|
return new Promise((resolvePromise, reject) => {
|
|
80
136
|
const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
|
|
@@ -106,9 +162,27 @@ export function forkSandboxed<T>(
|
|
|
106
162
|
});
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
// chant #1148 — forward, don't just capture. Both streams write to THIS
|
|
166
|
+
// process's stderr, prefixed and line-buffered, independent of
|
|
167
|
+
// `stderrBuf` below (which keeps accumulating raw stderr for
|
|
168
|
+
// `classifyChildError`'s use — forwarded and captured are not mutually
|
|
169
|
+
// exclusive, the same bytes feed both).
|
|
170
|
+
const forwardLine = (line: string): void => {
|
|
171
|
+
process.stderr.write(`${outputPrefix} ${line}\n`);
|
|
172
|
+
};
|
|
173
|
+
const stdoutForwarder = lineBuffered(forwardLine);
|
|
174
|
+
const stderrForwarder = lineBuffered(forwardLine);
|
|
175
|
+
|
|
176
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
177
|
+
stdoutForwarder.push(chunk);
|
|
178
|
+
});
|
|
179
|
+
child.stdout?.on("end", () => stdoutForwarder.flush());
|
|
180
|
+
|
|
109
181
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
110
182
|
stderrBuf += chunk.toString();
|
|
183
|
+
stderrForwarder.push(chunk);
|
|
111
184
|
});
|
|
185
|
+
child.stderr?.on("end", () => stderrForwarder.flush());
|
|
112
186
|
|
|
113
187
|
child.on("message", (msg: unknown) => {
|
|
114
188
|
if (settled || !isResponse(msg)) return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -281,6 +281,61 @@ describe("lint.policies execution under --sandbox (chant #1131)", () => {
|
|
|
281
281
|
expect(await run([])).toEqual([]);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* chant #1148 — a policy's own `console.log`/`console.error` used to go
|
|
286
|
+
* nowhere under `--sandbox` (noted as a residual when #1131 shipped). Same
|
|
287
|
+
* relay as the run-fallback and config children, keyed to the policy
|
|
288
|
+
* module's own basename rather than a generic tag.
|
|
289
|
+
*/
|
|
290
|
+
describe("console output forwarding (chant #1148)", () => {
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
vi.restoreAllMocks();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterEach(() => {
|
|
296
|
+
vi.restoreAllMocks();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** Spies on `process.stderr.write` and returns the lines captured so far — same shape as `../../cli/handlers/emulator.test.ts`'s `stdout()`/`stderr()` helpers. */
|
|
300
|
+
function captureStderr(): string[] {
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
303
|
+
lines.push(String(chunk));
|
|
304
|
+
return true;
|
|
305
|
+
});
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// See ./run.test.ts's identical helper doc: the IPC "message" the
|
|
310
|
+
// awaited call resolves on and the child's stdout/stderr pipe data are
|
|
311
|
+
// independent channels, so polling briefly avoids a flaky assertion.
|
|
312
|
+
async function waitFor(lines: string[], matcher: RegExp, timeoutMs = 5000): Promise<void> {
|
|
313
|
+
const start = Date.now();
|
|
314
|
+
while (Date.now() - start < timeoutMs) {
|
|
315
|
+
if (matcher.test(lines.join(""))) return;
|
|
316
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`stderr never matched ${matcher}. Captured so far:\n${lines.join("")}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
test("a policy's console.log/console.error are forwarded, prefixed with [policy:<module-basename>]", async () => {
|
|
322
|
+
const stderr = captureStderr();
|
|
323
|
+
await writeMarkerPolicy(
|
|
324
|
+
"org.ts",
|
|
325
|
+
`console.log("hello from policy stdout");\n` +
|
|
326
|
+
`console.error("hello from policy stderr");\n` +
|
|
327
|
+
`export const c = { id: "X", description: "d", check: () => [] };\n`,
|
|
328
|
+
);
|
|
329
|
+
armSandboxPolicyExecution();
|
|
330
|
+
|
|
331
|
+
const diags = await run(["org.ts"]);
|
|
332
|
+
|
|
333
|
+
expect(diags).toEqual([]);
|
|
334
|
+
await waitFor(stderr, /^\[policy:org\.ts\] hello from policy stdout$/m);
|
|
335
|
+
await waitFor(stderr, /^\[policy:org\.ts\] hello from policy stderr$/m);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
284
339
|
test("a program that runs a policy child EXITS — the child is not left holding the event loop", async () => {
|
|
285
340
|
// Found the hard way while measuring #1131's cost: unlike the run and
|
|
286
341
|
// config drivers, the policy driver keeps a `message` listener registered
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, rmSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
3
|
import type { PostSynthDiagnostic } from "../../lint/post-synth";
|
|
4
4
|
import { bundleDriver } from "./bundle";
|
|
5
5
|
import { generatePolicyDriverSource } from "./driver";
|
|
@@ -79,6 +79,17 @@ interface PolicyChildResponse {
|
|
|
79
79
|
error?: { name: string; file: string; message: string; type: string };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* chant #1148 — `[policy:<module-basename>]`, joined when a project declares
|
|
84
|
+
* more than one policy module (all of them share the one child — see the
|
|
85
|
+
* module doc above). One policy module is the only shape this repo's own
|
|
86
|
+
* corpus exercises (`lexicons/k8s/examples/org-policy`), so this is the
|
|
87
|
+
* common case, not a hypothetical.
|
|
88
|
+
*/
|
|
89
|
+
function policyOutputPrefix(policyPaths: readonly string[]): string {
|
|
90
|
+
return `[policy:${policyPaths.map((p) => basename(p)).join(",")}]`;
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
function isPolicyChildResponse(value: unknown): value is PolicyChildResponse {
|
|
83
94
|
return (
|
|
84
95
|
typeof value === "object" &&
|
|
@@ -158,6 +169,9 @@ export async function runPoliciesSandboxed(options: SandboxPolicyOptions): Promi
|
|
|
158
169
|
timeoutMs: POLICY_CHILD_TIMEOUT_MS,
|
|
159
170
|
label: `sandboxed evaluation of lint.policies (${policyPaths.length} module(s))`,
|
|
160
171
|
send: payload,
|
|
172
|
+
// chant #1148 — a policy's own console.log/error no longer goes
|
|
173
|
+
// nowhere; see `./fork.ts`'s `outputPrefix` doc.
|
|
174
|
+
outputPrefix: policyOutputPrefix(policyPaths),
|
|
161
175
|
},
|
|
162
176
|
isPolicyChildResponse,
|
|
163
177
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join, dirname, resolve } from "node:path";
|
|
@@ -176,4 +176,88 @@ describe("runFallbackFilesSandboxed — isolation", () => {
|
|
|
176
176
|
expect(ref.getLogicalName?.()).toBe("dataBucket");
|
|
177
177
|
expect(ref.attribute).toBe("Arn");
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* chant #1148 — a run-fallback file's own `console.log`/`console.error`
|
|
182
|
+
* used to go nowhere: the child's stdout was piped but never read, and its
|
|
183
|
+
* stderr was captured only for `classifyChildError`'s use, never surfaced
|
|
184
|
+
* on a successful run. `./fork.ts` now relays both, line-buffered and
|
|
185
|
+
* prefixed, to THIS process's stderr — diagnostics crossing as data was
|
|
186
|
+
* never meant to mean incidental output vanishes.
|
|
187
|
+
*/
|
|
188
|
+
describe("console output forwarding (chant #1148)", () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
vi.restoreAllMocks();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
vi.restoreAllMocks();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Spies on `process.stderr.write` and returns the lines captured so far — same shape as `../../cli/handlers/emulator.test.ts`'s `stdout()`/`stderr()` helpers. */
|
|
198
|
+
function captureStderr(): string[] {
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
201
|
+
lines.push(String(chunk));
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The child's IPC "message" (what the awaited call resolves on) and its
|
|
208
|
+
// stdout/stderr pipe data are two independent channels — nothing orders
|
|
209
|
+
// one ahead of the other, and #1147 deliberately keeps it that way (see
|
|
210
|
+
// the hang-regression test in policy-boundary.test.ts). Polling briefly
|
|
211
|
+
// rather than asserting immediately avoids a flaky test without giving
|
|
212
|
+
// the production path anything to wait for.
|
|
213
|
+
async function waitFor(lines: string[], matcher: RegExp, timeoutMs = 5000): Promise<void> {
|
|
214
|
+
const start = Date.now();
|
|
215
|
+
while (Date.now() - start < timeoutMs) {
|
|
216
|
+
if (matcher.test(lines.join(""))) return;
|
|
217
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`stderr never matched ${matcher}. Captured so far:\n${lines.join("")}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("both console.log and console.error are forwarded, prefixed with [sandbox:run]", async () => {
|
|
223
|
+
const stderr = captureStderr();
|
|
224
|
+
const file = join(testDir, "noisy.ts");
|
|
225
|
+
await writeFile(
|
|
226
|
+
file,
|
|
227
|
+
`
|
|
228
|
+
console.log("hello from run-fallback stdout");
|
|
229
|
+
console.error("hello from run-fallback stderr");
|
|
230
|
+
export const value = "ok";
|
|
231
|
+
`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
235
|
+
|
|
236
|
+
expect(result.errors).toEqual([]);
|
|
237
|
+
await waitFor(stderr, /^\[sandbox:run\] hello from run-fallback stdout$/m);
|
|
238
|
+
await waitFor(stderr, /^\[sandbox:run\] hello from run-fallback stderr$/m);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a file that exits hard still has its stderr in the classified error (forwarding doesn't replace capture)", async () => {
|
|
242
|
+
const stderr = captureStderr();
|
|
243
|
+
const file = join(testDir, "crashes.ts");
|
|
244
|
+
await writeFile(
|
|
245
|
+
file,
|
|
246
|
+
`
|
|
247
|
+
console.error("about to exit hard");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
`,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
253
|
+
|
|
254
|
+
// A hard process.exit() takes the whole bundled child with it — this is
|
|
255
|
+
// fork.ts's pre-existing "child exited before reporting results" path,
|
|
256
|
+
// fed by the SAME stderrBuf that now also feeds forwarding.
|
|
257
|
+
expect(result.errors).toHaveLength(1);
|
|
258
|
+
expect(result.errors[0].message).toMatch(/about to exit hard/);
|
|
259
|
+
|
|
260
|
+
await waitFor(stderr, /^\[sandbox:run\] about to exit hard$/m);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
179
263
|
});
|
|
@@ -112,6 +112,9 @@ export async function runFallbackFilesSandboxed(
|
|
|
112
112
|
env: { PATH: process.env.PATH ?? "" },
|
|
113
113
|
timeoutMs: CHILD_TIMEOUT_MS,
|
|
114
114
|
label: "sandboxed run",
|
|
115
|
+
// chant #1148 — a run-fallback file's own console.log/error no
|
|
116
|
+
// longer goes nowhere; see `./fork.ts`'s `outputPrefix` doc.
|
|
117
|
+
outputPrefix: "[sandbox:run]",
|
|
115
118
|
},
|
|
116
119
|
isChildResponse,
|
|
117
120
|
);
|
package/src/env.test.ts
CHANGED
|
@@ -40,6 +40,18 @@ describe("unknownEnvError", () => {
|
|
|
40
40
|
test("rejects an undeclared env with a clear message", () => {
|
|
41
41
|
expect(unknownEnvError("stage", ["dev", "prod"])).toMatch(/Unknown environment "stage".*dev, prod/);
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
// #1166 — `environments` entries may now be `{ name, endpoint }`; validation
|
|
45
|
+
// reduces to names either way, unaffected by an object entry's endpoint.
|
|
46
|
+
test("accepts an object-form declared env, mixed with bare strings", () => {
|
|
47
|
+
expect(unknownEnvError("floci", ["prod", { name: "floci", endpoint: "http://localhost:4566" }])).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects an undeclared env against a mixed string/object list, naming both", () => {
|
|
51
|
+
expect(
|
|
52
|
+
unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
|
|
53
|
+
).toMatch(/Unknown environment "stage".*prod, floci/);
|
|
54
|
+
});
|
|
43
55
|
});
|
|
44
56
|
|
|
45
57
|
describe("env-aware discovery (#505)", () => {
|
package/src/env.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { environmentNames, type EnvironmentDeclaration } from "./config";
|
|
22
|
+
|
|
21
23
|
/** The environment variable the CLI sets from `--env`. */
|
|
22
24
|
export const ENV_VAR = "CHANT_ENV";
|
|
23
25
|
|
|
@@ -30,10 +32,16 @@ export function env(fallback?: string): string | undefined {
|
|
|
30
32
|
* Validate a requested environment against the project's declared `environments`
|
|
31
33
|
* (`chant.config`). Returns an error message for an unknown env, or `undefined`
|
|
32
34
|
* when it's valid (or when the project declares no environments, in which case
|
|
33
|
-
* any name is accepted).
|
|
35
|
+
* any name is accepted). `declared` entries may be a bare name or `{ name,
|
|
36
|
+
* endpoint }` (#1166) — {@link environmentNames} reduces either to the names
|
|
37
|
+
* this checks against.
|
|
34
38
|
*/
|
|
35
|
-
export function unknownEnvError(
|
|
39
|
+
export function unknownEnvError(
|
|
40
|
+
requested: string | undefined,
|
|
41
|
+
declared: EnvironmentDeclaration[] | undefined,
|
|
42
|
+
): string | undefined {
|
|
36
43
|
if (!requested || !declared || declared.length === 0) return undefined;
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const names = environmentNames(declared) ?? [];
|
|
45
|
+
if (names.includes(requested)) return undefined;
|
|
46
|
+
return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
|
|
39
47
|
}
|
package/src/fold/fold.ts
CHANGED
|
@@ -298,8 +298,18 @@ export function collectConsts(sourceFile: ts.SourceFile): Map<string, ts.Express
|
|
|
298
298
|
return consts;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
301
|
+
/**
|
|
302
|
+
* A property/element key foldable without execution: identifier, string, or
|
|
303
|
+
* numeric literal.
|
|
304
|
+
*
|
|
305
|
+
* Exported for chant #1023's composite-factory interpreter
|
|
306
|
+
* (../discovery/fold-import.ts), which walks object literals itself — a
|
|
307
|
+
* factory body may construct a resource inside one, which {@link fold} has no
|
|
308
|
+
* case for — and must reject a computed key with the identical message
|
|
309
|
+
* {@link fold} would, rather than growing a second, silently divergent copy of
|
|
310
|
+
* this rule.
|
|
311
|
+
*/
|
|
312
|
+
export function propName(node: ts.PropertyName): string {
|
|
303
313
|
if (isLiteralPropertyName(node)) return node.text;
|
|
304
314
|
throw foldError(node, computedPropertyNameMessage(node));
|
|
305
315
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
|
|
2
|
+
import { buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
|
|
3
3
|
|
|
4
4
|
// A fixture "snapshot" — what a lexicon's describeResources() returns for a live
|
|
5
5
|
// environment (managed-only). Two AWS resources; the subnet references the VPC by
|
|
@@ -140,4 +140,31 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
|
140
140
|
const ir = sourceOverlayGraphs(declared, liveDup);
|
|
141
141
|
expect(ir.edges.filter((e) => e.from === "app-ingress" && e.to === "web-vpc")).toHaveLength(1);
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
// #1089 — "not deployed yet" is a claim the read has to support.
|
|
145
|
+
it("paints a declared node nobody could read `neutral`, not `accent`", () => {
|
|
146
|
+
const ir = sourceOverlayGraphs(declared, live, {
|
|
147
|
+
unobserved: { "planned-db": { reason: "no-binding", detail: "no kubectl context" } },
|
|
148
|
+
});
|
|
149
|
+
const node = ir.nodes.find((x) => x.id === "planned-db")!;
|
|
150
|
+
expect((node.attrs as { _status?: string })._status).toBe("neutral");
|
|
151
|
+
expect((node.attrs as { _unobserved?: string })._unobserved).toBe("no-binding");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("still paints a confirmed-absent declared node `accent`", () => {
|
|
155
|
+
const ir = sourceOverlayGraphs(declared, live, { unobserved: {} });
|
|
156
|
+
expect((ir.nodes.find((x) => x.id === "planned-db")!.attrs as { _status?: string })._status).toBe("accent");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("collectUnobserved (#1089)", () => {
|
|
161
|
+
it("unions every observation's holes", () => {
|
|
162
|
+
expect(
|
|
163
|
+
collectUnobserved([
|
|
164
|
+
{ lexicon: "aws", resources: {}, unobserved: { a: { reason: "read-failed" } } },
|
|
165
|
+
{ lexicon: "k8s", resources: {} },
|
|
166
|
+
{ lexicon: "gcp", resources: {}, unobserved: { b: { reason: "no-binding" } } },
|
|
167
|
+
]),
|
|
168
|
+
).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
|
|
169
|
+
});
|
|
143
170
|
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
|
|
|
6
6
|
import { getProvenance } from "./provenance";
|
|
7
7
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
8
8
|
import type { ResourceMetadata } from "./lexicon";
|
|
9
|
+
import type { UnobservedEntity } from "./observation";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Graph IR — the engine-neutral, lint-gated representation of a project's
|
|
@@ -419,6 +420,12 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
|
|
|
419
420
|
export interface LiveObservation {
|
|
420
421
|
lexicon: string;
|
|
421
422
|
resources: Record<string, ResourceMetadata>;
|
|
423
|
+
/**
|
|
424
|
+
* Declared entities the lexicon could not observe (#1089), keyed by name.
|
|
425
|
+
* They are not live nodes — but they are not confirmed-absent either, so the
|
|
426
|
+
* overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
|
|
427
|
+
*/
|
|
428
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
422
429
|
}
|
|
423
430
|
|
|
424
431
|
/**
|
|
@@ -446,7 +453,10 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
446
453
|
attrs: meta.attributes ?? {},
|
|
447
454
|
};
|
|
448
455
|
if (meta.physicalId) node.physicalId = meta.physicalId;
|
|
449
|
-
|
|
456
|
+
// `unknown` is a legitimate verdict on the metadata (#1089) but carries no
|
|
457
|
+
// information for a painter, and the IR's `ownership` field means "a
|
|
458
|
+
// verdict was reached" — so only owned/foreign land on the node.
|
|
459
|
+
if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
|
|
450
460
|
nodes.push(node);
|
|
451
461
|
(byLexicon[lexicon] ??= []).push(name);
|
|
452
462
|
// A live lexicon maps to one deployable stack, same as the source IR.
|
|
@@ -465,22 +475,60 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
465
475
|
return { nodes, edges: [], groups };
|
|
466
476
|
}
|
|
467
477
|
|
|
478
|
+
/** How an overlay learns which declared nodes were never looked at (#1089). */
|
|
479
|
+
export interface OverlayOptions {
|
|
480
|
+
/**
|
|
481
|
+
* Declared entities the observation could not read, keyed by name (union of
|
|
482
|
+
* every {@link LiveObservation}'s `unobserved`). They are tagged `neutral`
|
|
483
|
+
* instead of `accent`: "not yet provisioned" is a claim the read never
|
|
484
|
+
* supported.
|
|
485
|
+
*/
|
|
486
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The union of every observation's unobserved entities — the input to the overlays. */
|
|
490
|
+
export function collectUnobserved(observations: LiveObservation[]): Record<string, UnobservedEntity> {
|
|
491
|
+
const out: Record<string, UnobservedEntity> = {};
|
|
492
|
+
for (const o of observations) Object.assign(out, o.unobserved ?? {});
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Paint status a node carries in an overlay. `neutral` = chant could not look. */
|
|
497
|
+
type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
|
|
498
|
+
|
|
499
|
+
function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
|
|
500
|
+
return {
|
|
501
|
+
...n,
|
|
502
|
+
attrs: {
|
|
503
|
+
...n.attrs,
|
|
504
|
+
_status: status,
|
|
505
|
+
...(unobserved ? { _unobserved: unobserved.reason } : {}),
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
468
510
|
/**
|
|
469
511
|
* Overlay the declared graph on the provisioned one (#780, `chant graph --live
|
|
470
512
|
* --overlay`) and classify each resource, tagging a `_status` a renderer colours:
|
|
471
513
|
* - **managed** (declared + provisioned) → `good`
|
|
472
514
|
* - **foreign** (provisioned, not declared) → `warn`
|
|
473
|
-
* - **pending** (declared,
|
|
515
|
+
* - **pending** (declared, provider confirmed absent) → `accent`
|
|
516
|
+
* - **unobserved** (declared, chant could not look — #1089) → `neutral`,
|
|
517
|
+
* plus an `_unobserved` attr carrying the reason
|
|
474
518
|
* Live nodes keep their edges/containment; pending nodes are appended (they have
|
|
475
519
|
* no live edges). Sorted; the live groups pass through unchanged.
|
|
476
520
|
*/
|
|
477
|
-
export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
|
|
521
|
+
export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
478
522
|
const declaredIds = new Set(declared.nodes.map((n) => n.id));
|
|
479
523
|
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
480
|
-
const
|
|
524
|
+
const unobserved = opts?.unobserved ?? {};
|
|
481
525
|
|
|
482
|
-
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
483
|
-
for (const n of declared.nodes)
|
|
526
|
+
const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
|
|
527
|
+
for (const n of declared.nodes) {
|
|
528
|
+
if (liveIds.has(n.id)) continue;
|
|
529
|
+
const u = unobserved[n.id];
|
|
530
|
+
nodes.push(u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent"));
|
|
531
|
+
}
|
|
484
532
|
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
485
533
|
|
|
486
534
|
return { ...live, nodes };
|
|
@@ -497,27 +545,35 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
|
|
|
497
545
|
* Each declared node is classified against live observation and tagged `_status`:
|
|
498
546
|
* - **managed** (declared + provisioned) → `good`, carrying the observed
|
|
499
547
|
* `physicalId` / `ownership` onto the declared node
|
|
500
|
-
* - **pending** (declared,
|
|
548
|
+
* - **pending** (declared, provider confirmed absent) → `accent`
|
|
549
|
+
* - **unobserved** (declared, chant could not look — #1089) → `neutral`, with
|
|
550
|
+
* the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
|
|
551
|
+
* to paint the whole estate "pending", which is the diagram equivalent of
|
|
552
|
+
* planning a create for something that already exists.
|
|
501
553
|
* **Foreign** resources (provisioned, not declared) are appended and tagged
|
|
502
554
|
* `warn`, together with any live-reconstructed edges that touch them — a declared
|
|
503
555
|
* edge cannot describe an undeclared resource. Declared groups/exports pass
|
|
504
556
|
* through unchanged; nodes and edges are sorted for deterministic output.
|
|
505
557
|
*/
|
|
506
|
-
export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR): GraphIR {
|
|
558
|
+
export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
507
559
|
const liveById = new Map(live.nodes.map((n) => [n.id, n]));
|
|
508
560
|
const declaredIds = new Set(declared.nodes.map((n) => n.id));
|
|
509
561
|
const foreignIds = new Set(live.nodes.filter((n) => !declaredIds.has(n.id)).map((n) => n.id));
|
|
510
|
-
const
|
|
562
|
+
const unobserved = opts?.unobserved ?? {};
|
|
511
563
|
|
|
512
564
|
const nodes: IRNode[] = declared.nodes.map((n) => {
|
|
513
565
|
const obs = liveById.get(n.id);
|
|
514
|
-
if (!obs)
|
|
566
|
+
if (!obs) {
|
|
567
|
+
const u = unobserved[n.id];
|
|
568
|
+
// unobserved — declared, and nobody looked; not "pending"
|
|
569
|
+
return u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent");
|
|
570
|
+
}
|
|
515
571
|
const merged: IRNode = { ...n }; // managed — carry the observed identity
|
|
516
572
|
if (obs.physicalId) merged.physicalId = obs.physicalId;
|
|
517
573
|
if (obs.ownership) merged.ownership = obs.ownership;
|
|
518
|
-
return
|
|
574
|
+
return tagStatus(merged, "good");
|
|
519
575
|
});
|
|
520
|
-
for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(
|
|
576
|
+
for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
|
|
521
577
|
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
522
578
|
|
|
523
579
|
// Declared edges are the canvas (the cross-substrate topology). Add only the
|