@intentius/chant 0.24.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 (72) 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/config-import.d.ts +33 -0
  5. package/dist/config-import.d.ts.map +1 -0
  6. package/dist/config-sandbox.d.ts +47 -0
  7. package/dist/config-sandbox.d.ts.map +1 -0
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/discovery/entity-wire-codec.d.ts +15 -10
  11. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  12. package/dist/discovery/graph.d.ts.map +1 -1
  13. package/dist/discovery/sandbox/config-run.d.ts +24 -0
  14. package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
  15. package/dist/discovery/sandbox/config-wire.d.ts +84 -0
  16. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
  17. package/dist/discovery/sandbox/driver.d.ts +49 -0
  18. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  19. package/dist/discovery/sandbox/fork.d.ts +70 -0
  20. package/dist/discovery/sandbox/fork.d.ts.map +1 -0
  21. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  22. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  23. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  24. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  25. package/dist/discovery/sandbox/run.d.ts +10 -20
  26. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  27. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  28. package/dist/lexicon-output.d.ts +62 -6
  29. package/dist/lexicon-output.d.ts.map +1 -1
  30. package/dist/lint/config.d.ts +6 -0
  31. package/dist/lint/config.d.ts.map +1 -1
  32. package/dist/lint/policy-import.d.ts +50 -0
  33. package/dist/lint/policy-import.d.ts.map +1 -0
  34. package/dist/lint/policy-sandbox.d.ts +89 -0
  35. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  36. package/dist/lint/policy.d.ts +12 -1
  37. package/dist/lint/policy.d.ts.map +1 -1
  38. package/dist/stack-output.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/build.test.ts +96 -1
  41. package/src/build.ts +36 -12
  42. package/src/cli/commands/build.ts +46 -5
  43. package/src/cli/main.test.ts +93 -17
  44. package/src/cli/main.ts +111 -11
  45. package/src/config-import.ts +43 -0
  46. package/src/config-sandbox.ts +138 -0
  47. package/src/config.ts +14 -5
  48. package/src/discovery/entity-wire-codec.ts +35 -21
  49. package/src/discovery/entity-wire.test.ts +26 -0
  50. package/src/discovery/graph.test.ts +40 -1
  51. package/src/discovery/graph.ts +8 -2
  52. package/src/discovery/sandbox/config-boundary.test.ts +239 -0
  53. package/src/discovery/sandbox/config-run.ts +130 -0
  54. package/src/discovery/sandbox/config-wire.test.ts +110 -0
  55. package/src/discovery/sandbox/config-wire.ts +195 -0
  56. package/src/discovery/sandbox/driver.ts +200 -0
  57. package/src/discovery/sandbox/fork.ts +148 -0
  58. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  59. package/src/discovery/sandbox/policy-run.ts +180 -0
  60. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  61. package/src/discovery/sandbox/policy-wire.ts +277 -0
  62. package/src/discovery/sandbox/run.ts +28 -85
  63. package/src/intrinsic-interpolation.test.ts +27 -1
  64. package/src/intrinsic-interpolation.ts +10 -2
  65. package/src/lexicon-output.test.ts +173 -1
  66. package/src/lexicon-output.ts +123 -14
  67. package/src/lint/config.ts +8 -4
  68. package/src/lint/policy-import.ts +70 -0
  69. package/src/lint/policy-sandbox.ts +123 -0
  70. package/src/lint/policy.ts +20 -2
  71. package/src/stack-output.test.ts +118 -0
  72. package/src/stack-output.ts +21 -5
@@ -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
+ });
@@ -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
+ }