@intentius/chant 0.27.0 → 0.28.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 (38) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  4. package/dist/discovery/sandbox/fork.d.ts +25 -0
  5. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  6. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  8. package/dist/kubectl-context.d.ts +73 -0
  9. package/dist/kubectl-context.d.ts.map +1 -0
  10. package/dist/lint/config.d.ts +80 -0
  11. package/dist/lint/config.d.ts.map +1 -1
  12. package/dist/lint/policy.d.ts +8 -2
  13. package/dist/lint/policy.d.ts.map +1 -1
  14. package/dist/lint/post-synth.d.ts +18 -1
  15. package/dist/lint/post-synth.d.ts.map +1 -1
  16. package/dist/stack-output.d.ts +9 -4
  17. package/dist/stack-output.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/commands/build.test.ts +190 -0
  20. package/src/cli/commands/build.ts +34 -2
  21. package/src/cli/commands/lint.ts +17 -25
  22. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  23. package/src/discovery/sandbox/config-run.ts +3 -0
  24. package/src/discovery/sandbox/fork.ts +75 -1
  25. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  26. package/src/discovery/sandbox/policy-run.ts +15 -1
  27. package/src/discovery/sandbox/run.test.ts +85 -1
  28. package/src/discovery/sandbox/run.ts +3 -0
  29. package/src/kubectl-context.test.ts +94 -0
  30. package/src/kubectl-context.ts +126 -0
  31. package/src/lint/config.test.ts +93 -1
  32. package/src/lint/config.ts +108 -0
  33. package/src/lint/policy.test.ts +90 -0
  34. package/src/lint/policy.ts +17 -5
  35. package/src/lint/post-synth.test.ts +4 -0
  36. package/src/lint/post-synth.ts +30 -1
  37. package/src/stack-output.test.ts +21 -3
  38. package/src/stack-output.ts +21 -9
@@ -4,6 +4,7 @@ import { resolveCliBuildParams } from "../build-params-cli";
4
4
  import type { Serializer, SerializerResult } from "../../serializer";
5
5
  import type { LexiconPlugin } from "../../lexicon";
6
6
  import { runPostSynthChecks } from "../../lint/post-synth";
7
+ import { applyConfiguredSeverity } from "../../lint/config";
7
8
  import { loadPolicyChecks } from "../../lint/policy";
8
9
  import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
9
10
  import { sortedJsonReplacer } from "../../utils";
@@ -282,6 +283,16 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
282
283
  }
283
284
 
284
285
  // Run post-synth checks from plugins — each plugin only sees its own lexicon's output
