@intentius/chant 0.25.0 → 0.27.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 (56) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts +6 -5
  3. package/dist/cli/commands/build.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/entity-wire-codec.d.ts +14 -9
  8. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  9. package/dist/discovery/graph.d.ts.map +1 -1
  10. package/dist/discovery/sandbox/config-wire.d.ts +16 -0
  11. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/driver.d.ts +29 -0
  13. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +18 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  17. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  18. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  19. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  20. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  21. package/dist/lexicon-output.d.ts.map +1 -1
  22. package/dist/lint/policy-import.d.ts +50 -0
  23. package/dist/lint/policy-import.d.ts.map +1 -0
  24. package/dist/lint/policy-sandbox.d.ts +89 -0
  25. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  26. package/dist/lint/policy.d.ts +12 -1
  27. package/dist/lint/policy.d.ts.map +1 -1
  28. package/dist/stack-output.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/build.test.ts +77 -1
  31. package/src/build.ts +15 -2
  32. package/src/cli/commands/build.test.ts +16 -2
  33. package/src/cli/commands/build.ts +42 -13
  34. package/src/cli/main.test.ts +99 -17
  35. package/src/cli/main.ts +111 -13
  36. package/src/config.test.ts +28 -1
  37. package/src/config.ts +16 -12
  38. package/src/discovery/entity-wire-codec.ts +14 -9
  39. package/src/discovery/graph.test.ts +40 -1
  40. package/src/discovery/graph.ts +8 -2
  41. package/src/discovery/sandbox/config-wire.ts +21 -0
  42. package/src/discovery/sandbox/driver.ts +132 -0
  43. package/src/discovery/sandbox/fork.ts +39 -1
  44. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  45. package/src/discovery/sandbox/policy-run.ts +180 -0
  46. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  47. package/src/discovery/sandbox/policy-wire.ts +277 -0
  48. package/src/intrinsic-interpolation.test.ts +27 -1
  49. package/src/intrinsic-interpolation.ts +10 -2
  50. package/src/lexicon-output.test.ts +36 -0
  51. package/src/lexicon-output.ts +12 -2
  52. package/src/lint/policy-import.ts +70 -0
  53. package/src/lint/policy-sandbox.ts +123 -0
  54. package/src/lint/policy.ts +20 -2
  55. package/src/stack-output.test.ts +118 -0
  56. package/src/stack-output.ts +21 -5
package/src/cli/main.ts CHANGED
@@ -7,6 +7,7 @@ import { loadPlugins, resolveProjectLexicons } from "./plugins";
7
7
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
8
8
  import { loadChantConfigUpward } from "../config";
9
9
  import { armSandboxConfigEvaluation } from "../config-sandbox";
10
+ import { armSandboxPolicyExecution } from "../lint/policy-import";
10
11
  import { ENV_VAR, unknownEnvError } from "../env";
11
12
  import { initRuntime } from "../runtime-adapter";
12
13
  import { runBuild } from "./handlers/build";
@@ -27,10 +28,61 @@ import { runGraph } from "./handlers/graph";
27
28
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
28
29
  import { runEmulator } from "./handlers/emulator";
29
30
 
31
+ /**
32
+ * Long-form flags that are pure booleans in {@link parseArgs} — their branch
33
+ * below sets a field to `true` and never consumes a following array element.
34
+ * Used only to reject a joined `--flag=value` form for these (chant #1127):
35
+ * a boolean has no value to assign, and silently reinterpreting the joined
36
+ * value as the next positional argument (path, component name, ...) would be
37
+ * exactly the kind of silent misparse this issue exists to close. `--report`
38
+ * is deliberately excluded — it's context-sensitive (bare boolean vs a SARIF
39
+ * path, decided by lookahead), so a joined value for it is legitimate and
40
+ * already handled correctly once split.
41
+ */
42
+ const BOOLEAN_FLAGS = new Set([
43
+ "--help",
44
+ "--force",
45
+ "--fix",
46
+ "--watch",
47
+ "--verbose",
48
+ "--live",
49
+ "--overlay",
50
+ "--owned",
51
+ "--verbatim",
52
+ "--apply-rewrites",
53
+ "--write",
54
+ "--strict",
55
+ "--validate",
56
+ "--use-composites",
57
+ "--stacks",
58
+ "--components",
59
+ "--up",
60
+ "--down",
61
+ "--include-dependents",
62
+ "--local",
63
+ "--temporal",
64
+ "--json",
65
+ "--progress-json",
66
+ "--update-snapshot",
67
+ "--run-examples",
68
+ "--check",
69
+ "--bump",
70
+ "--no-release-record",
71
+ "--fold",
72
+ "--no-fold",
73
+ "--sandbox",
74
+ ]);
75
+
30
76
  /**
31
77
  * Parse command line arguments
32
78
  */
