@intentius/chant 0.117.0 → 0.118.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.117.0",
3
+ "version": "0.118.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.ts CHANGED
@@ -19,6 +19,7 @@ import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/i
19
19
  import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
20
20
  import { buildDependencyGraph } from "./discovery/graph";
21
21
  import { topologicalSort } from "./sort";
22
+ import { statSync } from "node:fs";
22
23
  import { resolve } from "node:path";
23
24
 
24
25
  /**
@@ -641,6 +642,15 @@ function generateManifest(
641
642
  };
642
643
  }
643
644
 
645
+ /** The directory a build discovered from, for build-root contributors; undefined when the build's path is not one (an entity-set JSON's label). */
646
+ function sourceDirOf(path: string): string | undefined {
647
+ try {
648
+ return statSync(path).isDirectory() ? resolve(path) : undefined;
649
+ } catch {
650
+ return undefined;
651
+ }
652
+ }
653
+
644
654
  /** What merging build-root contributions produced: non-fatal render notes and
645
655
  * fatal messages (a failed contributor, a name collision). */
646
656
  export interface BuildRootMergeResult {
@@ -667,6 +677,7 @@ export interface BuildRootMergeResult {
667
677
  export async function mergeBuildRootEntities(
668
678
  entities: Map<string, Declarable>,
669
679
  contributors: ReadonlyArray<BuildRootContributor>,
680
+ sourceDir?: string,
670
681
  ): Promise<BuildRootMergeResult> {
671
682
  const warnings: string[] = [];
672
683
  const errors: string[] = [];
@@ -677,7 +688,7 @@ export async function mergeBuildRootEntities(
677
688
  // ciphertext file to resolve). Contributors run in order, so this also
678
689
  // carries what earlier contributors added; the merge below is still the
679
690
  // only writer, which is what keeps the collision refusal meaningful.
680
- const contribution = await contribute({ entities });
691
+ const contribution = await contribute({ entities, ...(sourceDir !== undefined ? { sourceDir } : {}) });
681
692
  warnings.push(...(contribution.warnings ?? []));
682
693
  for (const [name, entity] of contribution.entities) {
683
694
  if (entities.has(name)) {
@@ -809,7 +820,7 @@ async function buildFromDiscoveryResult(
809
820
  // object, and re-running the contributors there would duplicate every
810
821
  // contributed entity once per child.
811
822
  if (!parentBuildStack && options?.buildRoots) {
812
- const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots);
823
+ const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots, sourceDirOf(resolvedPathForChildStack));
813
824
  warnings.push(...merged.warnings);
814
825
  for (const message of merged.errors) {
815
826
  errors.push(new BuildErrorClass("", message));
@@ -922,7 +922,8 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
922
922
  const resourceCount = result.entities.size;
923
923
  const fileCount = result.sourceFileCount;
924
924
 
925
- if (fileCount === 0 && errors.length === 0) {
925
+ // A build root (a lexicon's own source files, a rendered overlay) can declare everything without one .ts file.
926
+ if (fileCount === 0 && resourceCount === 0 && errors.length === 0) {
926
927
  console.error(formatInfo("No source files found — create .ts files in the target directory"));
927
928
  }
928
929
 
@@ -5,13 +5,25 @@
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
- import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
8
+ import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
9
9
  import { formatError } from "../format";
10
10
  import type { CommandContext } from "../registry";
11
11
 
12
12
  export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
13
13
  const { args } = ctx;
14
14
  const specFile = args.opsSpec;
15
+ if (specFile && args.recordPlans) {
16
+ try {
17
+ const spec = parseOpWavesSpec(readFileSync(resolve(specFile), "utf-8"), `--spec ${specFile}`);
18
+ const plans = await recordOpWaveHeadPlans({ spec });
19
+ if (args.json) console.log(JSON.stringify(plans, null, 2));
20
+ else for (const w of plans.waves) console.error(`wave ${w.wave} (${w.name}) at ${plans.head.slice(0, 12)}: ${w.digest}`);
21
+ return 0;
22
+ } catch (err) {
23
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
24
+ return 1;
25
+ }
26
+ }
15
27
  if (!specFile || args.wave === undefined) {
16
28
  console.error(formatError({
17
29
  message: "`chant run wave` needs --spec <file> and --wave <k>",
package/src/cli/main.ts CHANGED
@@ -108,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
108
108
  "--root-only",
109
109
  "--generated",
110
110
  "--decide",
111
+ "--record-plans",
111
112
  ]);
112
113
 
113
114
  /**
@@ -359,6 +360,8 @@ export function parseArgs(args: string[]): ParsedArgs {
359
360
  result.wave = wave;
360
361
  } else if (arg === "--decide") {
361
362
  result.decide = true;
363
+ } else if (arg === "--record-plans") {
364
+ result.recordPlans = true;
362
365
  } else if (arg === "--share") {
363
366
  const raw = args[++i];
364
367
  const share = Number(raw);
@@ -925,7 +928,10 @@ Ops:
925
928
  run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
926
929
  waves spec (#3679): the job a generated waves
927
930
  pipeline runs. --decide plans and decides only;
928
- --share <i> applies one share of a decided wave
931
+ --share <i> applies one share of a decided wave.
932
+ --record-plans (no --wave), in a pull request's
933
+ pipeline: plan every wave at its head and record the
934
+ digests a pr-review approval mode compares
929
935
  run resume [--op <name>] Start again each CI job that waits at a gate an
930
936
  approval now answers, through the forge's API
931
937
  (#3683); approves nothing. --dry-run lists them
@@ -211,7 +211,7 @@ export function collectBuildRootContributors(
211
211
  .filter((plugin) => typeof plugin.buildRoots === "function")
212
212
  // `entities` is not bindable here — discovery has not run — so the merge
213
213
  // hands it in when it calls the closure (#1828 / SOPS provenance).
214
- .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
214
+ .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities, ...(ctx?.sourceDir !== undefined ? { sourceDir: ctx.sourceDir } : {}) }));
215
215
  return [...codegen, ...roots];
216
216
  }
217
217
 
@@ -252,6 +252,8 @@ export interface ParsedArgs {
252
252
  wave?: number;
253
253
  /** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
254
254
  decide?: boolean;
255
+ /** `chant run wave --record-plans` (#3684) — plan every wave at a pull request's head and record the digests for a `pr-review` gate. */
256
+ recordPlans?: boolean;
255
257
  /** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
256
258
  share?: number;
257
259
  /** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
@@ -116,3 +116,21 @@ export async function findInfraFiles(path: string, options?: FindInfraFilesOptio
116
116
  accept: (name, full) => isSourceFileName(name) && !hasDiscoveryMarkerSync(full),
117
117
  });
118
118
  }
119
+
120
+ /**
121
+ * Find the files under `path` that `accept` wants, through the same walk and
122
+ * the same project globs as {@link findInfraFiles}: a lexicon that reads
123
+ * source of its own beside the TypeScript (the sql lexicon's `.sql` files)
124
+ * sees exactly the tree source discovery sees, so `dist`, git-ignored paths,
125
+ * child projects and the project's `exclude` globs are left out for it too.
126
+ */
127
+ export async function findSourceFiles(path: string, accept: (name: string, full: string) => boolean, options?: FindInfraFilesOptions): Promise<string[]> {
128
+ const globs = options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path)));
129
+ return walkDiscovery({
130
+ walker: "source",
131
+ root: path,
132
+ globs,
133
+ excludeDirs: await workspaceMemberDirs(path),
134
+ accept,
135
+ });
136
+ }
package/src/lexicon.ts CHANGED
@@ -984,6 +984,15 @@ export interface BuildRootContext {
984
984
  * treat an absent map as an empty one.
985
985
  */
986
986
  entities?: ReadonlyMap<string, Declarable>;
987
+ /**
988
+ * The directory this build discovered its source in (the path handed to
989
+ * `build()`, which `--src` or `sourceDir` chose), absolute. A contributor
990
+ * that reads files beside the typed source (the sql lexicon's `.sql`
991
+ * files) reads them here, so it sees the tree discovery saw. Optional: a
992
+ * caller that is not a build (the graph paths) omits it, and a hook then
993
+ * falls back to `sourceDir` in `config`, resolved against `projectRoot`.
994
+ */
995
+ sourceDir?: string;
987
996
  }
988
997
 
989
998
  /**
@@ -1003,7 +1012,7 @@ export interface BuildRootContribution {
1003
1012
  * the argument is still assignable, which is what every pre-#1828 hook does.
1004
1013
  */
1005
1014
  export type BuildRootContributor = (
1006
- ctx: Pick<BuildRootContext, "entities">,
1015
+ ctx: Pick<BuildRootContext, "entities" | "sourceDir">,
1007
1016
  ) => Promise<BuildRootContribution>;
1008
1017
 
1009
1018
  /**
@@ -222,6 +222,13 @@ export interface GateResolutionRecord {
222
222
  * verifies. Absent on an unsigned approval.
223
223
  */
224
224
  seal?: GateApprovalSeal;
225
+ /**
226
+ * The forge review this approval came from (#3684): a wave whose approval
227
+ * mode is `pr-review` writes it when the merged pull request's head was
228
+ * approved by a writer other than its author and planned this digest.
229
+ * `resolvedBy` is the first of `approvers`.
230
+ */
231
+ review?: { pr: number; head: string; approvers: string[] };
225
232
  /**
226
233
  * The answer record that passed the gate, on a gate that asks a decision
227
234
  * point (#3170). The run writes this resolution itself when the point's
@@ -0,0 +1,225 @@
1
+ import { afterAll, describe, expect, test } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { ForgeFetch } from "../pr-forge";
6
+ import { waveSetDigest } from "../gated-waves";
7
+ import { sealGateApproval } from "../workspace/trust/seal";
8
+ import { TestRepo, hasSshKeygen } from "../workspace/trust/test-repo";
9
+ import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
10
+ import { memoryGateLedgerPort } from "./gate";
11
+ import { githubReviews, forgejoReviews, gitlabReviews, type MergedReview, type ReviewSource } from "./gate-review";
12
+ import type { OpWavesSpec } from "./op-waves";
13
+ import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
14
+
15
+ const HEAD = "h".repeat(40);
16
+ const MERGE = "m".repeat(40);
17
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
18
+
19
+ /** A fake forge answering GETs by path. */
20
+ function forge(routes: Record<string, unknown>): ForgeFetch {
21
+ return async (url) => {
22
+ const { pathname } = new URL(url);
23
+ if (!(pathname in routes)) return { ok: false, status: 404, text: async () => "not found" };
24
+ return { ok: true, status: 200, text: async () => JSON.stringify(routes[pathname]) };
25
+ };
26
+ }
27
+
28
+ const opts = (fetch: ForgeFetch, apiBase = "https://api.github.com") => ({ apiBase, repo: "acme/db", token: "t", host: "github.com", fetch });
29
+
30
+ describe("the merged pull request's review", () => {
31
+ const pr = { number: 7, user: { login: "carol" }, head: { sha: HEAD }, html_url: "https://github.com/acme/db/pull/7" };
32
+ const base = {
33
+ "/repos/acme/db/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/pulls": [{ number: 7, merged_at: "x", merge_commit_sha: MERGE }],
34
+ "/repos/acme/db/pulls/7": pr,
35
+ "/repos/acme/db/collaborators/alice/permission": { permission: "write", role_name: "write" },
36
+ "/repos/acme/db/collaborators/dave/permission": { permission: "read", role_name: "read" },
37
+ };
38
+
39
+ test("GitHub: the author's own approval never counts", async () => {
40
+ const review = await githubReviews(opts(forge({ ...base, "/repos/acme/db/pulls/7/reviews": [{ user: { login: "carol" }, state: "APPROVED", commit_id: HEAD }] }))).mergedReview(MERGE);
41
+ expect(review).toMatchObject({ pr: 7, author: "github:carol", head: HEAD, approvers: [] });
42
+ expect(review!.refused[0]).toMatch(/github:carol is the author/);
43
+ });
44
+
45
+ test("GitHub: a writer's approval of the head counts; a reader's, or one of an older commit, does not", async () => {
46
+ const review = await githubReviews(
47
+ opts(
48
+ forge({
49
+ ...base,
50
+ "/repos/acme/db/pulls/7/reviews": [
51
+ { user: { login: "alice" }, state: "APPROVED", commit_id: "old" },
52
+ { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
53
+ { user: { login: "dave" }, state: "APPROVED", commit_id: HEAD },
54
+ ],
55
+ }),
56
+ ),
57
+ ).mergedReview(MERGE);
58
+ expect(review!.approvers).toEqual(["github:alice"]);
59
+ expect(review!.refused).toEqual(["github:dave approved without write access"]);
60
+ });
61
+
62
+ test("GitHub: a writer who requests changes holds the review back", async () => {
63
+ const review = await githubReviews(
64
+ opts(
65
+ forge({
66
+ ...base,
67
+ "/repos/acme/db/collaborators/bob/permission": { permission: "write", role_name: "maintain" },
68
+ "/repos/acme/db/pulls/7/reviews": [
69
+ { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
70
+ { user: { login: "bob" }, state: "CHANGES_REQUESTED", commit_id: HEAD },
71
+ ],
72
+ }),
73
+ ),
74
+ ).mergedReview(MERGE);
75
+ expect(review!.approvers).toEqual([]);
76
+ });
77
+
78
+ test("Forgejo: only an official review that is neither stale nor dismissed counts", async () => {
79
+ const review = await forgejoReviews(
80
+ opts(
81
+ forge({
82
+ "/api/v1/repos/acme/db/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/pull": { number: 7 },
83
+ "/api/v1/repos/acme/db/pulls/7": pr,
84
+ "/api/v1/repos/acme/db/pulls/7/reviews": [
85
+ { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD, official: true },
86
+ { user: { login: "dave" }, state: "APPROVED", commit_id: HEAD, official: false },
87
+ { user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true, stale: true },
88
+ ],
89
+ }),
90
+ "https://code.example.org/api/v1",
91
+ ),
92
+ ).mergedReview(MERGE);
93
+ expect(review!.approvers).toEqual(["forgejo@github.com:alice"]);
94
+ expect(review!.refused).toHaveLength(2);
95
+ });
96
+
97
+ test("GitLab: an approval counts only where a push removes approvals, and never the author's", async () => {
98
+ const routes = {
99
+ "/api/v4/projects/7/repository/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/merge_requests": [{ iid: 3, state: "merged", merge_commit_sha: MERGE, sha: HEAD, author: { username: "carol" } }],
100
+ "/api/v4/projects/7/merge_requests/3/approvals": { approved_by: [{ user: { id: 1, username: "carol" } }, { user: { id: 2, username: "alice" } }] },
101
+ "/api/v4/projects/7/members/all/2": { access_level: 30 },
102
+ };
103
+ const keeps = await gitlabReviews({ ...opts(forge({ ...routes, "/api/v4/projects/7/approvals": { reset_approvals_on_push: false } }), "https://gl/api/v4"), repo: "7", host: "gitlab.com" }).mergedReview(MERGE);
104
+ expect(keeps!.approvers).toEqual([]);
105
+ const resets = await gitlabReviews({ ...opts(forge({ ...routes, "/api/v4/projects/7/approvals": { reset_approvals_on_push: true } }), "https://gl/api/v4"), repo: "7", host: "gitlab.com" }).mergedReview(MERGE);
106
+ expect(resets).toMatchObject({ pr: 3, head: HEAD, approvers: ["gitlab:alice"] });
107
+ expect(resets!.refused[0]).toMatch(/gitlab:carol is the author/);
108
+ });
109
+ });
110
+
111
+ const spec = (approval: "pr-review" | "sealed"): OpWavesSpec => ({
112
+ name: "migrations",
113
+ op: "migrate",
114
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
115
+ apply: ["tool", "apply", "{target}"],
116
+ waves: [{ name: "prod", runs: [{ target: "prod" }], approval }],
117
+ });
118
+
119
+ function harness(plan: string, dir?: string) {
120
+ const cwd = dir ?? mkdtempSync(join(tmpdir(), "gate-review-"));
121
+ const applied: string[] = [];
122
+ const exec: OpWaveExec = (argv, at) => {
123
+ if (argv[1] === "plan") {
124
+ mkdirSync(join(at, argv[4]!, ".."), { recursive: true });
125
+ writeFileSync(join(at, argv[4]!), JSON.stringify({ planDigest: plan }));
126
+ }
127
+ if (argv[1] === "apply") applied.push(argv[2]!);
128
+ return 0;
129
+ };
130
+ return { cwd, exec, applied };
131
+ }
132
+
133
+ describe("a pr-review wave (#3684)", () => {
134
+ const set = (plan: string) => waveSetDigest([{ member: "prod", planDigest: plan }]);
135
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec("pr-review")) });
136
+ const reviewed = (approvers: string[], refused: string[] = []): ReviewSource => ({
137
+ kind: "github",
138
+ async mergedReview(): Promise<MergedReview> {
139
+ return { pr: 7, author: "github:carol", head: HEAD, approvers, refused, url: "https://github.com/acme/db/pull/7" };
140
+ },
141
+ });
142
+ const plans = (d: string) => async () => ({ version: 1 as const, name: "migrations", head: HEAD, timestamp: "t", waves: [{ wave: 1, name: "prod", digest: d }] });
143
+
144
+ test("the author's own review applies nothing", async () => {
145
+ const h = harness(digest("a"));
146
+ const result = await runOpWave({
147
+ spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
148
+ gates: memoryGateLedgerPort(), reviews: reviewed([], ["github:carol is the author, whose own review never counts"]), headPlans: plans(set(digest("a"))),
149
+ });
150
+ expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { approval: "pr-review", status: "waiting" } });
151
+ expect(result.decision!.review!.reason).toMatch(/is the author/);
152
+ expect(h.applied).toEqual([]);
153
+ });
154
+
155
+ test("a writer's review of the head approves the digest the head planned, and the ledger records it", async () => {
156
+ const h = harness(digest("a"));
157
+ const gates = memoryGateLedgerPort();
158
+ const result = await runOpWave({
159
+ spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
160
+ gates, reviews: reviewed(["github:alice"]), headPlans: plans(set(digest("a"))),
161
+ });
162
+ expect(result).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", via: "pr-review", approvedBy: "github:alice" } });
163
+ expect(gates.resolved[0]).toMatchObject({ resolvedBy: "github:alice", planDigest: set(digest("a")), review: { pr: 7, head: HEAD, approvers: ["github:alice"] } });
164
+ });
165
+
166
+ test("a plan that moved after the review waits for a new approval", async () => {
167
+ const h = harness(digest("b"));
168
+ const result = await runOpWave({
169
+ spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
170
+ gates: memoryGateLedgerPort(), reviews: reviewed(["github:alice"]), headPlans: plans(set(digest("a"))),
171
+ });
172
+ expect(result.exitCode).toBe(3);
173
+ expect(result.decision!.review!.reason).toMatch(/plan moved after the review/);
174
+ });
175
+
176
+ test("--record-plans records each wave's digest at the head", async () => {
177
+ const h = harness(digest("a"));
178
+ let written: unknown;
179
+ const recorded = await recordOpWaveHeadPlans({ spec: spec("pr-review"), cwd: h.cwd, exec: h.exec, head: HEAD, now: "t", write: async (p) => void (written = p) });
180
+ expect(recorded.waves).toEqual([{ wave: 1, name: "prod", digest: set(digest("a")) }]);
181
+ expect(written).toEqual(recorded);
182
+ });
183
+ });
184
+
185
+ describe.skipIf(!hasSshKeygen)("a sealed wave (#3684)", () => {
186
+ const repo = new TestRepo("3684-sealed");
187
+ const alice = repo.key("alice");
188
+ const mallory = repo.key("mallory");
189
+ afterAll(() => rmSync(join(repo.dir, ".."), { recursive: true, force: true }));
190
+
191
+ // Base: the spec and a signers file listing alice. The applied change adds mallory's key.
192
+ writeFileSync(join(repo.dir, "waves.json"), JSON.stringify(spec("sealed")));
193
+ mkdirSync(join(repo.dir, ".chant"), { recursive: true });
194
+ writeFileSync(join(repo.dir, ".chant/allowed_signers"), `alice ${alice.pub}\n`);
195
+ repo.git(["add", "-A"]);
196
+ repo.git(["commit", "-q", "-m", "base"]);
197
+ writeFileSync(join(repo.dir, ".chant/allowed_signers"), `alice ${alice.pub}\nmallory ${mallory.pub}\n`);
198
+ repo.git(["add", "-A"]);
199
+ repo.git(["commit", "-q", "-m", "the change adds a signer"]);
200
+
201
+ const plan = digest("a");
202
+ const set = waveSetDigest([{ member: "prod", planDigest: plan }]);
203
+ const approval = (by: string, keyFile?: string): GateResolutionRecord => {
204
+ const a = { op: "migrations", gate: "migrations-wave-1", resolvedBy: by, timestamp: "2026-10-10T00:00:00Z", planDigest: set };
205
+ return { version: 1, ...a, ...(keyFile ? { seal: sealGateApproval(keyFile, a) } : {}) };
206
+ };
207
+ const run = (resolutions: GateResolutionRecord[]) => {
208
+ const h = harness(plan, repo.dir);
209
+ return runOpWave({ spec: spec("sealed"), specFile: "waves.json", wave: 1, cwd: repo.dir, exec: h.exec, gates: memoryGateLedgerPort({ resolutions }), now: "2026-10-10T01:00:00Z" });
210
+ };
211
+
212
+ test("an unsigned approval applies nothing, and the hint asks for --sign", async () => {
213
+ const result = await run([approval("alice")]);
214
+ expect(result).toMatchObject({ exitCode: 3, decision: { approval: "sealed", status: "waiting" } });
215
+ expect(result.decision!.approve).toMatch(/--sign$/);
216
+ });
217
+
218
+ test("a seal by a key the change itself added applies nothing", async () => {
219
+ expect((await run([approval("mallory", mallory.file)])).exitCode).toBe(3);
220
+ });
221
+
222
+ test("a seal by a signer listed at base applies the wave", async () => {
223
+ expect(await run([approval("alice", alice.file)])).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
224
+ });
225
+ });