@intentius/chant 0.124.48 → 0.124.49

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 (167) hide show
  1. package/dist/cli/handlers/approve.d.ts.map +1 -1
  2. package/dist/cli/handlers/approve.js +20 -12
  3. package/dist/cli/handlers/graph-reader.d.ts +26 -0
  4. package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
  5. package/dist/cli/handlers/graph-reader.js +41 -0
  6. package/dist/cli/handlers/graph.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.js +14 -0
  8. package/dist/cli/handlers/pr.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.js +40 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +22 -0
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +11 -0
  14. package/dist/cli/registry.d.ts +8 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/pr-run.d.ts +3 -0
  17. package/dist/components/pr-run.d.ts.map +1 -1
  18. package/dist/components/pr-run.js +1 -0
  19. package/dist/config.d.ts +16 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +4 -0
  22. package/dist/gate-decision.d.ts +9 -0
  23. package/dist/gate-decision.d.ts.map +1 -1
  24. package/dist/gate-decision.js +22 -7
  25. package/dist/lexicon.d.ts +23 -0
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/op/activities/activity-contracts.d.ts +4 -4
  28. package/dist/op/gate-approval-base.d.ts +25 -0
  29. package/dist/op/gate-approval-base.d.ts.map +1 -0
  30. package/dist/op/gate-approval-base.js +71 -0
  31. package/dist/op/gate-approval.d.ts +7 -4
  32. package/dist/op/gate-approval.d.ts.map +1 -1
  33. package/dist/op/gate-approval.js +2 -25
  34. package/dist/op/gate-name.d.ts +13 -0
  35. package/dist/op/gate-name.d.ts.map +1 -1
  36. package/dist/op/gate-name.js +14 -1
  37. package/dist/op/gate.d.ts +4 -3
  38. package/dist/op/gate.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +9 -2
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-executor.js +24 -5
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +4 -0
  45. package/dist/pr-migrations.d.ts +62 -0
  46. package/dist/pr-migrations.d.ts.map +1 -0
  47. package/dist/pr-migrations.js +50 -0
  48. package/dist/pr-post.js +1 -1
  49. package/dist/wave-runtime.mjs +219 -218
  50. package/dist/workspace/check.schema.json +4 -2
  51. package/dist/workspace/checks/gate-approval.d.ts +58 -0
  52. package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
  53. package/dist/workspace/checks/gate-approval.js +103 -0
  54. package/dist/workspace/checks.d.ts +1 -0
  55. package/dist/workspace/checks.d.ts.map +1 -1
  56. package/dist/workspace/checks.js +4 -0
  57. package/dist/workspace/declaration.d.ts +7 -0
  58. package/dist/workspace/declaration.d.ts.map +1 -1
  59. package/dist/workspace/declaration.js +13 -1
  60. package/dist/workspace/declaration.schema.json +33 -0
  61. package/dist/workspace/exit-cli.d.ts +44 -0
  62. package/dist/workspace/exit-cli.d.ts.map +1 -0
  63. package/dist/workspace/exit-cli.js +198 -0
  64. package/dist/workspace/exit.d.ts +159 -0
  65. package/dist/workspace/exit.d.ts.map +1 -0
  66. package/dist/workspace/exit.js +480 -0
  67. package/dist/workspace/exit.schema.json +257 -0
  68. package/dist/workspace/graph-cache.d.ts +3 -1
  69. package/dist/workspace/graph-cache.d.ts.map +1 -1
  70. package/dist/workspace/graph-cache.js +12 -1
  71. package/dist/workspace/import-fetch.d.ts +38 -0
  72. package/dist/workspace/import-fetch.d.ts.map +1 -0
  73. package/dist/workspace/import-fetch.js +99 -0
  74. package/dist/workspace/import-init.d.ts +26 -0
  75. package/dist/workspace/import-init.d.ts.map +1 -0
  76. package/dist/workspace/import-init.js +130 -0
  77. package/dist/workspace/import-source.d.ts +147 -0
  78. package/dist/workspace/import-source.d.ts.map +1 -0
  79. package/dist/workspace/import-source.js +370 -0
  80. package/dist/workspace/init.d.ts +4 -0
  81. package/dist/workspace/init.d.ts.map +1 -1
  82. package/dist/workspace/init.js +70 -3
  83. package/dist/workspace/kind-readers.d.ts +6 -0
  84. package/dist/workspace/kind-readers.d.ts.map +1 -1
  85. package/dist/workspace/kind-readers.js +18 -0
  86. package/dist/workspace/kinds.d.ts +24 -1
  87. package/dist/workspace/kinds.d.ts.map +1 -1
  88. package/dist/workspace/kinds.js +64 -4
  89. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  90. package/dist/workspace/lineage-upgrade.js +13 -4
  91. package/dist/workspace/new-migrations.d.ts +47 -0
  92. package/dist/workspace/new-migrations.d.ts.map +1 -0
  93. package/dist/workspace/new-migrations.js +91 -0
  94. package/dist/workspace/pr-report.schema.json +46 -0
  95. package/dist/workspace/promote.d.ts +96 -0
  96. package/dist/workspace/promote.d.ts.map +1 -0
  97. package/dist/workspace/promote.js +263 -0
  98. package/dist/workspace/reason-codes.d.ts +13 -0
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +14 -0
  101. package/dist/workspace/search-contract.d.ts +1 -1
  102. package/dist/workspace/search-contract.d.ts.map +1 -1
  103. package/dist/workspace/workspace-kinds.schema.json +50 -7
  104. package/package.json +1 -1
  105. package/src/cli/handlers/approve.ts +10 -2
  106. package/src/cli/handlers/graph-reader.test.ts +31 -0
  107. package/src/cli/handlers/graph-reader.ts +51 -0
  108. package/src/cli/handlers/graph.ts +14 -0
  109. package/src/cli/handlers/pr.ts +38 -1
  110. package/src/cli/main.test.ts +2 -0
  111. package/src/cli/main.ts +22 -0
  112. package/src/cli/parse-args.ts +8 -0
  113. package/src/cli/registry.ts +8 -0
  114. package/src/components/pr-env-run-bundle.test.ts +1 -0
  115. package/src/components/pr-run.ts +4 -0
  116. package/src/config.ts +16 -0
  117. package/src/gate-decision-bundle.test.ts +4 -2
  118. package/src/gate-decision-identity.test.ts +11 -0
  119. package/src/gate-decision.ts +26 -6
  120. package/src/lexicon.ts +24 -0
  121. package/src/op/gate-approval-at-base.test.ts +97 -0
  122. package/src/op/gate-approval-base.ts +72 -0
  123. package/src/op/gate-approval.ts +10 -21
  124. package/src/op/gate-name.ts +16 -1
  125. package/src/op/gate.ts +4 -3
  126. package/src/op/local-executor.ts +25 -5
  127. package/src/pr-loop-bundle.test.ts +3 -2
  128. package/src/pr-loop.test.ts +52 -0
  129. package/src/pr-loop.ts +12 -0
  130. package/src/pr-migrations.ts +97 -0
  131. package/src/pr-post.ts +1 -1
  132. package/src/workspace/__fixtures__/import/spacelift.json +9 -0
  133. package/src/workspace/__fixtures__/import/tfc.json +13 -0
  134. package/src/workspace/behold-kinds.test.ts +1 -0
  135. package/src/workspace/check-contract.test.ts +3 -2
  136. package/src/workspace/check.schema.json +4 -2
  137. package/src/workspace/checks/gate-approval.test.ts +77 -0
  138. package/src/workspace/checks/gate-approval.ts +121 -0
  139. package/src/workspace/checks.ts +4 -0
  140. package/src/workspace/declaration.schema.json +33 -0
  141. package/src/workspace/declaration.ts +19 -2
  142. package/src/workspace/exit-cli.ts +219 -0
  143. package/src/workspace/exit.schema.json +257 -0
  144. package/src/workspace/exit.test.ts +185 -0
  145. package/src/workspace/exit.ts +560 -0
  146. package/src/workspace/graph-cache.ts +12 -2
  147. package/src/workspace/identity.test.ts +18 -3
  148. package/src/workspace/import-fetch.ts +151 -0
  149. package/src/workspace/import-init.ts +147 -0
  150. package/src/workspace/import-source.test.ts +183 -0
  151. package/src/workspace/import-source.ts +462 -0
  152. package/src/workspace/init.test.ts +55 -1
  153. package/src/workspace/init.ts +66 -4
  154. package/src/workspace/kind-readers.test.ts +36 -0
  155. package/src/workspace/kind-readers.ts +18 -1
  156. package/src/workspace/kinds.test.ts +23 -0
  157. package/src/workspace/kinds.ts +81 -5
  158. package/src/workspace/lineage-upgrade.test.ts +20 -0
  159. package/src/workspace/lineage-upgrade.ts +16 -7
  160. package/src/workspace/new-migrations.test.ts +98 -0
  161. package/src/workspace/new-migrations.ts +109 -0
  162. package/src/workspace/pr-report.schema.json +46 -0
  163. package/src/workspace/promote.test.ts +114 -0
  164. package/src/workspace/promote.ts +302 -0
  165. package/src/workspace/reason-codes.test.ts +4 -0
  166. package/src/workspace/reason-codes.ts +14 -0
  167. package/src/workspace/workspace-kinds.schema.json +50 -7
