@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.
- 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
package/src/op/gate-approval.ts
CHANGED
|
@@ -43,6 +43,9 @@ import { createHash } from "node:crypto";
|
|
|
43
43
|
import { lexiconModulePath, importLexiconPackage } from "../lexicon-module";
|
|
44
44
|
import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
|
|
45
45
|
import { GATE_PLAN_CONTEXT_KEY } from "./gate-plan-context";
|
|
46
|
+
import { GATE_APPROVAL_MODES, gateBaseApprovalProblems, gatePolicyVersion, isContextValue, isGatePolicyRef } from "./gate-approval-base";
|
|
47
|
+
|
|
48
|
+
export { GATE_APPROVAL_MODES, gateBaseApprovalProblems, gatePolicyVersion, isGatePolicyRef } from "./gate-approval-base";
|
|
46
49
|
import { quorumProblems, type GateQuorumRule, type GateQuorumSpec } from "./gate-quorum";
|
|
47
50
|
|
|
48
51
|
export type { GateQuorumRule, GateQuorumSpec } from "./gate-quorum";
|
|
@@ -50,7 +53,6 @@ export type { GateQuorumRule, GateQuorumSpec } from "./gate-quorum";
|
|
|
50
53
|
/** What a policy's decision does to the gate. */
|
|
51
54
|
export type GateApprovalMode = "log-only" | "enforce";
|
|
52
55
|
|
|
53
|
-
export const GATE_APPROVAL_MODES: readonly GateApprovalMode[] = ["log-only", "enforce"];
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* A policy set a lexicon rendered for a gate — plain data, so it lands in the
|
|
@@ -159,10 +161,6 @@ export interface GatePolicyEvaluator {
|
|
|
159
161
|
evaluateGatePolicy(policy: GatePolicyRef, request: GatePolicyRequest): Promise<GatePolicyAnswer> | GatePolicyAnswer;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
/** The version of a gate policy: `sha256:` and the hex digest of its text. A lexicon's helper stamps it, and OPS015 checks it still matches. */
|
|
163
|
-
export function gatePolicyVersion(text: string): string {
|
|
164
|
-
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
165
|
-
}
|
|
166
164
|
|
|
167
165
|
/**
|
|
168
166
|
* The digest of a gate's resolved approval context (#3774): `sha256:` and the
|
|
@@ -180,17 +178,6 @@ function sortedKeys(_key: string, value: unknown): unknown {
|
|
|
180
178
|
return value;
|
|
181
179
|
}
|
|
182
180
|
|
|
183
|
-
export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
|
|
184
|
-
if (typeof value !== "object" || value === null) return false;
|
|
185
|
-
const v = value as Record<string, unknown>;
|
|
186
|
-
return (
|
|
187
|
-
v.kind === "gate-policy" &&
|
|
188
|
-
typeof v.lexicon === "string" && v.lexicon !== "" &&
|
|
189
|
-
typeof v.name === "string" && v.name !== "" &&
|
|
190
|
-
typeof v.version === "string" && v.version !== "" &&
|
|
191
|
-
typeof v.text === "string" && v.text.trim() !== ""
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
181
|
|
|
195
182
|
/**
|
|
196
183
|
* Everything wrong with an `approval` block, one line each. The `gate()`
|
|
@@ -256,13 +243,15 @@ export function gateApprovalProblems(approval: unknown): string[] {
|
|
|
256
243
|
return problems;
|
|
257
244
|
}
|
|
258
245
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
246
|
+
/** A gate's `policy`, `mode` and `context` as `identity.gates.<gate>` declares them at base (#4404). A step output reference is not data a declaration can hold, so a context value is a plain one. */
|
|
247
|
+
export interface GateBaseApproval {
|
|
248
|
+
policy?: GatePolicyRef | null;
|
|
249
|
+
mode?: GateApprovalMode | null;
|
|
250
|
+
context?: Record<string, string | number | boolean | string[]> | null;
|
|
264
251
|
}
|
|
265
252
|
|
|
253
|
+
|
|
254
|
+
|
|
266
255
|
/** Import `lexicon`'s evaluator. Throws a message that names the package when it is not installed or exports none. */
|
|
267
256
|
export async function loadGatePolicyEvaluator(lexicon: string): Promise<GatePolicyEvaluator> {
|
|
268
257
|
// chant #2520 — a lexicon declared by module path exports its evaluator from that module.
|
package/src/op/gate-name.ts
CHANGED
|
@@ -37,6 +37,21 @@ export const FAN_OUT_GATE_OP = "fan-out";
|
|
|
37
37
|
*/
|
|
38
38
|
export const WORKSPACE_UPGRADE_GATE_OP = "workspace-upgrade";
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The op name a `chant workspace exit` gate is recorded under (#3177). The gate
|
|
42
|
+
* is the exit's scope (`workspace`, or the member names joined with `+`), and
|
|
43
|
+
* it binds the digest of the exit's patch. Like an upgrade it is a command
|
|
44
|
+
* with no `*.op.ts` behind it, and leaving is a person's decision.
|
|
45
|
+
*/
|
|
46
|
+
export const WORKSPACE_EXIT_GATE_OP = "workspace-exit";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The op name a `chant workspace promote` gate is recorded under (#3178). The
|
|
50
|
+
* gate is the member, and it binds the digest of the promotion's patch. A
|
|
51
|
+
* command with no `*.op.ts` behind it, like the upgrade.
|
|
52
|
+
*/
|
|
53
|
+
export const WORKSPACE_PROMOTE_GATE_OP = "workspace-promote";
|
|
54
|
+
|
|
40
55
|
/**
|
|
41
56
|
* The op name the override gate records denials and overrides under (#3760):
|
|
42
57
|
* `_gates/policy-override.jsonl`, gate the subject a policy denied. Like a
|
|
@@ -69,7 +84,7 @@ const PR_GATE_OP = /^pr-\d+(?:-[a-z0-9][a-z0-9-]*)?$/;
|
|
|
69
84
|
* no Op config to read `neverOverMcp` from, the pending record carries it.
|
|
70
85
|
*/
|
|
71
86
|
export function isNeverOverMcpGateOp(op: string): boolean {
|
|
72
|
-
return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || op === OVERRIDE_GATE_OP || op === RELEASE_GATE_OP || PR_GATE_OP.test(op);
|
|
87
|
+
return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || op === WORKSPACE_EXIT_GATE_OP || op === OVERRIDE_GATE_OP || op === RELEASE_GATE_OP || PR_GATE_OP.test(op);
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
|
package/src/op/gate.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
|
|
|
50
50
|
import { isModelAuthored } from "../lifecycle/gate-origin";
|
|
51
51
|
import { appliedGatePath, isApprovalSpent, parseAppliedGateLines, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
52
52
|
import { sortedJsonReplacer } from "../utils";
|
|
53
|
-
import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from "./gate-approval";
|
|
53
|
+
import { gateContextDigest, type GateApprover, type GateBaseApproval, type ResolvedGateApproval } from "./gate-approval";
|
|
54
54
|
import {
|
|
55
55
|
quorumNeed, quorumRuleProgress, quorumRules, ruleNeedsProof, samePrincipal, trustQuorumJudge, UNPROVEN_JUDGE,
|
|
56
56
|
type GateQuorumJudge, type GateQuorumRuleProgress, type GateQuorumSpec,
|
|
@@ -130,10 +130,11 @@ export interface GateLedgerPort {
|
|
|
130
130
|
export interface GateApprovalRule {
|
|
131
131
|
/**
|
|
132
132
|
* `quorum` (#4324) is the entry's `identity.gates.<gate>.quorum` at base:
|
|
133
|
-
* the rules an Op gate of that name takes in place of its own.
|
|
133
|
+
* the rules an Op gate of that name takes in place of its own. `policy`,
|
|
134
|
+
* `mode` and `context` (#4404) are read the same way. Each is absent or
|
|
134
135
|
* null when the entry sets none.
|
|
135
136
|
*/
|
|
136
|
-
requirement: { gate: string; class: string | null; quorum?: GateQuorumSpec | null };
|
|
137
|
+
requirement: { gate: string; class: string | null; quorum?: GateQuorumSpec | null } & GateBaseApproval;
|
|
137
138
|
/** Why `approval` doesn't count, or null when it does. */
|
|
138
139
|
refuses(approval: GateResolutionRecord): string | null;
|
|
139
140
|
}
|
package/src/op/local-executor.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { gateName } from "./gate-name";
|
|
|
41
41
|
import { resolveGateAliases } from "./gate-alias";
|
|
42
42
|
import { gatePointOf } from "./gate-point";
|
|
43
43
|
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
44
|
-
import type { ResolvedGateApproval } from "./gate-approval";
|
|
44
|
+
import type { GateBaseApproval, ResolvedGateApproval } from "./gate-approval";
|
|
45
45
|
import { quorumRules, quorumRulesFor, type GateQuorumSpec } from "./gate-quorum";
|
|
46
46
|
import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
|
|
47
47
|
import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
|
|
@@ -741,16 +741,36 @@ function gateFn(step: GateStep): string {
|
|
|
741
741
|
* `approval.quorum` is not read then, so a change can't lower the quorum its
|
|
742
742
|
* own run needs. The base quorum applies to a gate whose Op declares no
|
|
743
743
|
* `approval` block too. With none at base, the Op's quorum applies as before.
|
|
744
|
+
*
|
|
745
|
+
* #4404: `policy`, `mode` and `context` are read the same way, each from its
|
|
746
|
+
* own `identity.gates.<gate>` field (`baseApproval`). A field the entry sets
|
|
747
|
+
* replaces the Op's. The base `context` wins key by key: a change can't edit a
|
|
748
|
+
* key the entry declares, but a key only the Op declares (often a step output
|
|
749
|
+
* the run computes) still reaches the policy. A field the entry leaves out
|
|
750
|
+
* still comes from the Op, and `chant workspace check` (WSP153) reports it.
|
|
744
751
|
*/
|
|
745
752
|
export function resolveGateApproval(
|
|
746
753
|
step: GateStep,
|
|
747
754
|
resultsById: ReadonlyMap<string, unknown>,
|
|
748
755
|
env?: string,
|
|
749
756
|
atBase?: GateQuorumSpec | null,
|
|
757
|
+
baseApproval?: GateBaseApproval | null,
|
|
750
758
|
): ResolvedGateApproval | undefined {
|
|
751
759
|
const baseQuorum = atBase ?? undefined;
|
|
752
|
-
const
|
|
753
|
-
|
|
760
|
+
const base = {
|
|
761
|
+
policy: baseApproval?.policy ?? undefined,
|
|
762
|
+
mode: baseApproval?.mode ?? undefined,
|
|
763
|
+
context: baseApproval?.context ?? undefined,
|
|
764
|
+
};
|
|
765
|
+
const anyBase = baseQuorum !== undefined || base.policy !== undefined || base.mode !== undefined || base.context !== undefined;
|
|
766
|
+
const opApproval = step.approval ?? (anyBase ? {} : undefined);
|
|
767
|
+
if (!opApproval) return undefined;
|
|
768
|
+
const authored = {
|
|
769
|
+
...opApproval,
|
|
770
|
+
...(base.policy !== undefined ? { policy: base.policy } : {}),
|
|
771
|
+
...(base.mode !== undefined ? { mode: base.mode } : {}),
|
|
772
|
+
...(base.context !== undefined ? { context: { ...(opApproval.context ?? {}), ...base.context } } : {}),
|
|
773
|
+
};
|
|
754
774
|
const quorum = baseQuorum ?? authored.quorum;
|
|
755
775
|
let context: Record<string, unknown> | undefined;
|
|
756
776
|
if (authored.context) {
|
|
@@ -801,7 +821,7 @@ async function runGateStep(
|
|
|
801
821
|
// a run over a missing digest.
|
|
802
822
|
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
803
823
|
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
804
|
-
// #4324: the workspace's rule for this gate at base, read once: its quorum
|
|
824
|
+
// #4324, #4404: the workspace's rule for this gate at base, read once: its quorum, policy, mode and context
|
|
805
825
|
// replaces the Op's, and evaluateGate judges approvals by it. A read that
|
|
806
826
|
// fails ends the step as a failing record, as a ledger read does below.
|
|
807
827
|
let rule: GateApprovalRule | null;
|
|
@@ -810,7 +830,7 @@ async function runGateStep(
|
|
|
810
830
|
} catch (err) {
|
|
811
831
|
return { record: { phase: phaseName, fn: gateFn(step), args: {}, status: "fail", durationMs: Date.now() - start, error: errMessage(err) } };
|
|
812
832
|
}
|
|
813
|
-
const approval = resolveGateApproval(step, resultsById, gates.env, rule?.requirement.quorum);
|
|
833
|
+
const approval = resolveGateApproval(step, resultsById, gates.env, rule?.requirement.quorum, rule?.requirement);
|
|
814
834
|
// #3947: a gate that resumes stores the outputs the steps after it read.
|
|
815
835
|
const resumeAt = gates.resume?.gates.get(step);
|
|
816
836
|
let checkpoint: GateCheckpoint | undefined;
|
|
@@ -40,8 +40,9 @@ describe("the pull-request loop bundle", () => {
|
|
|
40
40
|
"packages/core/src/plan-summary.ts",
|
|
41
41
|
"packages/core/src/pr-forge.ts",
|
|
42
42
|
"packages/core/src/pr-loop.ts",
|
|
43
|
+
"packages/core/src/pr-migrations.ts",
|
|
43
44
|
]);
|
|
44
|
-
// About 89 KB today, after the hooks line (#4288), the blocked set (#4291), the scheduled-run reads (#4289)
|
|
45
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
45
|
+
// About 89 KB today, after the hooks line (#4288), the blocked set (#4291), the scheduled-run reads (#4289) the requires checks (#4301) and the migration notes (#4407), about 97 KB.
|
|
46
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(100 * 1024);
|
|
46
47
|
});
|
|
47
48
|
});
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -424,3 +424,55 @@ describe("the policy in the report and the note (#4294)", () => {
|
|
|
424
424
|
expect(prStatusDescription({ ...denied, stage: "apply", status: "refused", refusal: "policy-denied" })).toBe("refused: the policy denied the plan (main.deny_public)");
|
|
425
425
|
});
|
|
426
426
|
});
|
|
427
|
+
|
|
428
|
+
describe("new migration files in the plan note (#4407)", () => {
|
|
429
|
+
const migrations: NonNullable<PrReport["migrations"]> = [
|
|
430
|
+
{
|
|
431
|
+
member: "ledger",
|
|
432
|
+
reader: "goose-dir",
|
|
433
|
+
file: "db/00002_index.sql",
|
|
434
|
+
version: "00002",
|
|
435
|
+
name: "index",
|
|
436
|
+
type: "versioned",
|
|
437
|
+
dialect: "postgres",
|
|
438
|
+
statements: [
|
|
439
|
+
{
|
|
440
|
+
index: 1,
|
|
441
|
+
sql: "CREATE INDEX orders_status_idx ON orders (status)",
|
|
442
|
+
outcome: "classified",
|
|
443
|
+
changes: [{ rule: "SQLPG241", class: "concurrently", label: "needs CONCURRENTLY", title: "Create an index without CONCURRENTLY", lock: "SHARE" }],
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
index: 2,
|
|
447
|
+
sql: "ALTER TABLE orders ALTER COLUMN a TYPE bigint",
|
|
448
|
+
outcome: "undecided",
|
|
449
|
+
changes: [],
|
|
450
|
+
candidates: [
|
|
451
|
+
{ rule: "SQLPG206", class: "metadata", label: "metadata only", title: "t" },
|
|
452
|
+
{ rule: "SQLPG207", class: "rewrite", label: "ACCESS EXCLUSIVE rewrite or scan", title: "t" },
|
|
453
|
+
],
|
|
454
|
+
note: "x",
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
const withMigrations: PrReport = { ...planReport, migrations };
|
|
460
|
+
|
|
461
|
+
test("the report validates and the note lists each statement with its class, rule and lock", () => {
|
|
462
|
+
expectValid(withMigrations);
|
|
463
|
+
const note = renderPrNote(withMigrations);
|
|
464
|
+
expect(note).toContain("New migration files (1), classified offline. Nothing is applied and no server is contacted.");
|
|
465
|
+
expect(note).toContain("`db/00002_index.sql` (member `ledger`, postgres)");
|
|
466
|
+
expect(note).toContain("- 1. `CREATE INDEX orders_status_idx ON orders (status)`: needs CONCURRENTLY (SQLPG241, SHARE)");
|
|
467
|
+
expect(note).toContain("undecided between SQLPG206 metadata only and SQLPG207 ACCESS EXCLUSIVE rewrite or scan");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a report with none renders as before", () => {
|
|
471
|
+
expect(renderPrNote(planReport)).not.toContain("migration");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a note line cannot be broken out of by a file's text", () => {
|
|
475
|
+
const note = renderPrNote({ ...withMigrations, migrations: [{ ...migrations[0]!, statements: [], error: "bad\n\n# heading `x`" }] });
|
|
476
|
+
expect(note).toContain("- not read: bad # heading `x`");
|
|
477
|
+
});
|
|
478
|
+
});
|
package/src/pr-loop.ts
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
import type { ChangeSetAction, ChangeSetDocument } from "./change-set";
|
|
51
51
|
import type { PrRequireOutcome } from "./pr-requires";
|
|
52
|
+
import { renderMigrationLines, type PrMigrationFile } from "./pr-migrations";
|
|
52
53
|
import { GITHUB_COMMENT_LIMIT, groupChangeSet, renderPlanSummaryMarkdown } from "./plan-summary";
|
|
53
54
|
|
|
54
55
|
/** The schema a pull-request report names in `$schema`. */
|
|
@@ -84,6 +85,9 @@ export const PR_REPORT_POLICY_FLOOR = "0.124.41";
|
|
|
84
85
|
/** The first chant whose apply report can carry `beforeMerge` and the `requires-unmet` and `before-merge-off` refusals (#4301, ws-106). */
|
|
85
86
|
export const PR_REPORT_BEFORE_MERGE_FLOOR = "0.124.42";
|
|
86
87
|
|
|
88
|
+
/** The first chant whose plan report can carry `migrations` (#4407). */
|
|
89
|
+
export const PR_REPORT_MIGRATIONS_FLOOR = "0.124.49";
|
|
90
|
+
|
|
87
91
|
/** The gate a pull request's apply waits on, unless the pipeline names another. */
|
|
88
92
|
export const PR_APPLY_GATE = "pr-apply";
|
|
89
93
|
|
|
@@ -436,6 +440,13 @@ export interface PrReport {
|
|
|
436
440
|
* {@link PR_REPORT_OVERDUE_FLOOR}.
|
|
437
441
|
*/
|
|
438
442
|
overdue?: PrOverdue[];
|
|
443
|
+
/**
|
|
444
|
+
* Plan stage (#4407): the migration files the pull request adds in a
|
|
445
|
+
* migration-directory workspace member, each statement classified offline.
|
|
446
|
+
* Absent when it adds none. Added within contract 1 by chant
|
|
447
|
+
* {@link PR_REPORT_MIGRATIONS_FLOOR}.
|
|
448
|
+
*/
|
|
449
|
+
migrations?: PrMigrationFile[];
|
|
439
450
|
/** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
|
|
440
451
|
selection: {
|
|
441
452
|
changed: string[];
|
|
@@ -690,6 +701,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
|
|
|
690
701
|
out.push("", `Hooks: ${passed} passed${other.length > 0 ? `; ${other.join("; ")}` : ""}.`);
|
|
691
702
|
}
|
|
692
703
|
out.push(...policyLines(report));
|
|
704
|
+
out.push(...renderMigrationLines(report.migrations));
|
|
693
705
|
if (report.selection.unclaimed.length > 0) {
|
|
694
706
|
out.push("", `Changed, and deployed by no component: ${report.selection.unclaimed.map(code).join(", ")}.`);
|
|
695
707
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migration files a pull request adds, classified for the plan note
|
|
3
|
+
* (#4407). This is the "check" adoption level: each new migration in a
|
|
4
|
+
* migration-directory workspace member (goose, golang-migrate,
|
|
5
|
+
* clickhouse-migrations, Flyway) is read offline and its statements are given
|
|
6
|
+
* the class the lexicon's classifier gives the same change. Nothing is applied
|
|
7
|
+
* and no server is contacted.
|
|
8
|
+
*
|
|
9
|
+
* Core holds the shapes and the note's lines. The classes come from a
|
|
10
|
+
* lexicon's `classifyMigrations` (`./lexicon.ts`), the way a lexicon's
|
|
11
|
+
* `classifyDisruption` supplies the disruption of an update. Pure: it imports
|
|
12
|
+
* nothing, so `./pr-loop.ts` can render it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One change a statement makes, under one classifier rule. */
|
|
16
|
+
export interface MigrationChange {
|
|
17
|
+
rule: string;
|
|
18
|
+
/** The rule's class, e.g. `rebuild`, `concurrently`, `metadata`. */
|
|
19
|
+
class: string;
|
|
20
|
+
/** The class as a report names it, e.g. `REBUILD`. */
|
|
21
|
+
label: string;
|
|
22
|
+
title: string;
|
|
23
|
+
/** Postgres: the lock the rule names. */
|
|
24
|
+
lock?: string;
|
|
25
|
+
destructive?: boolean;
|
|
26
|
+
note?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What one statement came to: `undecided` lists `candidates`; `unclassified` and `data` say why in `note`. */
|
|
30
|
+
export interface MigrationStatement {
|
|
31
|
+
/** 1-based position among the file's up statements. */
|
|
32
|
+
index: number;
|
|
33
|
+
/** The statement's first line, cut to 120 characters. */
|
|
34
|
+
sql: string;
|
|
35
|
+
outcome: "classified" | "undecided" | "unclassified" | "data";
|
|
36
|
+
changes: MigrationChange[];
|
|
37
|
+
candidates?: MigrationChange[];
|
|
38
|
+
note?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a lexicon answers for one new migration file. */
|
|
42
|
+
export interface MigrationFileClassification {
|
|
43
|
+
/** The file, relative to the workspace root. */
|
|
44
|
+
file: string;
|
|
45
|
+
statements: MigrationStatement[];
|
|
46
|
+
/** Set when the file could not be read (a Flyway placeholder with no value): why. */
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A new migration file in the report's `migrations`. */
|
|
51
|
+
export interface PrMigrationFile extends MigrationFileClassification {
|
|
52
|
+
/** The workspace member whose directory holds it. */
|
|
53
|
+
member: string;
|
|
54
|
+
/** The reader that listed it: `goose-dir`, `golang-migrate-dir`, `clickhouse-migrations-dir` or `flyway-dir`. */
|
|
55
|
+
reader: string;
|
|
56
|
+
version: string;
|
|
57
|
+
name: string;
|
|
58
|
+
type: "versioned" | "repeatable" | "baseline" | "undo";
|
|
59
|
+
dialect: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One line of plain text: the report is read from a file the head's job wrote, so nothing in it breaks the note's layout. */
|
|
63
|
+
const line = (s: string): string => s.replace(/\s+/g, " ").trim();
|
|
64
|
+
|
|
65
|
+
const code = (s: string): string => `\`${line(s).replace(/`/g, "'")}\``;
|
|
66
|
+
|
|
67
|
+
const changeText = (c: MigrationChange): string => `${c.label} (${c.rule}${c.lock ? `, ${c.lock}` : ""})${c.destructive ? ", removes data" : ""}`;
|
|
68
|
+
|
|
69
|
+
/** The statement's row in the note. */
|
|
70
|
+
function statementText(s: MigrationStatement): string {
|
|
71
|
+
if (s.outcome === "classified") return s.changes.map(changeText).join("; ") + (s.note ? `. ${line(s.note)}` : "");
|
|
72
|
+
if (s.outcome === "undecided") {
|
|
73
|
+
const decided = s.changes.length > 0 ? `${s.changes.map(changeText).join("; ")}; ` : "";
|
|
74
|
+
return `${decided}undecided between ${(s.candidates ?? []).map((c) => `${c.rule} ${c.label}`).join(" and ")}${s.note ? `: ${line(s.note)}` : ""}`;
|
|
75
|
+
}
|
|
76
|
+
if (s.outcome === "unclassified") return `no class${s.note ? `: ${line(s.note)}` : ""}`;
|
|
77
|
+
return `data, not schema${s.note ? `: ${line(s.note)}` : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The note's section: one heading line per new file and a list of its
|
|
82
|
+
* statements with their classes. Empty when the pull request adds none.
|
|
83
|
+
*/
|
|
84
|
+
export function renderMigrationLines(files: readonly PrMigrationFile[] | undefined): string[] {
|
|
85
|
+
if (!files || files.length === 0) return [];
|
|
86
|
+
const out: string[] = ["", `New migration files (${files.length}), classified offline. Nothing is applied and no server is contacted.`];
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
out.push("", `${code(f.file)} (member ${code(f.member)}, ${f.dialect}${f.type === "versioned" ? "" : `, ${f.type}`})`);
|
|
89
|
+
if (f.error) {
|
|
90
|
+
out.push(`- not read: ${line(f.error)}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (f.statements.length === 0) out.push("- no up statements");
|
|
94
|
+
for (const s of f.statements) out.push(`- ${s.index}. ${code(s.sql)}: ${statementText(s)}`);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
package/src/pr-post.ts
CHANGED
|
@@ -137,7 +137,7 @@ export async function postPlanReport(input: PlanPostInput): Promise<PlanPostResu
|
|
|
137
137
|
state = prStatusState(report);
|
|
138
138
|
description = prStatusDescription(report);
|
|
139
139
|
// A member the change does not reach has nothing to say on the pull request (as pr-plan's own publish).
|
|
140
|
-
if (!(input.member && report.status === "nothing" && !report.overdue?.length)) note = planNoteFor(report, input.forge, prNoteMarker(input.env, input.member));
|
|
140
|
+
if (!(input.member && report.status === "nothing" && !report.overdue?.length && !report.migrations?.length)) note = planNoteFor(report, input.forge, prNoteMarker(input.env, input.member));
|
|
141
141
|
} catch (err) {
|
|
142
142
|
refused = `the plan report does not render: ${err instanceof Error ? err.message : String(err)}`;
|
|
143
143
|
state = "failure";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"account": "acme",
|
|
3
|
+
"stacks": [
|
|
4
|
+
{ "id": "vpc", "name": "VPC", "space": "root", "projectRoot": "terraform/vpc", "vendor": "terraform", "labels": ["env:prod"] },
|
|
5
|
+
{ "id": "db", "name": "Database", "projectRoot": "terraform/db", "labels": ["env:prod"], "policies": ["p1"], "dependsOn": [{ "stack": "vpc", "references": [{ "outputName": "subnet_ids", "inputName": "TF_VAR_subnets" }] }] },
|
|
6
|
+
{ "id": "cfg", "name": "ansible-config", "vendor": "ansible", "projectRoot": "ansible", "dependsOn": [{ "stack": "db" }, { "stack": "nope" }], "contexts": ["c1"] }
|
|
7
|
+
],
|
|
8
|
+
"policies": [{ "id": "p1", "name": "approval", "type": "APPROVAL" }]
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"organization": "acme",
|
|
3
|
+
"projects": [{ "id": "prj-1", "name": "platform" }],
|
|
4
|
+
"workspaces": [
|
|
5
|
+
{ "id": "ws-net", "name": "network-prod", "project": "prj-1", "workingDirectory": "infra/network", "tags": ["env:prod"], "variables": [{ "key": "region" }, { "key": "token", "sensitive": true }], "vcsRepo": "acme/infra" },
|
|
6
|
+
{ "id": "ws-app", "name": "app-prod", "project": "prj-1", "workingDirectory": "infra/app", "tags": ["env:prod"], "runTriggers": ["ws-net", "ws-gone"] },
|
|
7
|
+
{ "id": "ws-app-dev", "name": "app-dev", "workingDirectory": "infra/app", "tags": ["environment:dev"], "runTriggers": ["network-prod"] }
|
|
8
|
+
],
|
|
9
|
+
"policySets": [
|
|
10
|
+
{ "name": "no-public-s3", "kind": "sentinel", "enforcement": "hard-mandatory", "workspaces": ["ws-app"] },
|
|
11
|
+
{ "name": "tags-required", "global": true }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -46,6 +46,7 @@ function install(root: string, from: string): string {
|
|
|
46
46
|
mkdirSync(dir, { recursive: true });
|
|
47
47
|
cpSync(join(from, "package.json"), join(dir, "package.json"));
|
|
48
48
|
cpSync(join(from, "workspace-kinds.json"), join(dir, "workspace-kinds.json"));
|
|
49
|
+
if (existsSync(join(from, "schemas"))) cpSync(join(from, "schemas"), join(dir, "schemas"), { recursive: true });
|
|
49
50
|
const targets = new Set<string>(["./index.js"]);
|
|
50
51
|
const collect = (entry: unknown): void => {
|
|
51
52
|
if (typeof entry === "string") targets.add(entry.replace("*", "workspace-kinds"));
|
|
@@ -16,6 +16,7 @@ import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
|
|
|
16
16
|
import { SECURITY_FINDING_CODES } from "./checks/security";
|
|
17
17
|
import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
|
|
18
18
|
import { CI_BRANCH_FINDING_CODES } from "./checks/ci-branch";
|
|
19
|
+
import { GATE_APPROVAL_FINDING_CODES } from "./checks/gate-approval";
|
|
19
20
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
20
21
|
import { CHECK_CODES, CHECK_CONTRACT_VERSION, CHECK_ERROR_CODES, CHECK_OUTPUT_SCHEMA_ID, runChecks, type CheckDocument } from "./lineage-check";
|
|
21
22
|
|
|
@@ -41,8 +42,8 @@ describe("check output schema", () => {
|
|
|
41
42
|
test("lists exactly the codes the code can return", () => {
|
|
42
43
|
expect(schema.$defs.lockFinding.properties.code.enum).toEqual([...CHECK_CODES]);
|
|
43
44
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...CHECK_ERROR_CODES]);
|
|
44
|
-
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES, ...CI_BRANCH_FINDING_CODES]);
|
|
45
|
-
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES, ...CI_BRANCH_FINDING_CODES]);
|
|
45
|
+
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES, ...CI_BRANCH_FINDING_CODES, ...GATE_APPROVAL_FINDING_CODES]);
|
|
46
|
+
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES, ...CI_BRANCH_FINDING_CODES, ...GATE_APPROVAL_FINDING_CODES]);
|
|
46
47
|
});
|
|
47
48
|
});
|
|
48
49
|
|
|
@@ -349,7 +349,8 @@
|
|
|
349
349
|
"ci-workflow-pipeline-missing",
|
|
350
350
|
"ci-workflow-pipeline-differs",
|
|
351
351
|
"ci-workflow-publish-missing",
|
|
352
|
-
"ci-branch-route-differs"
|
|
352
|
+
"ci-branch-route-differs",
|
|
353
|
+
"gate-approval-unbased"
|
|
353
354
|
],
|
|
354
355
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
355
356
|
}
|
|
@@ -472,7 +473,8 @@
|
|
|
472
473
|
"ci-workflow-pipeline-missing",
|
|
473
474
|
"ci-workflow-pipeline-differs",
|
|
474
475
|
"ci-workflow-publish-missing",
|
|
475
|
-
"ci-branch-route-differs"
|
|
476
|
+
"ci-branch-route-differs",
|
|
477
|
+
"gate-approval-unbased"
|
|
476
478
|
],
|
|
477
479
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
478
480
|
},
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP153 (#4404): an Op gate's approval block that `identity.gates` does not
|
|
3
|
+
* set at base.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { runDeclarationChecks } from "../checks";
|
|
12
|
+
import { gateUses, unbasedFields } from "./gate-approval";
|
|
13
|
+
import { REASONS } from "../reason-codes";
|
|
14
|
+
|
|
15
|
+
const scratch: string[] = [];
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
function repo(files: Record<string, string>): string {
|
|
20
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-approval-")));
|
|
21
|
+
scratch.push(root);
|
|
22
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
23
|
+
for (const [path, text] of Object.entries(files)) {
|
|
24
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, path), text);
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const op = (approval: unknown): string =>
|
|
31
|
+
JSON.stringify({ name: "release", phases: [{ name: "Gate", parallel: false, steps: [{ kind: "gate", gate: "ship", timeout: "48h", ...(approval ? { approval } : {}) }] }] });
|
|
32
|
+
const workspace = (gates: Record<string, unknown> | undefined, opText: string): Record<string, string> => ({
|
|
33
|
+
"chant.workspace.json": JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "app" }], ...(gates ? { identity: { gates } } : {}) }),
|
|
34
|
+
"app/dist/ops/release/op.json": opText,
|
|
35
|
+
});
|
|
36
|
+
const found = async (root: string) =>
|
|
37
|
+
(await runDeclarationChecks(root, (f) => f, { gather: false })).diagnostics.filter((d) => d.ruleId === "WSP153");
|
|
38
|
+
|
|
39
|
+
describe("WSP153: an approval block base does not back", () => {
|
|
40
|
+
test("a gate with an approval block and no entry at base is reported, with the fields it sets", async () => {
|
|
41
|
+
const [d] = await found(repo(workspace(undefined, op({ quorum: { count: 2 }, mode: "enforce" }))));
|
|
42
|
+
expect(d).toMatchObject({ code: "gate-approval-unbased", severity: "warning", entity: "ship" });
|
|
43
|
+
expect(d.message).toMatch(/gate "ship" in Op release sets quorum, mode in its approval block/);
|
|
44
|
+
expect(d.message).toMatch(/no entry for it at base/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("an entry that sets only some of the fields leaves the rest reported", async () => {
|
|
48
|
+
const root = repo(workspace({ ship: { quorum: { count: 2 } } }, op({ quorum: { count: 2 }, mode: "enforce", context: { risk: "low", team: "x" } })));
|
|
49
|
+
const [d] = await found(root);
|
|
50
|
+
expect(d.message).toMatch(/sets mode, context \(risk, team\)/);
|
|
51
|
+
expect(d.message).toMatch(/identity\.gates\.ship sets none of them/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an entry that sets every field the Op does is clean", async () => {
|
|
55
|
+
const approval = { quorum: { count: 2 }, mode: "enforce", context: { risk: "low" } };
|
|
56
|
+
const clean = repo(workspace({ ship: { quorum: { count: 2 }, mode: "enforce", context: { risk: "high" } } }, op(approval)));
|
|
57
|
+
expect(await found(clean)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a gate with no approval block, or an Op that is not built, is not reported", async () => {
|
|
61
|
+
expect(await found(repo(workspace(undefined, op(undefined))))).toEqual([]);
|
|
62
|
+
expect(await found(repo({ "chant.workspace.json": JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "app" }] }) }))).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("it reads gates nested in an effect and in onFailure", () => {
|
|
66
|
+
const uses = gateUses({
|
|
67
|
+
phases: [{ steps: [{ kind: "effect", steps: [{ kind: "gate", gate: "inner", approval: { mode: "enforce" } }] }] }],
|
|
68
|
+
onFailure: [{ steps: [{ kind: "gate", signalName: "undo", approval: { quorum: { count: 2 } } }] }],
|
|
69
|
+
});
|
|
70
|
+
expect(uses.map((u) => u.gate)).toEqual(["inner", "undo"]);
|
|
71
|
+
expect(unbasedFields(uses[0], undefined)).toEqual(["mode"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the code is a documented reason code", () => {
|
|
75
|
+
expect(REASONS["gate-approval-unbased"]).toMatch(/identity\.gates does not set at base/);
|
|
76
|
+
});
|
|
77
|
+
});
|