@intentius/chant 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +6 -5
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config.d.ts +13 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +14 -9
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +16 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +29 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +18 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +77 -1
- package/src/build.ts +15 -2
- package/src/cli/commands/build.test.ts +16 -2
- package/src/cli/commands/build.ts +42 -13
- package/src/cli/main.test.ts +99 -17
- package/src/cli/main.ts +111 -13
- package/src/config.test.ts +28 -1
- package/src/config.ts +16 -12
- package/src/discovery/entity-wire-codec.ts +14 -9
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-wire.ts +21 -0
- package/src/discovery/sandbox/driver.ts +132 -0
- package/src/discovery/sandbox/fork.ts +39 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +36 -0
- package/src/lexicon-output.ts +12 -2
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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(
|