@intentius/chant 0.124.48 → 0.124.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -12
- package/dist/cli/handlers/graph-reader.d.ts +26 -0
- package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
- package/dist/cli/handlers/graph-reader.js +41 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +40 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +22 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +11 -0
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +3 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/gate-decision.d.ts +9 -0
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +22 -7
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-approval-base.d.ts +25 -0
- package/dist/op/gate-approval-base.d.ts.map +1 -0
- package/dist/op/gate-approval-base.js +71 -0
- package/dist/op/gate-approval.d.ts +7 -4
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +2 -25
- package/dist/op/gate-name.d.ts +13 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +14 -1
- package/dist/op/gate.d.ts +4 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +9 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +24 -5
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +4 -0
- package/dist/pr-migrations.d.ts +62 -0
- package/dist/pr-migrations.d.ts.map +1 -0
- package/dist/pr-migrations.js +50 -0
- package/dist/pr-post.js +1 -1
- package/dist/wave-runtime.mjs +219 -218
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/gate-approval.d.ts +58 -0
- package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
- package/dist/workspace/checks/gate-approval.js +103 -0
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -0
- package/dist/workspace/declaration.d.ts +7 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +13 -1
- package/dist/workspace/declaration.schema.json +33 -0
- package/dist/workspace/exit-cli.d.ts +44 -0
- package/dist/workspace/exit-cli.d.ts.map +1 -0
- package/dist/workspace/exit-cli.js +198 -0
- package/dist/workspace/exit.d.ts +159 -0
- package/dist/workspace/exit.d.ts.map +1 -0
- package/dist/workspace/exit.js +480 -0
- package/dist/workspace/exit.schema.json +257 -0
- package/dist/workspace/graph-cache.d.ts +3 -1
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +12 -1
- package/dist/workspace/import-fetch.d.ts +38 -0
- package/dist/workspace/import-fetch.d.ts.map +1 -0
- package/dist/workspace/import-fetch.js +99 -0
- package/dist/workspace/import-init.d.ts +26 -0
- package/dist/workspace/import-init.d.ts.map +1 -0
- package/dist/workspace/import-init.js +130 -0
- package/dist/workspace/import-source.d.ts +147 -0
- package/dist/workspace/import-source.d.ts.map +1 -0
- package/dist/workspace/import-source.js +370 -0
- package/dist/workspace/init.d.ts +4 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/init.js +70 -3
- package/dist/workspace/kind-readers.d.ts +6 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +18 -0
- package/dist/workspace/kinds.d.ts +24 -1
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +64 -4
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +13 -4
- package/dist/workspace/new-migrations.d.ts +47 -0
- package/dist/workspace/new-migrations.d.ts.map +1 -0
- package/dist/workspace/new-migrations.js +91 -0
- package/dist/workspace/pr-report.schema.json +46 -0
- package/dist/workspace/promote.d.ts +96 -0
- package/dist/workspace/promote.d.ts.map +1 -0
- package/dist/workspace/promote.js +263 -0
- package/dist/workspace/reason-codes.d.ts +13 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +14 -0
- package/dist/workspace/search-contract.d.ts +1 -1
- package/dist/workspace/search-contract.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +50 -7
- package/package.json +1 -1
- package/src/cli/handlers/approve.ts +10 -2
- package/src/cli/handlers/graph-reader.test.ts +31 -0
- package/src/cli/handlers/graph-reader.ts +51 -0
- package/src/cli/handlers/graph.ts +14 -0
- package/src/cli/handlers/pr.ts +38 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +22 -0
- package/src/cli/parse-args.ts +8 -0
- package/src/cli/registry.ts +8 -0
- package/src/components/pr-env-run-bundle.test.ts +1 -0
- package/src/components/pr-run.ts +4 -0
- package/src/config.ts +16 -0
- package/src/gate-decision-bundle.test.ts +4 -2
- package/src/gate-decision-identity.test.ts +11 -0
- package/src/gate-decision.ts +26 -6
- package/src/lexicon.ts +24 -0
- package/src/op/gate-approval-at-base.test.ts +97 -0
- package/src/op/gate-approval-base.ts +72 -0
- package/src/op/gate-approval.ts +10 -21
- package/src/op/gate-name.ts +16 -1
- package/src/op/gate.ts +4 -3
- package/src/op/local-executor.ts +25 -5
- package/src/pr-loop-bundle.test.ts +3 -2
- package/src/pr-loop.test.ts +52 -0
- package/src/pr-loop.ts +12 -0
- package/src/pr-migrations.ts +97 -0
- package/src/pr-post.ts +1 -1
- package/src/workspace/__fixtures__/import/spacelift.json +9 -0
- package/src/workspace/__fixtures__/import/tfc.json +13 -0
- package/src/workspace/behold-kinds.test.ts +1 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/gate-approval.test.ts +77 -0
- package/src/workspace/checks/gate-approval.ts +121 -0
- package/src/workspace/checks.ts +4 -0
- package/src/workspace/declaration.schema.json +33 -0
- package/src/workspace/declaration.ts +19 -2
- package/src/workspace/exit-cli.ts +219 -0
- package/src/workspace/exit.schema.json +257 -0
- package/src/workspace/exit.test.ts +185 -0
- package/src/workspace/exit.ts +560 -0
- package/src/workspace/graph-cache.ts +12 -2
- package/src/workspace/identity.test.ts +18 -3
- package/src/workspace/import-fetch.ts +151 -0
- package/src/workspace/import-init.ts +147 -0
- package/src/workspace/import-source.test.ts +183 -0
- package/src/workspace/import-source.ts +462 -0
- package/src/workspace/init.test.ts +55 -1
- package/src/workspace/init.ts +66 -4
- package/src/workspace/kind-readers.test.ts +36 -0
- package/src/workspace/kind-readers.ts +18 -1
- package/src/workspace/kinds.test.ts +23 -0
- package/src/workspace/kinds.ts +81 -5
- package/src/workspace/lineage-upgrade.test.ts +20 -0
- package/src/workspace/lineage-upgrade.ts +16 -7
- package/src/workspace/new-migrations.test.ts +98 -0
- package/src/workspace/new-migrations.ts +109 -0
- package/src/workspace/pr-report.schema.json +46 -0
- package/src/workspace/promote.test.ts +114 -0
- package/src/workspace/promote.ts +302 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +14 -0
- package/src/workspace/workspace-kinds.schema.json +50 -7
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP153 (#4404): an Op gate's `approval` block that `identity.gates` does
|
|
3
|
+
* not back at base.
|
|
4
|
+
*
|
|
5
|
+
* A run reads a gate's `quorum`, `policy`, `mode` and `context` from
|
|
6
|
+
* `identity.gates.<gate>` in the declaration at base (#4324, #4404). A field
|
|
7
|
+
* the entry leaves out is read from the Op being run, which the change under
|
|
8
|
+
* review can edit, so a pull request can lower a quorum or switch a policy to
|
|
9
|
+
* `log-only` and be judged by its own edit. The Op-side fallback stays so an
|
|
10
|
+
* existing Op does not drop to one approval overnight. This check names each
|
|
11
|
+
* gate that still leans on it, and the fields it leans on.
|
|
12
|
+
*
|
|
13
|
+
* It reads each member's built Ops (`dist/ops/<name>/op.json`) and runs
|
|
14
|
+
* nothing. An Op that has not been built is not seen.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
|
|
18
|
+
import { joinPath } from "../tree";
|
|
19
|
+
import { read } from "./security";
|
|
20
|
+
|
|
21
|
+
export const WSP_GATE_APPROVAL_UNBASED = "WSP153";
|
|
22
|
+
|
|
23
|
+
/** The finding code WSP153 reports. */
|
|
24
|
+
export const GATE_APPROVAL_FINDING_CODES = ["gate-approval-unbased"] as const;
|
|
25
|
+
|
|
26
|
+
const memberDir = (dir: string) => (dir === "." ? "" : dir);
|
|
27
|
+
|
|
28
|
+
type Fields = "quorum" | "policy" | "mode" | "context";
|
|
29
|
+
|
|
30
|
+
interface GateUse {
|
|
31
|
+
gate: string;
|
|
32
|
+
/** What the Op's `approval` block sets. Context is the keys it sets. */
|
|
33
|
+
quorum: boolean;
|
|
34
|
+
policy: boolean;
|
|
35
|
+
mode: boolean;
|
|
36
|
+
context: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface StepJson {
|
|
40
|
+
kind?: string;
|
|
41
|
+
gate?: string;
|
|
42
|
+
signalName?: string;
|
|
43
|
+
approval?: { quorum?: unknown; policy?: unknown; mode?: unknown; context?: Record<string, unknown> };
|
|
44
|
+
steps?: StepJson[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The gates of a built Op that declare an `approval` block, with the fields it sets. A gate that asks a decision point has none. */
|
|
48
|
+
export function gateUses(op: { phases?: { steps?: StepJson[] }[]; onFailure?: { steps?: StepJson[] }[] }): GateUse[] {
|
|
49
|
+
const out: GateUse[] = [];
|
|
50
|
+
const visit = (step: StepJson): void => {
|
|
51
|
+
if (step.kind === "gate" && step.approval && typeof step.approval === "object") {
|
|
52
|
+
const a = step.approval;
|
|
53
|
+
out.push({
|
|
54
|
+
gate: step.gate ?? step.signalName ?? "",
|
|
55
|
+
quorum: a.quorum !== undefined,
|
|
56
|
+
policy: a.policy !== undefined,
|
|
57
|
+
mode: a.mode !== undefined,
|
|
58
|
+
context: a.context && typeof a.context === "object" ? Object.keys(a.context) : [],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (step.kind === "effect") (step.steps ?? []).forEach(visit);
|
|
62
|
+
};
|
|
63
|
+
for (const p of [...(op.phases ?? []), ...(op.onFailure ?? [])]) (p.steps ?? []).forEach(visit);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The fields of `use` that `entry` (the gate's `identity.gates` entry, or undefined) does not set, as phrases. Empty when base backs every one. */
|
|
68
|
+
export function unbasedFields(use: GateUse, entry: { quorum: unknown; policy: unknown; mode: unknown; context: Record<string, unknown> | null } | undefined): string[] {
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
const has = (f: Exclude<Fields, "context">): boolean => entry?.[f] !== null && entry?.[f] !== undefined;
|
|
71
|
+
for (const f of ["quorum", "policy", "mode"] as const) if (use[f] && !has(f)) out.push(f);
|
|
72
|
+
const loose = use.context.filter((k) => !(k in (entry?.context ?? {})));
|
|
73
|
+
if (loose.length > 0) out.push(`context (${loose.join(", ")})`);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const GATE_APPROVAL_CHECKS: readonly WorkspaceCheck[] = [
|
|
78
|
+
{
|
|
79
|
+
id: WSP_GATE_APPROVAL_UNBASED,
|
|
80
|
+
name: "gate-approval-unbased",
|
|
81
|
+
description: "An Op gate declares an approval block whose quorum, policy, mode or context identity.gates does not set at base, so the change being run can edit it.",
|
|
82
|
+
severity: "warning",
|
|
83
|
+
configurable: true,
|
|
84
|
+
check(ctx) {
|
|
85
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
86
|
+
const gates = ctx.declaration.identity?.gates ?? {};
|
|
87
|
+
for (const member of ctx.declaration.members) {
|
|
88
|
+
const opsDir = joinPath(memberDir(member.dir), "dist", "ops");
|
|
89
|
+
for (const entry of ctx.tree.list(opsDir) ?? []) {
|
|
90
|
+
if (entry.type !== "dir") continue;
|
|
91
|
+
const path = joinPath(opsDir, entry.name, "op.json");
|
|
92
|
+
const text = read(ctx.tree, path);
|
|
93
|
+
if (text === null) continue;
|
|
94
|
+
let op: Parameters<typeof gateUses>[0] & { name?: string };
|
|
95
|
+
try {
|
|
96
|
+
op = JSON.parse(text);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const use of gateUses(op)) {
|
|
101
|
+
const base = gates[use.gate];
|
|
102
|
+
const missing = unbasedFields(use, base);
|
|
103
|
+
if (missing.length === 0) continue;
|
|
104
|
+
out.push({
|
|
105
|
+
checkId: this.id,
|
|
106
|
+
severity: this.severity,
|
|
107
|
+
code: GATE_APPROVAL_FINDING_CODES[0],
|
|
108
|
+
message: `WSP153: gate "${use.gate}" in Op ${op.name ?? entry.name} sets ${missing.join(", ")} in its approval block, and ${base ? `identity.gates.${use.gate}` : "identity.gates"} sets ${base ? "none of them" : `no entry for it`} at base, so a change can edit ${missing.length === 1 ? "it" : "them"} for its own run; declare ${base ? "them" : `identity.gates.${use.gate}`} in chant.workspace.json`,
|
|
109
|
+
pointer: base ? base.pointer : "/identity",
|
|
110
|
+
entity: use.gate,
|
|
111
|
+
treeFile: path,
|
|
112
|
+
line: 1,
|
|
113
|
+
column: 1,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
];
|
package/src/workspace/checks.ts
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
|
|
41
41
|
* | WSP151 | what a member under `ci.workflow` still lacks (#4168 step 6) |
|
|
42
42
|
* | WSP152 | a member's generated pipeline targets the branch `ci.branches` routes it to (#4325) |
|
|
43
|
+
* | WSP153 | an Op gate's approval block that `identity.gates` does not set at base (#4404) |
|
|
43
44
|
* | WSP161 to WSP165 | the security groups the declaration opts into: what each still lacks (#4168 step 7) |
|
|
44
45
|
*/
|
|
45
46
|
|
|
@@ -65,6 +66,7 @@ import { DIAGRAM_CHECKS } from "./checks/diagrams";
|
|
|
65
66
|
import { SECURITY_CHECKS } from "./checks/security";
|
|
66
67
|
import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
|
|
67
68
|
import { CI_BRANCH_CHECKS } from "./checks/ci-branch";
|
|
69
|
+
import { GATE_APPROVAL_CHECKS } from "./checks/gate-approval";
|
|
68
70
|
import { ORG_CHECKS, type ForgeRepoList } from "./checks/org";
|
|
69
71
|
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
70
72
|
import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
|
|
@@ -425,6 +427,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
425
427
|
...CI_WORKFLOW_CHECKS,
|
|
426
428
|
// A member's pipeline targets the branch ci.branches routes it to (#4325), WSP152.
|
|
427
429
|
...CI_BRANCH_CHECKS,
|
|
430
|
+
// An Op gate's approval block that identity.gates does not set at base (#4404), WSP153.
|
|
431
|
+
...GATE_APPROVAL_CHECKS,
|
|
428
432
|
// The security groups the declaration opts into (#4168 step 7), WSP161 to WSP165.
|
|
429
433
|
...SECURITY_CHECKS,
|
|
430
434
|
];
|
|
@@ -175,6 +175,26 @@
|
|
|
175
175
|
{ "$ref": "#/$defs/gateQuorumRule" },
|
|
176
176
|
{ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/gateQuorumRule" } }
|
|
177
177
|
]
|
|
178
|
+
},
|
|
179
|
+
"policy": {
|
|
180
|
+
"$ref": "#/$defs/gatePolicyRef",
|
|
181
|
+
"description": "The policy set an Op gate of this name takes (#4404), in place of the Op's own approval.policy. Read at base, so a change can't swap in a permissive policy for its own run. Build the object with the owning lexicon's helper (the cedar lexicon's gatePolicy()) and paste it here. Added in schema 1 by chant 0.124.49, so a declaration that uses it sets minReader to 0.124.49 or newer."
|
|
182
|
+
},
|
|
183
|
+
"mode": {
|
|
184
|
+
"enum": ["log-only", "enforce"],
|
|
185
|
+
"description": "Whether the policy's decision binds the gate (#4404), in place of the Op's own approval.mode: log-only records it next to the approval, enforce passes the gate only on a permit. Read at base, so a change can't switch its own gate to a mode that passes it. Added in schema 1 by chant 0.124.49, so a declaration that uses it sets minReader to 0.124.49 or newer."
|
|
186
|
+
},
|
|
187
|
+
"context": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"description": "Plan attributes handed to the policy (#4404), over the Op's own approval.context, key by key: a change can't edit a key declared here, and a key only the Op declares still reaches the policy. A value is a plain one, since a step output reference is resolved at run time and a declaration can't hold one. plan is reserved. Added in schema 1 by chant 0.124.49, so a declaration that uses it sets minReader to 0.124.49 or newer.",
|
|
190
|
+
"additionalProperties": {
|
|
191
|
+
"oneOf": [
|
|
192
|
+
{ "type": "string" },
|
|
193
|
+
{ "type": "number" },
|
|
194
|
+
{ "type": "boolean" },
|
|
195
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
196
|
+
]
|
|
197
|
+
}
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
}
|
|
@@ -460,6 +480,19 @@
|
|
|
460
480
|
"type": "string",
|
|
461
481
|
"pattern": "^[a-z][a-z0-9-]{0,39}$"
|
|
462
482
|
},
|
|
483
|
+
"gatePolicyRef": {
|
|
484
|
+
"type": "object",
|
|
485
|
+
"additionalProperties": false,
|
|
486
|
+
"required": ["kind", "lexicon", "name", "version", "text"],
|
|
487
|
+
"description": "A gate policy set a lexicon rendered (#2508), as plain data.",
|
|
488
|
+
"properties": {
|
|
489
|
+
"kind": { "const": "gate-policy" },
|
|
490
|
+
"lexicon": { "type": "string", "minLength": 1, "description": "The lexicon whose gate-policy module evaluates text." },
|
|
491
|
+
"name": { "type": "string", "minLength": 1, "description": "A name for the policy set, carried into every recorded decision." },
|
|
492
|
+
"version": { "type": "string", "minLength": 1, "description": "sha256: and the hex digest of text. A recorded permit counts only under the version it was evaluated against." },
|
|
493
|
+
"text": { "type": "string", "minLength": 1, "description": "The policy set, in the lexicon's own language." }
|
|
494
|
+
}
|
|
495
|
+
},
|
|
463
496
|
"gateQuorumRule": {
|
|
464
497
|
"type": "object",
|
|
465
498
|
"additionalProperties": false,
|
|
@@ -31,6 +31,7 @@ import { loadAjv2020 } from "./ajv2020";
|
|
|
31
31
|
import { readChantPackageVersion } from "../package-version";
|
|
32
32
|
import { assertPrLoopHooks, type OpWaveHooks } from "../op/op-waves";
|
|
33
33
|
import { quorumProblems, type GateQuorumSpec } from "../op/gate-quorum";
|
|
34
|
+
import { gateBaseApprovalProblems, type GateApprovalMode, type GatePolicyRef } from "../op/gate-approval";
|
|
34
35
|
import { assertGatePolicySettings, type GatePolicySettings } from "../gate-policy";
|
|
35
36
|
|
|
36
37
|
export const DECLARATION_SCHEMA_ID = schema.$id;
|
|
@@ -167,6 +168,12 @@ export interface SignedGate {
|
|
|
167
168
|
* the Op's own `approval.quorum` then applies.
|
|
168
169
|
*/
|
|
169
170
|
quorum: GateQuorumSpec | null;
|
|
171
|
+
/** The policy an Op gate of this name takes in place of its own `approval.policy` (#4404). Null when the entry sets none. */
|
|
172
|
+
policy: GatePolicyRef | null;
|
|
173
|
+
/** The mode an Op gate of this name runs its policy in, in place of its own `approval.mode` (#4404). Null when the entry sets none. */
|
|
174
|
+
mode: GateApprovalMode | null;
|
|
175
|
+
/** The context an Op gate of this name hands its policy, in place of its own `approval.context` (#4404). Null when the entry sets none. */
|
|
176
|
+
context: Record<string, string | number | boolean | string[]> | null;
|
|
170
177
|
pointer: string;
|
|
171
178
|
}
|
|
172
179
|
|
|
@@ -1247,7 +1254,7 @@ function writeScopeOf(raw: unknown, members: Member[], at: (pointer: string, key
|
|
|
1247
1254
|
*/
|
|
1248
1255
|
function identityOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): IdentityPolicy | null {
|
|
1249
1256
|
if (raw === undefined) return null;
|
|
1250
|
-
const block = raw as { attribution?: "any" | "identified"; gates?: Record<string, { class?: string; quorum?: GateQuorumSpec }> };
|
|
1257
|
+
const block = raw as { attribution?: "any" | "identified"; gates?: Record<string, { class?: string; quorum?: GateQuorumSpec; policy?: GatePolicyRef; mode?: GateApprovalMode; context?: Record<string, string | number | boolean | string[]> }> };
|
|
1251
1258
|
const gates: Record<string, SignedGate> = {};
|
|
1252
1259
|
for (const [gate, entry] of Object.entries(block.gates ?? {})) {
|
|
1253
1260
|
const pointer = `/identity/gates/${gate.replace(/~/g, "~0").replace(/\//g, "~1")}`;
|
|
@@ -1255,7 +1262,17 @@ function identityOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorL
|
|
|
1255
1262
|
const [problem] = quorumProblems(entry.quorum, `identity.gates.${gate}.quorum`, { notAuthor: false });
|
|
1256
1263
|
if (problem) throw new WorkspaceReadError("declaration-invalid", problem.replace(/`/g, ""), at(`${pointer}/quorum`));
|
|
1257
1264
|
}
|
|
1258
|
-
|
|
1265
|
+
const [rest] = gateBaseApprovalProblems(entry, `identity.gates.${gate}`);
|
|
1266
|
+
if (rest) throw new WorkspaceReadError("declaration-invalid", rest.replace(/`/g, ""), at(pointer));
|
|
1267
|
+
gates[gate] = {
|
|
1268
|
+
gate,
|
|
1269
|
+
class: entry.class ?? null,
|
|
1270
|
+
quorum: entry.quorum !== undefined ? structuredClone(entry.quorum) : null,
|
|
1271
|
+
policy: entry.policy !== undefined ? structuredClone(entry.policy) : null,
|
|
1272
|
+
mode: entry.mode ?? null,
|
|
1273
|
+
context: entry.context !== undefined ? structuredClone(entry.context) : null,
|
|
1274
|
+
pointer,
|
|
1275
|
+
};
|
|
1259
1276
|
}
|
|
1260
1277
|
return { attribution: block.attribution ?? "any", gates };
|
|
1261
1278
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace exit [<member>...] [--dry-run] [--drill] [--output <file>] [--json]`
|
|
3
|
+
* (#3177).
|
|
4
|
+
*
|
|
5
|
+
* Stages the exit in a git worktree (see ./exit.ts), checks the staged tree
|
|
6
|
+
* with the same static checks the exit drill uses, then decides a gate. The
|
|
7
|
+
* gate is `workspace-exit/<scope>` on the gate ledger and binds the digest of
|
|
8
|
+
* the patch, as `workspace upgrade` does: the first run records the pending
|
|
9
|
+
* fact and exits 3; after `chant approve workspace-exit <scope>` the same
|
|
10
|
+
* command stages the same patch and applies it to the tree.
|
|
11
|
+
*
|
|
12
|
+
* `--drill` skips the worktree and the gate: it runs the exit on a scratch
|
|
13
|
+
* copy and prints the per-member result, the check CI runs on every commit.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
21
|
+
import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/format";
|
|
22
|
+
import type { CommandContext } from "../cli/registry";
|
|
23
|
+
import { approveCommand, describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
24
|
+
import { WORKSPACE_EXIT_GATE_OP } from "../op/gate-name";
|
|
25
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
26
|
+
import { describeDrill, ExitError, exitInDir, planExit, runExitDrill, verifyExit, type DrillReport, type ExitBuildRunner, type ExitResult } from "./exit";
|
|
27
|
+
import { spawnChant } from "./lineage-upgrade";
|
|
28
|
+
|
|
29
|
+
const USAGE = "chant workspace exit [<member>...] [--member <name>] [--dry-run] [--drill] [--output <patch file>] [--json]";
|
|
30
|
+
|
|
31
|
+
/** The exit code of a gated exit: the same as `chant run`'s gated run. */
|
|
32
|
+
export const EXIT_GATED = 3;
|
|
33
|
+
|
|
34
|
+
const EXIT_DIR = ".chant/exit";
|
|
35
|
+
|
|
36
|
+
export interface ExitCommandOptions {
|
|
37
|
+
root: string;
|
|
38
|
+
members?: string[];
|
|
39
|
+
dryRun?: boolean;
|
|
40
|
+
drill?: boolean;
|
|
41
|
+
output?: string;
|
|
42
|
+
json?: boolean;
|
|
43
|
+
/** For tests. */
|
|
44
|
+
runBuild?: ExitBuildRunner;
|
|
45
|
+
ledger?: GateLedgerPort;
|
|
46
|
+
now?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ExitOutcome = "drill" | "nothing-to-exit" | "checks-failed" | "dry-run" | "gated" | "applied";
|
|
50
|
+
|
|
51
|
+
export interface ExitCommandResult {
|
|
52
|
+
outcome: ExitOutcome;
|
|
53
|
+
exitCode: number;
|
|
54
|
+
result?: ExitResult;
|
|
55
|
+
report?: DrillReport;
|
|
56
|
+
digest?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function git(cwd: string, args: string[]): string {
|
|
60
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultBuild: ExitBuildRunner = (command, cwd, args) => spawnChant(command, cwd, args);
|
|
64
|
+
|
|
65
|
+
export async function exitCommand(opts: ExitCommandOptions): Promise<ExitCommandResult> {
|
|
66
|
+
const runBuild = opts.runBuild ?? defaultBuild;
|
|
67
|
+
const names = opts.members ?? [];
|
|
68
|
+
const print = (lines: string[]) => {
|
|
69
|
+
if (!opts.json) console.log(lines.join("\n"));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (opts.drill) {
|
|
73
|
+
const report = await runExitDrill({ root: opts.root, members: names, runBuild });
|
|
74
|
+
if (opts.json) console.log(JSON.stringify({ outcome: "drill", ...report }, null, 2));
|
|
75
|
+
else print(describeDrill(report));
|
|
76
|
+
return { outcome: "drill", exitCode: report.ok ? 0 : 1, report };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let repo: string;
|
|
80
|
+
let head: string;
|
|
81
|
+
try {
|
|
82
|
+
repo = git(opts.root, ["rev-parse", "--show-toplevel"]).trim();
|
|
83
|
+
head = git(opts.root, ["rev-parse", "HEAD"]).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
throw new ExitError("chant workspace exit stages its change in a git worktree, so the project must be in a git repository with at least one commit");
|
|
86
|
+
}
|
|
87
|
+
const dirty = git(repo, ["status", "--porcelain", "--untracked-files=all"])
|
|
88
|
+
.split("\n")
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.filter((l) => !l.slice(3).includes(`${EXIT_DIR}/`));
|
|
91
|
+
if (dirty.length > 0) {
|
|
92
|
+
throw new ExitError(`the tree has uncommitted changes (${dirty.slice(0, 5).map((l) => l.slice(3)).join(", ")}${dirty.length > 5 ? ", ..." : ""}). Commit them: the exit stages from HEAD and applies its patch to the tree.`);
|
|
93
|
+
}
|
|
94
|
+
const rel = relative(repo, opts.root).split(sep).join("/");
|
|
95
|
+
const holder = join(opts.root, EXIT_DIR);
|
|
96
|
+
mkdirSync(holder, { recursive: true });
|
|
97
|
+
const worktree = mkdtempSync(join(holder, "wt-"));
|
|
98
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
99
|
+
git(repo, ["worktree", "add", "--detach", "-q", worktree, head]);
|
|
100
|
+
const dispose = () => {
|
|
101
|
+
try {
|
|
102
|
+
git(repo, ["worktree", "remove", "--force", worktree]);
|
|
103
|
+
} catch {
|
|
104
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const project = rel ? join(worktree, rel) : worktree;
|
|
109
|
+
const plan = planExit(project, names);
|
|
110
|
+
const result = await exitInDir(plan, runBuild);
|
|
111
|
+
const report = verifyExit(project, result);
|
|
112
|
+
git(worktree, ["add", "-A"]);
|
|
113
|
+
const patch = git(worktree, ["diff", "--cached", "--binary", "HEAD"]);
|
|
114
|
+
const digest = `sha256:${createHash("sha256").update(patch).digest("hex").slice(0, 16)}`;
|
|
115
|
+
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
// printed once, below
|
|
118
|
+
} else {
|
|
119
|
+
print(describeExit(result, digest));
|
|
120
|
+
print(describeDrill(report));
|
|
121
|
+
}
|
|
122
|
+
const out = (outcome: ExitOutcome, exitCode: number, extra: Record<string, unknown> = {}): ExitCommandResult => {
|
|
123
|
+
if (opts.json) console.log(JSON.stringify({ outcome, digest, ...result, report, ...extra }, null, 2));
|
|
124
|
+
return { outcome, exitCode, result, report, digest };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (opts.output) writeFileSync(resolve(opts.output), patch);
|
|
128
|
+
if (!patch) {
|
|
129
|
+
if (!opts.json) console.error(formatInfo("nothing to exit: no member changed"));
|
|
130
|
+
return out("nothing-to-exit", 0);
|
|
131
|
+
}
|
|
132
|
+
if (!report.ok) {
|
|
133
|
+
if (!opts.json) console.error(formatError({ message: "the exit was not gated: its checks must pass first. The tree is unchanged." }));
|
|
134
|
+
return out("checks-failed", 1);
|
|
135
|
+
}
|
|
136
|
+
if (opts.dryRun) {
|
|
137
|
+
if (!opts.json) console.error(formatInfo("--dry-run: staged and checked; no gate recorded, the tree is unchanged"));
|
|
138
|
+
return out("dry-run", 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const gate = names.length > 0 ? names.join("+") : "workspace";
|
|
142
|
+
const check = await evaluateGate(opts.ledger ?? gitGateLedgerPort({ cwd: repo }), {
|
|
143
|
+
op: WORKSPACE_EXIT_GATE_OP,
|
|
144
|
+
gate,
|
|
145
|
+
description: `leave chant: ${gate}`,
|
|
146
|
+
planDigest: digest,
|
|
147
|
+
...(opts.now ? { now: opts.now } : {}),
|
|
148
|
+
});
|
|
149
|
+
if (!check.satisfied) {
|
|
150
|
+
if (!opts.json) {
|
|
151
|
+
const sealed = await gateIsSealed(gate);
|
|
152
|
+
if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_EXIT_GATE_OP, gate, check.mismatch, sealed) }));
|
|
153
|
+
console.error(
|
|
154
|
+
formatInfo(
|
|
155
|
+
`Gated: the patch ${digest} waits for approval. Review it (--output <file> writes it), then run \`${approveCommand(WORKSPACE_EXIT_GATE_OP, gate, undefined, undefined, sealed)}\` and repeat this command to apply it.`,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return out("gated", EXIT_GATED, { pending: check.pending });
|
|
160
|
+
}
|
|
161
|
+
applyPatch(repo, patch);
|
|
162
|
+
if (!opts.json) console.error(formatSuccess(`applied the approved exit (${digest}), approved by ${check.resolution.resolvedBy}. Review and commit it.`));
|
|
163
|
+
return out("applied", 0, { approvedBy: check.resolution.resolvedBy });
|
|
164
|
+
} finally {
|
|
165
|
+
dispose();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyPatch(repo: string, patch: string): void {
|
|
170
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-exit-patch-"));
|
|
171
|
+
const file = join(dir, "exit.patch");
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync(file, patch);
|
|
174
|
+
git(repo, ["apply", "--binary", "--whitespace=nowarn", file]);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const stderr = (err as { stderr?: Buffer | string }).stderr?.toString().trim();
|
|
177
|
+
throw new ExitError(`the approved patch no longer applies to the tree${stderr ? `: ${stderr}` : ""}`);
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One line per member, for the terminal. */
|
|
184
|
+
export function describeExit(result: ExitResult, digest: string): string[] {
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
for (const m of result.members) {
|
|
187
|
+
lines.push(`${m.name} ${m.kind} ${m.status}${m.detail ? ` (${m.detail})` : ""}`);
|
|
188
|
+
for (const n of m.native) lines.push(` native: ${n}`);
|
|
189
|
+
for (const d of m.deploy) lines.push(` deploy: ${d}`);
|
|
190
|
+
for (const c of m.ci) lines.push(` ci: ${c}`);
|
|
191
|
+
for (const o of m.ops) lines.push(` kept: ${o}`);
|
|
192
|
+
for (const s of m.steps) lines.push(` step: ${s}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push(` patch: ${digest}`);
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runWorkspaceExit(ctx: CommandContext): Promise<number> {
|
|
199
|
+
const { args } = ctx;
|
|
200
|
+
// positionals are ["exit", <member>...]
|
|
201
|
+
const named = [...(args.positionals ?? []).slice(1), ...(args.members ?? [])];
|
|
202
|
+
const root = findWorkspaceRoot(process.cwd())?.dir ?? process.cwd();
|
|
203
|
+
try {
|
|
204
|
+
const result = await exitCommand({
|
|
205
|
+
root,
|
|
206
|
+
members: [...new Set(named)],
|
|
207
|
+
dryRun: args.dryRun,
|
|
208
|
+
drill: args.drill,
|
|
209
|
+
output: args.output,
|
|
210
|
+
json: args.json,
|
|
211
|
+
});
|
|
212
|
+
return result.exitCode;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!(err instanceof ExitError)) throw err;
|
|
215
|
+
console.error(formatError({ message: err.message, hint: USAGE }));
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|