@intentius/chant 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +15 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +19 -0
- package/src/cli/handlers/lifecycle.ts +14 -7
- package/src/cli/handlers/run.test.ts +96 -0
- package/src/cli/handlers/run.ts +37 -1
- package/src/cli/main.ts +2 -0
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +133 -11
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +28 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2002 — the `policyGate` Op step must gate on the build `chant build`
|
|
3
|
+
* produces, not on a different one.
|
|
4
|
+
*
|
|
5
|
+
* `evaluateProjectPolicies` used to assemble `build()`'s options itself, two of
|
|
6
|
+
* them against `buildCommand`'s nine. It built with fold off (the path #1134
|
|
7
|
+
* retired as the default), without the project's config (so serializers lost
|
|
8
|
+
* their lexicon-scoped dialect settings) and without `buildRoots` (so
|
|
9
|
+
* config-declared roots contributed nothing) — a gate could pass a build `chant
|
|
10
|
+
* build` fails. Both callers now assemble through
|
|
11
|
+
* `../cli/build-options.ts`'s `resolveProjectBuildOptions`, and the first test
|
|
12
|
+
* below fails if either ever grows an option the other does not get.
|
|
13
|
+
*
|
|
14
|
+
* `build()` is wrapped rather than replaced: every assertion here is about a
|
|
15
|
+
* real build of a real fixture, and the wrapper only records what each caller
|
|
16
|
+
* asked for.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import type { BuildOptions, BuildResult } from "../build";
|
|
23
|
+
|
|
24
|
+
const recorder = vi.hoisted(() => ({
|
|
25
|
+
options: [] as (BuildOptions | undefined)[],
|
|
26
|
+
results: [] as BuildResult[],
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../build", async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal<typeof import("../build")>();
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
build: async (...args: Parameters<typeof actual.build>) => {
|
|
34
|
+
recorder.options.push(args[3]);
|
|
35
|
+
const result = await actual.build(...args);
|
|
36
|
+
recorder.results.push(result);
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const { evaluateProjectPolicies } = await import("./policy");
|
|
43
|
+
const { buildCommand } = await import("../cli/commands/build");
|
|
44
|
+
const { resolveProjectLexicons, loadPlugins } = await import("../cli/plugins");
|
|
45
|
+
|
|
46
|
+
/** Run `chant build` over a fixture the way `cli/main.ts` drives it. */
|
|
47
|
+
async function runChantBuild(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
48
|
+
const plugins = await loadPlugins(await resolveProjectLexicons(dir));
|
|
49
|
+
recorder.options.length = 0;
|
|
50
|
+
recorder.results.length = 0;
|
|
51
|
+
const outcome = await buildCommand({
|
|
52
|
+
path: dir,
|
|
53
|
+
format: "json",
|
|
54
|
+
output: join(dir, "out", "build.json"),
|
|
55
|
+
serializers: plugins.map((p) => p.serializer),
|
|
56
|
+
plugins,
|
|
57
|
+
});
|
|
58
|
+
expect(outcome.errors).toEqual([]);
|
|
59
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Run the `policyGate` step's entry point over the same fixture. */
|
|
63
|
+
async function runPolicyGate(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
|
|
64
|
+
recorder.options.length = 0;
|
|
65
|
+
recorder.results.length = 0;
|
|
66
|
+
await evaluateProjectPolicies({ path: dir });
|
|
67
|
+
return { options: recorder.options[0]!, result: recorder.results[0]! };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build-root contributors are freshly bound closures — compare their count. */
|
|
71
|
+
function comparable(options: BuildOptions): Record<string, unknown> {
|
|
72
|
+
return { ...options, buildRoots: options.buildRoots?.length ?? 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TRIVIAL_POLICY =
|
|
76
|
+
`export const check = {\n` +
|
|
77
|
+
` id: "ORG-PARITY",\n` +
|
|
78
|
+
` description: "records nothing; the build is what is under test",\n` +
|
|
79
|
+
` check: () => [],\n` +
|
|
80
|
+
`};\n`;
|
|
81
|
+
|
|
82
|
+
describe("policyGate builds the same project chant build does (chant #2002)", () => {
|
|
83
|
+
let testDir: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
testDir = join(tmpdir(), `chant-policy-parity-${Date.now()}-${Math.random()}`);
|
|
87
|
+
await mkdir(join(testDir, "policies"), { recursive: true });
|
|
88
|
+
await writeFile(join(testDir, "policies", "org.ts"), TRIVIAL_POLICY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(testDir, { recursive: true, force: true });
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A forgejo project: `forgejo.runnerLabels` is read by the serializer off
|
|
98
|
+
* `SerializeContext.config` (lexicons/forgejo/src/serializer.ts), so a build
|
|
99
|
+
* that drops `config` emits the default label instead of the project's.
|
|
100
|
+
*/
|
|
101
|
+
async function writeForgejoProject(): Promise<void> {
|
|
102
|
+
await writeFile(
|
|
103
|
+
join(testDir, "chant.config.ts"),
|
|
104
|
+
`export default {\n` +
|
|
105
|
+
` lexicons: ["forgejo"],\n` +
|
|
106
|
+
` forgejo: { runnerLabels: { "ubuntu-latest": "self-hosted-arm64" } },\n` +
|
|
107
|
+
` lint: { policies: ["policies/org.ts"] },\n` +
|
|
108
|
+
`};\n`,
|
|
109
|
+
);
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(testDir, "ci.ts"),
|
|
112
|
+
`import { Workflow, Job, Step } from "@intentius/chant-lexicon-github";\n` +
|
|
113
|
+
`export const workflow = new Workflow({ name: "CI", on: { push: {} } });\n` +
|
|
114
|
+
`export const buildJob = new Job({\n` +
|
|
115
|
+
` "runs-on": "ubuntu-latest",\n` +
|
|
116
|
+
` steps: [new Step({ name: "Build", run: "npm run build" })],\n` +
|
|
117
|
+
`});\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("both callers hand build() the identical option set", async () => {
|
|
122
|
+
await writeForgejoProject();
|
|
123
|
+
|
|
124
|
+
const cli = await runChantBuild(testDir);
|
|
125
|
+
const gate = await runPolicyGate(testDir);
|
|
126
|
+
|
|
127
|
+
// The drift this issue is about is an option one caller passes and the
|
|
128
|
+
// other does not, so the key sets are asserted on their own — a new option
|
|
129
|
+
// added at one call site fails here even if its value happens to match.
|
|
130
|
+
expect(Object.keys(gate.options).sort()).toEqual(Object.keys(cli.options).sort());
|
|
131
|
+
expect(comparable(gate.options)).toEqual(comparable(cli.options));
|
|
132
|
+
expect(gate.options.fold).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the project's lexicon-scoped dialect settings reach the gate's serializers", async () => {
|
|
136
|
+
await writeForgejoProject();
|
|
137
|
+
|
|
138
|
+
const cli = await runChantBuild(testDir);
|
|
139
|
+
const gate = await runPolicyGate(testDir);
|
|
140
|
+
|
|
141
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
142
|
+
const emitted = JSON.stringify([...gate.result.outputs.values()]);
|
|
143
|
+
// The project's own label, not `DEFAULT_RUNNER_LABELS`' "docker" — which is
|
|
144
|
+
// what the gate emitted while it built without `config`.
|
|
145
|
+
expect(emitted).toContain("self-hosted-arm64");
|
|
146
|
+
expect(emitted).not.toContain("docker");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("entities from a config-declared build root are in the set the gate sees", async () => {
|
|
150
|
+
await writeFile(
|
|
151
|
+
join(testDir, "chant.config.ts"),
|
|
152
|
+
`export default { lexicons: ["cedar"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
153
|
+
);
|
|
154
|
+
// cedar's buildRoots hook contributes the project's schema as an entity
|
|
155
|
+
// (lexicons/cedar/src/schema-artifact.ts). Without `buildRoots` the gate
|
|
156
|
+
// never saw it, so a policy over it could not fail.
|
|
157
|
+
await writeFile(join(testDir, "schema.cedarschema"), `entity User;\n`);
|
|
158
|
+
|
|
159
|
+
const cli = await runChantBuild(testDir);
|
|
160
|
+
const gate = await runPolicyGate(testDir);
|
|
161
|
+
|
|
162
|
+
expect([...gate.result.entities.keys()].sort()).toEqual([...cli.result.entities.keys()].sort());
|
|
163
|
+
expect([...gate.result.entities.keys()]).toContain("cedarSchema");
|
|
164
|
+
expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a foldable source module does not execute during the gate's build", async () => {
|
|
168
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
169
|
+
await writeFile(
|
|
170
|
+
join(testDir, "chant.config.ts"),
|
|
171
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
172
|
+
);
|
|
173
|
+
await writeFile(
|
|
174
|
+
join(testDir, "ns.ts"),
|
|
175
|
+
// Module-level: runs if and only if the file is imported. Fold reads the
|
|
176
|
+
// constructor call statically and never imports the module.
|
|
177
|
+
`process.env.CHANT_2002_SOURCE_RAN = "1";\n` +
|
|
178
|
+
`import { Namespace } from "@intentius/chant-lexicon-k8s";\n` +
|
|
179
|
+
`export const ns = new Namespace({ metadata: { name: "demo" } });\n`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const gate = await runPolicyGate(testDir);
|
|
183
|
+
|
|
184
|
+
expect(gate.options.fold).toBe(true);
|
|
185
|
+
expect(gate.result.foldDecisions.find((d) => d.file.endsWith("ns.ts"))?.mode).toBe("fold");
|
|
186
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("build.fold: false is honoured too — the config decides, not the gate", async () => {
|
|
190
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(testDir, "chant.config.ts"),
|
|
193
|
+
`export default { lexicons: ["k8s"], build: { fold: false }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
194
|
+
);
|
|
195
|
+
// No lexicon import: with fold off every discovered file is imported, and
|
|
196
|
+
// this fixture lives outside the repo where a bare `@intentius/*`
|
|
197
|
+
// specifier does not resolve. The marker is all this test needs.
|
|
198
|
+
await writeFile(join(testDir, "marker.ts"), `process.env.CHANT_2002_SOURCE_RAN = "1";\nexport const value = 1;\n`);
|
|
199
|
+
|
|
200
|
+
const gate = await runPolicyGate(testDir);
|
|
201
|
+
|
|
202
|
+
expect(gate.options.fold).toBe(false);
|
|
203
|
+
expect(process.env.CHANT_2002_SOURCE_RAN).toBe("1");
|
|
204
|
+
delete process.env.CHANT_2002_SOURCE_RAN;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("build.sandbox: true is reported as a divergence rather than silently ignored", async () => {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(testDir, "chant.config.ts"),
|
|
210
|
+
`export default { lexicons: ["k8s"], build: { sandbox: true }, lint: { policies: ["policies/org.ts"] } };\n`,
|
|
211
|
+
);
|
|
212
|
+
const stderr: string[] = [];
|
|
213
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderr.push(s); });
|
|
214
|
+
|
|
215
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
216
|
+
|
|
217
|
+
expect(evaluation.warnings).toHaveLength(1);
|
|
218
|
+
expect(evaluation.warnings[0]).toContain("build.sandbox is enabled");
|
|
219
|
+
expect(stderr.join("\n")).toContain("build.sandbox is enabled");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("no sandbox opt-in, no warning", async () => {
|
|
223
|
+
await writeFile(
|
|
224
|
+
join(testDir, "chant.config.ts"),
|
|
225
|
+
`export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const evaluation = await evaluateProjectPolicies({ path: testDir });
|
|
229
|
+
|
|
230
|
+
expect(evaluation.warnings).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/lint/policy.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { resolve, dirname } from "node:path";
|
|
|
7
7
|
import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../config";
|
|
8
8
|
import { resolveBuildParams } from "../build-params";
|
|
9
9
|
import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
10
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../cli/build-options";
|
|
11
|
+
import { formatWarning } from "../cli/format";
|
|
10
12
|
import { build } from "../build";
|
|
11
13
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
12
14
|
import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
|
|
@@ -63,6 +65,15 @@ export interface PolicyEvaluation {
|
|
|
63
65
|
* of the finding just vanishing.
|
|
64
66
|
*/
|
|
65
67
|
suppressed: PostSynthDiagnostic[];
|
|
68
|
+
/**
|
|
69
|
+
* chant #2002 — divergences between this evaluation and what `chant build`
|
|
70
|
+
* would do, named rather than left silent. Today that is a project whose
|
|
71
|
+
* resolved `build.sandbox` is `true`: the gate builds unsandboxed, because
|
|
72
|
+
* running it inside the boundary needs the per-execution arming decision
|
|
73
|
+
* tracked on #1157. Also written to stderr — the gate's caller is an
|
|
74
|
+
* activity wrapper that surfaces output, not a structured result.
|
|
75
|
+
*/
|
|
76
|
+
warnings: string[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/**
|
|
@@ -70,6 +81,13 @@ export interface PolicyEvaluation {
|
|
|
70
81
|
* standalone — used by the `policyGate` Op step to gate an apply on the same
|
|
71
82
|
* organizational policy `chant build` enforces. Loads the project's lexicons,
|
|
72
83
|
* builds, then runs the policy pack with `env` (explicit, else `ownership.env`).
|
|
84
|
+
*
|
|
85
|
+
* chant #2002 — "the same organizational policy `chant build` enforces" only
|
|
86
|
+
* holds if it is the same build. The build options are assembled by
|
|
87
|
+
* `../cli/build-options.ts`'s `resolveProjectBuildOptions`, the single function
|
|
88
|
+
* `chant build` also calls, rather than by a second list here; a build option
|
|
89
|
+
* added for one caller and not the other is what let a gate pass a build
|
|
90
|
+
* `chant build` fails.
|
|
73
91
|
*/
|
|
74
92
|
export async function evaluateProjectPolicies(opts: {
|
|
75
93
|
path: string;
|
|
@@ -96,10 +114,37 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
96
114
|
}
|
|
97
115
|
const env = opts.env ?? resolveOwnershipEnv(config, params.provenance);
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
// chant #2002 — the build options come from the shared assembler
|
|
118
|
+
// (`../cli/build-options.ts`), the same one `chant build` uses, so the gate
|
|
119
|
+
// decides on the project `chant build` produces: folded by default (#1134),
|
|
120
|
+
// with the project's config reaching the serializers, and with
|
|
121
|
+
// config-declared build roots contributing their entities. Assembling a
|
|
122
|
+
// second, shorter list here is what let the two drift apart.
|
|
123
|
+
const modes = resolveBuildModes(config);
|
|
124
|
+
const warnings: string[] = [];
|
|
125
|
+
if (modes.sandbox) {
|
|
126
|
+
// Resolved, reported, and not yet honoured. Building inside the boundary
|
|
127
|
+
// needs per-execution arming (#1157) — until that lands, saying so is the
|
|
128
|
+
// difference between a known gap and a silent one.
|
|
129
|
+
warnings.push(
|
|
130
|
+
`build.sandbox is enabled for this project, but the policy gate builds it in this process — the project's source files and its lint.policies modules execute here, unsandboxed (chant #1157). Run \`chant build --sandbox\` for a sandboxed build of the same project.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
for (const warning of warnings) console.error(formatWarning({ message: warning }));
|
|
134
|
+
|
|
135
|
+
const result = await build(
|
|
136
|
+
buildPath,
|
|
137
|
+
serializers,
|
|
138
|
+
undefined,
|
|
139
|
+
resolveProjectBuildOptions({
|
|
140
|
+
config,
|
|
141
|
+
configDir,
|
|
142
|
+
plugins,
|
|
143
|
+
modes: { fold: modes.fold, sandbox: false },
|
|
144
|
+
ownership: resolveOwnershipMarker(config, params.provenance),
|
|
145
|
+
buildParams: params.provenance,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
103
148
|
if (result.errors.length > 0) {
|
|
104
149
|
throw new Error("Build failed — cannot evaluate policy on a broken build");
|
|
105
150
|
}
|
|
@@ -108,7 +153,7 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
108
153
|
? await loadPolicyChecks(config.lint.policies, configDir)
|
|
109
154
|
: [];
|
|
110
155
|
if (checks.length === 0) {
|
|
111
|
-
return { diagnostics: [], violations: [], env, suppressed: [] };
|
|
156
|
+
return { diagnostics: [], violations: [], env, suppressed: [], warnings };
|
|
112
157
|
}
|
|
113
158
|
|
|
114
159
|
const raw = runPostSynthChecks(checks, result, env);
|
|
@@ -118,5 +163,5 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
118
163
|
// fails on it either, and vice versa for a check turned UP to "error".
|
|
119
164
|
const { diagnostics, suppressed } = applyConfiguredSeverity(raw, config.lint?.rules);
|
|
120
165
|
const violations = diagnostics.filter((d) => d.severity === "error");
|
|
121
|
-
return { diagnostics, violations, env, suppressed };
|
|
166
|
+
return { diagnostics, violations, env, suppressed, warnings };
|
|
122
167
|
}
|
package/src/op/local-executor.ts
CHANGED
|
@@ -136,6 +136,34 @@ export function findGate(config: OpConfig): GateStep | undefined {
|
|
|
136
136
|
return undefined;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
|
|
141
|
+
* including steps nested inside effect steps), if any.
|
|
142
|
+
*
|
|
143
|
+
* chant #2003 — `--sandbox` is a GLOBAL flag (`../cli/registry.ts`), and
|
|
144
|
+
* `../cli/main.ts` arms the process-wide policy latch off it for every command,
|
|
145
|
+
* `chant run` included. A `policyGate` step then reaches `loadPolicyChecks`,
|
|
146
|
+
* which refuses while armed with a message written for a chant maintainer —
|
|
147
|
+
* shown to a user who passed a documented flag. The combination cannot be
|
|
148
|
+
* honoured until the gate can build and load policies inside the boundary
|
|
149
|
+
* (#1157), so both `chant run` paths pre-flight it with this and refuse before
|
|
150
|
+
* any phase executes, the same shape as {@link findGate}'s pre-flight.
|
|
151
|
+
*/
|
|
152
|
+
export function findPolicyGateStep(config: OpConfig): ActivityStep | undefined {
|
|
153
|
+
const all = [...config.phases, ...(config.onFailure ?? [])];
|
|
154
|
+
const isPolicyGate = (s: StepDefinition): s is ActivityStep => isActivity(s) && s.fn === "policyGate";
|
|
155
|
+
for (const phase of all) {
|
|
156
|
+
for (const step of phase.steps) {
|
|
157
|
+
if (isPolicyGate(step)) return step;
|
|
158
|
+
if (isEffect(step)) {
|
|
159
|
+
const nested = step.steps.find(isPolicyGate);
|
|
160
|
+
if (nested) return nested;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
139
167
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
140
168
|
|
|
141
169
|
/**
|