@@ -0,0 +1,114 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
+ import type { GateLedgerPort } from "../op/gate";
7
+ import { nextRung, promoteCommand, setMemberKind, type PromoteRunner } from "./promote";
8
+
9
+ const git = (cwd: string, ...a: string[]) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...a], { cwd, encoding: "utf-8" });
10
+
11
+ function ledger(): GateLedgerPort & { approve(digest: string): void } {
12
+ const pending: never[] = [];
13
+ const resolutions: unknown[] = [];
14
+ return {
15
+ async read() {
16
+ return { resolutions, pending } as never;
17
+ },
18
+ async appendPending(input: Record<string, unknown>) {
19
+ (pending as unknown[]).push({ ...input, at: new Date().toISOString() });
20
+ return { record: input, pushed: false } as never;
21
+ },
22
+ approve(digest: string) {
23
+ resolutions.push({ gate: "net", planDigest: digest, resolvedBy: "alex", outcome: "approved", at: new Date().toISOString() });
24
+ },
25
+ } as never;
26
+ }
27
+
28
+ describe("the ladder (#3178)", () => {
29
+ test("terraform to choudoufu, choudoufu or other to chant, chant has no rung", () => {
30
+ expect(nextRung("terraform")).toEqual({ from: "terraform", to: "choudoufu" });
31
+ expect(nextRung("choudoufu")?.to).toBe("chant");
32
+ expect(nextRung("other")?.to).toBe("chant");
33
+ expect(nextRung("chant")).toBeUndefined();
34
+ });
35
+ test("setMemberKind changes only the named member", () => {
36
+ const text = JSON.stringify({ name: "w", schema: 1, members: [{ name: "a", dir: "a", kind: "terraform" }, { name: "b", dir: "b", kind: "terraform" }] }, null, 2);
37
+ const out = JSON.parse(setMemberKind(text, "chant.workspace.json", "b", "choudoufu"));
38
+ expect(out.members.map((m: { kind: string }) => m.kind)).toEqual(["terraform", "choudoufu"]);
39
+ });
40
+ });
41
+
42
+ describe("chant workspace promote (#3178)", () => {
43
+ let root: string;
44
+ let calls: { argv: string[]; cwd: string }[];
45
+ const runner: PromoteRunner = async (argv, cwd) => {
46
+ calls.push({ argv, cwd });
47
+ if (argv[0] === "chant") mkdirSync(join(cwd, "src"), { recursive: true });
48
+ if (argv[0] === "chant") writeFileSync(join(cwd, "src", "index.ts"), "export {};\n");
49
+ return { exitCode: 0, output: "" };
50
+ };
51
+ beforeEach(() => {
52
+ vi.spyOn(console, "error").mockImplementation(() => {});
53
+ calls = [];
54
+ root = mkdtempSync(join(tmpdir(), "chant-promote-test-"));
55
+ git(root, "init", "-q", "-b", "main");
56
+ mkdirSync(join(root, "net"));
57
+ writeFileSync(join(root, "net", "main.tf"), "# tf\n");
58
+ writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({ name: "w", schema: 1, members: [{ name: "net", dir: "net", kind: "terraform" }] }, null, 2) + "\n");
59
+ git(root, "add", "-A");
60
+ git(root, "commit", "-qm", "init");
61
+ });
62
+ afterEach(() => {
63
+ rmSync(root, { recursive: true, force: true });
64
+ vi.restoreAllMocks();
65
+ });
66
+ const kind = () => JSON.parse(readFileSync(join(root, "chant.workspace.json"), "utf-8")).members[0].kind;
67
+
68
+ test("terraform to choudoufu: runs live-check and the adoption report in a worktree, gates, then applies and stamps", async () => {
69
+ const l = ledger();
70
+ const first = await promoteCommand({ root, member: "net", state: "/s/terraform.tfstate", runner, ledger: l });
71
+ expect(first.outcome).toBe("gated");
72
+ expect(first.exitCode).toBe(3);
73
+ expect(calls.map((c) => c.argv.slice(0, 2).join(" "))).toEqual(["choudoufu live-check", "choudoufu live-import"]);
74
+ expect(calls.every((c) => !c.argv.includes("-approve") && c.cwd !== join(realpathSync(root), "net"))).toBe(true);
75
+ expect(kind()).toBe("terraform");
76
+
77
+ l.approve(first.digest!);
78
+ calls = [];
79
+ const second = await promoteCommand({ root, member: "net", state: "/s/terraform.tfstate", runner, ledger: l });
80
+ expect(second.outcome).toBe("applied");
81
+ expect(second.digest).toBe(first.digest);
82
+ expect(kind()).toBe("choudoufu");
83
+ expect(calls.at(-1)?.argv).toContain("-approve");
84
+ expect(calls.at(-1)?.cwd).toBe(join(realpathSync(root), "net"));
85
+ });
86
+
87
+ test("a failing live-check stops before any gate and leaves the tree alone", async () => {
88
+ const l = ledger();
89
+ const failing: PromoteRunner = async (argv) => ({ exitCode: argv[1] === "live-check" ? 2 : 0, output: "unsupported construct" });
90
+ const r = await promoteCommand({ root, member: "net", state: "s", runner: failing, ledger: l });
91
+ expect(r.outcome).toBe("checks-failed");
92
+ expect(kind()).toBe("terraform");
93
+ expect(git(root, "status", "--porcelain")).toBe("");
94
+ expect((await l.read("workspace-promote")).pending).toHaveLength(0);
95
+ });
96
+
97
+ test("choudoufu to chant runs chant import and the patch carries the generated files", async () => {
98
+ writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({ name: "w", schema: 1, members: [{ name: "net", dir: "net", kind: "choudoufu" }] }, null, 2) + "\n");
99
+ git(root, "commit", "-qam", "kind");
100
+ const l = ledger();
101
+ const first = await promoteCommand({ root, member: "net", runner, ledger: l, dryRun: true });
102
+ expect(first.outcome).toBe("dry-run");
103
+ expect(first.patch).toContain("net/src/index.ts");
104
+ expect(calls[0].argv).toEqual(["chant", "import", ".", "--lexicon", "terraform", "--output", "src/"]);
105
+ expect(git(root, "status", "--porcelain")).toBe("");
106
+ });
107
+
108
+ test("terraform needs --state, other needs --lexicon, chant is the top", async () => {
109
+ await expect(promoteCommand({ root, member: "net", runner })).rejects.toThrow(/--state/);
110
+ writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({ name: "w", schema: 1, members: [{ name: "net", dir: "net", kind: "other", because: "x" }] }, null, 2) + "\n");
111
+ git(root, "commit", "-qam", "other");
112
+ await expect(promoteCommand({ root, member: "net", runner })).rejects.toThrow(/--lexicon/);
113
+ });
114
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * `chant workspace promote <member>` (#3178): move one member one rung up the
3
+ * kind ladder, `other` or `terraform`, then `choudoufu`, then `chant`.
4
+ *
5
+ * The change is staged the way `workspace upgrade` stages one (#2550): in a
6
+ * git worktree at HEAD, so the tree is never touched until the gate passes.
7
+ * The gate is `workspace-promote/<member>` on the gate ledger and binds the
8
+ * digest of the patch the staging produced. The first run records a pending
9
+ * fact and exits 3. After `chant approve workspace-promote <member>`, the same
10
+ * command stages again, finds the approval for the same digest, applies the
11
+ * patch, and runs the rung's follow-up (for `choudoufu`, stamping the
12
+ * ownership markers with `live-import -approve`).
13
+ *
14
+ * Rungs:
15
+ *
16
+ * - `terraform` to `choudoufu`: `choudoufu live-check` in the member's
17
+ * directory must pass, then `choudoufu live-import -state=<--state>
18
+ * -estate=<member>` prints its ratification report (read-only: it stamps
19
+ * nothing without `-approve`). The patch changes the member's kind.
20
+ * - `choudoufu` or `other` to `chant`: `chant import` writes TypeScript into
21
+ * the member's directory. The patch holds those files and the kind change.
22
+ *
23
+ * Commands run through an injectable runner, so a test never starts a binary.
24
+ */
25
+
26
+ import { execFileSync, spawn } from "node:child_process";
27
+ import { existsSync, mkdirSync, realpathSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join, relative, resolve, sep } from "node:path";
30
+ import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/format";
31
+ import type { CommandContext } from "../cli/registry";
32
+ import { computePlanDigest } from "../lifecycle/plan-digest";
33
+ import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
34
+ import { approveCommand, describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
35
+ import { WORKSPACE_PROMOTE_GATE_OP } from "../op/gate-name";
36
+ import { DECLARATION_FILES, readDeclaration } from "./declaration";
37
+ import { setProperty } from "./json-edit";
38
+ import { workingTree } from "./tree";
39
+
40
+ export const PROMOTE_PLAN_KIND = "workspace-promote";
41
+ export const PROMOTE_GATED_EXIT = 3;
42
+ const USAGE = "chant workspace promote <member> [--state <terraform.tfstate>] [--lexicon <lexicon>] [--dry-run] [--output <patch file>] [--json]";
43
+
44
+ export class PromoteError extends Error {}
45
+
46
+ /** The ladder, lowest rung first. `other` and `terraform` share the bottom rung. */
47
+ export const LADDER = ["terraform", "choudoufu", "chant"] as const;
48
+
49
+ export interface Rung {
50
+ from: string;
51
+ to: "choudoufu" | "chant";
52
+ }
53
+
54
+ /** The next rung for a member of `kind`, or undefined at the top or off the ladder. */
55
+ export function nextRung(kind: string): Rung | undefined {
56
+ if (kind === "terraform") return { from: kind, to: "choudoufu" };
57
+ if (kind === "choudoufu" || kind === "other") return { from: kind, to: "chant" };
58
+ return undefined;
59
+ }
60
+
61
+ export interface CommandRun {
62
+ label: string;
63
+ argv: string[];
64
+ /** Directory relative to the project root. */
65
+ cwd: string;
66
+ }
67
+ export type PromoteRunner = (argv: string[], cwd: string) => Promise<{ exitCode: number; output: string }>;
68
+
69
+ export const spawnRunner: PromoteRunner = (argv, cwd) =>
70
+ new Promise((done) => {
71
+ const [bin, ...rest] = argv;
72
+ const child = spawn(bin, rest, { cwd, stdio: ["ignore", "pipe", "pipe"], env: process.env });
73
+ const out: Buffer[] = [];
74
+ child.stdout.on("data", (c: Buffer) => out.push(c));
75
+ child.stderr.on("data", (c: Buffer) => out.push(c));
76
+ child.on("error", (e) => done({ exitCode: 127, output: String(e.message) }));
77
+ child.on("close", (code) => done({ exitCode: code ?? 1, output: Buffer.concat(out).toString("utf-8") }));
78
+ });
79
+
80
+ /** The commands the rung runs in the staging worktree. */
81
+ export function stagingRuns(rung: Rung, member: { name: string; dir: string }, opts: { state?: string; lexicon?: string }): CommandRun[] {
82
+ if (rung.to === "choudoufu") {
83
+ if (!opts.state) throw new PromoteError(`promoting ${member.name} to choudoufu reads its state for the adoption report: pass --state <terraform.tfstate>`);
84
+ return [
85
+ { label: "choudoufu live-check", argv: ["choudoufu", "live-check"], cwd: member.dir },
86
+ { label: "choudoufu live-import (report)", argv: ["choudoufu", "live-import", `-state=${opts.state}`, `-estate=${member.name}`], cwd: member.dir },
87
+ ];
88
+ }
89
+ const lexicon = opts.lexicon ?? (rung.from === "choudoufu" ? "terraform" : undefined);
90
+ if (!lexicon) throw new PromoteError(`promoting ${member.name} (kind other) to chant needs --lexicon <lexicon>: what chant import should read it as`);
91
+ return [{ label: "chant import", argv: ["chant", "import", ".", "--lexicon", lexicon, "--output", "src/"], cwd: member.dir }];
92
+ }
93
+
94
+ /** After the approved patch is applied: stamping ownership markers on the live resources. */
95
+ export function followUpRuns(rung: Rung, member: { name: string; dir: string }, opts: { state?: string }): CommandRun[] {
96
+ if (rung.to !== "choudoufu") return [];
97
+ return [{ label: "choudoufu live-import -approve", argv: ["choudoufu", "live-import", `-state=${opts.state}`, `-estate=${member.name}`, "-approve"], cwd: member.dir }];
98
+ }
99
+
100
+ function git(cwd: string, args: string[]): string {
101
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
102
+ }
103
+
104
+ export interface PromoteOptions {
105
+ root: string;
106
+ member: string;
107
+ state?: string;
108
+ lexicon?: string;
109
+ dryRun?: boolean;
110
+ output?: string;
111
+ json?: boolean;
112
+ /** For tests. */
113
+ runner?: PromoteRunner;
114
+ ledger?: GateLedgerPort;
115
+ now?: string;
116
+ }
117
+
118
+ export type PromoteOutcome = "top-of-ladder" | "checks-failed" | "dry-run" | "gated" | "applied";
119
+ export interface PromoteResult {
120
+ outcome: PromoteOutcome;
121
+ exitCode: number;
122
+ member: string;
123
+ from?: string;
124
+ to?: string;
125
+ digest?: string;
126
+ patch?: string;
127
+ ran?: { label: string; exitCode: number }[];
128
+ }
129
+
130
+ function declarationFile(root: string): string {
131
+ const f = DECLARATION_FILES.find((n) => existsSync(join(root, n)));
132
+ if (!f) throw new PromoteError(`no ${DECLARATION_FILES.join(" or ")} in ${root}`);
133
+ return f;
134
+ }
135
+
136
+ /** Rewrite the member's `kind` in the declaration text. */
137
+ export function setMemberKind(text: string, file: string, member: string, kind: string): string {
138
+ const raw = (file.endsWith(".jsonc") ? text.replace(/^\s*\/\/.*$/gm, "") : text);
139
+ const parsed = JSON.parse(raw) as { members?: { name?: string }[] };
140
+ const i = (parsed.members ?? []).findIndex((m) => m.name === member);
141
+ if (i < 0) throw new PromoteError(`the declaration has no member "${member}"`);
142
+ return setProperty(text, file.endsWith(".jsonc"), `/members/${i}`, "kind", kind);
143
+ }
144
+
145
+ export async function promoteCommand(opts: PromoteOptions): Promise<PromoteResult> {
146
+ const root = realpathSync(resolve(opts.root));
147
+ const run = opts.runner ?? spawnRunner;
148
+ const decl = readDeclaration(workingTree(root));
149
+ const found = decl.members.find((m) => m.name === opts.member);
150
+ if (!found) throw new PromoteError(`the declaration has no member "${opts.member}"; chant workspace ls lists them`);
151
+ const say = (m: string): void => {
152
+ if (!opts.json) console.error(m);
153
+ };
154
+ const rung = nextRung(found.kind);
155
+ if (!rung) {
156
+ say(formatInfo(`${found.name} is kind ${found.kind}, which has no rung above it on the ladder (terraform or other, choudoufu, chant)`));
157
+ return { outcome: "top-of-ladder", exitCode: 0, member: found.name, from: found.kind };
158
+ }
159
+ const member = { name: found.name, dir: found.dir === "." ? "" : found.dir };
160
+ const staging = stagingRuns(rung, member, opts);
161
+
162
+ let repo: string;
163
+ let head: string;
164
+ try {
165
+ repo = git(root, ["rev-parse", "--show-toplevel"]).trim();
166
+ head = git(root, ["rev-parse", "HEAD"]).trim();
167
+ } catch {
168
+ throw new PromoteError("chant workspace promote stages its change in a git worktree, so the project must be in a git repository with at least one commit");
169
+ }
170
+ const projectRel = relative(repo, root).split(sep).join("/");
171
+ const declFile = declarationFile(root);
172
+ const watched = [declFile, member.dir].filter(Boolean).map((p) => [projectRel, p].filter(Boolean).join("/"));
173
+ const dirty = git(repo, ["status", "--porcelain", "--untracked-files=all", "--", ...watched]).split("\n").filter(Boolean);
174
+ if (dirty.length > 0) {
175
+ throw new PromoteError(`${found.name} has uncommitted changes (${dirty.slice(0, 5).map((l) => l.slice(3)).join(", ")}). Commit them: promote stages from HEAD and applies its patch to the tree.`);
176
+ }
177
+
178
+ const holder = mkdtempSync(join(tmpdir(), "chant-promote-"));
179
+ const worktree = join(holder, "wt");
180
+ const dispose = (): void => {
181
+ try {
182
+ git(repo, ["worktree", "remove", "--force", worktree]);
183
+ } catch {
184
+ // Fall through to the directory removal and a prune.
185
+ }
186
+ rmSync(holder, { recursive: true, force: true });
187
+ try {
188
+ git(repo, ["worktree", "prune"]);
189
+ } catch {
190
+ // Nothing to prune.
191
+ }
192
+ };
193
+
194
+ try {
195
+ git(repo, ["worktree", "add", "-q", "--detach", worktree, head]);
196
+ const project = projectRel ? join(worktree, projectRel) : worktree;
197
+ const ran: { label: string; exitCode: number }[] = [];
198
+ for (const step of staging) {
199
+ const cwd = join(project, step.cwd);
200
+ mkdirSync(cwd, { recursive: true });
201
+ const result = await run(step.argv, cwd);
202
+ ran.push({ label: step.label, exitCode: result.exitCode });
203
+ if (!opts.json) say(formatInfo(`${step.label}: exit ${result.exitCode}`));
204
+ if (result.exitCode !== 0) {
205
+ say(formatError({ message: `${step.label} failed in the staging worktree`, hint: result.output.trimEnd().split("\n").slice(-20).join("\n") }));
206
+ say(formatError({ message: "the promotion was not gated: its checks must pass first. The tree is unchanged." }));
207
+ if (opts.json) console.log(JSON.stringify({ outcome: "checks-failed", member: found.name, ran }, null, 2));
208
+ return { outcome: "checks-failed", exitCode: 1, member: found.name, from: rung.from, to: rung.to, ran };
209
+ }
210
+ }
211
+ const declPath = join(project, declFile);
212
+ writeFileSync(declPath, setMemberKind(readFileSync(declPath, "utf-8"), declFile, found.name, rung.to));
213
+ git(worktree, ["add", "-A"]);
214
+ const patch = git(worktree, ["diff", "--cached", "--binary", "HEAD"]);
215
+ const digest = computePlanDigest(PROMOTE_PLAN_KIND, { member: found.name, from: rung.from, to: rung.to, patch });
216
+ const base: PromoteResult = { outcome: "dry-run", exitCode: 0, member: found.name, from: rung.from, to: rung.to, digest, patch, ran };
217
+ const report = (outcome: PromoteOutcome, exitCode: number, extra: Record<string, unknown> = {}): PromoteResult => {
218
+ if (opts.json) console.log(JSON.stringify({ outcome, member: found.name, from: rung.from, to: rung.to, digest, ran, ...extra }, null, 2));
219
+ return { ...base, outcome, exitCode };
220
+ };
221
+ say(formatInfo(`${found.name}: ${rung.from} to ${rung.to}, patch ${digest}`));
222
+ if (opts.output) writeFileSync(resolve(opts.output), patch);
223
+ if (opts.dryRun) {
224
+ say(formatInfo("--dry-run: staged and checked; no gate recorded, the tree is unchanged"));
225
+ return report("dry-run", 0);
226
+ }
227
+
228
+ const check = await evaluateGate(opts.ledger ?? gitGateLedgerPort({ cwd: repo }), {
229
+ op: WORKSPACE_PROMOTE_GATE_OP,
230
+ gate: found.name,
231
+ description: `promote ${found.name} from ${rung.from} to ${rung.to}`,
232
+ planDigest: digest,
233
+ ...(opts.now ? { now: opts.now } : {}),
234
+ });
235
+ if (!check.satisfied) {
236
+ if (!opts.json) {
237
+ const sealed = await gateIsSealed(found.name);
238
+ if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_PROMOTE_GATE_OP, found.name, check.mismatch, sealed) }));
239
+ if (isLifecycleLocalNote(check.pushWarning)) console.error(formatInfo(LIFECYCLE_LOCAL_NOTE));
240
+ else if (check.pushWarning) console.error(formatWarning({ message: check.pushWarning }));
241
+ console.error(
242
+ formatInfo(
243
+ `Gated: the patch ${digest} waits for approval. Review it (--output <file> writes it), then run \`${approveCommand(WORKSPACE_PROMOTE_GATE_OP, found.name, undefined, undefined, sealed)}\` and repeat this command to apply it.`,
244
+ ),
245
+ );
246
+ }
247
+ return report("gated", PROMOTE_GATED_EXIT, { pending: check.pending });
248
+ }
249
+
250
+ const patchDir = mkdtempSync(join(tmpdir(), "chant-promote-patch-"));
251
+ try {
252
+ const file = join(patchDir, "promote.patch");
253
+ writeFileSync(file, patch);
254
+ try {
255
+ git(repo, ["apply", "--binary", "--whitespace=nowarn", file]);
256
+ } catch (err) {
257
+ const stderr = (err as { stderr?: Buffer | string }).stderr?.toString().trim();
258
+ throw new PromoteError(`the approved patch no longer applies to the tree${stderr ? `: ${stderr}` : ""}`);
259
+ }
260
+ } finally {
261
+ rmSync(patchDir, { recursive: true, force: true });
262
+ }
263
+ for (const step of followUpRuns(rung, member, opts)) {
264
+ const result = await run(step.argv, join(root, step.cwd));
265
+ ran.push({ label: step.label, exitCode: result.exitCode });
266
+ if (result.exitCode !== 0) {
267
+ say(formatError({ message: `${step.label} failed after the patch was applied`, hint: result.output.trimEnd().split("\n").slice(-20).join("\n") }));
268
+ return report("applied", 1, { followUpFailed: step.label });
269
+ }
270
+ }
271
+ say(formatSuccess(`applied the approved promotion of "${found.name}" to ${rung.to} (${digest}). Review and commit it.`));
272
+ return report("applied", 0);
273
+ } finally {
274
+ dispose();
275
+ }
276
+ }
277
+
278
+ export async function runWorkspacePromote(ctx: CommandContext): Promise<number> {
279
+ const { args } = ctx;
280
+ if (!args.extraPositional || args.extraPositional2) {
281
+ console.error(formatError({ message: "promote takes exactly one member", hint: USAGE }));
282
+ return 1;
283
+ }
284
+ try {
285
+ const result = await promoteCommand({
286
+ root: process.cwd(),
287
+ member: args.extraPositional,
288
+ state: args.statePath,
289
+ lexicon: args.lexicon,
290
+ dryRun: args.dryRun,
291
+ output: args.output,
292
+ json: args.json,
293
+ });
294
+ return result.exitCode;
295
+ } catch (err) {
296
+ if (err instanceof PromoteError) {
297
+ console.error(formatError({ message: err.message, hint: USAGE }));
298
+ return 1;
299
+ }
300
+ throw err;
301
+ }
302
+ }
@@ -31,6 +31,7 @@ import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
31
31
  import { SECURITY_FINDING_CODES } from "./checks/security";
