@intentius/chant 0.26.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 (46) hide show
  1. package/dist/cli/commands/build.d.ts +6 -5
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/config.d.ts +13 -10
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  8. package/dist/discovery/sandbox/fork.d.ts +25 -0
  9. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  10. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  12. package/dist/kubectl-context.d.ts +73 -0
  13. package/dist/kubectl-context.d.ts.map +1 -0
  14. package/dist/lint/config.d.ts +80 -0
  15. package/dist/lint/config.d.ts.map +1 -1
  16. package/dist/lint/policy.d.ts +8 -2
  17. package/dist/lint/policy.d.ts.map +1 -1
  18. package/dist/lint/post-synth.d.ts +18 -1
  19. package/dist/lint/post-synth.d.ts.map +1 -1
  20. package/dist/stack-output.d.ts +9 -4
  21. package/dist/stack-output.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/build.test.ts +206 -2
  24. package/src/cli/commands/build.ts +43 -10
  25. package/src/cli/commands/lint.ts +17 -25
  26. package/src/cli/main.test.ts +6 -0
  27. package/src/cli/main.ts +10 -2
  28. package/src/config.test.ts +28 -1
  29. package/src/config.ts +16 -12
  30. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  31. package/src/discovery/sandbox/config-run.ts +3 -0
  32. package/src/discovery/sandbox/fork.ts +75 -1
  33. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  34. package/src/discovery/sandbox/policy-run.ts +15 -1
  35. package/src/discovery/sandbox/run.test.ts +85 -1
  36. package/src/discovery/sandbox/run.ts +3 -0
  37. package/src/kubectl-context.test.ts +94 -0
  38. package/src/kubectl-context.ts +126 -0
  39. package/src/lint/config.test.ts +93 -1
  40. package/src/lint/config.ts +108 -0
  41. package/src/lint/policy.test.ts +90 -0
  42. package/src/lint/policy.ts +17 -5
  43. package/src/lint/post-synth.test.ts +4 -0
  44. package/src/lint/post-synth.ts +30 -1
  45. package/src/stack-output.test.ts +21 -3
  46. package/src/stack-output.ts +21 -9
@@ -1,12 +1,14 @@
1
1
  import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { buildCommand, resolveBuildFormat, type BuildOptions } from "./build";
3
3
  import type { Serializer } from "../../serializer";
4
+ import type { LexiconPlugin } from "../../lexicon";
4
5
  import { parseYAML } from "../../yaml";
5
6
  import { mkdir, rm, writeFile } from "node:fs/promises";
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import { join, dirname, resolve as resolvePath } from "node:path";
8
9
  import { tmpdir } from "node:os";
9
10
  import { fileURLToPath } from "node:url";
11
+ import { resetSandboxPolicyExecutionForTests } from "../../lint/policy-sandbox";
10
12
 
