@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
@@ -0,0 +1,89 @@
1
+ import { type PostSynthCheck, type PostSynthContext, type PostSynthDiagnostic } from "./post-synth.js";
2
+ import { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests } from "./policy-import.js";
3
+ import type { EncodablePolicyBuildResult } from "../discovery/sandbox/policy-wire.js";
4
+ /**
5
+ * chant #1131 — decides WHERE a project's `lint.policies` checks run.
6
+ *
7
+ * A policy is project-authored code. Every other piece of project code moved
8
+ * behind the `--sandbox` boundary in chant #1045 (run-fallback source), #1093
9
+ * (composite factories, constructors, intrinsic tags) and #1113
10
+ * (`chant.config.ts`), and all three had to leave this one out: the config
11
+ * declares its policies as *paths*, so putting the config behind the boundary
12
+ * did not put them there with it. `chant build` imported each policy module and
13
+ * called its `check` function in the CLI's own process, after discovery was
14
+ * over, with the CLI's filesystem, network, environment and process-spawn
15
+ * access. This module closes it.
16
+ *
17
+ * ## Why an armed process mode rather than a threaded option
18
+ *
19
+ * The same reason `../config-sandbox.ts` gives. `./policy.ts`'s
20
+ * `loadPolicyChecks` is exported from chant-core's public surface and called
21
+ * from more than one place (`../cli/commands/build.ts` for a build, and
22
+ * `evaluateProjectPolicies` for the `policyGate` Op step). Threading a
23
+ * `sandbox` flag to each is fail-OPEN: miss one and project code executes in
24
+ * the CLI process with nothing to notice. Arming the process once, before any
25
+ * policy is loaded, is fail-CLOSED — `loadPolicyChecks` REFUSES while armed, so
26
+ * a call site nobody remembered gets a loud error rather than a silent
27
+ * execution.
28
+ *
29
+ * ## What arms it
30
+ *
31
+ * Both ways of turning sandboxing on, unlike `../config-sandbox.ts`. That
32
+ * asymmetry is not an oversight: the config has a bootstrap limit (reading
33
+ * `build.sandbox` out of `chant.config.ts` means running it, so only the CLI
34
+ * flag, known before any config is touched, can cover the config's own
35
+ * evaluation). Policies have no such limit — they are loaded long after the
36
+ * config is known — so `build.sandbox: true` in a project's config sandboxes
37
+ * them just as `--sandbox` does, and `../cli/commands/build.ts` arms this from
38
+ * the RESOLVED value.
39
+ *
40
+ * There is deliberately no disarm: a security mode that can be turned off
41
+ * partway through a process is not one.
42
+ */
43
+ /**
44
+ * The mode itself lives on `./policy-import.ts` — the leaf module that actually
45
+ * performs an in-process policy import, so the refusal sits on the narrowest
46
+ * possible thing and `./policy.ts` can consult it without importing this file
47
+ * (which imports `./policy.ts`). Re-exported here because this is where the
48
+ * decision is documented and where callers look.
49
+ *
50
+ * `armSandboxPolicyExecution` is called from `../cli/commands/build.ts` once
51
+ * `build.sandbox`/`--sandbox` has resolved, and from `../cli/main.ts` off the
52
+ * parsed flag.
53
+ */
54
+ export { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests };
55
+ export interface ProjectPolicyRun {
56
+ /** `lint.policies` as declared in the config — relative paths, resolved against {@link configDir}. */
57
+ policies: readonly string[];
58
+ /** The `chant.config.*` directory (`lint.policies` paths are relative to it), also the child's read allowance. */
59
+ configDir: string;
60
+ /** The merged, serialized build result the checks run over. */
61
+ buildResult: EncodablePolicyBuildResult & PostSynthContext["buildResult"];
62
+ /** `--env`, else `ownership.env`. Becomes `ctx.env`. */
63
+ env?: string;
64
+ /**
65
+ * Checks the caller already loaded into THIS process, before the build ran.
66
+ *
67
+ * `chant build` has always loaded `lint.policies` up front rather than at the
68
+ * point of use, so a policy path that doesn't resolve fails the command
69
+ * immediately — including when the build itself goes on to fail, which is
70
+ * exactly when a typo'd path would otherwise go unnoticed. Preserving that
71
+ * for the unsandboxed path is the whole reason this field exists.
72
+ *
73
+ * Ignored when armed: under `--sandbox` there is nothing loaded here to pass,
74
+ * and `loadPolicyChecks` refuses outright.
75
+ */
76
+ preloaded?: PostSynthCheck[];
77
+ }
78
+ /**
79
+ * Run a project's organizational policy checks over a finished build and return
80
+ * their diagnostics. Sandboxed in a post-merge child when armed, in-process
81
+ * otherwise.
82
+ *
83
+ * The unarmed path is byte-for-byte what `chant build` has always done: load
84
+ * the modules, hand every check one `PostSynthContext`, concatenate what they
85
+ * return. The armed path does the same thing on the other side of a process
86
+ * boundary — see `../discovery/sandbox/policy-run.ts`.
87
+ */
88
+ export declare function runProjectPolicies(run: ProjectPolicyRun): Promise<PostSynthDiagnostic[]>;
89
+ //# sourceMappingURL=policy-sandbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-sandbox.d.ts","sourceRoot":"","sources":["../../src/lint/policy-sandbox.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,yBAAyB,EACzB,6BAA6B,EAC7B,mCAAmC,EACpC,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AAEnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH;;;;;;;;;;GAUG;AACH,OAAO,EAAE,yBAAyB,EAAE,6BAA6B,EAAE,mCAAmC,EAAE,CAAC;AAEzG,MAAM,WAAW,gBAAgB;IAC/B,sGAAsG;IACtG,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,kHAAkH;IAClH,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,WAAW,EAAE,0BAA0B,GAAG,gBAAgB,CAAC,aAAa,CAAC,CAAC;IAC1E,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAoB9F"}
@@ -1,5 +1,16 @@
1
1
  import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth.js";