286
+ //
287
+ // chant #1138 — every diagnostic collected below (a lexicon-shipped check's
288
+ // AND a project's `lint.policies`') is resolved against `lint.rules` before
289
+ // it becomes an error/warning line, through the exact same
290
+ // `applyConfiguredSeverity` (../../lint/post-synth.ts) that keys off
291
+ // `diag.checkId` the way `lintCommand` keys an AST/COMP* diagnostic off its
292
+ // rule id — so `lint.rules: { WAW019: "off" }` suppresses a post-synth
293
+ // finding just as it suppresses a pre-synth one. A finding it suppresses is
294
+ // counted, not dropped silently — see `suppressedPostSynthCount` below.
295
+ let suppressedPostSynthCount = 0;
285
296
  if (result.errors.length === 0 && options.plugins) {
286
297
  for (const plugin of options.plugins) {
287
298
  if (!plugin.postSynthChecks) continue;
@@ -301,7 +312,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
301
312
 
302
313
  const scopedResult = { ...result, outputs: scopedOutputs };
303
314
  const postDiags = runPostSynthChecks(checks, scopedResult, env);
304
- for (const diag of postDiags) {
315
+ const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(postDiags, config.lint?.rules);
316
+ suppressedPostSynthCount += suppressed.length;
317
+ for (const diag of activeDiags) {
305
318
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
306
319
  const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
307
320
  if (diag.severity === "error") {
@@ -321,6 +334,15 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
321
334
  // result to a post-merge sandboxed child, which imports the policy modules
322
335
  // and runs their checks there, and only plain `PostSynthDiagnostic`s come
323
336
  // back. Unsandboxed, it is the same in-process load-and-run as before.
337
+ //
338
+ // #1138 — `applyConfiguredSeverity` runs HERE, in the parent, after the
339
+ // sandboxed child (when armed) has already returned — never inside it.
340
+ // The child only knows the policy paths and the encoded build result, not
341
+ // this project's resolved `lint.rules`, and by design nothing about the
342
+ // suppression surface needs to cross that boundary: both the plain and
343
+ // the `--sandbox` path funnel through this identical call with the
344
+ // identical `config.lint?.rules`, so a sandboxed and an unsandboxed build
345
+ // of the same project apply the same config to the same diagnostics.
324
346
  if (policies.length > 0) {
325
347
  const policyDiags = await runProjectPolicies({
326
348
  policies,
@@ -329,7 +351,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
329
351
  env,
330
352
  preloaded: preloadedPolicyChecks,
331
353
  });
332
- for (const diag of policyDiags) {
354
+ const { diagnostics: activePolicyDiags, suppressed } = applyConfiguredSeverity(policyDiags, config.lint?.rules);
355
+ suppressedPostSynthCount += suppressed.length;
356
+ for (const diag of activePolicyDiags) {
333
357
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
334
358
  const where = diag.lexicon ? ` (${diag.lexicon})` : "";
335
359
  const msg = `[policy:${diag.checkId}] ${prefix}${diag.message}${where}`;
@@ -337,6 +361,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
337
361
  else warnings.push(formatWarning({ message: msg }));
338
362
  }
339
363
  }
364
+
365
+ if (suppressedPostSynthCount > 0) {
366
+ warnings.push(
367
+ formatWarning({
368
+ message: `${suppressedPostSynthCount} post-synth finding(s) suppressed via lint.rules (severity "off")`,
369
+ }),
370
+ );
371
+ }
340
372
  }
341
373
 
342
374
  // Empty-output guard: source files were discovered but no lexicon produced
@@ -18,8 +18,7 @@ import { formatError, formatInfo } from "../format";
18
18
  import { GENERATED_MARKER } from "../../discovery/files";
19
19
 
20
20
  // Import config loader
21
- import { loadConfig, resolveRulesForFile, parseRuleConfig, findProjectRoot } from "../../lint/config";
22
- import type { RuleConfig } from "../../lint/rule";
21
+ import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
23
22
 
24
23
  /**
25
24
  * Type guard to check if a value conforms to the LintRule interface.
@@ -263,28 +262,22 @@ function getDefaultRules(
263
262
  const ruleOptions = new Map<string, Record<string, unknown>>();
264
263
 
265
264
  for (const [ruleId, rule] of allRules) {
266
- const configValue: RuleConfig | undefined = effectiveRules?.[ruleId];
267
-
268
- if (configValue === undefined) {
269
- // Rule not mentioned in config — include with default severity
270
- rules.push(rule);
271
- continue;
272
- }
273
-
274
- const parsed = parseRuleConfig(configValue);
265
+ // chant #1138 the same resolution post-synth checks and COMP* checks
266
+ // now go through too (`resolveConfiguredSeverity`, ../../lint/config.ts),
267
+ // so `lint.rules: { ID: "off" }` suppresses a rule id identically no
268
+ // matter which phase produced it.
269
+ const { severity, options } = resolveConfiguredSeverity(effectiveRules, ruleId, rule.severity);
275
270
 
276
271
  // Skip rules that are explicitly turned off
277
- if (parsed.severity === "off") continue;
272
+ if (severity === "off") continue;
278
273
 
279
- // Override severity from config
280
- rules.push({
281
- ...rule,
282
- severity: parsed.severity as "error" | "warning" | "info",
283
- });
274
+ // Override severity from config (a no-op when the rule wasn't mentioned —
275
+ // `severity` is then just `rule.severity` again)
276
+ rules.push({ ...rule, severity });
284
277
 
285
278
  // Store options if present
286
- if (parsed.options) {
287
- ruleOptions.set(ruleId, parsed.options);
279
+ if (options) {
280
+ ruleOptions.set(ruleId, options);
288
281
  }
289
282
  }
290
283
 
@@ -394,12 +387,11 @@ async function runComponentCheckDiagnostics(
394
387
  // Discovery errors (COMP000) always surface at error severity — not user-configurable.
395
388
  let severity = d.severity;
396
389
  if (d.checkId !== "COMP000") {
397
- const configValue = config.rules?.[d.checkId];
398
- if (configValue !== undefined) {
399
- const parsed = parseRuleConfig(configValue);
400
- if (parsed.severity === "off") continue;
401
- severity = parsed.severity;
402
- }
390
+ // chant #1138 — same resolution function AST rules and post-synth
391
+ // checks use (`resolveConfiguredSeverity`, ../../lint/config.ts).
392
+ const resolved = resolveConfiguredSeverity(config.rules, d.checkId, d.severity);
393
+ if (resolved.severity === "off") continue;
394
+ severity = resolved.severity;
403
395
  }
404
396
 
405
397
  const disable = fileLevelDisable(d.file, d.checkId);
@@ -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
  );
@@ -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 } = options;
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
  );
@@ -0,0 +1,94 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ const execMock = vi.fn();
4
+ vi.mock("node:child_process", async () => {
5
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
6
+ return {
7
+ ...actual,
8
+ exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
9
+ Promise.resolve(execMock(cmd)).then(
10
+ (out) => cb(null, out as { stdout: string; stderr: string }),
11
+ (err) => cb(err as Error, { stdout: "", stderr: "" }),
12
+ );
13
+ },
14
+ };
15
+ });
16
+
17
+ const { resolveClusterTarget, ClusterBindingMismatchError } = await import("./kubectl-context");
18
+
19
+ describe("resolveClusterTarget (chant #1100)", () => {
20
+ beforeEach(() => {
21
+ execMock.mockReset();
22
+ });
23
+
24
+ test("no binding declared: returns ambient source, warns visibly, never probes kubectl", async () => {
25
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
26
+
27
+ const target = await resolveClusterTarget({}, "prod", "k8s");
28
+
29
+ expect(target).toEqual({ source: "ambient" });
30
+ expect(execMock).not.toHaveBeenCalled();
31
+ expect(warnSpy).toHaveBeenCalledWith(
32
+ expect.stringMatching(/\[k8s\].*environment "prod".*k8s\.profiles\.prod\.context/s),
33
+ );
34
+ warnSpy.mockRestore();
35
+ });
36
+
37
+ test("bound and ambient context matches: returns the bound context", async () => {
38
+ execMock.mockResolvedValue({ stdout: "prod-eks\n", stderr: "" });
39
+
40
+ const target = await resolveClusterTarget(
41
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
42
+ "prod",
43
+ "k8s",
44
+ );
45
+
46
+ expect(target).toEqual({ context: "prod-eks", source: "bound" });
47
+ expect(execMock).toHaveBeenCalledWith(expect.stringContaining("current-context"));
48
+ });
49
+
50
+ test("bound and ambient context cannot be determined: proceeds with the bound context anyway", async () => {
51
+ execMock.mockRejectedValue(new Error("no kubeconfig"));
52
+
53
+ const target = await resolveClusterTarget(
54
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
55
+ "prod",
56
+ "k8s",
57
+ );
58
+
59
+ expect(target).toEqual({ context: "prod-eks", source: "bound" });
60
+ });
61
+
62
+ test("bound and ambient context mismatches: refuses loudly, naming env/expected/ambient", async () => {
63
+ execMock.mockResolvedValue({ stdout: "staging-eks\n", stderr: "" });
64
+
65
+ const err: unknown = await resolveClusterTarget(
66
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
67
+ "prod",
68
+ "k8s",
69
+ ).catch((e: unknown) => e);
70
+
71
+ expect(err).toBeInstanceOf(ClusterBindingMismatchError);
72
+ const mismatch = err as InstanceType<typeof ClusterBindingMismatchError>;
73
+ expect(mismatch.environment).toBe("prod");
74
+ expect(mismatch.expectedContext).toBe("prod-eks");
75
+ expect(mismatch.ambientContext).toBe("staging-eks");
76
+ expect(mismatch.message).toContain('environment "prod"');
77
+ expect(mismatch.message).toContain('"prod-eks"');
78
+ expect(mismatch.message).toContain('"staging-eks"');
79
+ });
80
+
81
+ test("bound for a different environment than the one requested: treated as unbound for this environment", async () => {
82
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
83
+
84
+ const target = await resolveClusterTarget(
85
+ { k8s: { profiles: { staging: { context: "staging-eks" } } } },
86
+ "prod",
87
+ "k8s",
88
+ );
89
+
90
+ expect(target).toEqual({ source: "ambient" });
91
+ expect(execMock).not.toHaveBeenCalled();
92
+ warnSpy.mockRestore();
93
+ });
94
+ });