@intentius/chant 0.25.0 → 0.26.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 (50) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/discovery/entity-wire-codec.d.ts +14 -9
  5. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  6. package/dist/discovery/graph.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/config-wire.d.ts +16 -0
  8. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  9. package/dist/discovery/sandbox/driver.d.ts +29 -0
  10. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/fork.d.ts +18 -0
  12. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  13. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  14. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  15. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  16. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  17. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  18. package/dist/lexicon-output.d.ts.map +1 -1
  19. package/dist/lint/policy-import.d.ts +50 -0
  20. package/dist/lint/policy-import.d.ts.map +1 -0
  21. package/dist/lint/policy-sandbox.d.ts +89 -0
  22. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  23. package/dist/lint/policy.d.ts +12 -1
  24. package/dist/lint/policy.d.ts.map +1 -1
  25. package/dist/stack-output.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/build.test.ts +77 -1
  28. package/src/build.ts +15 -2
  29. package/src/cli/commands/build.ts +33 -5
  30. package/src/cli/main.test.ts +93 -17
  31. package/src/cli/main.ts +101 -11
  32. package/src/discovery/entity-wire-codec.ts +14 -9
  33. package/src/discovery/graph.test.ts +40 -1
  34. package/src/discovery/graph.ts +8 -2
  35. package/src/discovery/sandbox/config-wire.ts +21 -0
  36. package/src/discovery/sandbox/driver.ts +132 -0
  37. package/src/discovery/sandbox/fork.ts +39 -1
  38. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  39. package/src/discovery/sandbox/policy-run.ts +180 -0
  40. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  41. package/src/discovery/sandbox/policy-wire.ts +277 -0
  42. package/src/intrinsic-interpolation.test.ts +27 -1
  43. package/src/intrinsic-interpolation.ts +10 -2
  44. package/src/lexicon-output.test.ts +36 -0
  45. package/src/lexicon-output.ts +12 -2
  46. package/src/lint/policy-import.ts +70 -0
  47. package/src/lint/policy-sandbox.ts +123 -0
  48. package/src/lint/policy.ts +20 -2
  49. package/src/stack-output.test.ts +118 -0
  50. package/src/stack-output.ts +21 -5