2
- /** Load project policy checks (one or more `PostSynthCheck` exports) from files. */
2
+ /**
3
+ * Load project policy checks (one or more `PostSynthCheck` exports) from files,
4
+ * **into this process**.
5
+ *
6
+ * chant #1131 — refuses while `./policy-sandbox.ts` is armed. Under `--sandbox`
7
+ * the policy modules are imported and their checks run inside a child process
8
+ * (`runProjectPolicies`); reaching this function anyway means a call site is
9
+ * about to execute project-authored code in the CLI's own process, which is
10
+ * precisely the thing the flag promises does not happen. Failing loudly is the
11
+ * only honest option — falling through would make `--sandbox` mean less than it
12
+ * says without anything visible to notice.
13
+ */
3
14
  export declare function loadPolicyChecks(paths: string[], configDir: string): Promise<PostSynthCheck[]>;
4
15
  export interface PolicyEvaluation {
5
16
  /** All policy diagnostics (errors + warnings). */
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/lint/policy.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExE,oFAAoF;AACpF,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAiBpG;AAED,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,2EAA2E;IAC3E,UAAU,EAAE,mBAAmB,EAAE,CAAC;IAClC,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA8B5B"}
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/lint/policy.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGxE;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAuBpG;AAED,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,2EAA2E;IAC3E,UAAU,EAAE,mBAAmB,EAAE,CAAC;IAClC,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA8B5B"}
@@ -1 +1 @@
1
- {"version":3,"file":"stack-output.d.ts","sourceRoot":"","sources":["../src/stack-output.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAE1D;;GAEG;AACH,eAAO,MAAM,mBAAmB,eAAkC,CAAC;AAEnE;;;GAGG;AACH,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,QAAQ,CAAC,CAAC,mBAAmB,CAAC,EAAE,IAAI,CAAC;IACrC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;mDAC+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,OAAO,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAgBD;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAOlE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,GAAG,SAAS,EACxB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACjC,WAAW,CAuBb"}
1
+ {"version":3,"file":"stack-output.d.ts","sourceRoot":"","sources":["../src/stack-output.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAG1D;;GAEG;AACH,eAAO,MAAM,mBAAmB,eAAkC,CAAC;AAEnE;;;GAGG;AACH,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,QAAQ,CAAC,CAAC,mBAAmB,CAAC,EAAE,IAAI,CAAC;IACrC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;mDAC+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,OAAO,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAqBD;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAOlE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,GAAG,SAAS,EACxB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACjC,WAAW,CAiCb"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.test.ts CHANGED
@@ -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 { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs, computeStackGraph } from "./build";
3
3
  import { output } from "./lexicon-output";
4
4
  import { AttrRef } from "./attrref";
@@ -599,6 +599,58 @@ describe("detectCrossLexiconRefs", () => {
599
599
  const detected = detectCrossLexiconRefs(entities);
600
600
  expect(detected).toHaveLength(0);
601
601
  });
602
+
603
+ // chant #1137 — `detectCrossLexiconRefs`'s walk used to check
604
+ // `value instanceof AttrRef`, which returns false for an AttrRef built by
605
+ // a SEPARATELY-LOADED copy of `./attrref` (the same dual-npm-copy hazard
606
+ // #1122 fixed for `LexiconOutput`: a lexicon pinned to a chant range that
607
+ // doesn't overlap the project's own gets its own nested
608
+ // `node_modules/@intentius/chant`). `vi.resetModules()` + a fresh dynamic
609
+ // import reproduces that split module graph exactly. Before the fix, a
610
+ // foreign AttrRef here falls through to the generic object walk instead
611
+ // of being recognized, and the auto-detected `Outputs` entry vanishes
612
+ // silently — no error, just a missing cross-lexicon output.
613
+ test("detects a cross-lexicon ref built by a second, separately-loaded copy of AttrRef", async () => {
614
+ const alphaBucket = {
615
+ lexicon: "alpha",
616
+ entityType: "Alpha::Storage::Bucket",
617
+ [DECLARABLE_MARKER]: true,
618
+ } as Declarable;
619
+
620
+ vi.resetModules();
621
+ const secondCopy = await import("./attrref");
622
+
623
+ // Sanity check that this really is a distinct module instance — the
624
+ // premise the rest of the test depends on.
625
+ expect(secondCopy.AttrRef).not.toBe(AttrRef);
626
+
627
+ const foreignRef = new secondCopy.AttrRef(alphaBucket, "Endpoint");
628
+
629
+ // The historic bug: instanceof fails across separately-loaded copies of
630
+ // chant-core, even though the two classes are structurally identical.
631
+ expect(foreignRef instanceof AttrRef).toBe(false);
632
+
633
+ const ghAction = {
634
+ lexicon: "github",
635
+ entityType: "Action",
636
+ [DECLARABLE_MARKER]: true,
637
+ props: { url: foreignRef },
638
+ } as unknown as Declarable;
639
+
640
+ const entities = new Map<string, Declarable>([
641
+ ["dataBucket", alphaBucket],
642
+ ["deployAction", ghAction],
643
+ ]);
644
+
645
+ // The fix: `isAttrRefLike` duck-types on shape, so a foreign-copy
646
+ // AttrRef is still recognized and auto-detected as a cross-lexicon output.
647
+ const detected = detectCrossLexiconRefs(entities);
648
+ expect(detected).toHaveLength(1);
649
+ expect(detected[0].sourceLexicon).toBe("alpha");
650
+ expect(detected[0].sourceEntity).toBe("dataBucket");
651
+ expect(detected[0].sourceAttribute).toBe("Endpoint");
652
+ vi.resetModules();
653
+ });
602
654
  });
603
655
 
604
656
  describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
@@ -646,4 +698,28 @@ describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
646
698
  );
647
699
  expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
648
700
  });