11
13
  describe("buildCommand", () => {
12
14
  let testDir: string;
@@ -322,7 +324,7 @@ export const testEntity = {
322
324
  expect(byName.get("env")).toEqual({ name: "env", value: "from-file", source: "params-file" });
323
325
  });
324
326
 
325
- test("--fold is opt-in: omitting it builds via the unchanged run path", async () => {
327
+ test("fold is the default (#1134): omitting the flag folds; --no-fold restores the run path", async () => {
326
328
  await writeFile(
327
329
  join(testDir, "test.infra.ts"),
328
330
  `
@@ -345,7 +347,21 @@ export const testEntity = {
345
347
  expect(result.success).toBe(true);
346
348
  expect(result.resourceCount).toBe(1);
347
349
  const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
348
- expect(anyFoldLine).toBe(false);
350
+ expect(anyFoldLine).toBe(true);
351
+
352
+ errorSpy.mockClear();
353
+ const runResult = await buildCommand({
354
+ path: testDir,
355
+ format: "json",
356
+ serializers: [mockSerializer],
357
+ fold: false,
358
+ });
359
+ expect(runResult.success).toBe(true);
360
+ expect(runResult.resourceCount).toBe(1);
361
+ const anyFoldLineOff = errorSpy.mock.calls
362
+ .map((call) => String(call[0]))
363
+ .some((line) => line.includes("[fold:"));
364
+ expect(anyFoldLineOff).toBe(false);
349
365
  } finally {
350
366
  errorSpy.mockRestore();
351
367
  }
@@ -500,6 +516,194 @@ export const testEntity = {
500
516
  { Key: "env", Value: "prod" },
501
517
  ]);
502
518
  });
519
+
520
+ /**
521
+ * chant #1138 — post-synth findings (a lexicon-shipped check's, and a
522
+ * project's `lint.policies`') honor `lint.rules` severity overrides the
523
+ * same way a pre-synth COR/EVL/COMP diagnostic does. `runComponentChecks`
524
+ * proved the shape (`component-lint.test.ts`); this proves the post-synth
525
+ * side of the same fix.
526
+ */
527
+ describe("post-synth findings honor lint.rules (chant #1138)", () => {
528
+ // `armSandboxPolicyExecution` is a one-way, process-global latch — once a
529
+ // `--sandbox` build in this file arms it, EVERY later `buildCommand` call
530
+ // in this worker (sandboxed or not) would otherwise see it armed and
531
+ // refuse to load policies in-process. Reset it around each test here so
532
+ // the plain/sandboxed pairs below are actually independent.
533
+ beforeEach(() => resetSandboxPolicyExecutionForTests());
534
+ afterEach(() => resetSandboxPolicyExecutionForTests());
535
+
536
+ /**
537
+ * One trivial declarable using the `mockSerializer`'s "test" lexicon, so
538
+ * `result.outputs` is non-empty and the build's own "discovered source
539
+ * but produced no output" guard (`../commands/build.ts`) doesn't fire —
540
+ * these tests are about post-synth suppression, not that guard.
541
+ */
542
+ async function writeTrivialEntity(): Promise<void> {
543
+ await writeFile(
544
+ join(testDir, "main.ts"),
545
+ `export const e = { lexicon: "test", entityType: "TestEntity", [Symbol.for("chant.declarable")]: true };\n`,
546
+ );
547
+ }
548
+
549
+ /** A minimal `LexiconPlugin` whose one post-synth check always emits one fixed diagnostic — the fixed-input half of a severity-override test. */
550
+ function fakePostSynthPlugin(checkId: string, severity: "error" | "warning" | "info"): LexiconPlugin {
551
+ return {
552
+ name: "fake",
553
+ serializer: { name: "fake", rulePrefix: "FAKE", serialize: () => "{}" },
554
+ generate: async () => {},
555
+ validate: async () => {},
556
+ coverage: async () => {},
557
+ package: async () => {},
558
+ postSynthChecks: () => [
559
+ {
560
+ id: checkId,
561
+ description: "test check",
562
+ check: () => [{ checkId, severity, message: `${checkId} triggered` }],
563
+ },
564
+ ],
565
+ };
566
+ }
567
+
568
+ test("lint.rules off suppresses a lexicon-shipped post-synth finding and reports a suppressed count", async () => {
569
+ await writeTrivialEntity();
570
+ await writeFile(
571
+ join(testDir, "chant.config.ts"),
572
+ `export default { lint: { rules: { "FAKE001": "off" } } };\n`,
573
+ );
574
+
575
+ const result = await buildCommand({
576
+ path: testDir,
577
+ format: "json",
578
+ serializers: [mockSerializer],
579
+ plugins: [fakePostSynthPlugin("FAKE001", "error")],
580
+ });
581
+
582
+ expect(result.success).toBe(true);
583
+ expect(result.errors).toEqual([]);
584
+ expect(result.errors.join("\n")).not.toContain("FAKE001 triggered");
585
+ expect(result.warnings.some((w) => w.includes("1 post-synth finding(s) suppressed"))).toBe(true);
586
+ });
587
+
588
+ test("lint.rules downgrades an error-severity post-synth check to warning, so it no longer fails the build", async () => {
589
+ await writeTrivialEntity();
590
+ await writeFile(
591
+ join(testDir, "chant.config.ts"),
592
+ `export default { lint: { rules: { "FAKE002": "warning" } } };\n`,
593
+ );
594
+
595
+ const result = await buildCommand({
596
+ path: testDir,
597
+ format: "json",
598
+ serializers: [mockSerializer],
599
+ plugins: [fakePostSynthPlugin("FAKE002", "error")],
600
+ });
601
+
602
+ expect(result.success).toBe(true);
603
+ expect(result.errors).toEqual([]);
604
+ expect(result.warnings.some((w) => w.includes("FAKE002 triggered"))).toBe(true);
605
+ });
606
+
607
+ test("lint.rules upgrades a warning-severity post-synth check to error, so it now fails the build", async () => {
608
+ await writeTrivialEntity();
609
+ await writeFile(
610
+ join(testDir, "chant.config.ts"),
611
+ `export default { lint: { rules: { "FAKE003": "error" } } };\n`,
612
+ );
613
+
614
+ const result = await buildCommand({
615
+ path: testDir,
616
+ format: "json",
617
+ serializers: [mockSerializer],
618
+ plugins: [fakePostSynthPlugin("FAKE003", "warning")],
619
+ });
620
+
621
+ expect(result.success).toBe(false);
622
+ expect(result.errors.some((e) => e.includes("FAKE003 triggered"))).toBe(true);
623
+ });
624
+
625
+ test("an unconfigured post-synth check id is unaffected — no drift from this fix for the common case", async () => {
626
+ await writeTrivialEntity();
627
+ await writeFile(join(testDir, "chant.config.ts"), `export default {};\n`);
628
+
629
+ const result = await buildCommand({
630
+ path: testDir,
631
+ format: "json",
632
+ serializers: [mockSerializer],
633
+ plugins: [fakePostSynthPlugin("FAKE004", "error")],
634
+ });
635
+
636
+ expect(result.success).toBe(false);
637
+ expect(result.errors.some((e) => e.includes("FAKE004 triggered"))).toBe(true);
638
+ expect(result.warnings.some((w) => w.includes("suppressed"))).toBe(false);
639
+ });
640
+
641
+ /** Write a `lint.policies` project: one policy file whose check always emits one fixed diagnostic. */
642
+ async function writePolicyProject(checkId: string, severity: "error" | "warning", rules: Record<string, string>): Promise<void> {
643
+ await mkdir(join(testDir, "policies"), { recursive: true });
644
+ await writeTrivialEntity();
645
+ await writeFile(
646
+ join(testDir, "policies", "org.ts"),
647
+ `export const check = {\n` +
648
+ ` id: ${JSON.stringify(checkId)},\n` +
649
+ ` description: "test policy",\n` +
650
+ ` check: () => [{ checkId: ${JSON.stringify(checkId)}, severity: ${JSON.stringify(severity)}, message: ${JSON.stringify(`${checkId} triggered`)} }],\n` +
651
+ `};\n`,
652
+ );
653
+ await writeFile(
654
+ join(testDir, "chant.config.ts"),
655
+ `export default { lint: { policies: ["policies/org.ts"], rules: ${JSON.stringify(rules)} } };\n`,
656
+ );
657
+ }
658
+
659
+ test(
660
+ "lint.rules off suppresses a lint.policies finding identically under plain and --sandbox builds",
661
+ async () => {
662
+ await writePolicyProject("ORG-OFF", "error", { "ORG-OFF": "off" });
663
+
664
+ const plain = await buildCommand({ path: testDir, format: "json", serializers: [mockSerializer], plugins: [] });
665
+ const sandboxed = await buildCommand({
666
+ path: testDir,
667
+ format: "json",
668
+ serializers: [mockSerializer],
669
+ plugins: [],
670
+ fold: true,
671
+ sandbox: true,
672
+ });
673
+
674
+ for (const result of [plain, sandboxed]) {
675
+ expect(result.success).toBe(true);
676
+ expect(result.errors).toEqual([]);
677
+ expect(result.warnings.some((w) => w.includes("1 post-synth finding(s) suppressed"))).toBe(true);
678
+ }
679
+ },
680
+ 30_000,
681
+ );
682
+
683
+ test(
684
+ "lint.rules upgrades a lint.policies warning to error identically under plain and --sandbox builds",
685
+ async () => {
686
+ await writePolicyProject("ORG-UP", "warning", { "ORG-UP": "error" });
687
+
688
+ const plain = await buildCommand({ path: testDir, format: "json", serializers: [mockSerializer], plugins: [] });
689
+ const sandboxed = await buildCommand({
690
+ path: testDir,
691
+ format: "json",
692
+ serializers: [mockSerializer],
693
+ plugins: [],
694
+ fold: true,
695
+ sandbox: true,
696
+ });
697
+
698
+ for (const result of [plain, sandboxed]) {
699
+ expect(result.success).toBe(false);
700
+ expect(result.errors.some((e) => e.includes("[policy:ORG-UP]") && e.includes("ORG-UP triggered"))).toBe(true);
701
+ expect(result.warnings.some((w) => w.includes("suppressed"))).toBe(false);
702
+ }
703
+ },
704
+ 30_000,
705
+ );
706
+ });
503
707
  });
504
708
 
505
709
  // ── #284 bug 1: -o extension drives format when --format is absent ────────
@@ -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";
@@ -35,11 +36,12 @@ export interface BuildOptions {
35
36
  */
36
37
  env?: string;
37
38
  /**
38
- * chant #1022 (epic #1019) — opt-in: fold source modules statically
39
- * instead of importing/running them (`chant build --fold`). Falls back to
40
- * run per-file for anything the folder can't represent. Merged with the
41
- * project's `chant.config.ts` `build.fold` via {@link resolveFoldEnabled}
42
- * this flag, when true, always wins for the invocation.
39
+ * chant #1022/#1134 (epic #1019) — fold source modules statically instead
40
+ * of importing/running them; the DEFAULT build path since #1134. Falls
41
+ * back to run per-file for anything the folder can't represent. Tri-state:
42
+ * `--fold` → true, `--no-fold` false, unset → the project config /
43
+ * default via {@link resolveFoldEnabled}. An explicit flag always wins for
44
+ * the invocation, in either direction.
43
45
  */
44
46
  fold?: boolean;
45
47
 
@@ -147,9 +149,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
147
149
  const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
148
150
  const policies = config.lint?.policies ?? [];
149
151
 
150
- // #1022 — opt-in fold path: the CLI flag wins over `chant.config.ts`'s
151
- // `build.fold`, which wins over the (unchanged) default of running every
152
- // module.
152
+ // #1022/#1134 — fold is the default build path: an explicit CLI flag
153
+ // (--fold/--no-fold) wins over `chant.config.ts`'s `build.fold`, which
154
+ // wins over the default of `true`.
153
155
  const fold = resolveFoldEnabled(config, options.fold);
154
156
 
155
157
  // #1045 Phase 2 — opt-in sandboxed execution of run-fallback files (or,
@@ -281,6 +283,16 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
281
283
  }
282
284
 
283
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;
284
296
  if (result.errors.length === 0 && options.plugins) {
285
297
  for (const plugin of options.plugins) {
286
298
  if (!plugin.postSynthChecks) continue;
@@ -300,7 +312,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
300
312
 
301
313
  const scopedResult = { ...result, outputs: scopedOutputs };
302
314
  const postDiags = runPostSynthChecks(checks, scopedResult, env);
303
- 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) {
304
318
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
305
319
  const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
306
320
  if (diag.severity === "error") {
@@ -320,6 +334,15 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
320
334
  // result to a post-merge sandboxed child, which imports the policy modules
321
335
  // and runs their checks there, and only plain `PostSynthDiagnostic`s come
322
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.
323
346
  if (policies.length > 0) {
324
347
  const policyDiags = await runProjectPolicies({
325
348
  policies,
@@ -328,7 +351,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
328
351
  env,
329
352
  preloaded: preloadedPolicyChecks,
330
353
  });
331
- 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) {
332
357
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
333
358
  const where = diag.lexicon ? ` (${diag.lexicon})` : "";
334
359
  const msg = `[policy:${diag.checkId}] ${prefix}${diag.message}${where}`;
@@ -336,6 +361,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
336
361
  else warnings.push(formatWarning({ message: msg }));
337
362
  }
338
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
+ }
339
372
  }
340
373
 
341
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);
@@ -4,6 +4,12 @@ import { parseArgs, waitForStreamDrain } from "./main";
4
4
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
5
5
 
6
6
  describe("parseArgs", () => {
7
+ test("--fold and --no-fold set the tri-state fold option (#1134)", () => {
8
+ expect(parseArgs(["build", "src"]).fold).toBeUndefined();
9
+ expect(parseArgs(["build", "src", "--fold"]).fold).toBe(true);
10
+ expect(parseArgs(["build", "src", "--no-fold"]).fold).toBe(false);
11
+ });
12
+
7
13
  test("parses command as first positional arg", () => {
8
14
  const result = parseArgs(["build"]);
9
15
  expect(result.command).toBe("build");
package/src/cli/main.ts CHANGED
@@ -69,6 +69,7 @@ const BOOLEAN_FLAGS = new Set([
69
69
  "--bump",
70
70
  "--no-release-record",
71
71
  "--fold",
72
+ "--no-fold",
72
73
  "--sandbox",
73
74
  ]);
74
75
 
@@ -294,6 +295,10 @@ export function parseArgs(args: string[]): ParsedArgs {
294
295
  result.noReleaseRecord = true;
295
296
  } else if (arg === "--fold") {
296
297
  result.fold = true;
298
+ } else if (arg === "--no-fold") {
299
+ // chant #1134 — fold is the default build path; this is the explicit
300
+ // opt-out, and like --fold it beats chant.config.ts's build.fold.
301
+ result.fold = false;
297
302
  } else if (arg === "--sandbox") {
298
303
  result.sandbox = true;
299
304
  } else if (arg === "--param") {
@@ -514,8 +519,11 @@ Options:
514
519
  to run per-file for anything else outside the fold
515
520
  subset (a cross-file-only reference, a re-export,
516
521
  \`export default\`, ...). Logs which path each file
517
- took. Default: off (also settable via
518
- chant.config.ts's build.fold: true; #1022)
522
+ took. DEFAULT since #1134 this flag forces it on
523
+ over a chant.config.ts \`build.fold: false\`.
524
+ --no-fold (build) Opt out of folding for this invocation: every
525
+ source module is imported and run, the pre-#1134
526
+ behavior. Beats chant.config.ts's build.fold.
519
527
  --sandbox (build) Run run-fallback source files (or every
520
528
  file, without --fold) together, isolated, in one
521
529
  sandboxed child process instead of in-process
@@ -1,5 +1,11 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { loadChantConfig, DEFAULT_CHANT_CONFIG, resolveAutoReleaseDisabled, resolveSbomFormat } from "./config";
2
+ import {
3
+ loadChantConfig,
4
+ DEFAULT_CHANT_CONFIG,
5
+ resolveAutoReleaseDisabled,
6
+ resolveFoldEnabled,
7
+ resolveSbomFormat,
8
+ } from "./config";
3
9
  import { writeFileSync, mkdirSync, rmSync } from "fs";
4
10
  import { join } from "path";
5
11
 
@@ -128,6 +134,27 @@ describe("loadChantConfig", () => {
128
134
  });
129
135
  });
130
136
 
137
+ describe("resolveFoldEnabled (#1134 — fold is the default build path)", () => {
138
+ test("default (no flag, no config) → fold ON", () => {
139
+ expect(resolveFoldEnabled({})).toBe(true);
140
+ });
141
+
142
+ test("config build.fold: false turns it off; true keeps it on", () => {
143
+ expect(resolveFoldEnabled({ build: { fold: false } })).toBe(false);
144
+ expect(resolveFoldEnabled({ build: { fold: true } })).toBe(true);
145
+ expect(resolveFoldEnabled({ build: {} })).toBe(true);
146
+ });
147
+
148
+ test("--fold (flag true) beats config false", () => {
149
+ expect(resolveFoldEnabled({ build: { fold: false } }, true)).toBe(true);
150
+ });
151
+
152
+ test("--no-fold (flag false) beats config true and the default", () => {
153
+ expect(resolveFoldEnabled({ build: { fold: true } }, false)).toBe(false);
154
+ expect(resolveFoldEnabled({}, false)).toBe(false);
155
+ });
156
+ });
157
+
131
158
  describe("resolveAutoReleaseDisabled", () => {
132
159
  test("default (no flag, no config) → not disabled", () => {
133
160
  expect(resolveAutoReleaseDisabled({})).toBe(false);
package/src/config.ts CHANGED
@@ -139,12 +139,12 @@ export interface ChantConfig {
139
139
  */
140
140
  build?: {
141
141
  /**
142
- * Opt-in: fold source modules statically instead of importing/running
143
- * them, falling back to run per-file for anything the folder can't
144
- * represent (composite factory calls, non-`new` exports, …). Default
145
- * `false`. The `--fold` CLI flag overrides this per-invocation (a flag
146
- * of `true` always wins; the flag cannot force fold *off* when this is
147
- * `true`). See {@link resolveFoldEnabled}.
142
+ * Fold source modules statically instead of importing/running them,
143
+ * falling back to run per-file for anything the folder can't represent.
144
+ * DEFAULT `true` since chant #1134 — set `false` to make this project
145
+ * run every module (the pre-#1134 behavior). The `--fold`/`--no-fold`
146
+ * CLI flags override this per-invocation in either direction. See
147
+ * {@link resolveFoldEnabled}.
148
148
  */
149
149
  fold?: boolean;
150
150
 
@@ -353,14 +353,18 @@ export function resolveAutoReleaseDisabled(config: ChantConfig, cliFlag?: boolea
353
353
 
354
354
  /**
355
355
  * Whether `chant build` should use the fold path (#1022, epic #1019)
356
- * instead of running each source module. Opt-in: off unless the CLI's
357
- * `--fold` flag was passed (`cliFlag`) or the project config sets
358
- * `build.fold: true` the flag always wins for that one invocation,
359
- * regardless of config.
356
+ * instead of running each source module. DEFAULT-ON since chant #1134: fold
357
+ * is the build path unless something turns it off. Precedence, most specific
358
+ * wins: an explicit CLI flag (`--fold` true, `--no-fold` false, arriving
359
+ * here as `cliFlag`), then the project config's `build.fold`, then the
360
+ * default of `true`. The epic's evidence base for the flip — coverage,
361
+ * byte-identity, and the sandbox execution boundary — is recorded on #1134
362
+ * and #1090.
360
363
  */
361
364
  export function resolveFoldEnabled(config: ChantConfig, cliFlag?: boolean): boolean {
362
- if (cliFlag) return true;
363
- return config.build?.fold === true;
365
+ if (cliFlag !== undefined) return cliFlag;
366
+ if (config.build?.fold !== undefined) return config.build.fold;
367
+ return true;
364
368
  }
365
369
 
366
370
  /**
@@ -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
  );