@@ -0,0 +1,277 @@
1
+ import type { Declarable } from "../../declarable";
2
+ import type { SerializerResult } from "../../serializer";
3
+ import type { PostSynthContext, PostSynthDiagnostic } from "../../lint/post-synth";
4
+ import { decodeEntitySet, encodeEntitySet, type EntitySetWire } from "../entity-wire-codec";
5
+ import { scanValueWireSafety, type ConfigWireOffender } from "./config-wire";
6
+
7
+ /**
8
+ * chant #1131 — what a build result looks like on its way INTO the sandboxed
9
+ * policy child, and what a `PostSynthDiagnostic` must look like on its way back
10
+ * out.
11
+ *
12
+ * A project's `lint.policies` checks are project-authored functions chant calls
13
+ * over the finished build. #1113 put `chant.config.ts` behind the `--sandbox`
14
+ * boundary but could not take the policies with it, because the config declares
15
+ * them as *paths*: the modules were still imported and their `check` functions
16
+ * still invoked in the CLI's own process, with the CLI's filesystem, network,
17
+ * environment and process-spawn access, after discovery was over.
18
+ *
19
+ * The shape of the fix is forced by what a policy is. It is not data that can
20
+ * be evaluated somewhere and carried back — it is a callback over the resolved
21
+ * resources, so it has to run somewhere it can see them. It also cannot run in
22
+ * the #1045 discovery child, because that child only ever sees the run-fallback
23
+ * *subset*: folded files are collected in the parent, and serialization happens
24
+ * in the parent, so no complete view of the build exists on that side. What
25
+ * does exist, after the parent has merged and serialized, is a build result
26
+ * that is *nearly* data already — which is what this module makes explicit.
27
+ *
28
+ * So: one more child, after the merge, handed the encoded build result,
29
+ * importing the policy modules inside the boundary and returning plain
30
+ * diagnostics. Same bundling (`./bundle.ts`), same spawn and `--permission`
31
+ * profile (`./fork.ts`), same error classification (`./child-errors.ts`), same
32
+ * "only JSON crosses, and anything else is named, never dropped" contract
33
+ * (`./config-wire.ts`, whose walk is reused directly).
34
+ *
35
+ * ## What crosses, and what that costs
36
+ *
37
+ * `PostSynthContext` gives a check five things. Four of them are already data:
38
+ * `outputs` (each lexicon's serialized text), `warnings`, `errors`,
39
+ * `sourceFileCount`. The fifth, `entities`, is a live `Map<string, Declarable>`
40
+ * whose cross-entity references are object identity and `WeakRef`s — the exact
41
+ * problem chant #1045 Phase 1 solved for discovery, so this reuses that codec
42
+ * (`../entity-wire-codec.ts`) rather than inventing a second one.
43
+ *
44
+ * That reuse is what makes the round trip cheap AND what defines its limits.
45
+ * `encodeEntitySet`/`decodeEntitySet` is documented as producing entities
46
+ * "behaviorally indistinguishable" from the in-process ones for chant's own
47
+ * consumers (serializers, the dependency graph, cross-lexicon detection), and
48
+ * a policy is a consumer of the same shape. Where it is NOT identical is
49
+ * written down in `docs/.../architecture/sandbox.mdx` and pinned by
50
+ * `./policy-wire.test.ts`:
51
+ *
52
+ * - An intrinsic (`Sub`, `Ref`, gitlab's `!reference`, …) decodes to a
53
+ * marker-bearing wrapper exposing `toJSON()`/`toYAML()`, not to an instance
54
+ * of the lexicon's own intrinsic class.
55
+ * - A decoded entity is a plain marker-bearing object, not an instance of the
56
+ * lexicon's resource class, and its `lexicon`/`entityType`/`kind`/`props`/
57
+ * `attributes` are non-enumerable (so `Object.keys(entity)` sees only the
58
+ * per-attribute/extra fields). Reads — `entity.props`, `entity.entityType`,
59
+ * `isDeclarable(entity)`, `instanceof AttrRef` — all still work.
60
+ * - A `ChildProjectInstance` (`nestedStack()`) has no wire form at all;
61
+ * `encodeEntitySet` throws rather than mis-encoding it, so a `--sandbox`
62
+ * build that both uses `nestedStack()` and declares `lint.policies` fails
63
+ * loudly. (No corpus entry uses `nestedStack()`.)
64
+ * - `errors` cross as the plain objects `DiscoveryError`/`BuildError`'s own
65
+ * `toJSON()` produces, not as `Error` instances. In practice this is never
66
+ * observable: `chant build` runs policies only when the build produced no
67
+ * errors at all, so the array is always empty at that point.
68
+ *
69
+ * Crucially, the first two are NOT new under `--sandbox`: a sandboxed build
70
+ * already merges decoded entities for every run-fallback file (`./run.ts`), so
71
+ * a policy running in-process on a `--sandbox` build is already looking at
72
+ * decoded entities for part of the set. Encoding is idempotent over a decoded
73
+ * entity (verified in `./policy-wire.test.ts`), so this child widens that from
74
+ * "the run-fallback subset" to "all of them" and changes nothing else.
75
+ */
76
+
77
+ /** Wire form of one lexicon's serialized output — already data on both sides; carried as-is. */
78
+ export type PolicyOutputWire = string | { primary: string; files?: Record<string, string>; warnings?: string[] };
79
+
80
+ /** A build result as pure JSON, for the sandboxed policy child. */
81
+ export interface PolicyBuildResultWire {
82
+ /** `../entity-wire-codec.ts`'s format — the same one the #1045 discovery child returns. */
83
+ entities: EntitySetWire;
84
+ /** `Map` entries as pairs; a `Map` itself JSON-stringifies to `{}`. */
85
+ outputs: Array<[string, PolicyOutputWire]>;
86
+ warnings: string[];
87
+ /** `DiscoveryError`/`BuildError`'s own `toJSON()` output. Always empty in practice — see the module doc. */
88
+ errors: Array<Record<string, unknown>>;
89
+ sourceFileCount: number;
90
+ /** `BuildResult.dependencies` (`Map<string, Set<string>>`) as pairs of arrays. Not part of `PostSynthContext`'s declared surface; carried so `ctx.buildResult` is not silently narrower than the object the in-process path passes. */
91
+ dependencies: Array<[string, string[]]>;
92
+ /** `BuildResult.manifest` — plain data (lexicons, cross-lexicon outputs, deploy order, stack graph). Same "not declared, still carried" reasoning as {@link dependencies}. */
93
+ manifest?: unknown;
94
+ /** `BuildResult.foldDecisions` — plain data. */
95
+ foldDecisions?: unknown[];
96
+ /** `BuildResult.buildParams` — plain data (#1064 provenance records). */
97
+ buildParams?: unknown[];
98
+ }
99
+
100
+ /** The subset of `BuildResult` this module knows how to carry. Structurally satisfied by `../../build.ts`'s `BuildResult`. */
101
+ export interface EncodablePolicyBuildResult {
102
+ outputs: Map<string, string | SerializerResult>;
103
+ entities: Map<string, Declarable>;
104
+ warnings: string[];
105
+ errors: ReadonlyArray<{ name: string; message: string; toJSON?: () => unknown }>;
106
+ sourceFileCount: number;
107
+ dependencies?: Map<string, Set<string>>;
108
+ manifest?: unknown;
109
+ foldDecisions?: unknown[];
110
+ buildParams?: unknown[];
111
+ }
112
+
113
+ /**
114
+ * Encode a merged, serialized build result for the policy child.
115
+ *
116
+ * Runs in the PARENT. Throws — never drops — when the result holds something
117
+ * the wire cannot represent: `encodeEntitySet`'s own refusals (a `nestedStack()`
118
+ * child project, an `AttrRef` that never got a logical name) propagate, and the
119
+ * finished payload is walked by `./config-wire.ts`'s scan so a serializer that
120
+ * somehow produced a non-data output is named by key path rather than silently
121
+ * mangled by `JSON.stringify`.
122
+ */
123
+ export function encodePolicyBuildResult(result: EncodablePolicyBuildResult): PolicyBuildResultWire {
124
+ const wire: PolicyBuildResultWire = {
125
+ entities: encodeEntitySet(result.entities),
126
+ outputs: [...result.outputs].map(([name, output]) => [name, encodeOutput(output)]),
127
+ warnings: [...result.warnings],
128
+ errors: result.errors.map((err) =>
129
+ typeof err.toJSON === "function"
130
+ ? (err.toJSON() as Record<string, unknown>)
131
+ : { name: err.name, message: err.message },
132
+ ),
133
+ sourceFileCount: result.sourceFileCount,
134
+ dependencies: result.dependencies ? [...result.dependencies].map(([name, deps]) => [name, [...deps]]) : [],
135
+ };
136
+ if (result.manifest !== undefined) wire.manifest = result.manifest;
137
+ if (result.foldDecisions !== undefined) wire.foldDecisions = result.foldDecisions;
138
+ if (result.buildParams !== undefined) wire.buildParams = result.buildParams;
139
+
140
+ const offenders = scanValueWireSafety(wire, "buildResult");
141
+ if (offenders.length > 0) {
142
+ throw new Error(formatPolicyWireOffenders("the build result", offenders));
143
+ }
144
+ return wire;
145
+ }
146
+
147
+ function encodeOutput(output: string | SerializerResult): PolicyOutputWire {
148
+ if (typeof output === "string") return output;
149
+ const encoded: { primary: string; files?: Record<string, string>; warnings?: string[] } = { primary: output.primary };
150
+ if (output.files !== undefined) encoded.files = { ...output.files };
151
+ if (output.warnings !== undefined) encoded.warnings = [...output.warnings];
152
+ return encoded;
153
+ }
154
+
155
+ /**
156
+ * Rebuild the `PostSynthContext["buildResult"]` a check expects, from the wire.
157
+ *
158
+ * Runs INSIDE the child. The maps and the live entity graph are reconstructed
159
+ * here (`decodeEntitySet`), so a check sees the same kind of object it sees
160
+ * in-process — `ctx.entities` is a real `Map`, its values are real
161
+ * `Declarable`s, and an `AttrRef` between two of them is a real `AttrRef`
162
+ * carrying its resolved logical name.
163
+ */
164
+ export function decodePolicyBuildResult(wire: PolicyBuildResultWire): PostSynthContext["buildResult"] {
165
+ const decoded = {
166
+ outputs: new Map<string, string | SerializerResult>(wire.outputs),
167
+ entities: decodeEntitySet(wire.entities),
168
+ warnings: wire.warnings ?? [],
169
+ errors: (wire.errors ?? []) as unknown as Array<{ message: string; name: string }>,
170
+ sourceFileCount: wire.sourceFileCount ?? 0,
171
+ dependencies: new Map<string, Set<string>>((wire.dependencies ?? []).map(([n, d]) => [n, new Set(d)])),
172
+ manifest: wire.manifest,
173
+ foldDecisions: wire.foldDecisions ?? [],
174
+ buildParams: wire.buildParams ?? [],
175
+ };
176
+ return decoded as unknown as PostSynthContext["buildResult"];
177
+ }
178
+
179
+ /** One thing a policy returned that cannot cross back, with the policy module it came from. */
180
+ export interface PolicyDiagnosticOffender extends ConfigWireOffender {
181
+ /** Absolute path of the `lint.policies` module whose check returned it. */
182
+ policy: string;
183
+ }
184
+
185
+ /**
186
+ * Validate what one policy module's checks returned, INSIDE the child, before
187
+ * it goes anywhere near the IPC channel.
188
+ *
189
+ * Two separate questions, both answered here rather than by hoping
190
+ * `JSON.stringify` behaves:
191
+ *
192
+ * 1. Is it data? A `PostSynthDiagnostic` is declared as five plain fields, but
193
+ * a check is arbitrary project code and can return anything. A function, a
194
+ * `Date`, a class instance, a circular reference — `JSON.stringify` would
195
+ * drop or rewrite each of them without a word, and the CLI would print a
196
+ * diagnostic that is not the one the policy produced.
197
+ * 2. Is it a diagnostic? A missing `checkId`, or a `severity` outside
198
+ * `error`/`warning`/`info`, is reported here instead of turning into an
199
+ * undefined-shaped line in the build's error list.
200
+ */
201
+ export function scanPolicyDiagnostics(
202
+ diagnostics: unknown,
203
+ policyPath: string,
204
+ ): PolicyDiagnosticOffender[] {
205
+ const out: PolicyDiagnosticOffender[] = [];
206
+ if (!Array.isArray(diagnostics)) {
207
+ out.push({ policy: policyPath, path: "<return value>", found: describeShape(diagnostics) });
208
+ return out;
209
+ }
210
+
211
+ for (const offender of scanValueWireSafety(diagnostics, "")) {
212
+ out.push({ policy: policyPath, ...offender });
213
+ }
214
+ if (out.length > 0) return out;
215
+
216
+ const severities = new Set(["error", "warning", "info"]);
217
+ diagnostics.forEach((diag, i) => {
218
+ const d = diag as Partial<PostSynthDiagnostic> | null;
219
+ if (d === null || typeof d !== "object" || Array.isArray(d)) {
220
+ out.push({ policy: policyPath, path: `[${i}]`, found: describeShape(diag) });
221
+ return;
222
+ }
223
+ if (typeof d.checkId !== "string" || d.checkId.length === 0) {
224
+ out.push({ policy: policyPath, path: `[${i}].checkId`, found: "not a non-empty string" });
225
+ }
226
+ if (typeof d.message !== "string") {
227
+ out.push({ policy: policyPath, path: `[${i}].message`, found: "not a string" });
228
+ }
229
+ if (typeof d.severity !== "string" || !severities.has(d.severity)) {
230
+ out.push({ policy: policyPath, path: `[${i}].severity`, found: `not one of "error", "warning", "info"` });
231
+ }
232
+ });
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Thrown INSIDE the child by the per-check wrapper `./driver.ts` generates,
238
+ * when a check returns something that cannot cross back. Carries the offenders
239
+ * so the driver can report them as data rather than as a message string it
240
+ * would then have to parse.
241
+ *
242
+ * A class rather than a tagged object because the driver tests it with
243
+ * `instanceof`: both halves come from this one module, bundled once, so there
244
+ * is exactly one class identity inside the child.
245
+ */
246
+ export class PolicyWireError extends Error {
247
+ readonly offenders: PolicyDiagnosticOffender[];
248
+
249
+ constructor(offenders: PolicyDiagnosticOffender[]) {
250
+ super(formatPolicyWireOffenders("a policy check's return value", offenders));
251
+ this.name = "PolicyWireError";
252
+ this.offenders = offenders;
253
+ }
254
+ }
255
+
256
+ function describeShape(value: unknown): string {
257
+ if (value === null) return "null";
258
+ if (Array.isArray(value)) return "an array";
259
+ if (value === undefined) return "undefined";
260
+ return `a ${typeof value}`;
261
+ }
262
+
263
+ /** Render offenders as the body of a build error — one line each, naming the policy module. */
264
+ export function formatPolicyWireOffenders(
265
+ subject: string,
266
+ offenders: ReadonlyArray<ConfigWireOffender & { policy?: string }>,
267
+ ): string {
268
+ const lines = offenders.map((o) => {
269
+ const where = o.policy ? `${o.policy}${o.path ? ` ${o.path}` : ""}` : o.path || "<root>";
270
+ return ` ${where}: ${o.found}`;
271
+ });
272
+ return [
273
+ `Cannot run lint.policies inside the --sandbox boundary: ${subject} holds values that are not data.`,
274
+ ...lines,
275
+ `Under --sandbox a policy check runs in an isolated child process and only JSON crosses back, so every value a check returns must be a string, number, boolean, null, array or plain object, and every diagnostic must have a string checkId, a string message, and a severity of "error", "warning" or "info". Return plain diagnostics, or drop --sandbox for this build.`,
276
+ ].join("\n");
277
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, expect, test, vi } from "vitest";
2
2
  import { buildInterpolatedString, defaultInterpolationSerializer } from "./intrinsic-interpolation";
3
3
  import { AttrRef } from "./attrref";
4
4
  import { INTRINSIC_MARKER } from "./intrinsic";
@@ -54,6 +54,32 @@ describe("defaultInterpolationSerializer", () => {
54
54
  expect(() => serialize(ref)).toThrow("logical name not set");
55
55
  });
56
56
 
57
+ // chant #1137 — this dispatch used to check `value instanceof AttrRef`,
58
+ // which returns false for an AttrRef built by a SEPARATELY-LOADED copy of
59
+ // `./attrref` (the same dual-npm-copy hazard #1122 fixed for
60
+ // `LexiconOutput`). Because AttrRef also implements Intrinsic, the miss
61
+ // was not loud: a foreign AttrRef fell into the generic Intrinsic branch
62
+ // below instead, whose `toJSON()` returns the `{__attrRef}` wire envelope
63
+ // (not a `Ref`), silently stringified to `[object Object]` in the
64
+ // interpolated output. `vi.resetModules()` + a fresh dynamic import
65
+ // reproduces the split module graph exactly.
66
+ test("serializes an AttrRef built by a second, separately-loaded copy of AttrRef", async () => {
67
+ vi.resetModules();
68
+ const secondCopy = await import("./attrref");
69
+ expect(secondCopy.AttrRef).not.toBe(AttrRef);
70
+
71
+ const parent = {};
72
+ const foreignRef = new secondCopy.AttrRef(parent, "Arn");
73
+ foreignRef._setLogicalName("MyBucket");
74
+
75
+ // The historic bug: instanceof fails across separately-loaded copies of
76
+ // chant-core, even though the two classes are structurally identical.
77
+ expect(foreignRef instanceof AttrRef).toBe(false);
78
+
79
+ expect(serialize(foreignRef)).toBe("${MyBucket.Arn}");
80
+ vi.resetModules();
81
+ });
82
+
57
83
  test("serializes Intrinsic with Ref toJSON", () => {
58
84
  const intrinsic = {
59
85
  [INTRINSIC_MARKER]: true as const,
@@ -9,6 +9,7 @@
9
9
  import { AttrRef } from "./attrref";
10
10
  import { INTRINSIC_MARKER } from "./intrinsic";
11
11
  import { DECLARABLE_MARKER } from "./declarable";
12
+ import { isAttrRefLike } from "./utils";
12
13
 
13
14
  export type InterpolationValueSerializer = (value: unknown) => string;
14
15
 
@@ -26,8 +27,15 @@ export function defaultInterpolationSerializer(
26
27
  serializeRef: (refName: string) => string,
27
28
  ): InterpolationValueSerializer {
28
29
  return (value: unknown): string => {
29
- // Handle AttrRef
30
- if (value instanceof AttrRef) {
30
+ // Handle AttrRef. Duck-type, not `instanceof` (chant #1137): a lexicon
31
+ // built against a separate copy of `@intentius/chant` produces AttrRefs
32
+ // that fail `instanceof AttrRef` here but carry the same shape — and
33
+ // since AttrRef also implements Intrinsic, missing this branch does not
34
+ // fail loud. The value instead falls into the generic Intrinsic branch
35
+ // below, which calls `toJSON()` (returning the `{__attrRef}` wire
36
+ // envelope, not a `Ref`) and silently stringifies it to `[object
37
+ // Object]` in the interpolated output.
38
+ if (isAttrRefLike(value)) {
31
39
  const logicalName = value.getLogicalName();
32
40
  if (!logicalName) {
33
41
  throw new Error(
@@ -87,6 +87,42 @@ describe("LexiconOutput", () => {
87
87
  expect(lo.getOutputValue()).toEqual({ "Fn::GetAtt": ["myBucket", "Arn"] });
88
88
  });
89
89
 
90
+ // chant #1137 — the constructor used to check `ref instanceof AttrRef`,
91
+ // which returns false for an AttrRef built by a SEPARATELY-LOADED copy of
92
+ // `./attrref` (the same dual-npm-copy hazard #1122 fixed for
93
+ // `isLexiconOutput` itself). Because AttrRef also implements Intrinsic,
94
+ // the miss was not loud: a foreign AttrRef fell into the `isIntrinsic(ref)`
95
+ // branch instead, stored as `_intrinsic` rather than recognized as an
96
+ // AttrRef-sourced output — so `getOutputValue()` called the foreign
97
+ // AttrRef's own `toJSON()` (the `{__attrRef}` wire envelope) instead of
98
+ // emitting `Fn::GetAtt`, a broken Output with no error at synth time.
99
+ // `vi.resetModules()` + a fresh dynamic import reproduces the split
100
+ // module graph exactly.
101
+ test("recognizes an AttrRef built by a second, separately-loaded copy of AttrRef", async () => {
102
+ const bucket = new MockResource();
103
+
104
+ vi.resetModules();
105
+ const secondCopy = await import("./attrref");
106
+ expect(secondCopy.AttrRef).not.toBe(AttrRef);
107
+
108
+ const foreignRef = new secondCopy.AttrRef(bucket, "Arn");
109
+
110
+ // The historic bug: instanceof fails across separately-loaded copies of
111
+ // chant-core, even though the two classes are structurally identical.
112
+ expect(foreignRef instanceof AttrRef).toBe(false);
113
+
114
+ const lo = new LexiconOutput(foreignRef, "BucketArn");
115
+ lo._setSourceEntity("myBucket");
116
+
117
+ // The fix: recognized as AttrRef-sourced, not misfiled as a generic
118
+ // Intrinsic — sourceAttribute is set and getOutputValue() emits a real
119
+ // Fn::GetAtt, not the foreign AttrRef's own wire-envelope toJSON().
120
+ expect(lo.sourceLexicon).toBe("testdom");
121
+ expect(lo.sourceAttribute).toBe("Arn");
122
+ expect(lo.getOutputValue()).toEqual({ "Fn::GetAtt": ["myBucket", "Arn"] });
123
+ vi.resetModules();
124
+ });
125
+
90
126
  test("getOutputValue() returns intrinsic toJSON for Intrinsic-based output", () => {
91
127
  const mockIntrinsic = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ "Fn::Sub": "http://${Param}/path" }) };
92
128
  const lo = new LexiconOutput(mockIntrinsic, "MyUrl");
@@ -1,5 +1,6 @@
1
1
  import { INTRINSIC_MARKER, isIntrinsic, type Intrinsic } from "./intrinsic";
2
2
  import { AttrRef } from "./attrref";
3
+ import { isAttrRefLike } from "./utils";
3
4
 
4
5
  /** A value `output()` accepts that is already fully resolved — not a
5
6
  * reference to anything, just data the author computed (a literal, a prop,
@@ -100,7 +101,16 @@ export class LexiconOutput implements Intrinsic {
100
101
  value: true,
101
102
  enumerable: false,
102
103
  });
103
- if (ref instanceof AttrRef) {
104
+ // Duck-type, not `instanceof` (chant #1137): a lexicon built against a
105
+ // separate copy of `@intentius/chant` produces AttrRefs that fail
106
+ // `instanceof AttrRef` here but carry the same shape — and since AttrRef
107
+ // also implements Intrinsic, missing this branch does not fail loud.
108
+ // The value instead falls into the `isIntrinsic(ref)` branch below,
109
+ // stored as `_intrinsic` rather than an AttrRef-sourced output, so
110
+ // `getOutputValue()` later calls the foreign AttrRef's own `toJSON()`
111
+ // (the `{__attrRef}` wire envelope) instead of emitting `Fn::GetAtt` —
112
+ // a broken Output emitted with no error at synth time.
113
+ if (isAttrRefLike(ref)) {
104
114
  const parent = ref.parent.deref();
105
115
  if (!parent) {
106
116
  throw new Error("Cannot create LexiconOutput: parent entity has been garbage collected");
@@ -132,7 +142,7 @@ export class LexiconOutput implements Intrinsic {
132
142
  // a reference to anything. The `string` arm of the exported type
133
143
  // exists for a documented reason: a generated resource's attribute
134
144
  // accessor is typed `string` at the TypeScript level but is a real
135
- // `AttrRef` at runtime, caught by `instanceof AttrRef` above — so
145
+ // `AttrRef` at runtime, caught by the `isAttrRefLike` check above — so
136
146
  // anything that reaches this branch genuinely has no source entity
137
147
  // or attribute, and is recorded to be emitted as a plain `Value`
138
148
  // rather than a fabricated `Fn::GetAtt` (chant #1121).
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The single place chant imports a project's `lint.policies` module **in the
3
+ * CLI's own process** — and the flag that says it may not.
4
+ *
5
+ * The third member of the same family as `../discovery/import.ts`'s
6
+ * `importModule` (project source) and `../config-import.ts`'s
7
+ * `importConfigModule` (`chant.config.ts`): one narrow module whose only job is
8
+ * "execute project-authored code here", so that "did any project code run in
9
+ * this process?" has one place to look and one place to instrument — see
10
+ * `examples/sandbox-execution-boundary.test.ts`, which spies on all three.
11
+ *
12
+ * Before chant #1131 `./policy.ts` called `await import(resolved)` inline,
13
+ * which is why the corpus boundary gate could not see it even after #1113 put
14
+ * the config behind the boundary: there was nothing named to wrap.
15
+ *
16
+ * The armed flag lives here rather than in `./policy-sandbox.ts` (which owns
17
+ * the *decision* and documents the reasoning, and re-exports these three
18
+ * functions as its public surface) for one structural reason: `./policy.ts`
19
+ * needs to consult it, `./policy-sandbox.ts` needs to call `./policy.ts`, and a
20
+ * flag on a leaf module with no imports of its own breaks what would otherwise
21
+ * be an import cycle. It also puts the check on the narrowest possible thing —
22
+ * the function that actually executes project code — rather than on a caller
23
+ * that might forget to ask.
24
+ */
25
+
26
+ /** Whether this process must run project policy modules inside the sandbox boundary instead of here. */
27
+ let armed = false;
28
+
29
+ /**
30
+ * Arm sandboxed policy execution for the rest of this process. See
31
+ * `./policy-sandbox.ts` for what arms it and why it is a process mode rather
32
+ * than a threaded option. Idempotent; there is deliberately no disarm.
33
+ */
34
+ export function armSandboxPolicyExecution(): void {
35
+ armed = true;
36
+ }
37
+
38
+ /** Whether {@link armSandboxPolicyExecution} has been called. */
39
+ export function isSandboxPolicyExecutionArmed(): boolean {
40
+ return armed;
41
+ }
42
+
43
+ /** Test-only reset — vitest gives each test file its own module registry, so this exists for suites that arm and disarm within one file. */
44
+ export function resetSandboxPolicyExecutionForTests(): void {
45
+ armed = false;
46
+ }
47
+
48
+ /** The shape a policy module evaluates to — chant reads its exported values and keeps the `PostSynthCheck`-shaped ones. */
49
+ export type PolicyModuleNamespace = Record<string, unknown>;
50
+
51
+ /**
52
+ * Import a policy module into THIS process and return its module namespace.
53
+ * Node's ESM registry caches it, so repeated loads within one CLI invocation
54
+ * evaluate the file once.
55
+ *
56
+ * Refuses while armed. Under `--sandbox` the policy modules are imported inside
57
+ * a child process (`../discovery/sandbox/policy-run.ts`); arriving here anyway
58
+ * means something is about to execute project-authored code in the CLI's own
59
+ * process, which is exactly what the flag promises does not happen. Falling
60
+ * through would make `--sandbox` mean less than it says with nothing visible to
61
+ * notice, so this throws instead.
62
+ */
63
+ export async function importPolicyModule(policyPath: string): Promise<PolicyModuleNamespace> {
64
+ if (armed) {
65
+ throw new Error(
66
+ `Refusing to import the policy module ${policyPath} into the chant process under --sandbox: it is project-authored code and must be imported inside the sandbox boundary (packages/core/src/lint/policy-sandbox.ts's runProjectPolicies).`,
67
+ );
68
+ }
69
+ return (await import(policyPath)) as PolicyModuleNamespace;
70
+ }
@@ -0,0 +1,123 @@
1
+ import { resolve } from "node:path";
2
+ import {
3
+ runPostSynthChecks,
4
+ type PostSynthCheck,
5
+ type PostSynthContext,
6
+ type PostSynthDiagnostic,
7
+ } from "./post-synth";
8
+ import { loadPolicyChecks } from "./policy";
9
+ import {
10
+ armSandboxPolicyExecution,
11
+ isSandboxPolicyExecutionArmed,
12
+ resetSandboxPolicyExecutionForTests,
13
+ } from "./policy-import";
14
+ import type { EncodablePolicyBuildResult } from "../discovery/sandbox/policy-wire";
15
+
16
+ /**
17
+ * chant #1131 — decides WHERE a project's `lint.policies` checks run.
18
+ *
19
+ * A policy is project-authored code. Every other piece of project code moved
20
+ * behind the `--sandbox` boundary in chant #1045 (run-fallback source), #1093
21
+ * (composite factories, constructors, intrinsic tags) and #1113
22
+ * (`chant.config.ts`), and all three had to leave this one out: the config
23
+ * declares its policies as *paths*, so putting the config behind the boundary
24
+ * did not put them there with it. `chant build` imported each policy module and
25
+ * called its `check` function in the CLI's own process, after discovery was
26
+ * over, with the CLI's filesystem, network, environment and process-spawn
27
+ * access. This module closes it.
28
+ *
29
+ * ## Why an armed process mode rather than a threaded option
30
+ *
31
+ * The same reason `../config-sandbox.ts` gives. `./policy.ts`'s
32
+ * `loadPolicyChecks` is exported from chant-core's public surface and called
33
+ * from more than one place (`../cli/commands/build.ts` for a build, and
34
+ * `evaluateProjectPolicies` for the `policyGate` Op step). Threading a
35
+ * `sandbox` flag to each is fail-OPEN: miss one and project code executes in
36
+ * the CLI process with nothing to notice. Arming the process once, before any
37
+ * policy is loaded, is fail-CLOSED — `loadPolicyChecks` REFUSES while armed, so
38
+ * a call site nobody remembered gets a loud error rather than a silent
39
+ * execution.
40
+ *
41
+ * ## What arms it
42
+ *
43
+ * Both ways of turning sandboxing on, unlike `../config-sandbox.ts`. That
44
+ * asymmetry is not an oversight: the config has a bootstrap limit (reading
45
+ * `build.sandbox` out of `chant.config.ts` means running it, so only the CLI
46
+ * flag, known before any config is touched, can cover the config's own
47
+ * evaluation). Policies have no such limit — they are loaded long after the
48
+ * config is known — so `build.sandbox: true` in a project's config sandboxes
49
+ * them just as `--sandbox` does, and `../cli/commands/build.ts` arms this from
50
+ * the RESOLVED value.
51
+ *
52
+ * There is deliberately no disarm: a security mode that can be turned off
53
+ * partway through a process is not one.
54
+ */
55
+
56
+ /**
57
+ * The mode itself lives on `./policy-import.ts` — the leaf module that actually
58
+ * performs an in-process policy import, so the refusal sits on the narrowest
59
+ * possible thing and `./policy.ts` can consult it without importing this file
60
+ * (which imports `./policy.ts`). Re-exported here because this is where the
61
+ * decision is documented and where callers look.
62
+ *
63
+ * `armSandboxPolicyExecution` is called from `../cli/commands/build.ts` once
64
+ * `build.sandbox`/`--sandbox` has resolved, and from `../cli/main.ts` off the
65
+ * parsed flag.
66
+ */
67
+ export { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests };
68
+
69
+ export interface ProjectPolicyRun {
70
+ /** `lint.policies` as declared in the config — relative paths, resolved against {@link configDir}. */
71
+ policies: readonly string[];
72
+ /** The `chant.config.*` directory (`lint.policies` paths are relative to it), also the child's read allowance. */
73
+ configDir: string;
74
+ /** The merged, serialized build result the checks run over. */
75
+ buildResult: EncodablePolicyBuildResult & PostSynthContext["buildResult"];
76
+ /** `--env`, else `ownership.env`. Becomes `ctx.env`. */
77
+ env?: string;
78
+ /**
79
+ * Checks the caller already loaded into THIS process, before the build ran.
80
+ *
81
+ * `chant build` has always loaded `lint.policies` up front rather than at the
82
+ * point of use, so a policy path that doesn't resolve fails the command
83
+ * immediately — including when the build itself goes on to fail, which is
84
+ * exactly when a typo'd path would otherwise go unnoticed. Preserving that
85
+ * for the unsandboxed path is the whole reason this field exists.
86
+ *
87
+ * Ignored when armed: under `--sandbox` there is nothing loaded here to pass,
88
+ * and `loadPolicyChecks` refuses outright.
89
+ */
90
+ preloaded?: PostSynthCheck[];
91
+ }
92
+
93
+ /**
94
+ * Run a project's organizational policy checks over a finished build and return
95
+ * their diagnostics. Sandboxed in a post-merge child when armed, in-process
96
+ * otherwise.
97
+ *
98
+ * The unarmed path is byte-for-byte what `chant build` has always done: load
99
+ * the modules, hand every check one `PostSynthContext`, concatenate what they
100
+ * return. The armed path does the same thing on the other side of a process
101
+ * boundary — see `../discovery/sandbox/policy-run.ts`.
102
+ */
103
+ export async function runProjectPolicies(run: ProjectPolicyRun): Promise<PostSynthDiagnostic[]> {
104
+ if (run.policies.length === 0) return [];
105
+
106
+ if (!isSandboxPolicyExecutionArmed()) {
107
+ const checks = run.preloaded ?? (await loadPolicyChecks([...run.policies], run.configDir));
108
+ if (checks.length === 0) return [];
109
+ return runPostSynthChecks(checks, run.buildResult, run.env);
110
+ }
111
+
112
+ // Dynamic, not static, for the same reason `../config-sandbox.ts` imports
113
+ // `../discovery/sandbox/config-run` dynamically: this pulls in `esbuild`, a
114
+ // large CJS package no unsandboxed build should pay to load.
115
+ const { runPoliciesSandboxed } = await import("../discovery/sandbox/policy-run");
116
+ const { diagnostics } = await runPoliciesSandboxed({
117
+ policyPaths: run.policies.map((p) => resolve(run.configDir, p)),
118
+ buildResult: run.buildResult,
119
+ env: run.env,
120
+ projectRoot: run.configDir,
121
+ });
122
+ return diagnostics;
123
+ }
@@ -9,15 +9,33 @@ import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
9
9
  import { build } from "../build";
10
10
  import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
11
11
  import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
12
+ import { importPolicyModule, isSandboxPolicyExecutionArmed } from "./policy-import";
12
13
 
13
- /** Load project policy checks (one or more `PostSynthCheck` exports) from files. */
14
+ /**
15
+ * Load project policy checks (one or more `PostSynthCheck` exports) from files,
16
+ * **into this process**.
17
+ *
18
+ * chant #1131 — refuses while `./policy-sandbox.ts` is armed. Under `--sandbox`
19
+ * the policy modules are imported and their checks run inside a child process
20
+ * (`runProjectPolicies`); reaching this function anyway means a call site is
21
+ * about to execute project-authored code in the CLI's own process, which is
22
+ * precisely the thing the flag promises does not happen. Failing loudly is the
23
+ * only honest option — falling through would make `--sandbox` mean less than it
24
+ * says without anything visible to notice.
25
+ */
14
26
  export async function loadPolicyChecks(paths: string[], configDir: string): Promise<PostSynthCheck[]> {
27
+ if (isSandboxPolicyExecutionArmed()) {
28
+ throw new Error(
29
+ `Cannot load lint.policies (${paths.join(", ")}) in the chant process under --sandbox: a policy module is project-authored code, and it must be imported inside the sandbox boundary. This is a chant bug — the caller should route through runProjectPolicies() (packages/core/src/lint/policy-sandbox.ts).`,
30
+ );
31
+ }
32
+
15
33
  const checks: PostSynthCheck[] = [];
16
34
  for (const p of paths) {
17
35
  const resolved = resolve(configDir, p);
18
36
  let mod: Record<string, unknown>;
19
37
  try {
20
- mod = (await import(resolved)) as Record<string, unknown>;
38
+ mod = await importPolicyModule(resolved);
21
39
  } catch (err) {
22
40
  throw new Error(
23
41
  `Failed to load policy "${p}": ${err instanceof Error ? err.message : String(err)}`,