@intentius/chant 0.50.0 → 0.52.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/cli/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +40 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +22 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +109 -0
- package/src/cli/handlers/lifecycle.ts +32 -8
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +240 -0
- package/src/cli/handlers/run.ts +76 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +11 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +165 -11
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +35 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
package/src/lifecycle/replay.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { readEnvironmentSnapshots } from "./git";
|
|
18
18
|
import { unqualifiedKey } from "./identity";
|
|
19
19
|
import type { LiveObservation } from "../graph-ir";
|
|
20
|
-
import type { LifecycleSnapshot } from "./types";
|
|
20
|
+
import type { LifecycleSnapshot, ObservationDepth } from "./types";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Rebuild observations from a recorded snapshot (#1266).
|
|
@@ -52,7 +52,10 @@ export async function replaySnapshots(
|
|
|
52
52
|
environment: string,
|
|
53
53
|
ref: string,
|
|
54
54
|
scopedStacks: Set<string>,
|
|
55
|
-
): Promise<
|
|
55
|
+
): Promise<
|
|
56
|
+
| { observations: LiveObservation[]; commit: string; timestamp: string; depth: ObservationDepth }
|
|
57
|
+
| { error: string; hint?: string }
|
|
58
|
+
> {
|
|
56
59
|
if (ref !== "latest" && ref !== "true") {
|
|
57
60
|
return {
|
|
58
61
|
error: `chant search --at only accepts "latest" for now, got "${ref}"`,
|
|
@@ -69,6 +72,10 @@ export async function replaySnapshots(
|
|
|
69
72
|
const observations: LiveObservation[] = [];
|
|
70
73
|
let commit = "";
|
|
71
74
|
let timestamp = "";
|
|
75
|
+
// "deep" when ANY recorded lexicon read that deep — a consumer comparing
|
|
76
|
+
// against this replay (#1268) needs to know a richer record exists, even
|
|
77
|
+
// though a replay itself only ever turns `resources` back into identity.
|
|
78
|
+
let depth: ObservationDepth = "identity";
|
|
72
79
|
// Ambient and dependency resources are keyed by physical id and are
|
|
73
80
|
// account-level: the default security group three stacks each recorded is one
|
|
74
81
|
// group, not three. Managed resources are stack-qualified below and cannot
|
|
@@ -187,6 +194,7 @@ export async function replaySnapshots(
|
|
|
187
194
|
if (!timestamp || (snapshot.timestamp && snapshot.timestamp < timestamp)) {
|
|
188
195
|
timestamp = snapshot.timestamp ?? timestamp;
|
|
189
196
|
}
|
|
197
|
+
if (snapshot.depth === "deep") depth = "deep";
|
|
190
198
|
}
|
|
191
|
-
return { observations, commit, timestamp };
|
|
199
|
+
return { observations, commit, timestamp, depth };
|
|
192
200
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2002 — the `policyGate` Op step must gate on the build `chant build`
|
|
3
|
+
* produces, not on a different one.
|
|
4
|
+
*
|
|
5
|
+
* `evaluateProjectPolicies` used to assemble `build()`'s options itself, two of
|
|
6
|
+
* them against `buildCommand`'s nine. It built with fold off (the path #1134
|
|
7
|
+
* retired as the default), without the project's config (so serializers lost
|
|
8
|
+
* their lexicon-scoped dialect settings) and without `buildRoots` (so
|
|
9
|
+
* config-declared roots contributed nothing) — a gate could pass a build `chant
|
|
10
|
+
* build` fails. Both callers now assemble through
|
|
11
|
+
* `../cli/build-options.ts`'s `resolveProjectBuildOptions`, and the first test
|
|
12
|
+
* below fails if either ever grows an option the other does not get.
|
|
13
|
+
*
|
|
14
|
+
* `build()` is wrapped rather than replaced: every assertion here is about a
|
|
15
|
+
* real build of a real fixture, and the wrapper only records what each caller
|
|
16
|
+
* asked for.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import type { BuildOptions, BuildResult } from "../build";
|
|
23
|
+
|
|
24
|
+
const recorder = vi.hoisted(() => ({
|
|
25
|
+
options: [] as (BuildOptions | undefined)[],
|
|
26
|
+
results: [] as BuildResult[],
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../build", async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal<typeof import("../build")>();
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
build: async (...args: Parameters<typeof actual.build>) => {
|
|
34
|
+
recorder.options.push(args[3]);
|
|
35
|
+
const result = await actual.build(...args);
|
|
36
|
+
recorder.results.push(result);
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const { evaluateProjectPolicies } = await import("./policy");
|
|
43
|
+
const { buildCommand } = await import("../cli/commands/build");
|
|
44
|
+
const { resolveProjectLexicons, loadPlugins } = await import("../cli/plugins");
|
|
45
|
+
|
|
46
|
+
/** Run `chant build` over a fixture the way `cli/main.ts` drives it. */
|
|
47
|
+
async function runChantBuild(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
48
|
+
const plugins = await loadPlugins(await resolveProjectLexicons(dir));
|
|
49
|
+
recorder.options.length = 0;
|
|
50
|
+
recorder.results.length = 0;
|
|
51
|
+
const outcome = await buildCommand({
|
|
52
|
+
path: dir,
|
|
53
|
+
format: "json",
|
|
54
|
+
output: join(dir, "out", "build.json"),
|
|
55
|
+
serializers: plugins.map((p) => p.serializer),
|
|
56
|
+
plugins,
|
|
57
|
+
});
|
|
58
|
+
expect(outcome.errors).toEqual([]);
|
|
59
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Run the `policyGate` step's entry point over the same fixture. */
|
|
63
|
+
async function runPolicyGate(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
64
|
+
recorder.options.length = 0;
|
|
65
|
+
recorder.results.length = 0;
|
|
66
|
+
await evaluateProjectPolicies({ path: dir });
|
|
67
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build-root contributors are freshly bound closures — compare their count. */
|
|
71
|
+
function comparable(options: BuildOptions): Record<string, unknown> {
|
|
72
|
+
return { ...options, buildRoots: options.buildRoots?.length ?? 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TRIVIAL_POLICY =
|
|
76
|
+
`export const check = {\n` +
|
|
77
|
+
` id: "ORG-PARITY",\n` +
|
|
78
|
+
` description: "records nothing; the build is what is under test",\n` +
|
|
79
|
+
` check: () => [],\n` +
|
|
80
|
+
`};\n`;
|
|
81
|
+
|
|
82
|
+
describe("policyGate builds the same project chant build does (chant #2002)", () => {
|
|
83
|
+
let testDir: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
testDir = join(tmpdir(), `chant-policy-parity-${Date.now()}-${Math.random()}`);
|
|
87
|
+
await mkdir(join(testDir, "policies"), { recursive: true });
|
|
88
|
+
await writeFile(join(testDir, "policies", "org.ts"), TRIVIAL_POLICY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(testDir, { recursive: true, force: true });
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A forgejo project: `forgejo.runnerLabels` is read by the serializer off
|
|
98
|
+
* `SerializeContext.config` (lexicons/forgejo/src/serializer.ts), so a build
|
|
99
|
+
* that drops `config` emits the default label instead of the project's.
|
|
100
|
+
*/
|
|
101
|
+
async function writeForgejoProject(): Promise<void> {
|
|
102
|
+
await writeFile(
|
|
103
|
+
join(testDir, "chant.config.ts"),
|
|
104
|
+
`export default {\n` +
|
|
105
|
+
` lexicons: ["forgejo"],\n` +
|
|
106
|
+
` forgejo: { runnerLabels: { "ubuntu-latest": "self-hosted-arm64" } },\n` +
|
|
107
|
+
` lint: { policies: ["policies/org.ts"] },\n` +
|
|
108
|
+
`};\n`,
|
|
109
|
+
);
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(testDir, "ci.ts"),
|
|
112
|
+
`import { Workflow, Job, Step } from "@intentius/chant-lexicon-github";\n` +
|
|
113
|
+
`export const workflow = new Workflow({ name: "CI", on: { push: {} } });\n` +
|
|
114
|
+
`export const buildJob = new Job({\n` +
|
|
115
|
+
` "runs-on": "ubuntu-latest",\n` +
|
|
116
|
+
` steps: [new Step({ name: "Build", run: "npm run build" })],\n` +
|
|
117
|
+
`});\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("both callers hand build() the identical option set", async () => {
|
|
122
|
+
await writeForgejoProject();
|
|
123
|
+
|
|
124
|
+
const cli = await runChantBuild(testDir);
|
|
125
|
+
const gate = await runPolicyGate(testDir);
|
|
126
|
+
|
|
127
|
+
// The drift this issue is about is an option one caller passes and the
|
|
128
|
+
// other does not, so the key sets are asserted on their own — a new option
|
|
129
|
+
// added at one call site fails here even if its value happens to match.
|
|
130
|
+
expect(Object.keys(gate.options).sort()).toEqual(Object.keys(cli.options).sort());
|
|
131
|
+
expect(comparable(gate.options)).toEqual(comparable(cli.options));
|
|
132
|
+
expect(gate.options.fold).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the project's lexicon-scoped dialect settings reach the gate's serializers", async () => {
|
|
136
|
+
await writeForgejoProject();
|
|
137
|
+
|
|
138
|
+
const cli = await runChantBuild(testDir);
|
|
139
|
+
const gate = await runPolicyGate(testDir);
|
|
140
|
+
|
|
141
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
142
|
+
const emitted = JSON.stringify([...gate.result.outputs.values()]);
|
|
143
|
+
// The project's own label, not `DEFAULT_RUNNER_LABELS`' "docker" — which is
|
|
144
|
+
// what the gate emitted while it built without `config`.
|
|
145
|
+
expect(emitted).toContain("self-hosted-arm64");
|
|
146
|
+
expect(emitted).not.toContain("docker");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("entities from a config-declared build root are in the set the gate sees", async () => {
|
|
150
|
+
await writeFile(
|
|
151
|
+
join(testDir, "chant.config.ts"),
|
|
152
|
+
`export default { lexicons: ["cedar"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
153
|
+
);
|
|
154
|
+
// cedar's buildRoots hook contributes the project's schema as an entity
|
|
155
|
+
// (lexicons/cedar/src/schema-artifact.ts). Without `buildRoots` the gate
|
|
156
|
+
// never saw it, so a policy over it could not fail.
|
|
157
|
+
await writeFile(join(testDir, "schema.cedarschema"), `entity User;\n`);
|
|
158
|
+
|
|
159
|
+
const cli = await runChantBuild(testDir);
|
|
160
|
+
const gate = await runPolicyGate(testDir);
|
|
161
|
+
|
|
162
|
+
expect([...gate.result.entities.keys()].sort()).toEqual([...cli.result.entities.keys()].sort());
|
|
163
|
+
expect([...gate.result.entities.keys()]).toContain("cedarSchema");
|
|
164
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a foldable source module does not execute during the gate's build", async () => {
|
|
168
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
169
|
+
await writeFile(
|
|
170
|
+
join(testDir, "chant.config.ts"),
|
|
171
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
172
|
+
);
|
|
173
|
+
await writeFile(
|
|
174
|
+
join(testDir, "ns.ts"),
|
|
175
|
+
// Module-level: runs if and only if the file is imported. Fold reads the
|
|
176
|
+
// constructor call statically and never imports the module.
|
|
177
|
+
`process.env.CHANT_2002_SOURCE_RAN = "1";\n` +
|
|
178
|
+
`import { Namespace } from "@intentius/chant-lexicon-k8s";\n` +
|
|
179
|
+
`export const ns = new Namespace({ metadata: { name: "demo" } });\n`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const gate = await runPolicyGate(testDir);
|
|
183
|
+
|
|
184
|
+
expect(gate.options.fold).toBe(true);
|
|
185
|
+
expect(gate.result.foldDecisions.find((d) => d.file.endsWith("ns.ts"))?.mode).toBe("fold");
|
|
186
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("build.fold: false is honoured too — the config decides, not the gate", async () => {
|
|
190
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(testDir, "chant.config.ts"),
|
|
193
|
+
`export default { lexicons: ["k8s"], build: { fold: false }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
194
|
+
);
|
|
195
|
+
// No lexicon import: with fold off every discovered file is imported, and
|
|
196
|
+
// this fixture lives outside the repo where a bare `@intentius/*`
|
|
197
|
+
// specifier does not resolve. The marker is all this test needs.
|
|
198
|
+
await writeFile(join(testDir, "marker.ts"), `process.env.CHANT_2002_SOURCE_RAN = "1";\nexport const value = 1;\n`);
|
|
199
|
+
|
|
200
|
+
const gate = await runPolicyGate(testDir);
|
|
201
|
+
|
|
202
|
+
expect(gate.options.fold).toBe(false);
|
|
203
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBe("1");
|
|
204
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("build.sandbox: true is reported as a divergence rather than silently ignored", async () => {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(testDir, "chant.config.ts"),
|
|
210
|
+
`export default { lexicons: ["k8s"], build: { sandbox: true }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
211
|
+
);
|
|
212
|
+
const stderr: string[] = [];
|
|
213
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderr.push(s); });
|
|
214
|
+
|
|
215
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
216
|
+
|
|
217
|
+
expect(evaluation.warnings).toHaveLength(1);
|
|
218
|
+
expect(evaluation.warnings[0]).toContain("build.sandbox is enabled");
|
|
219
|
+
expect(stderr.join("\n")).toContain("build.sandbox is enabled");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("no sandbox opt-in, no warning", async () => {
|
|
223
|
+
await writeFile(
|
|
224
|
+
join(testDir, "chant.config.ts"),
|
|
225
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
229
|
+
|
|
230
|
+
expect(evaluation.warnings).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/lint/policy.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { resolve, dirname } from "node:path";
|
|
|
7
7
|
import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../config";
|
|
8
8
|
import { resolveBuildParams } from "../build-params";
|
|
9
9
|
import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
10
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../cli/build-options";
|
|
11
|
+
import { formatWarning } from "../cli/format";
|
|
10
12
|
import { build } from "../build";
|
|
11
13
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
12
14
|
import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
|
|
@@ -63,6 +65,15 @@ export interface PolicyEvaluation {
|
|
|
63
65
|
* of the finding just vanishing.
|
|
64
66
|
*/
|
|
65
67
|
suppressed: PostSynthDiagnostic[];
|
|
68
|
+
/**
|
|
69
|
+
* chant #2002 — divergences between this evaluation and what `chant build`
|
|
70
|
+
* would do, named rather than left silent. Today that is a project whose
|
|
71
|
+
* resolved `build.sandbox` is `true`: the gate builds unsandboxed, because
|
|
72
|
+
* running it inside the boundary needs the per-execution arming decision
|
|
73
|
+
* tracked on #1157. Also written to stderr — the gate's caller is an
|
|
74
|
+
* activity wrapper that surfaces output, not a structured result.
|
|
75
|
+
*/
|
|
76
|
+
warnings: string[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/**
|
|
@@ -70,6 +81,13 @@ export interface PolicyEvaluation {
|
|
|
70
81
|
* standalone — used by the `policyGate` Op step to gate an apply on the same
|
|
71
82
|
* organizational policy `chant build` enforces. Loads the project's lexicons,
|
|
72
83
|
* builds, then runs the policy pack with `env` (explicit, else `ownership.env`).
|
|
84
|
+
*
|
|
85
|
+
* chant #2002 — "the same organizational policy `chant build` enforces" only
|
|
86
|
+
* holds if it is the same build. The build options are assembled by
|
|
87
|
+
* `../cli/build-options.ts`'s `resolveProjectBuildOptions`, the single function
|
|
88
|
+
* `chant build` also calls, rather than by a second list here; a build option
|
|
89
|
+
* added for one caller and not the other is what let a gate pass a build
|
|
90
|
+
* `chant build` fails.
|
|
73
91
|
*/
|
|
74
92
|
export async function evaluateProjectPolicies(opts: {
|
|
75
93
|
path: string;
|
|
@@ -96,10 +114,37 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
96
114
|
}
|
|
97
115
|
const env = opts.env ?? resolveOwnershipEnv(config, params.provenance);
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
// chant #2002 — the build options come from the shared assembler
|
|
118
|
+
// (`../cli/build-options.ts`), the same one `chant build` uses, so the gate
|
|
119
|
+
// decides on the project `chant build` produces: folded by default (#1134),
|
|
120
|
+
// with the project's config reaching the serializers, and with
|
|
121
|
+
// config-declared build roots contributing their entities. Assembling a
|
|
122
|
+
// second, shorter list here is what let the two drift apart.
|
|
123
|
+
const modes = resolveBuildModes(config);
|
|
124
|
+
const warnings: string[] = [];
|
|
125
|
+
if (modes.sandbox) {
|
|
126
|
+
// Resolved, reported, and not yet honoured. Building inside the boundary
|
|
127
|
+
// needs per-execution arming (#1157) — until that lands, saying so is the
|
|
128
|
+
// difference between a known gap and a silent one.
|
|
129
|
+
warnings.push(
|
|
130
|
+
`build.sandbox is enabled for this project, but the policy gate builds it in this process — the project's source files and its lint.policies modules execute here, unsandboxed (chant #1157). Run \`chant build --sandbox\` for a sandboxed build of the same project.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
for (const warning of warnings) console.error(formatWarning({ message: warning }));
|
|
134
|
+
|
|
135
|
+
const result = await build(
|
|
136
|
+
buildPath,
|
|
137
|
+
serializers,
|
|
138
|
+
undefined,
|
|
139
|
+
resolveProjectBuildOptions({
|
|
140
|
+
config,
|
|
141
|
+
configDir,
|
|
142
|
+
plugins,
|
|
143
|
+
modes: { fold: modes.fold, sandbox: false },
|
|
144
|
+
ownership: resolveOwnershipMarker(config, params.provenance),
|
|
145
|
+
buildParams: params.provenance,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
103
148
|
if (result.errors.length > 0) {
|
|
104
149
|
throw new Error("Build failed — cannot evaluate policy on a broken build");
|
|
105
150
|
}
|
|
@@ -108,7 +153,7 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
108
153
|
? await loadPolicyChecks(config.lint.policies, configDir)
|
|
109
154
|
: [];
|
|
110
155
|
if (checks.length === 0) {
|
|
111
|
-
return { diagnostics: [], violations: [], env, suppressed: [] };
|
|
156
|
+
return { diagnostics: [], violations: [], env, suppressed: [], warnings };
|
|
112
157
|
}
|
|
113
158
|
|
|
114
159
|
const raw = runPostSynthChecks(checks, result, env);
|
|
@@ -118,5 +163,5 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
118
163
|
// fails on it either, and vice versa for a check turned UP to "error".
|
|
119
164
|
const { diagnostics, suppressed } = applyConfiguredSeverity(raw, config.lint?.rules);
|
|
120
165
|
const violations = diagnostics.filter((d) => d.severity === "error");
|
|
121
|
-
return { diagnostics, violations, env, suppressed };
|
|
166
|
+
return { diagnostics, violations, env, suppressed, warnings };
|
|
122
167
|
}
|
package/src/op/local-executor.ts
CHANGED
|
@@ -21,7 +21,13 @@ import { isStepOutputRef } from "./step-output-ref";
|
|
|
21
21
|
export interface StepRecord {
|
|
22
22
|
phase: string;
|
|
23
23
|
fn: string;
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Present for a local-executor record. Absent for one reconstructed from
|
|
26
|
+
* Temporal workflow history (op-progress.ts) — an activity's scheduled
|
|
27
|
+
* input isn't decoded there, so the field is simply omitted rather than
|
|
28
|
+
* populated with a guess.
|
|
29
|
+
*/
|
|
30
|
+
args?: Record<string, unknown>;
|
|
25
31
|
status: "ok" | "fail" | "skipped";
|
|
26
32
|
durationMs: number;
|
|
27
33
|
outcome?: { name: string; value: unknown };
|
|
@@ -130,6 +136,34 @@ export function findGate(config: OpConfig): GateStep | undefined {
|
|
|
130
136
|
return undefined;
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
|
|
141
|
+
* including steps nested inside effect steps), if any.
|
|
142
|
+
*
|
|
143
|
+
* chant #2003 — `--sandbox` is a GLOBAL flag (`../cli/registry.ts`), and
|
|
144
|
+
* `../cli/main.ts` arms the process-wide policy latch off it for every command,
|
|
145
|
+
* `chant run` included. A `policyGate` step then reaches `loadPolicyChecks`,
|
|
146
|
+
* which refuses while armed with a message written for a chant maintainer —
|
|
147
|
+
* shown to a user who passed a documented flag. The combination cannot be
|
|
148
|
+
* honoured until the gate can build and load policies inside the boundary
|
|
149
|
+
* (#1157), so both `chant run` paths pre-flight it with this and refuse before
|
|
150
|
+
* any phase executes, the same shape as {@link findGate}'s pre-flight.
|
|
151
|
+
*/
|
|
152
|
+
export function findPolicyGateStep(config: OpConfig): ActivityStep | undefined {
|
|
153
|
+
const all = [...config.phases, ...(config.onFailure ?? [])];
|
|
154
|
+
const isPolicyGate = (s: StepDefinition): s is ActivityStep => isActivity(s) && s.fn === "policyGate";
|
|
155
|
+
for (const phase of all) {
|
|
156
|
+
for (const step of phase.steps) {
|
|
157
|
+
if (isPolicyGate(step)) return step;
|
|
158
|
+
if (isEffect(step)) {
|
|
159
|
+
const nested = step.steps.find(isPolicyGate);
|
|
160
|
+
if (nested) return nested;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
134
168
|
|
|
135
169
|
/**
|
package/src/op/local-output.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
33
33
|
write(`[phase] ${currentPhase}`);
|
|
34
34
|
}
|
|
35
35
|
const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
|
|
36
|
-
const call = `${record.fn}(${formatArgs(record.args)})`;
|
|
36
|
+
const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
|
|
37
37
|
if (record.status === "skipped") {
|
|
38
38
|
write(` ${mark} ${call} skipped`);
|
|
39
39
|
} else {
|
package/src/testing.test.ts
CHANGED
|
@@ -3,8 +3,14 @@ import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { createMockPlugin } from "@intentius/chant-test-utils";
|
|
7
|
-
import {
|
|
6
|
+
import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
|
|
7
|
+
import {
|
|
8
|
+
deployStack,
|
|
9
|
+
testEnvName,
|
|
10
|
+
TeardownIncompleteError,
|
|
11
|
+
LiveAssertionError,
|
|
12
|
+
UnobservedAssertionError,
|
|
13
|
+
} from "./testing";
|
|
8
14
|
import type { ActivityFn } from "./op/activity-registry";
|
|
9
15
|
import type { TeardownExecution } from "./lexicon";
|
|
10
16
|
|
|
@@ -258,4 +264,85 @@ describe("deployStack", () => {
|
|
|
258
264
|
await expect(deployStack(harness(nativeApply, { applyTargets: {} }))).rejects.toThrow(/nothing to deploy/);
|
|
259
265
|
expect(nativeApply).not.toHaveBeenCalled();
|
|
260
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("assertLive", () => {
|
|
269
|
+
const nativeApply = vi.fn(async (_args: Record<string, unknown>) => ({ applied: 1, pruned: 0, notAttempted: 0 }));
|
|
270
|
+
|
|
271
|
+
test("resolves the metadata for an entity observed present and marker-matched", async () => {
|
|
272
|
+
await writeProject(dir);
|
|
273
|
+
const plugin = createMockPlugin({
|
|
274
|
+
name: "mock",
|
|
275
|
+
describeResources: (opts) =>
|
|
276
|
+
Promise.resolve({
|
|
277
|
+
bucket: { type: "Mock::Bucket", status: "READY", marker: { stack: "harness-fixture", env: opts.environment } },
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
281
|
+
|
|
282
|
+
const meta = await stack.assertLive("bucket");
|
|
283
|
+
expect(meta.status).toBe("READY");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("checks status when given", async () => {
|
|
287
|
+
await writeProject(dir);
|
|
288
|
+
const plugin = createMockPlugin({
|
|
289
|
+
name: "mock",
|
|
290
|
+
describeResources: (opts) =>
|
|
291
|
+
Promise.resolve({
|
|
292
|
+
bucket: { type: "Mock::Bucket", status: "READY", marker: { stack: "harness-fixture", env: opts.environment } },
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
296
|
+
|
|
297
|
+
await expect(stack.assertLive("bucket", { status: "READY" })).resolves.toBeDefined();
|
|
298
|
+
await expect(stack.assertLive("bucket", { status: "GONE" })).rejects.toThrow(LiveAssertionError);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("throws naming the entity when observed absent", async () => {
|
|
302
|
+
await writeProject(dir);
|
|
303
|
+
const plugin = createMockPlugin({ name: "mock", describeResources: staticObservation({}) });
|
|
304
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
305
|
+
|
|
306
|
+
await expect(stack.assertLive("bucket")).rejects.toThrow(/bucket.*observed absent/s);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("throws UnobservedAssertionError, not a plain failure, when NOT-OBSERVED", async () => {
|
|
310
|
+
await writeProject(dir);
|
|
311
|
+
const plugin = createMockPlugin({
|
|
312
|
+
name: "mock",
|
|
313
|
+
describeResources: staticObservation({}, { bucket: { reason: "no-binding", type: "Mock::Bucket" } }),
|
|
314
|
+
});
|
|
315
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
316
|
+
|
|
317
|
+
const err = await stack.assertLive("bucket").then(
|
|
318
|
+
() => undefined,
|
|
319
|
+
(e: unknown) => e,
|
|
320
|
+
);
|
|
321
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
322
|
+
expect(err).not.toBeInstanceOf(LiveAssertionError);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("throws when the observed resource carries another environment's marker — a same-named leftover cannot satisfy it", async () => {
|
|
326
|
+
await writeProject(dir);
|
|
327
|
+
const plugin = createMockPlugin({
|
|
328
|
+
name: "mock",
|
|
329
|
+
describeResources: staticObservation({
|
|
330
|
+
bucket: { type: "Mock::Bucket", status: "READY", marker: { stack: "harness-fixture", env: "test-someone-else-000000" } },
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
334
|
+
|
|
335
|
+
await expect(stack.assertLive("bucket")).rejects.toThrow(/not this deploy's/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("an unknown entity name throws before any read is attempted", async () => {
|
|
339
|
+
await writeProject(dir);
|
|
340
|
+
const describeResources = vi.fn(staticObservation({}));
|
|
341
|
+
const plugin = createMockPlugin({ name: "mock", describeResources });
|
|
342
|
+
const stack = await deployStack(harness(nativeApply, { plugins: [plugin] }));
|
|
343
|
+
|
|
344
|
+
await expect(stack.assertLive("nope")).rejects.toThrow(/no such entity/);
|
|
345
|
+
expect(describeResources).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
261
348
|
});
|
package/src/testing.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* in-process for exactly this suite's environment. Stateless by design:
|
|
16
16
|
* a crashed suite's environment is recovered by calling destroy again (or
|
|
17
17
|
* `chant lifecycle teardown <env> --yes`).
|
|
18
|
+
* - **assertLive** (#1857) — `describeResources()` against exactly one
|
|
19
|
+
* declared entity, turned into a pass or a thrown failure. Preserves
|
|
20
|
+
* #1089's tri-state rather than collapsing it: NOT-OBSERVED throws {@link
|
|
21
|
+
* UnobservedAssertionError}, never a silent pass and never an ordinary
|
|
22
|
+
* failure, because chant could not read the entity is not the same claim
|
|
23
|
+
* as chant read it and it is gone.
|
|
18
24
|
*
|
|
19
25
|
* ## Isolation
|
|
20
26
|
*
|
|
@@ -44,7 +50,7 @@ import { basename, join, resolve } from "node:path";
|
|
|
44
50
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
45
51
|
import { tmpdir } from "node:os";
|
|
46
52
|
import { build } from "./build";
|
|
47
|
-
import type
|
|
53
|
+
import { isResourceDeclarable, type Declarable } from "./declarable";
|
|
48
54
|
import type { SerializerResult } from "./serializer";
|
|
49
55
|
import {
|
|
50
56
|
loadChantConfigUpward,
|
|
@@ -55,12 +61,20 @@ import { resolveBuildParams, type BuildParamValue } from "./build-params";
|
|
|
55
61
|
import { ENV_VAR, unknownEnvError } from "./env";
|
|
56
62
|
import { applyLiveEndpoint } from "./live-endpoint";
|
|
57
63
|
import { loadPlugins, resolveProjectLexicons, collectBuildRootContributors } from "./cli/plugins";
|
|
58
|
-
import type { LexiconPlugin } from "./lexicon";
|
|
64
|
+
import type { LexiconPlugin, ResourceMetadata } from "./lexicon";
|
|
59
65
|
import type { OwnershipMarker } from "./ownership";
|
|
60
66
|
import { runOpLocally } from "./op/local-executor";
|
|
61
67
|
import { loadActivities, loadProfiles, type ActivityFn, type ActivityProfile } from "./op/activity-registry";
|
|
62
68
|
import type { OpConfig, ActivityStep } from "./op/types";
|
|
63
69
|
import { executeTeardown, type TeardownReport } from "./lifecycle/teardown";
|
|
70
|
+
import {
|
|
71
|
+
assertLiveEntity,
|
|
72
|
+
LiveAssertionError,
|
|
73
|
+
UnobservedAssertionError,
|
|
74
|
+
type AssertLiveOptions,
|
|
75
|
+
} from "./lifecycle/assert-live";
|
|
76
|
+
|
|
77
|
+
export { LiveAssertionError, UnobservedAssertionError, type AssertLiveOptions };
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
80
|
* Which `nativeApply` target deploys each lexicon's built output. Only these
|
|
@@ -118,6 +132,19 @@ export interface DeployedStack {
|
|
|
118
132
|
entities: Map<string, Declarable>;
|
|
119
133
|
/** The environment this deploy targeted — the teardown key. */
|
|
120
134
|
env: string;
|
|
135
|
+
/**
|
|
136
|
+
* Assert that a declared entity is live: observed present in this
|
|
137
|
+
* deploy's environment, not confirmed as another stack/env's resource,
|
|
138
|
+
* and — when `status` is given — reporting that status.
|
|
139
|
+
*
|
|
140
|
+
* Rides the observation contract (#1089): an entity `describeResources`
|
|
141
|
+
* could not cover throws {@link UnobservedAssertionError}, never a pass
|
|
142
|
+
* and never a plain failure — NOT-OBSERVED is not the same claim as
|
|
143
|
+
* absent. Observed-absent, a confirmed-foreign identity, or a status
|
|
144
|
+
* mismatch throws {@link LiveAssertionError}. Resolves to the entity's
|
|
145
|
+
* `ResourceMetadata` on success.
|
|
146
|
+
*/
|
|
147
|
+
assertLive(name: string, options?: AssertLiveOptions): Promise<ResourceMetadata>;
|
|
121
148
|
/**
|
|
122
149
|
* Tear down everything carrying this suite's marker `{ stack, env }`.
|
|
123
150
|
* Throws {@link TeardownIncompleteError} when any candidate failed to
|
|
@@ -321,6 +348,39 @@ export async function deployStack(options: DeployStackOptions): Promise<Deployed
|
|
|
321
348
|
applied.restore();
|
|
322
349
|
}
|
|
323
350
|
|
|
351
|
+
const assertLive = async (name: string, assertOptions: AssertLiveOptions = {}): Promise<ResourceMetadata> => {
|
|
352
|
+
const entity = result.entities.get(name);
|
|
353
|
+
if (!entity) {
|
|
354
|
+
throw new LiveAssertionError(
|
|
355
|
+
`assertLive("${name}"): no such entity — this deploy built ${[...result.entities.keys()].join(", ") || "nothing"}.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
const entityPlugin = plugins.find((p) => p.name === entity.lexicon);
|
|
359
|
+
if (!entityPlugin) {
|
|
360
|
+
throw new LiveAssertionError(`assertLive("${name}"): no loaded lexicon named "${entity.lexicon}".`);
|
|
361
|
+
}
|
|
362
|
+
const entityOutput = result.outputs.get(entity.lexicon);
|
|
363
|
+
const entityBuildOutput =
|
|
364
|
+
entityOutput === undefined ? "" : typeof entityOutput === "string" ? entityOutput : entityOutput.primary;
|
|
365
|
+
const props = isResourceDeclarable(entity) ? ((entity.props ?? {}) as Record<string, unknown>) : {};
|
|
366
|
+
|
|
367
|
+
const endpointForAssert = applyLiveEndpoint(config.environments, env, plugins);
|
|
368
|
+
try {
|
|
369
|
+
return await assertLiveEntity({
|
|
370
|
+
plugin: entityPlugin,
|
|
371
|
+
name,
|
|
372
|
+
entityType: entity.entityType,
|
|
373
|
+
props,
|
|
374
|
+
buildOutput: entityBuildOutput,
|
|
375
|
+
environment: env,
|
|
376
|
+
marker,
|
|
377
|
+
...assertOptions,
|
|
378
|
+
});
|
|
379
|
+
} finally {
|
|
380
|
+
endpointForAssert.restore();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
324
384
|
const destroy = async (): Promise<TeardownReport> => {
|
|
325
385
|
const endpointForTeardown = applyLiveEndpoint(config.environments, env, plugins);
|
|
326
386
|
let report: TeardownReport;
|
|
@@ -334,5 +394,5 @@ export async function deployStack(options: DeployStackOptions): Promise<Deployed
|
|
|
334
394
|
return report;
|
|
335
395
|
};
|
|
336
396
|
|
|
337
|
-
return { outputs: result.outputs, entities: result.entities, env, destroy };
|
|
397
|
+
return { outputs: result.outputs, entities: result.entities, env, assertLive, destroy };
|
|
338
398
|
}
|