32
32
  import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
33
33
  import { CI_BRANCH_FINDING_CODES } from "./checks/ci-branch";
34
+ import { GATE_APPROVAL_FINDING_CODES } from "./checks/gate-approval";
34
35
  import { WORK_ERROR_CODES, WORK_HISTORY_ERROR_CODES, WORK_LEASE_REFUSALS } from "./work-cli";
35
36
  import { RUN_STATEMENT_ERROR_CODES, RUNS_ERROR_CODES, RUNS_REASON_CODES, RUNS_WRITE_ERROR_CODES } from "./runs-cli";
36
37
  import { RUN_STATEMENT_FAILURE_CODES } from "./trust/run-statement";
@@ -51,6 +52,7 @@ import { DECLARATION_WRITE_ERROR_CODES } from "./declaration-write";
51
52
  import { TRUST_WRITE_ERROR_CODES } from "./trust/trust-write";
52
53
  import { BOX_PUBLISH_ERROR_CODES } from "./box-publish";
53
54
  import { WRITE_LOCK_ERROR_CODES } from "./write-lock-cli";
55
+ import { EXIT_CODES } from "./exit";
54
56
  import { WIP_ERROR_CODES, WIP_WRITE_ERROR_CODES } from "./wip";
55
57
  import { CI_GREEN_ERROR_CODES } from "./ci-green";