701
+
702
+ // chant #1137 — same dual-npm-copy hazard as detectCrossLexiconRefs above,
703
+ // this time for the cross-stack apply-ordering graph: a foreign-copy
704
+ // AttrRef that fails `instanceof` here used to fall through to the
705
+ // generic object walk instead of producing an edge, silently dropping a
706
+ // real cross-stack dependency (which can misorder — or fail to detect a
707
+ // cycle in — the apply order this graph exists to compute).
708
+ test("infers a consumer→producer edge from an AttrRef built by a second, separately-loaded copy", async () => {
709
+ const vpc = ent("aws");
710
+
711
+ vi.resetModules();
712
+ const secondCopy = await import("./attrref");
713
+ expect(secondCopy.AttrRef).not.toBe(AttrRef);
714
+
715
+ const foreignRef = new secondCopy.AttrRef(vpc, "id");
716
+ expect(foreignRef instanceof AttrRef).toBe(false); // the historic bug
717
+
718
+ const svc = ent("k8s", { vpcId: foreignRef });
719
+ const g = computeStackGraph(new Map([["vpc", vpc], ["svc", svc]]), ["aws", "k8s"]);
720
+
721
+ expect(g.edges).toEqual([{ from: "k8s", to: "aws" }]);
722
+ expect(g.order).toEqual(["aws", "k8s"]);
723
+ vi.resetModules();
724
+ });
649
725
  });
