@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,180 @@
1
+ import { realpathSync, rmSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { PostSynthDiagnostic } from "../../lint/post-synth";
4
+ import { bundleDriver } from "./bundle";
5
+ import { generatePolicyDriverSource } from "./driver";
6
+ import { forkSandboxed } from "./fork";
7
+ import {
8
+ encodePolicyBuildResult,
9
+ formatPolicyWireOffenders,
10
+ type EncodablePolicyBuildResult,
11
+ type PolicyDiagnosticOffender,
12
+ } from "./policy-wire";
13
+ import { ENV_VAR } from "../../env";
14
+
15
+ /**
16
+ * chant #1131 — runs a project's `lint.policies` checks inside a sandboxed
17
+ * child, over the build result the parent has already merged and serialized,
18
+ * and brings back plain `PostSynthDiagnostic`s.
19
+ *
20
+ * This closes the last residual chant #1093 and #1113 documented. `--sandbox`
21
+ * isolated project *source* (#1045), then project *fold-time* code (#1093),
22
+ * then the project's `chant.config.ts` (#1113) — but `chant build` still
23
+ * imported every `lint.policies` module and called its `check` function in the
24
+ * CLI's own process, after discovery, with the CLI's full filesystem, network,
25
+ * environment and process-spawn access. The config crossing as data did not
26
+ * take the policies with it: they are declared in it as *paths*.
27
+ *
28
+ * ## Why a second child rather than the first one
29
+ *
30
+ * A policy is a callback over the *whole* resolved build, and no such thing
31
+ * exists inside the #1045 discovery child. That child sees only the
32
+ * run-fallback subset — folded files are collected in the parent, the merge
33
+ * happens in the parent, and serialization (which is what most policies
34
+ * actually read) happens later still. Running policies there would hand them a
35
+ * partial, unserialized view and call it the same check. So the boundary moves
36
+ * to where the complete result exists: after the merge, in a child of its own.
37
+ *
38
+ * Deliberately the same machinery, not a parallel one — the same three modules
39
+ * `./config-run.ts` reuses:
40
+ * - `./bundle.ts` bundles the generated driver (`./driver.ts`'s
41
+ * `generatePolicyDriverSource`) with esbuild, so the child needs no runtime
42
+ * module resolution and no TypeScript loader.
43
+ * - `./fork.ts` spawns it with the identical `--permission` profile the
44
+ * run-fallback and config children get — one function, so the three cannot
45
+ * drift.
46
+ * - `./child-errors.ts` (inside the driver) classifies whatever it throws, so
47
+ * a permission denial names the policy file instead of leaking
48
+ * `ERR_ACCESS_DENIED`.
49
+ *
50
+ * The one addition is direction: the policy child receives its input over the
51
+ * same IPC channel it answers on (`SandboxForkOptions.send`). See that field's
52
+ * doc for why sending before the child boots is safe.
53
+ *
54
+ * ## What the child can and cannot do
55
+ *
56
+ * Exactly what a run-fallback file can: read the bundle directory and the
57
+ * project directory, nothing else; no writes, no spawning, no worker threads;
58
+ * an environment of `PATH` plus `CHANT_ENV` when set. That last one matches
59
+ * `./config-run.ts` for the same reason — `--env` is a value the user typed on
60
+ * the command line, and a policy that branches on the environment is the
61
+ * documented pattern (`ctx.env` carries it too, so this is belt-and-braces for
62
+ * a policy reading `process.env.CHANT_ENV` directly rather than a new
63
+ * capability).
64
+ *
65
+ * A policy that writes a report file, shells out to a scanner, or reads
66
+ * `~/.aws/credentials` therefore now FAILS, loudly, naming the file and the
67
+ * operation. That is the point of the flag, and it is a real behavior change
68
+ * for such a policy under `--sandbox` — see `docs/.../architecture/sandbox.mdx`.
69
+ */
70
+
71
+ /** A policy child is one bundle, one import per policy module, and a pass over data. Generous relative to that, tight enough that a hung policy is reported rather than waited on. */
72
+ const POLICY_CHILD_TIMEOUT_MS = 120_000;
73
+
74
+ interface PolicyChildResponse {
75
+ kind: "chant-policy";
76
+ ok: boolean;
77
+ diagnostics?: PostSynthDiagnostic[];
78
+ offenders?: PolicyDiagnosticOffender[];
79
+ error?: { name: string; file: string; message: string; type: string };
80
+ }
81
+
82
+ function isPolicyChildResponse(value: unknown): value is PolicyChildResponse {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ (value as { kind?: unknown }).kind === "chant-policy" &&
87
+ typeof (value as { ok?: unknown }).ok === "boolean"
88
+ );
89
+ }
90
+
91
+ export interface SandboxPolicyResult {
92
+ /** What the policy pack reported — plain data, validated inside the child before it crossed (see `./policy-wire.ts`'s `scanPolicyDiagnostics`). */
93
+ diagnostics: PostSynthDiagnostic[];
94
+ /** esbuild bundling wall-clock time. */
95
+ bundleMs: number;
96
+ /** Bundle size in bytes. */
97
+ bundleBytes: number;
98
+ /** Encoded build-result payload size in bytes — what crossed the IPC channel. */
99
+ payloadBytes: number;
100
+ }
101
+
102
+ export interface SandboxPolicyOptions {
103
+ /** Absolute paths to the project's `lint.policies` modules, in declaration order. */
104
+ policyPaths: readonly string[];
105
+ /** The merged, serialized build result the checks run over. */
106
+ buildResult: EncodablePolicyBuildResult;
107
+ /** The environment/stack this build was evaluated for (`--env`, else `ownership.env`) — becomes `ctx.env`. */
108
+ env?: string;
109
+ /** Directory the child is granted `--allow-fs-read` for: the project root (the `chant.config.*` directory, which `lint.policies` paths are resolved against). */
110
+ projectRoot: string;
111
+ }
112
+
113
+ /**
114
+ * Evaluate `policyPaths` against `buildResult` inside a sandboxed child.
115
+ *
116
+ * Throws — rather than degrading to an in-process run — when the policies
117
+ * cannot be evaluated inside the boundary or their diagnostics cannot cross it
118
+ * as JSON. Under `--sandbox` a policy pack that "almost" ran is not a safe
119
+ * thing to proceed with, and quietly falling back would give away the property
120
+ * the flag exists to provide.
121
+ */
122
+ export async function runPoliciesSandboxed(options: SandboxPolicyOptions): Promise<SandboxPolicyResult> {
123
+ const { policyPaths, buildResult, env, projectRoot } = options;
124
+
125
+ // Encoded FIRST, in the parent, before anything is bundled or spawned: this
126
+ // is where a build result that cannot cross is rejected by name (a
127
+ // `nestedStack()` child project, a serializer output that isn't data), and
128
+ // there is no point paying for a bundle to find that out.
129
+ const payload = {
130
+ kind: "chant-policy-request" as const,
131
+ buildResult: encodePolicyBuildResult(buildResult),
132
+ env: env ?? null,
133
+ };
134
+ const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf-8");
135
+
136
+ const driverSource = generatePolicyDriverSource(policyPaths);
137
+ const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
138
+
139
+ try {
140
+ let projectRealpath: string;
141
+ try {
142
+ projectRealpath = realpathSync(resolve(projectRoot));
143
+ } catch {
144
+ projectRealpath = resolve(projectRoot);
145
+ }
146
+
147
+ const childEnv: Record<string, string> = { PATH: process.env.PATH ?? "" };
148
+ const activeEnv = process.env[ENV_VAR];
149
+ if (activeEnv) childEnv[ENV_VAR] = activeEnv;
150
+
151
+ const response = await forkSandboxed(
152
+ {
153
+ bundlePath,
154
+ bundleDir,
155
+ projectRealpath,
156
+ externalReadPaths,
157
+ env: childEnv,
158
+ timeoutMs: POLICY_CHILD_TIMEOUT_MS,
159
+ label: `sandboxed evaluation of lint.policies (${policyPaths.length} module(s))`,
160
+ send: payload,
161
+ },
162
+ isPolicyChildResponse,
163
+ );
164
+
165
+ if (!response.ok) {
166
+ if (response.offenders && response.offenders.length > 0) {
167
+ throw new Error(formatPolicyWireOffenders("a policy check's return value", response.offenders));
168
+ }
169
+ throw new Error(
170
+ response.error?.message
171
+ ? `Failed to run lint.policies inside the --sandbox boundary: ${response.error.message}`
172
+ : `Failed to run lint.policies inside the --sandbox boundary`,
173
+ );
174
+ }
175
+
176
+ return { diagnostics: response.diagnostics ?? [], bundleMs: durationMs, bundleBytes: bytes, payloadBytes };
177
+ } finally {
178
+ rmSync(bundleDir, { recursive: true, force: true });
179
+ }
180
+ }
@@ -0,0 +1,310 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { AttrRef } from "../../attrref";
3
+ import { CHILD_PROJECT_MARKER } from "../../child-project";
4
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
5
+ import { INTRINSIC_MARKER, type Intrinsic } from "../../intrinsic";
6
+ import { DiscoveryError } from "../../errors";
7
+ import { createResource } from "../../runtime";
8
+ import { resolveAttrRefs } from "../resolve";
9
+ import { isAttrRefLike } from "../../utils";
10
+ import type { PostSynthCheck } from "../../lint/post-synth";
11
+ import { runPostSynthChecks } from "../../lint/post-synth";
12
+ import {
13
+ decodePolicyBuildResult,
14
+ encodePolicyBuildResult,
15
+ scanPolicyDiagnostics,
16
+ type EncodablePolicyBuildResult,
17
+ } from "./policy-wire";
18
+
19
+ /**
20
+ * chant #1131 — the contract between the CLI process and the sandboxed policy
21
+ * child, in both directions.
22
+ *
23
+ * These tests are the "document the difference precisely and pin it" half of
24
+ * the brief. A policy check under `--sandbox` no longer sees the parent's own
25
+ * live objects; it sees what survived `encodePolicyBuildResult` →
26
+ * `JSON.parse(JSON.stringify(...))` → `decodePolicyBuildResult`. Everything
27
+ * that is the SAME is asserted here so a regression is caught, and everything
28
+ * that is NARROWER is asserted here too, so the narrowing can never quietly
29
+ * change without a test failing.
30
+ */
31
+
32
+ /** What the IPC channel actually does to the payload. Every test goes through it — an in-memory hand-off would prove nothing. */
33
+ function overTheWire(result: EncodablePolicyBuildResult) {
34
+ const wire = encodePolicyBuildResult(result);
35
+ return decodePolicyBuildResult(JSON.parse(JSON.stringify(wire)));
36
+ }
37
+
38
+ function emptyResult(overrides: Partial<EncodablePolicyBuildResult> = {}): EncodablePolicyBuildResult {
39
+ return {
40
+ outputs: new Map(),
41
+ entities: new Map(),
42
+ warnings: [],
43
+ errors: [],
44
+ sourceFileCount: 0,
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ describe("the build result a sandboxed policy sees (chant #1131)", () => {
50
+ test("outputs — the surface the reference policy corpus actually reads — cross exactly", () => {
51
+ const result = emptyResult({
52
+ outputs: new Map<string, string | { primary: string; files?: Record<string, string>; warnings?: string[] }>([
53
+ ["k8s", "apiVersion: v1\nkind: Service\n"],
54
+ ["aws", { primary: '{"Resources":{}}', files: { "net.template.json": "{}" }, warnings: ["dropped a key"] }],
55
+ ]),
56
+ warnings: ["a build warning"],
57
+ sourceFileCount: 3,
58
+ });
59
+
60
+ const decoded = overTheWire(result);
61
+
62
+ expect(decoded.outputs.get("k8s")).toBe("apiVersion: v1\nkind: Service\n");
63
+ expect(decoded.outputs.get("aws")).toEqual({
64
+ primary: '{"Resources":{}}',
65
+ files: { "net.template.json": "{}" },
66
+ warnings: ["dropped a key"],
67
+ });
68
+ expect(decoded.outputs).toBeInstanceOf(Map);
69
+ expect(decoded.warnings).toEqual(["a build warning"]);
70
+ expect(decoded.sourceFileCount).toBe(3);
71
+ });
72
+
73
+ test("entities cross as a live Map of Declarables, with AttrRefs still resolved and still AttrRefs", () => {
74
+ const Vpc = createResource("Test::Vpc", "test", { vpcId: "VpcId" });
75
+ const Subnet = createResource("Test::Subnet", "test", { subnetId: "SubnetId" });
76
+ const vpc = new Vpc({ CidrBlock: "10.0.0.0/16" });
77
+ const subnet = new Subnet({ VpcId: (vpc as unknown as Record<string, AttrRef>).vpcId });
78
+ const entities = new Map<string, Declarable>([
79
+ ["Vpc", vpc as unknown as Declarable],
80
+ ["Subnet", subnet as unknown as Declarable],
81
+ ]);
82
+ resolveAttrRefs(entities);
83
+
84
+ const decoded = overTheWire(emptyResult({ entities }));
85
+
86
+ expect([...decoded.entities.keys()]).toEqual(["Vpc", "Subnet"]);
87
+ const decodedSubnet = decoded.entities.get("Subnet") as unknown as { props: { VpcId: unknown } };
88
+ expect(isAttrRefLike(decodedSubnet.props.VpcId)).toBe(true);
89
+ // A real AttrRef, not a `{__attrRef}` envelope — several chant call sites
90
+ // (and any policy doing the same) test `instanceof`, not duck typing.
91
+ expect(decodedSubnet.props.VpcId).toBeInstanceOf(AttrRef);
92
+ expect((decodedSubnet.props.VpcId as AttrRef).getLogicalName()).toBe("Vpc");
93
+ // The WeakRef points at the DECODED parent — self-consistent inside the
94
+ // child, which is all a check over `ctx.entities` can observe.
95
+ expect((decodedSubnet.props.VpcId as AttrRef).parent.deref()).toBe(decoded.entities.get("Vpc"));
96
+ });
97
+
98
+ test("a decoded entity's own enumerable keys match the original's", () => {
99
+ // `createResource` defines lexicon/entityType/kind/props/attributes
100
+ // non-enumerable and the per-attribute AttrRefs enumerable; the decoder
101
+ // reproduces exactly that split. So `Object.keys(entity)` — which a policy
102
+ // walking an entity generically would use — is the same on both sides.
103
+ const Bucket = createResource("Test::Bucket", "test", { arn: "Arn", name: "Name" });
104
+ const bucket = new Bucket({ Versioning: true });
105
+ const entities = new Map<string, Declarable>([["Bucket", bucket as unknown as Declarable]]);
106
+ resolveAttrRefs(entities);
107
+
108
+ const decoded = overTheWire(emptyResult({ entities }));
109
+
110
+ expect(Object.keys(decoded.entities.get("Bucket") as object)).toEqual(Object.keys(bucket as object));
111
+ expect((decoded.entities.get("Bucket") as unknown as { props: unknown }).props).toEqual({ Versioning: true });
112
+ expect(DECLARABLE_MARKER in (decoded.entities.get("Bucket") as object)).toBe(true);
113
+ });
114
+
115
+ test("encoding is idempotent over an already-decoded entity — the run-fallback subset pays nothing extra", () => {
116
+ // Under `--sandbox` the parent's merged entities ALREADY include decoded
117
+ // ones for every run-fallback file (`./run.ts`). If a second round trip
118
+ // narrowed them further, this child would be doing real damage to part of
119
+ // the set. It does not: encode∘decode∘encode === encode.
120
+ const Queue = createResource("Test::Queue", "test", { url: "Url" });
121
+ const Fn = createResource("Test::Fn", "test", {});
122
+ const queue = new Queue({ Fifo: true });
123
+ const fn = new Fn({ QueueUrl: (queue as unknown as Record<string, AttrRef>).url });
124
+ const entities = new Map<string, Declarable>([
125
+ ["Queue", queue as unknown as Declarable],
126
+ ["Fn", fn as unknown as Declarable],
127
+ ]);
128
+ resolveAttrRefs(entities);
129
+
130
+ const once = encodePolicyBuildResult(emptyResult({ entities }));
131
+ const twice = encodePolicyBuildResult(emptyResult({ entities: decodePolicyBuildResult(once).entities }));
132
+
133
+ expect(twice.entities).toEqual(once.entities);
134
+ });
135
+
136
+ test("NARROWER: an intrinsic decodes to a toJSON()-bearing wrapper, not to its own class", () => {
137
+ class Sub implements Intrinsic {
138
+ readonly [INTRINSIC_MARKER] = true as const;
139
+ constructor(readonly template: string) {}
140
+ toJSON(): unknown {
141
+ return { "Fn::Sub": this.template };
142
+ }
143
+ }
144
+ const Fn = createResource("Test::Fn", "test", {});
145
+ const fn = new Fn({ Name: new Sub("${AWS::StackName}-fn") });
146
+ const entities = new Map<string, Declarable>([["Fn", fn as unknown as Declarable]]);
147
+ resolveAttrRefs(entities);
148
+
149
+ const decoded = overTheWire(emptyResult({ entities }));
150
+ const name = (decoded.entities.get("Fn") as unknown as { props: { Name: Intrinsic & { template?: string } } }).props.Name;
151
+
152
+ // What still works: the marker, and the serialized form every serializer
153
+ // and every output-reading check goes through.
154
+ expect(INTRINSIC_MARKER in (name as object)).toBe(true);
155
+ expect(name.toJSON()).toEqual({ "Fn::Sub": "${AWS::StackName}-fn" });
156
+ // What does NOT: the class, and its own fields. A policy that reaches into
157
+ // an intrinsic's internals rather than its `toJSON()` sees nothing under
158
+ // `--sandbox`. Documented in docs/.../architecture/sandbox.mdx.
159
+ expect(name).not.toBeInstanceOf(Sub);
160
+ expect(name.template).toBeUndefined();
161
+ });
162
+
163
+ test("NARROWER: errors cross as plain objects, not Error instances", () => {
164
+ const result = emptyResult({ errors: [new DiscoveryError("/p/a.ts", "boom", "import")] });
165
+
166
+ const decoded = overTheWire(result);
167
+
168
+ expect(decoded.errors).toEqual([
169
+ { name: "DiscoveryError", file: "/p/a.ts", message: "boom", type: "import" },
170
+ ]);
171
+ expect(decoded.errors[0]).not.toBeInstanceOf(Error);
172
+ // Never observable in practice: `chant build` runs policies only when the
173
+ // build produced no errors at all, so this array is always empty there.
174
+ });
175
+
176
+ test("REFUSED, not dropped: a nestedStack() child project has no wire form", () => {
177
+ const child = { [DECLARABLE_MARKER]: true, [CHILD_PROJECT_MARKER]: true, lexicon: "test", entityType: "Test::Child" };
178
+ const entities = new Map<string, Declarable>([["Child", child as unknown as Declarable]]);
179
+
180
+ expect(() => encodePolicyBuildResult(emptyResult({ entities }))).toThrow(/child project \(nestedStack\(\)\)/);
181
+ });
182
+
183
+ test("REFUSED, not dropped: a serializer output that is not data is named by key path", () => {
184
+ const outputs = new Map<string, string | { primary: string }>([
185
+ ["weird", { primary: "ok", extra: () => 1 } as unknown as { primary: string }],
186
+ ]);
187
+
188
+ // `encodeOutput` copies only the declared fields, so a stray function on a
189
+ // SerializerResult never reaches the wire in the first place. The scan is
190
+ // the backstop for the fields that ARE carried.
191
+ const encoded = encodePolicyBuildResult(emptyResult({ outputs }));
192
+ expect(encoded.outputs).toEqual([["weird", { primary: "ok" }]]);
193
+
194
+ expect(() =>
195
+ encodePolicyBuildResult(emptyResult({ manifest: { generatedAt: new Date(0) } })),
196
+ ).toThrow(/buildResult\.manifest\.generatedAt: a Date/);
197
+ });
198
+
199
+ test("the undeclared-but-carried fields (dependencies, manifest, foldDecisions, buildParams) cross too", () => {
200
+ const result = emptyResult({
201
+ dependencies: new Map([["Subnet", new Set(["Vpc"])]]),
202
+ manifest: { lexicons: ["test"], outputs: {}, deployOrder: ["test"] },
203
+ foldDecisions: [{ file: "/p/a.ts", mode: "fold", resourceCount: 1 }],
204
+ buildParams: [{ name: "tier", value: "prod", source: "cli" }],
205
+ });
206
+
207
+ const decoded = overTheWire(result) as unknown as {
208
+ dependencies: Map<string, Set<string>>;
209
+ manifest: unknown;
210
+ foldDecisions: unknown[];
211
+ buildParams: unknown[];
212
+ };
213
+
214
+ expect(decoded.dependencies.get("Subnet")).toEqual(new Set(["Vpc"]));
215
+ expect(decoded.manifest).toEqual({ lexicons: ["test"], outputs: {}, deployOrder: ["test"] });
216
+ expect(decoded.foldDecisions).toEqual([{ file: "/p/a.ts", mode: "fold", resourceCount: 1 }]);
217
+ expect(decoded.buildParams).toEqual([{ name: "tier", value: "prod", source: "cli" }]);
218
+ });
219
+
220
+ test("a check run over the decoded result produces what it produces over the original", () => {
221
+ const Ingress = createResource("Test::Ingress", "test", {});
222
+ const entities = new Map<string, Declarable>([
223
+ ["Ing", new Ingress({ tls: [] }) as unknown as Declarable],
224
+ ]);
225
+ resolveAttrRefs(entities);
226
+ const result = emptyResult({ entities, outputs: new Map([["test", "kind: Ingress\n"]]) });
227
+
228
+ const check: PostSynthCheck = {
229
+ id: "T",
230
+ description: "reads both surfaces",
231
+ check(ctx) {
232
+ const out: Array<{ checkId: string; severity: "error"; message: string; entity?: string }> = [];
233
+ for (const [name, entity] of ctx.entities) {
234
+ const props = (entity as unknown as { props?: { tls?: unknown[] } }).props;
235
+ if (Array.isArray(props?.tls) && props.tls.length === 0) {
236
+ out.push({ checkId: "T", severity: "error", message: `${name} has no TLS in ${ctx.env}`, entity: name });
237
+ }
238
+ }
239
+ for (const [, o] of ctx.outputs) {
240
+ if (typeof o === "string" && o.includes("Ingress")) {
241
+ out.push({ checkId: "T", severity: "error", message: "output mentions Ingress" });
242
+ }
243
+ }
244
+ return out;
245
+ },
246
+ };
247
+
248
+ const inProcess = runPostSynthChecks([check], result as unknown as Parameters<typeof runPostSynthChecks>[1], "prod");
249
+ const sandboxed = runPostSynthChecks([check], overTheWire(result), "prod");
250
+
251
+ expect(sandboxed).toEqual(inProcess);
252
+ });
253
+ });
254
+
255
+ describe("what a policy is allowed to return (chant #1131)", () => {
256
+ const P = "/p/policies/org.ts";
257
+
258
+ test("plain diagnostics pass", () => {
259
+ expect(
260
+ scanPolicyDiagnostics(
261
+ [
262
+ { checkId: "A", severity: "error", message: "m", entity: "E", lexicon: "k8s" },
263
+ { checkId: "B", severity: "info", message: "n" },
264
+ ],
265
+ P,
266
+ ),
267
+ ).toEqual([]);
268
+ expect(scanPolicyDiagnostics([], P)).toEqual([]);
269
+ });
270
+
271
+ test("a function on a diagnostic is named, with the policy that produced it", () => {
272
+ const offenders = scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", fix: () => 1 }], P);
273
+
274
+ expect(offenders).toEqual([{ policy: P, path: "[0].fix", found: "a function" }]);
275
+ });
276
+
277
+ test("a Date, a class instance and a circular reference are each named", () => {
278
+ expect(scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", at: new Date(0) }], P)[0]).toMatchObject({
279
+ path: "[0].at",
280
+ found: "a Date",
281
+ });
282
+ expect(scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", err: new Error("x") }], P)[0]).toMatchObject({
283
+ found: "an Error",
284
+ });
285
+ const circular: Record<string, unknown> = { checkId: "A", severity: "error", message: "m" };
286
+ circular.self = circular;
287
+ expect(scanPolicyDiagnostics([circular], P)[0]).toMatchObject({ found: "a circular reference" });
288
+ });
289
+
290
+ test("a value that is data but is not a diagnostic is named too", () => {
291
+ expect(scanPolicyDiagnostics([{ severity: "error", message: "m" }], P)).toEqual([
292
+ { policy: P, path: "[0].checkId", found: "not a non-empty string" },
293
+ ]);
294
+ expect(scanPolicyDiagnostics([{ checkId: "A", severity: "fatal", message: "m" }], P)).toEqual([
295
+ { policy: P, path: "[0].severity", found: `not one of "error", "warning", "info"` },
296
+ ]);
297
+ expect(scanPolicyDiagnostics(["just a string"], P)).toEqual([
298
+ { policy: P, path: "[0]", found: "a string" },
299
+ ]);
300
+ });
301
+
302
+ test("a check that does not return an array at all is named", () => {
303
+ expect(scanPolicyDiagnostics(undefined, P)).toEqual([
304
+ { policy: P, path: "<return value>", found: "undefined" },
305
+ ]);
306
+ expect(scanPolicyDiagnostics({ checkId: "A" }, P)).toEqual([
307
+ { policy: P, path: "<return value>", found: "a object" },
308
+ ]);
309
+ });
310
+ });