56
58
  import { ROTATION_REFUSAL_CODES } from "./trust/rotation";
@@ -75,6 +77,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
75
77
  SECURITY_FINDING_CODES,
76
78
  CI_WORKFLOW_FINDING_CODES,
77
79
  CI_BRANCH_FINDING_CODES,
80
+ GATE_APPROVAL_FINDING_CODES,
78
81
  STATUS_REASON_CODES,
79
82
  STATUS_ERROR_CODES,
80
83
  STATUS_GATE_REASON_CODES,
@@ -144,6 +147,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
144
147
  SEARCH_MEMBER_REASON_CODES,
145
148
  CAPABILITIES_ERROR_CODES,
146
149
  CAPABILITY_SUPPORT_CODES,
150
+ EXIT_CODES,
147
151
  };
148
152
 
149
153
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */
@@ -275,6 +275,7 @@ export const REASONS = {
275
275
  "ci-workflow-pipeline-missing": "Under ci.workflow, no pipeline file runs the plan job, the merge job or chant ci tick.",
276
276
  "ci-workflow-pipeline-differs": "Under ci.workflow, the pipeline does not match the workflow: it applies on merge under trunk-curated, records under trunk-curated+apply-on-merge, or applies before merge, or fails to, against apply-before-merge.",
277
277
  "ci-workflow-publish-missing": "Under ci.workflow, no pipeline file runs chant release publish.",
278
+ "gate-approval-unbased": "An Op gate's approval block sets a quorum, policy, mode or context that identity.gates does not set at base, so the change being run can edit it.",
278
279
  "ci-branch-route-differs": "A member's generated pull-request pipeline targets a branch other than the one ci.branches routes the member to.",
279
280
  // A box that holds a credential (check, #2726). Each is also a WSP finding.
280
281
  "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
@@ -376,6 +377,19 @@ export const REASONS = {
376
377
  // The lineage lock (check).
377
378
  "lock-invalid": "The lineage lock can't be read.",
378
379
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
380
+ // workspace exit (#3177): why a member can't be exited, and what the exit drill finds wrong with one that was.
381
+ "missing-dir": "The chant member's directory does not exist, so there is nothing to build.",
382
+ "no-lexicon": "No lexicon is named in the member's chant.config.* or package.json, so there is nothing to build.",
383
+ "lexicon-unsupported": "A lexicon the member uses has no native target in workspace exit.",
384
+ "exit-failed": "The member's exit did not finish: its build failed, or it was refused.",
385
+ "choudoufu-steps-missing": "A choudoufu member's exit listed no roundtrip steps.",
386
+ "native-missing": "A native file the exit wrote is missing or empty.",
387
+ "native-unreadable": "A native file the exit wrote is not valid JSON, or has no content.",
388
+ "no-deploy-command": "A native file that can be deployed has no stock deploy command.",
389
+ "chant-config-left": "A chant.config.* file is still in the exited member.",
390
+ "chant-dependency": "The member's package.json still has a chant dependency, or a script that calls chant.",
391
+ "chant-in-ci": "A CI file of the exited member still calls chant.",
392
+ "declaration-not-other": "The declaration still gives the exited member a kind other than other.",
379
393
  } as const;
380
394
 
381
395
  export type ReasonCode = keyof typeof REASONS;
@@ -190,6 +190,45 @@
190
190
  }
