@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.
Files changed (75) hide show
  1. package/dist/cli/build-options.d.ts +68 -0
  2. package/dist/cli/build-options.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/op-progress.d.ts +57 -0
  6. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  7. package/dist/cli/handlers/run-client.d.ts +21 -1
  8. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  10. package/dist/cli/handlers/run.d.ts +0 -21
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/search.d.ts +22 -0
  13. package/dist/cli/handlers/search.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  16. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  17. package/dist/cli/registry.d.ts +33 -1
  18. package/dist/cli/registry.d.ts.map +1 -1
  19. package/dist/components/run-progress.d.ts +7 -5
  20. package/dist/components/run-progress.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +41 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/assert-live.d.ts +77 -0
  24. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  25. package/dist/lifecycle/change-set.d.ts +40 -0
  26. package/dist/lifecycle/change-set.d.ts.map +1 -1
  27. package/dist/lifecycle/disruption.d.ts +96 -0
  28. package/dist/lifecycle/disruption.d.ts.map +1 -0
  29. package/dist/lifecycle/index.d.ts +2 -0
  30. package/dist/lifecycle/index.d.ts.map +1 -1
  31. package/dist/lifecycle/replay.d.ts +2 -0
  32. package/dist/lifecycle/replay.d.ts.map +1 -1
  33. package/dist/lint/policy.d.ts +16 -0
  34. package/dist/lint/policy.d.ts.map +1 -1
  35. package/dist/op/local-executor.d.ts +22 -2
  36. package/dist/op/local-executor.d.ts.map +1 -1
  37. package/dist/testing.d.ts +23 -2
  38. package/dist/testing.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/build-options.test.ts +101 -0
  41. package/src/cli/build-options.ts +109 -0
  42. package/src/cli/commands/build.ts +24 -53
  43. package/src/cli/handlers/lifecycle.test.ts +109 -0
  44. package/src/cli/handlers/lifecycle.ts +32 -8
  45. package/src/cli/handlers/op-progress.test.ts +202 -0
  46. package/src/cli/handlers/op-progress.ts +192 -0
  47. package/src/cli/handlers/run-client.test.ts +82 -0
  48. package/src/cli/handlers/run-client.ts +85 -2
  49. package/src/cli/handlers/run-report.test.ts +62 -0
  50. package/src/cli/handlers/run-report.ts +20 -58
  51. package/src/cli/handlers/run.test.ts +240 -0
  52. package/src/cli/handlers/run.ts +76 -18
  53. package/src/cli/handlers/search-drift.test.ts +263 -0
  54. package/src/cli/handlers/search.ts +150 -1
  55. package/src/cli/main.ts +11 -0
  56. package/src/cli/mcp/op-tools.ts +17 -6
  57. package/src/cli/mcp/resource-handlers.ts +13 -5
  58. package/src/cli/registry.ts +33 -1
  59. package/src/components/run-progress.ts +9 -5
  60. package/src/lexicon.ts +51 -0
  61. package/src/lifecycle/assert-live.test.ts +125 -0
  62. package/src/lifecycle/assert-live.ts +154 -0
  63. package/src/lifecycle/change-set.test.ts +144 -1
  64. package/src/lifecycle/change-set.ts +165 -11
  65. package/src/lifecycle/disruption.test.ts +186 -0
  66. package/src/lifecycle/disruption.ts +224 -0
  67. package/src/lifecycle/index.ts +2 -0
  68. package/src/lifecycle/replay.test.ts +25 -0
  69. package/src/lifecycle/replay.ts +11 -3
  70. package/src/lint/policy-build-parity.test.ts +232 -0
  71. package/src/lint/policy.ts +51 -6
  72. package/src/op/local-executor.ts +35 -1
  73. package/src/op/local-output.ts +1 -1
  74. package/src/testing.test.ts +89 -2
  75. package/src/testing.ts +63 -3
@@ -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<{ observations: LiveObservation[]; commit: string; timestamp: string } | { error: string; hint?: string }> {
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
+ });
@@ -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
- const result = await build(buildPath, serializers, undefined, {
100
- ownership: resolveOwnershipMarker(config, params.provenance),
101
- buildParams: params.provenance,
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
  }
@@ -21,7 +21,13 @@ import { isStepOutputRef } from "./step-output-ref";
21
21
  export interface StepRecord {
22
22
  phase: string;
23
23
  fn: string;
24
- args: Record<string, unknown>;
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
  /**
@@ -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 {
@@ -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 { deployStack, testEnvName, TeardownIncompleteError } from "./testing";
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 { Declarable } from "./declarable";
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
  }