33
79
  export function parseArgs(args: string[]): ParsedArgs {
80
+ // Local mutable copy — chant #1127's joined-`--flag=value` splitting below
81
+ // rewrites the array in place (one token becomes two), so this must not
82
+ // mutate whatever array the caller passed in (e.g. `process.argv.slice(2)`
83
+ // is already a fresh copy, but callers shouldn't have to know that).
84
+ args = args.slice();
85
+
34
86
  const result: ParsedArgs = {
35
87
  command: "",
36
88
  path: ".",
@@ -67,7 +119,29 @@ export function parseArgs(args: string[]): ParsedArgs {
67
119
 
68
120
  let i = 0;
69
121
  while (i < args.length) {
70
- const arg = args[i];
122
+ let arg = args[i];
123
+
124
+ // chant #1127 — generic joined `--flag=value` support. Every value-taking
125
+ // flag below is matched by an exact `arg === "--flag"` check and then
126
+ // consumes the *next* array element (`args[++i]`) as its value; a joined
127
+ // token like `--env=prod` never matches any of those, doesn't match the
128
+ // trailing positional branch either (it starts with `-`), and used to
129
+ // vanish with no error. Splitting the token at its FIRST `=` and
130
+ // re-dispatching as two array elements makes every flag below see the
131
+ // exact shape it already handles — including a flag like `--param`
132
+ // whose own value legitimately contains `=` (`--param=tier=production`
133
+ // splits to flag `--param`, value `tier=production`, not further split
134
+ // on the second `=`).
135
+ if (arg.startsWith("--") && arg.includes("=")) {
136
+ const eq = arg.indexOf("=");
137
+ const flag = arg.slice(0, eq);
138
+ const value = arg.slice(eq + 1);
139
+ if (BOOLEAN_FLAGS.has(flag)) {
140
+ throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
141
+ }
142
+ args.splice(i, 1, flag, value);
143
+ arg = args[i];
144
+ }
71
145
 
72
146
  if (arg === "--help" || arg === "-h") {
73
147
  result.help = true;
@@ -221,22 +295,35 @@ export function parseArgs(args: string[]): ParsedArgs {
221
295
  result.noReleaseRecord = true;
222
296
  } else if (arg === "--fold") {
223
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;
224
302
  } else if (arg === "--sandbox") {
225
303
  result.sandbox = true;
226
304
  } else if (arg === "--param") {
305
+ // chant #1118/#1127 — `--param name=value` (space-separated) and
306
+ // `--param=name=value` (joined, split above at its first `=` into flag
307
+ // `--param` + value `name=value`) both land here and behave
308
+ // identically; there is no separate joined-form error anymore (the
309
+ // #1118 hard error this superseded only existed because the parser
310
+ // didn't support joined forms at all — now that it does, the joined
311
+ // form is just as valid as the space-separated one).
227
312
  (result.param ??= []).push(args[++i]);
228
- } else if (arg.startsWith("--param=")) {
229
- // chant #1118 — this parser never supports an `--flag=value` joined
230
- // form for any value-taking flag (every branch above is an exact `===`
231
- // match, so a joined token falls through unrecognized and is silently
232
- // dropped — see the "ignores unknown flags" case below). `--param
233
- // name=value` (space-separated) is the only accepted form. Rather than
234
- // teach the parser joined forms generally, `--param=name=value` is
235
- // called out as a hard error instead of a silent no-op: a dropped
236
- // `--param` can silently change what a build measures/deploys.
237
- throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
238
313
  } else if (arg === "--params-file") {
239
314
  result.paramsFile = args[++i];
315
+ } else if (arg.startsWith("--")) {
316
+ // chant #1127 — every recognized flag is matched above; anything left
317
+ // starting with `--` is unrecognized, whether it arrived bare
318
+ // (`--bogus`) or joined (`--bogus=value`, already split into
319
+ // `--bogus` + `value` above). This used to fall through silently (the
320
+ // "ignores unknown flags" case) — a typo'd or misremembered flag would
321
+ // vanish with no diagnostic, exactly like the silent-drop this issue
322
+ // closes for joined values. Point at --help rather than enumerating
323
+ // every flag here: this parser's flag set is one flat list shared by
324
+ // every command, not scoped per-command, so "the command's known
325
+ // flags" isn't something this loop can name in isolation.
326
+ throw new Error(`Unknown flag: ${arg}\nRun "chant --help" to see supported flags.`);
240
327
  } else if (!arg.startsWith("-")) {
241
328
  if (!result.command) {
242
329
  result.command = arg;
@@ -432,8 +519,11 @@ Options:
432
519
  to run per-file for anything else outside the fold
433
520
  subset (a cross-file-only reference, a re-export,
434
521
  \`export default\`, ...). Logs which path each file
435
- took. Default: off (also settable via
436
- 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.
437
527
  --sandbox (build) Run run-fallback source files (or every
438
528
  file, without --fold) together, isolated, in one
439
529
  sandboxed child process instead of in-process
@@ -608,6 +698,14 @@ async function main(): Promise<void> {
608
698
  // `../config-sandbox.ts`.
609
699
  if (args.sandbox) armSandboxConfigEvaluation();
610
700
 
701
+ // chant #1131 — the same for `lint.policies`. Armed from the flag here so
702
+ // the mode is set for the whole invocation, not just `chant build`; the build
703
+ // command arms it again from the RESOLVED value (a project's own
704
+ // `build.sandbox: true` also sandboxes its policies — unlike the config,
705
+ // policies have no bootstrap limit, since they load long after the config is
706
+ // known). See `../lint/policy-sandbox.ts`.
707
+ if (args.sandbox) armSandboxPolicyExecution();
708
+
611
709
  // Initialize runtime adapter early — before plugins or commands run.
612
710
  // chant #1117 — walks up from `args.path` to the project root: for a
613
711
  // subdirectory build/command (`chant build src/<stack> --env prod`) the
@@ -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
  /**
@@ -23,20 +23,25 @@
23
23
  * value) becomes a name-keyed marker instead. {@link decodeEntitySet} is the
24
24
  * inverse — it rebuilds a live `Map<string, Declarable>` whose entities are
25
25
  * BEHAVIORALLY indistinguishable from what `discover()` would have produced
26
- * in-process: real `AttrRef` instances (several call sites downstream key off
27
- * `instanceof AttrRef`, not just duck typing `intrinsic-interpolation.ts`'s
26
+ * in-process: real `AttrRef` instances, not a duck-typed `{__attrRef}`
27
+ * envelope alone. `new AttrRef(...)` here is plain, direct construction from
28
+ * this module's own class — this codec runs inside the same module graph as
29
+ * every downstream reader (`intrinsic-interpolation.ts`'s
28
30
  * `defaultInterpolationSerializer`, `discovery/graph.ts`'s
29
31
  * `buildDependencyGraph`, `build.ts`'s `detectCrossLexiconRefs`/
30
- * `computeStackGraph` so a plain `{__attrRef}` envelope alone is not
31
- * enough), and whole-entity embeds restored to the SAME object reference
32
- * (not a structurally-equal clone), so `entityNames.get(decl)` keeps working
33
- * by identity exactly as it does today.
32
+ * `computeStackGraph`, all converted to `isAttrRefLike` duck-typing by chant
33
+ * #1137 for the OTHER hazard, a separately-loaded lexicon copy) so there is
34
+ * no dual-package boundary to duck-type across here, and building the real
35
+ * class is simply less code than hand-assembling a shape-alike stand-in with
36
+ * matching methods, and whole-entity embeds restored to the SAME object
37
+ * reference (not a structurally-equal clone), so `entityNames.get(decl)`
38
+ * keeps working by identity exactly as it does today.
34
39
  *
35
40
  * `serializer-walker.ts`'s `walkValue` needs NO changes for this: it already
36
41
  * falls back to reading a plain `{__attrRef}` envelope (added for intrinsics
37
42
  * whose own `toJSON()` embeds one). `decodeEntitySet` goes further and
38
- * reconstructs the real class so every OTHER `instanceof AttrRef` call site
39
- * keeps working too, not just the walker.
43
+ * reconstructs the real class, both simpler here and a belt-and-suspenders
44
+ * match for any call site that still checks `instanceof AttrRef` directly.
40
45
  *
41
46
  * Naming happens exactly once, inside the boundary — `resolveAttrRefs`
42
47
  * (./resolve.ts) runs as part of `discover()`, before `encodeEntitySet` is
@@ -85,7 +90,7 @@ import { isChildProject } from "../child-project";
85
90
  * form. `refs` additionally captures any `AttrRef`/whole-entity reference
86
91
  * found while walking the intrinsic's OWN fields (not through `toJSON()`)
87
92
  * — `buildDependencyGraph` and `detectCrossLexiconRefs`/`computeStackGraph`
88
- * walk raw entity property trees looking for `instanceof AttrRef`/a
93
+ * walk raw entity property trees looking for an `AttrRef`-like value/a
89
94
  * tracked `Declarable`, not through `toJSON()`, so a ref nested inside e.g.
90
95
  * a `Sub` template needs to still be discoverable post-decode for
91
96
  * cross-lexicon output auto-detection and dependency ordering to keep
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, test, expect, vi } from "vitest";
2
2
  import { buildDependencyGraph } from "./graph";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "../declarable";
4
4
  import { AttrRef } from "../attrref";
@@ -513,4 +513,43 @@ describe("buildDependencyGraph", () => {
513
513
  expect(graph.get("Entity3")?.has("Entity2")).toBe(true);
514
514
  expect(graph.get("Entity3")?.size).toBe(1);
515
515
  });
516
+
517
+ // chant #1137 — `findDependencies` used to check `value instanceof
518
+ // AttrRef`, which returns false for an AttrRef built by a SEPARATELY-
519
+ // LOADED copy of `../attrref` (the same dual-npm-copy hazard #1122 fixed
520
+ // for `LexiconOutput`). `vi.resetModules()` + a fresh dynamic import
521
+ // reproduces that split module graph exactly. Before the fix, a foreign
522
+ // AttrRef here recurses into the object's own (unhelpful) fields instead
523
+ // of being recorded as a dependency, silently dropping the edge — which
524
+ // can misorder the file-discovery build order this graph exists to compute.
525
+ test("detects dependency from an AttrRef built by a second, separately-loaded copy", async () => {
526
+ const parent: Declarable = {
527
+ lexicon: "test",
528
+ entityType: "parent",
529
+ [DECLARABLE_MARKER]: true,
530
+ };
531
+
532
+ vi.resetModules();
533
+ const secondCopy = await import("../attrref");
534
+ expect(secondCopy.AttrRef).not.toBe(AttrRef);
535
+
536
+ const foreignRef = new secondCopy.AttrRef(parent, "someAttr");
537
+ expect(foreignRef instanceof AttrRef).toBe(false); // the historic bug
538
+
539
+ const child: Declarable & { ref: AttrRef } = {
540
+ lexicon: "test",
541
+ entityType: "child",
542
+ [DECLARABLE_MARKER]: true,
543
+ ref: foreignRef,
544
+ };
545
+
546
+ const entities = new Map([
547
+ ["Parent", parent],
548
+ ["Child", child],
549
+ ]);
550
+ const graph = buildDependencyGraph(entities);
551
+
552
+ expect(graph.get("Child")?.has("Parent")).toBe(true);
553
+ vi.resetModules();
554
+ });
516
555
  });
@@ -1,6 +1,7 @@
1
1
  import type { Declarable } from "../declarable";
2
2
  import { isDeclarable } from "../declarable";
3
3
  import { AttrRef } from "../attrref";
4
+ import { isAttrRefLike } from "../utils";
4
5
 
5
6
  /**
6
7
  * Builds a dependency graph from a collection of entities
@@ -95,8 +96,13 @@ function findDependencies(
95
96
  return;
96
97
  }
97
98
 
98
- // Check if this is an AttrRef
99
- if (value instanceof AttrRef) {
99
+ // Check if this is an AttrRef. Duck-type, not `instanceof` (chant #1137):
100
+ // a lexicon built against a separate copy of `@intentius/chant` produces
101
+ // AttrRefs that fail `instanceof AttrRef` here but carry the same shape.
102
+ // Without this, the dependency edge is silently dropped instead of
103
+ // recorded, which can misorder — or fail to detect a cycle in — the
104
+ // file-discovery build order this graph exists to compute.
105
+ if (isAttrRefLike(value)) {
100
106
  if (visited.has(value)) {
101
107
  return;
102
108
  }
@@ -18,6 +18,12 @@
18
18
  * the project code it is inspecting — so it must not import anything, and must
19
19
  * not touch the filesystem, the environment or the process.
20
20
  *
21
+ * chant #1131 reuses the same walk for the OTHER direction: a `lint.policies`
22
+ * check runs inside a sandboxed child and its `PostSynthDiagnostic[]` has to
23
+ * come back as data (`./policy-wire.ts`). That is the same "JSON is lossy
24
+ * without complaining" problem with a different root value, so the walk is
25
+ * exported as {@link scanValueWireSafety} rather than copied.
26
+ *
21
27
  * ## What `ChantConfig` legally holds
22
28
  *
23
29
  * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
@@ -133,6 +139,21 @@ function walk(
133
139
  seen.delete(obj);
134
140
  }
135
141
 
142
+ /**
143
+ * chant #1131 — report every value ANYWHERE in `value` that cannot cross the
144
+ * sandbox boundary as JSON, with paths rooted at `rootPath`.
145
+ *
146
+ * {@link scanConfigWireSafety} is the config-shaped entry point (it tolerates a
147
+ * module namespace object at the root); this one takes the value as given, so
148
+ * an array root (`diagnostics`) or a plain object root both work. Same rules,
149
+ * same `undefined`-object-property allowance — see the module doc.
150
+ */
151
+ export function scanValueWireSafety(value: unknown, rootPath = ""): ConfigWireOffender[] {
152
+ const out: ConfigWireOffender[] = [];
153
+ walk(value, rootPath, 0, new Set<object>(), out);
154
+ return out;
155
+ }
156
+
136
157
  /**
137
158
  * Report every value in `config` that cannot cross the sandbox boundary as
138
159
  * JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
@@ -43,6 +43,9 @@ const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
43
43
  const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
44
44
  // chant #1113 — the config driver's serializability contract (see ./config-wire.ts).
45
45
  const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
46
+ // chant #1131 — the policy driver's build-result decoding + diagnostics contract.
47
+ const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
48
+ const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
46
49
 
47
50
  export interface GenerateDriverOptions {
48
51
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -213,3 +216,132 @@ export function generateConfigDriverSource(configPath: string): string {
213
216
  `});`,
214
217
  ].join("\n");
215
218
  }
219
+
220
+ /**
221
+ * chant #1131 — generate the driver module that imports a project's
222
+ * `lint.policies` modules INSIDE the sandboxed child, runs their checks over
223
+ * the build result the parent hands it, and sends back plain
224
+ * `PostSynthDiagnostic`s.
225
+ *
226
+ * Same machinery again: literal-specifier dynamic `import()`s esbuild can trace
227
+ * and inline, `./child-errors.ts` for classification so a permission denial
228
+ * names the policy file, one IPC message back. Two things are specific to this
229
+ * one:
230
+ *
231
+ * - **It receives before it sends.** The run and config drivers are fully
232
+ * parameterized by their generated source; a policy check needs the finished
233
+ * build result, which is neither known at bundle time nor something to bake
234
+ * into a source literal. It arrives as one IPC message (see `./fork.ts`'s
235
+ * `send`). The `process.on("message", …)` registration is top-level and
236
+ * synchronous, so it is in place before the event loop can deliver anything
237
+ * — a message the parent sent before the child finished booting is queued on
238
+ * the channel, not lost.
239
+ * - **Checks run wrapped, not raw.** `runPostSynthChecks` (chant's own, from
240
+ * `../../lint/post-synth.ts`) is invoked ONCE over every check from every
241
+ * policy module, exactly as `cli/commands/build.ts` invokes it in-process —
242
+ * so the checks share one `PostSynthContext` and run in one order, and the
243
+ * diagnostics come back in the same sequence. The wrapper around each check
244
+ * is what makes a bad return value attributable: it scans that check's own
245
+ * output and throws a `PolicyWireError` naming the module the check was
246
+ * loaded from, rather than reporting an offending index in a merged array.
247
+ */
248
+ export function generatePolicyDriverSource(policyPaths: readonly string[]): string {
249
+ const lines: string[] = [
250
+ `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
251
+ `import { decodePolicyBuildResult, scanPolicyDiagnostics, PolicyWireError } from ${lit(POLICY_WIRE_MODULE)};`,
252
+ `import { runPostSynthChecks, isPostSynthCheck } from ${lit(POST_SYNTH_MODULE)};`,
253
+ ``,
254
+ `function send(payload) {`,
255
+ ` if (typeof process.send === "function") process.send(payload);`,
256
+ ` else console.log(JSON.stringify(payload));`,
257
+ `}`,
258
+ ``,
259
+ `function fail(file, err, type) {`,
260
+ ` if (err instanceof PolicyWireError) {`,
261
+ ` send({ kind: "chant-policy", ok: false, offenders: err.offenders });`,
262
+ ` return;`,
263
+ ` }`,
264
+ ` send({ kind: "chant-policy", ok: false, error: classifyChildError(file, err, type).toJSON() });`,
265
+ `}`,
266
+ ``,
267
+ // The wrapper described in the doc above: same id/description so any
268
+ // chant-side reporting keyed off them is unchanged, same ctx, same return
269
+ // value — plus the per-check serializability scan.
270
+ `function guard(check, policy) {`,
271
+ ` return {`,
272
+ ` id: check.id,`,
273
+ ` description: check.description,`,
274
+ ` check(ctx) {`,
275
+ ` const produced = check.check(ctx);`,
276
+ ` const offenders = scanPolicyDiagnostics(produced, policy);`,
277
+ ` if (offenders.length > 0) throw new PolicyWireError(offenders);`,
278
+ ` return produced;`,
279
+ ` },`,
280
+ ` };`,
281
+ `}`,
282
+ ``,
283
+ `async function main(request) {`,
284
+ ` let buildResult;`,
285
+ ` try {`,
286
+ ` buildResult = decodePolicyBuildResult(request.buildResult);`,
287
+ ` } catch (err) {`,
288
+ ` fail("", err, "resolution");`,
289
+ ` return;`,
290
+ ` }`,
291
+ ``,
292
+ ` const checks = [];`,
293
+ ];
294
+
295
+ // One block per policy module, in the order `lint.policies` declares them —
296
+ // the same order `loadPolicyChecks` collects in, so the diagnostics sequence
297
+ // matches the in-process one exactly.
298
+ for (const policyPath of policyPaths) {
299
+ lines.push(
300
+ ` try {`,
301
+ ` const mod = await import(${lit(policyPath)});`,
302
+ ` for (const value of Object.values(mod)) {`,
303
+ ` if (isPostSynthCheck(value)) checks.push(guard(value, ${lit(policyPath)}));`,
304
+ ` }`,
305
+ ` } catch (err) {`,
306
+ ` fail(${lit(policyPath)}, err, "import");`,
307
+ ` return;`,
308
+ ` }`,
309
+ );
310
+ }
311
+
312
+ lines.push(
313
+ ``,
314
+ ` let diagnostics;`,
315
+ ` try {`,
316
+ ` diagnostics = runPostSynthChecks(checks, buildResult, request.env ?? undefined);`,
317
+ ` } catch (err) {`,
318
+ ` fail("", err, "resolution");`,
319
+ ` return;`,
320
+ ` }`,
321
+ ``,
322
+ ` send({ kind: "chant-policy", ok: true, diagnostics });`,
323
+ `}`,
324
+ ``,
325
+ // Registered synchronously at module top level — see the doc above on why
326
+ // that is what makes the parent's send-before-boot safe.
327
+ //
328
+ // Removed again as soon as the input arrives, and that is not tidiness: a
329
+ // `message` listener REFS the IPC channel, so leaving it registered would
330
+ // keep this child's event loop alive after it had already answered — and a
331
+ // live channel keeps the PARENT's alive too. `chant build` would not have
332
+ // noticed (`cli/main.ts` ends in `process.exit`); anything embedding chant
333
+ // as a library would have hung. `./fork.ts` kills the child on receipt as
334
+ // the other half of the same fix.
335
+ `let started = false;`,
336
+ `function onRequest(request) {`,
337
+ ` if (started) return;`,
338
+ ` if (!request || request.kind !== "chant-policy-request") return;`,
339
+ ` started = true;`,
340
+ ` process.off("message", onRequest);`,
341
+ ` main(request).catch((err) => fail("", err, "resolution"));`,
342
+ `}`,
343
+ `process.on("message", onRequest);`,
344
+ );
345
+
346
+ return lines.join("\n");
347
+ }
@@ -45,6 +45,24 @@ export interface SandboxForkOptions {
45
45
  timeoutMs: number;
46
46
  /** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
47
47
  label: string;
48
+ /**
49
+ * chant #1131 — an optional payload sent to the child over the SAME IPC
50
+ * channel its response comes back on, immediately after the fork.
51
+ *
52
+ * The run and config children are fully described by their generated driver
53
+ * source, so they need nothing inbound. The policy child does: its input is
54
+ * the finished build result, which exists only after the parent has merged
55
+ * and serialized, long after the bundle was built. Sending it rather than
56
+ * baking it into a source literal keeps the bundle small (esbuild would
57
+ * otherwise parse a multi-megabyte literal) and keeps it off disk.
58
+ *
59
+ * Safe to send before the child has booted: `child.send` writes to the IPC
60
+ * pipe and Node queues the message until the child's channel is read, and
61
+ * the driver registers its `process.on("message", …)` synchronously at module
62
+ * top level — before the event loop can deliver anything. This is NOT a
63
+ * second protocol: same channel, same JSON, same one-message-back response.
64
+ */
65
+ send?: Record<string, unknown>;
48
66
  }
49
67
 
50
68
  /**
@@ -56,7 +74,7 @@ export function forkSandboxed<T>(
56
74
  options: SandboxForkOptions,
57
75
  isResponse: (value: unknown) => value is T,
58
76
  ): Promise<T> {
59
- const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label } = options;
77
+ const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } = options;
60
78
 
61
79
  return new Promise((resolvePromise, reject) => {
62
80
  const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
@@ -78,6 +96,16 @@ export function forkSandboxed<T>(
78
96
  reject(new Error(`${label} timed out after ${timeoutMs}ms`));
79
97
  }, timeoutMs);
80
98
 
99
+ if (send !== undefined) {
100
+ child.send(send, (err) => {
101
+ if (settled || !err) return;
102
+ settled = true;
103
+ clearTimeout(timeout);
104
+ child.kill();
105
+ reject(new Error(`${label}: failed to send the child its input: ${err.message}`));
106
+ });
107
+ }
108
+
81
109
  child.stderr?.on("data", (chunk: Buffer) => {
82
110
  stderrBuf += chunk.toString();
83
111
  });
@@ -86,6 +114,16 @@ export function forkSandboxed<T>(
86
114
  if (settled || !isResponse(msg)) return;
87
115
  settled = true;
88
116
  clearTimeout(timeout);
117
+ // chant #1131 — the child's entire job is to send this one message, so
118
+ // once it has arrived there is nothing left to wait for. Killing it here
119
+ // rather than hoping it exits on its own closes a real hang: an open
120
+ // handle on the child side (a `setInterval` in project source, an
121
+ // `http.Server` a policy started, or simply an IPC listener the driver
122
+ // registered to RECEIVE its input) keeps the child's event loop alive,
123
+ // and a live IPC channel then keeps the PARENT's alive too. `chant build`
124
+ // never noticed because `cli/main.ts` ends with `process.exit`; anything
125
+ // embedding chant as a library would have hung forever.
126
+ child.kill();
89
127
  resolvePromise(msg);
90
128
  });
91
129