191
191
  },
192
192
  "additionalProperties": false
193
+ },
194
+ "anyDocument": {
195
+ "type": "object",
196
+ "description": "The directory passes when a file directly in it, whose name matches one of in, parses as YAML or JSON and one of its documents satisfies an entry of match (#3179). An entry maps JSON Pointers to the values allowed there, all of which must hold; a * in a value matches any run of characters, so [\"*\"] means the pointer has a value. A file that does not parse is no evidence either way.",
197
+ "required": [
198
+ "in",
199
+ "match"
200
+ ],
201
+ "properties": {
202
+ "in": {
203
+ "type": "array",
204
+ "minItems": 1,
205
+ "uniqueItems": true,
206
+ "items": {
207
+ "$ref": "#/$defs/fileName"
208
+ }
209
+ },
210
+ "match": {
211
+ "type": "array",
212
+ "minItems": 1,
213
+ "items": {
214
+ "type": "object",
215
+ "minProperties": 1,
216
+ "propertyNames": {
217
+ "pattern": "^(/[^/]+)+$"
218
+ },
219
+ "additionalProperties": {
220
+ "type": "array",
221
+ "minItems": 1,
222
+ "uniqueItems": true,
223
+ "items": {
224
+ "type": "string",
225
+ "minLength": 1
226
+ }
227
+ }
228
+ }
229
+ }
230
+ },
231
+ "additionalProperties": false
193
232
  }