package/src/build.ts CHANGED
@@ -7,6 +7,7 @@ import type { BuildParamProvenance } from "./provenance";
7
7
  import { DiscoveryError, BuildError as BuildErrorClass } from "./errors";
8
8
  import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
9
9
  import { AttrRef } from "./attrref";
10
+ import { isAttrRefLike } from "./utils";
10
11
  import { isChildProject, type ChildProjectInstance } from "./child-project";
11
12
  import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/index";
12
13
  import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
@@ -77,7 +78,13 @@ export function computeStackGraph(
77
78
  if (value === null || value === undefined || typeof value !== "object") return;
78
79
  if (visited.has(value)) return;
79
80
  visited.add(value);
80
- if (value instanceof AttrRef) {
81
+ // Duck-type, not `instanceof` (chant #1137): a lexicon built against a
82
+ // separate copy of `@intentius/chant` produces AttrRefs that fail
83
+ // `instanceof AttrRef` here but carry the same shape. Without this, a
84
+ // real cross-stack dependency silently falls through to the generic
85
+ // object walk below instead of producing an edge, which can misorder —
86
+ // or entirely drop — the apply order this graph exists to compute.
87
+ if (isAttrRefLike(value)) {
81
88
  const parent = value.parent.deref();
82
89
  const producer = parent ? (parent as Record<string, unknown>).lexicon : undefined;
83
90
  if (typeof producer === "string" && producer !== consumer) addEdge(consumer, producer);
@@ -370,7 +377,13 @@ export function detectCrossLexiconRefs(
370
377
  if (visited.has(value)) return;
371
378
  visited.add(value);
372
379
 
373
- if (value instanceof AttrRef) {
380
+ // Duck-type, not `instanceof` (chant #1137): a lexicon built against a
381
+ // separate copy of `@intentius/chant` produces AttrRefs that fail
382
+ // `instanceof AttrRef` here but carry the same shape. Without this, a
383
+ // real cross-lexicon reference silently falls through to the generic
384
+ // object walk below instead of auto-creating a `LexiconOutput`, and the
385
+ // whole `Outputs` entry for it vanishes (same failure shape as #1122).
386
+ if (isAttrRefLike(value)) {
374
387
  const parent = value.parent.deref();
375
388
  if (!parent) return;
376
389
 
@@ -322,7 +322,7 @@ export const testEntity = {
322
322
  expect(byName.get("env")).toEqual({ name: "env", value: "from-file", source: "params-file" });
323
323
  });
324
324
 
325
- test("--fold is opt-in: omitting it builds via the unchanged run path", async () => {
325
+ test("fold is the default (#1134): omitting the flag folds; --no-fold restores the run path", async () => {
326
326
  await writeFile(
327
327
  join(testDir, "test.infra.ts"),
328
328
  `
@@ -345,7 +345,21 @@ export const testEntity = {
345
345
  expect(result.success).toBe(true);
346
346
  expect(result.resourceCount).toBe(1);
347
347
  const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
348
- expect(anyFoldLine).toBe(false);
348
+ expect(anyFoldLine).toBe(true);
349
+
350
+ errorSpy.mockClear();
351
+ const runResult = await buildCommand({
352
+ path: testDir,
353
+ format: "json",
354
+ serializers: [mockSerializer],
355
+ fold: false,
356
+ });
357
+ expect(runResult.success).toBe(true);
358
+ expect(runResult.resourceCount).toBe(1);
359
+ const anyFoldLineOff = errorSpy.mock.calls
360
+ .map((call) => String(call[0]))
361
+ .some((line) => line.includes("[fold:"));
362
+ expect(anyFoldLineOff).toBe(false);
349
363
  } finally {
350
364
  errorSpy.mockRestore();
351
365
  }
@@ -5,6 +5,7 @@ import type { Serializer, SerializerResult } from "../../serializer";
5
5
  import type { LexiconPlugin } from "../../lexicon";
6
6
  import { runPostSynthChecks } from "../../lint/post-synth";
7
7
  import { loadPolicyChecks } from "../../lint/policy";
8
+ import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
8
9
  import { sortedJsonReplacer } from "../../utils";
9
10
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
10
11
  import { writeFileSync, mkdirSync } from "fs";
@@ -34,11 +35,12 @@ export interface BuildOptions {
34
35
  */
35
36
  env?: string;
36
37
  /**
37
- * chant #1022 (epic #1019) — opt-in: fold source modules statically
38
- * instead of importing/running them (`chant build --fold`). Falls back to
39
- * run per-file for anything the folder can't represent. Merged with the
40
- * project's `chant.config.ts` `build.fold` via {@link resolveFoldEnabled}
41
- * this flag, when true, always wins for the invocation.
38
+ * chant #1022/#1134 (epic #1019) — fold source modules statically instead
39
+ * of importing/running them; the DEFAULT build path since #1134. Falls
40
+ * back to run per-file for anything the folder can't represent. Tri-state:
41
+ * `--fold` → true, `--no-fold` false, unset → the project config /
42
+ * default via {@link resolveFoldEnabled}. An explicit flag always wins for
43
+ * the invocation, in either direction.
42
44
  */
43
45
  fold?: boolean;
44
46
 
@@ -144,13 +146,11 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
144
146
  // Project-authored organizational policy checks (lint.policies), run over the
145
147
  // resolved resources during build. Resolve paths relative to the config dir.
146
148
  const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
147
- const policyChecks = config.lint?.policies?.length
148
- ? await loadPolicyChecks(config.lint.policies, configDir)
149
- : [];
149
+ const policies = config.lint?.policies ?? [];
150
150
 
151
- // #1022 — opt-in fold path: the CLI flag wins over `chant.config.ts`'s
152
- // `build.fold`, which wins over the (unchanged) default of running every
153
- // module.
151
+ // #1022/#1134 — fold is the default build path: an explicit CLI flag
152
+ // (--fold/--no-fold) wins over `chant.config.ts`'s `build.fold`, which
153
+ // wins over the default of `true`.
154
154
  const fold = resolveFoldEnabled(config, options.fold);
155
155
 
156
156
  // #1045 Phase 2 — opt-in sandboxed execution of run-fallback files (or,
@@ -158,6 +158,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
158
158
  // as fold, resolved independently.
159
159
  const sandbox = resolveSandboxEnabled(config, options.sandbox);
160
160
 
161
+ // #1131 — arm sandboxed policy execution from the RESOLVED value, before any
162
+ // policy module could be loaded. Resolved, not `options.sandbox`, because
163
+ // policies have none of the config's bootstrap limit: they are loaded long
164
+ // after `build.sandbox` is known, so a config-only opt-in sandboxes them just
165
+ // as the CLI flag does. Arming (rather than threading a flag to each caller)
166
+ // makes `loadPolicyChecks` refuse process-wide — a call site that forgot to
167
+ // ask gets a loud error instead of quietly running project code here.
168
+ if (sandbox) armSandboxPolicyExecution();
169
+
170
+ // Unsandboxed, the policy pack is still loaded HERE, before the build — a
171
+ // policy path that doesn't resolve has always failed the command up front,
172
+ // including when the build itself then fails, and #1131 does not change that.
173
+ // Sandboxed, there is nothing to load in this process at all.
174
+ const preloadedPolicyChecks =
175
+ !sandbox && policies.length > 0 ? await loadPolicyChecks([...policies], configDir) : undefined;
176
+
161
177
  // #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
162
178
  // `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
163
179
  // a config-only opt-in cannot have covered its own evaluation; only the CLI
@@ -298,8 +314,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
298
314
 
299
315
  // Project-authored organizational policy — cross-cutting, so it sees every
300
316
  // lexicon's output at once (not scoped per-plugin), with the current env.
301
- if (policyChecks.length > 0) {
302
- const policyDiags = runPostSynthChecks(policyChecks, result, env);
317
+ //
318
+ // #1131 under `--sandbox` this is where the LAST piece of project-
319
+ // authored code the CLI used to execute in its own process moves behind
320
+ // the boundary: `runProjectPolicies` hands the merged, serialized build
321
+ // result to a post-merge sandboxed child, which imports the policy modules
322
+ // and runs their checks there, and only plain `PostSynthDiagnostic`s come
323
+ // back. Unsandboxed, it is the same in-process load-and-run as before.
324
+ if (policies.length > 0) {
325
+ const policyDiags = await runProjectPolicies({
326
+ policies,
327
+ configDir,
328
+ buildResult: result,
329
+ env,
330
+ preloaded: preloadedPolicyChecks,
331
+ });
303
332
  for (const diag of policyDiags) {
304
333
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
305
334
  const where = diag.lexicon ? ` (${diag.lexicon})` : "";
@@ -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");
@@ -130,10 +136,23 @@ describe("parseArgs", () => {
130
136
  expect(result.help).toBe(false);
131
137
  });
132
138
 
133
- test("ignores unknown flags", () => {
134
- const result = parseArgs(["build", "--unknown", "value"]);
139
+ test("throws on an unknown bare flag instead of silently ignoring it (chant #1127)", () => {
140
+ // Was "ignores unknown flags" — pinned the old silent-drop as intended
141
+ // behavior. #1127 flips it: an unrecognized `--flag` is a hard error.
142
+ expect(() => parseArgs(["build", "--unknown", "value"])).toThrow(/Unknown flag: --unknown/);
143
+ });
144
+
145
+ test("unknown flag error points at --help", () => {
146
+ expect(() => parseArgs(["build", "--unknown"])).toThrow(/--help/);
147
+ });
148
+
149
+ test("throws on an unknown joined flag (--unknown=value)", () => {
150
+ expect(() => parseArgs(["build", "--unknown=value"])).toThrow(/Unknown flag: --unknown/);
151
+ });
152
+
153
+ test("unknown short flags are still silently ignored (unchanged; out of #1127 scope)", () => {
154
+ const result = parseArgs(["build", "-x", "value"]);
135
155
  expect(result.command).toBe("build");
136
- // Unknown flags are silently ignored
137
156
  });
138
157
 
139
158
  test("parses --watch flag", () => {
@@ -271,27 +290,90 @@ describe("parseArgs", () => {
271
290
  expect(result.paramsFile).toBe("./params.json");
272
291
  });
273
292
 
274
- // ── --param=name=value hard error (chant #1118) ──────────────────────────
275
- // The joined `--flag=value` form is not supported anywhere in this parser
276
- // (see "ignores unknown flags" above) — a dropped --param can silently
277
- // change what a build measures/deploys, so this form is rejected loudly
278
- // instead of silently accepted as a no-op.
293
+ test("plain --param name=value is unaffected", () => {
294
+ const result = parseArgs(["build", "src", "--param", "tier=production"]);
295
+ expect(result.param).toEqual(["tier=production"]);
296
+ });
297
+
298
+ // ── generic --flag=value joined form (chant #1127) ────────────────────────
299
+ // #1118 taught this parser to hard-error `--param=name=value` specifically,
300
+ // because it was the one flag known (from #1118's investigation) to sit
301
+ // behind a silent drop. #1127's audit found the drop was general — every
302
+ // value-taking flag shares it — so the fix is general too: split any
303
+ // `--flag=value` token at its first `=` and re-dispatch as `--flag` +
304
+ // `value`, the exact shape every branch below already handles. This
305
+ // supersedes #1118's `--param=` hard error entirely: the joined form is now
306
+ // just as valid as the space-separated one, for every flag, not a rejected
307
+ // special case for one flag.
279
308
 
280
- test("--param=name=value throws instead of silently dropping", () => {
281
- expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param=tier=production/);
309
+ test("--param=name=value now works instead of throwing — joined form matches the space-separated form", () => {
310
+ const result = parseArgs(["build", "src", "--param=tier=production"]);
311
+ expect(result.param).toEqual(["tier=production"]);
282
312
  });
283
313
 
284
- test("--param=name=value error names the working form", () => {
285
- expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param name=value/);
314
+ test("--env=value joined form works", () => {
315
+ const result = parseArgs(["build", "src", "--env=staging"]);
316
+ expect(result.env).toBe("staging");
286
317
  });
287
318
 
288
- test("--param= (empty value) also throws", () => {
289
- expect(() => parseArgs(["build", "src", "--param="])).toThrow(/--param=/);
319
+ test("--format=value joined form works", () => {
320
+ const result = parseArgs(["build", "src", "--format=yaml"]);
321
+ expect(result.format).toBe("yaml");
290
322
  });
291
323
 
292
- test("plain --param name=value is unaffected", () => {
293
- const result = parseArgs(["build", "src", "--param", "tier=production"]);
294
- expect(result.param).toEqual(["tier=production"]);
324
+ test("--lexicon=value joined form works", () => {
325
+ const result = parseArgs(["build", "src", "--lexicon=aws"]);
326
+ expect(result.lexicon).toBe("aws");
327
+ });
328
+
329
+ test("repeated --param=name=value (joined) accumulates in order, same as space-separated", () => {
330
+ const result = parseArgs(["build", "src", "--param=tier=production", "--param=env=staging"]);
331
+ expect(result.param).toEqual(["tier=production", "env=staging"]);
332
+ });
333
+
334
+ test("joined form only splits on the FIRST '=' — a value containing '=' is preserved whole", () => {
335
+ // --param's own value shape is `name=value`, so `--param=tier=production`
336
+ // must split into flag `--param` + value `tier=production`, not further
337
+ // fragment on the second `=`.
338
+ const result = parseArgs(["build", "src", "--param=tier=production=east"]);
339
+ expect(result.param).toEqual(["tier=production=east"]);
340
+ });
341
+
342
+ test("joined form works mixed with space-separated flags in the same invocation", () => {
343
+ const result = parseArgs(["build", "src", "--env=prod", "--format", "json", "--lexicon=k8s"]);
344
+ expect(result.env).toBe("prod");
345
+ expect(result.format).toBe("json");
346
+ expect(result.lexicon).toBe("k8s");
347
+ });
348
+
349
+ // ── boolean-only flag given a joined value (chant #1127) ──────────────────
350
+ // Decision: reject it. A boolean flag (--fold, --watch, --json, ...) has no
351
+ // value slot — its branch just sets a field to `true` and never consumes a
352
+ // following token. Silently coercing "true"/"false" would need to invent
353
+ // parsing rules (what about "1", "yes", mixed case?) for a form none of
354
+ // this CLI's flags need; silently dropping the value and reinterpreting it
355
+ // as the next positional (a path, a component name, ...) is exactly the
356
+ // silent misparse #1127 closes. So it errors, naming the flag as boolean.
357
+
358
+ test("a boolean flag given a joined value throws, naming the flag as boolean", () => {
359
+ expect(() => parseArgs(["build", "src", "--fold=true"])).toThrow(/--fold is a boolean flag/);
360
+ });
361
+
362
+ test("boolean-with-value error does not silently reinterpret the value as a positional", () => {
363
+ expect(() => parseArgs(["build", "src", "--watch=false"])).toThrow(/--watch is a boolean flag/);
364
+ });
365
+
366
+ test("--json=1 (another boolean flag) also throws", () => {
367
+ expect(() => parseArgs(["run", "myop", "--json=1"])).toThrow(/--json is a boolean flag/);
368
+ });
369
+
370
+ test("--report keeps its context-sensitive bare-vs-value behavior when joined", () => {
371
+ // --report is deliberately not in the boolean-reject set: bare --report is
372
+ // a boolean (`run`), but --report <path> is a SARIF destination (migrate).
373
+ // The joined form should resolve the same way the space-separated one does.
374
+ const result = parseArgs(["migrate", "wf.yml", "--report=out.sarif"]);
375
+ expect(result.reportFile).toBe("out.sarif");
376
+ expect(result.report).toBeUndefined();
295
377
  });
296
378
  });
297
379