194
233
  },
195
234
  "additionalProperties": false
@@ -201,22 +240,26 @@
201
240
  },
202
241
  "graph": {
203
242
  "type": "object",
204
- "description": "How chant workspace graph reads a member of this kind (#2874). chant writes a reader project of its own outside the workspace, with a chant.config.json that declares lexicon and puts config under that lexicon's key, and runs the member's chant graph there. In every string key and value of config, {member} becomes the member's name, {dir} its absolute directory and {workspace} the absolute workspace root. Without this field a member of the kind is listed with kind-not-run.",
205
- "required": [
206
- "lexicon",
207
- "config"
208
- ],
243
+ "description": "How chant workspace graph reads a member of this kind (#2874, #4413). chant writes a reader project of its own outside the workspace and runs the member's chant graph there. The project either declares a lexicon and puts config under that lexicon's key (lexicon), or names a graph reader module the supplying package ships (reader). In every string key and value of config, {member} becomes the member's name, {dir} its absolute directory and {workspace} the absolute workspace root. Without this field a member of the kind is listed with kind-not-run.",
209
244
  "properties": {
210
245
  "lexicon": {
211
246
  "type": "string",
212
247
  "pattern": "^[a-z][a-z0-9-]{0,39}$",
213
- "description": "The lexicon that reads the member, by name. The package that supplies the kind must be @intentius/chant-lexicon-<lexicon>, so the lexicon chant loads is the one the declaration pins."
248
+ "description": "The lexicon that reads the member, by name. The package that supplies the kind must be @intentius/chant-lexicon-<lexicon>, so the lexicon chant loads is the one the declaration pins. Not with reader."
249
+ },
250
+ "reader": {
251
+ "type": "string",
252
+ "pattern": "^(?!/)(?!.*\\.\\.)(?!.*\\\\)[^\\u0000-\\u001f]+$",
253
+ "description": "A graph reader module, as a path inside the supplying package (#4413): an ES module whose default export is (input: { dir, config }) => GraphIR. The member's own chant graph loads it in the reader project, so it runs under the member's toolchain, and it must only read files. Any package may supply it, not only a lexicon. Not with lexicon. A chant older than this field can't read a kinds file that uses it."
214
254
  },
215
255
  "config": {
216
256
  "type": "object",
217
- "description": "The lexicon's config namespace for the reader project, such as terraform's roots. Placeholders are substituted; nothing else is interpreted."
257
+ "description": "With lexicon, the lexicon's config namespace for the reader project, such as terraform's roots. With reader, the config object the module is called with (optional). Placeholders are substituted; nothing else is interpreted."
218
258
  }
219
259
  },
260
+ "if": { "properties": { "reader": true }, "required": ["reader"] },
261
+ "then": { "not": { "properties": { "lexicon": true }, "required": ["lexicon"] } },
262
+ "else": { "properties": { "lexicon": true, "config": true }, "required": ["lexicon", "config"] },
220
263
  "patternProperties": {
221
264
  "^x-": true
222
265
  },