@intentius/chant 0.65.0 → 0.66.1
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/behaviour-delta.d.ts +181 -0
- package/dist/behaviour-delta.d.ts.map +1 -0
- package/dist/behaviour-http.d.ts +106 -0
- package/dist/behaviour-http.d.ts.map +1 -0
- package/dist/behaviour-overlay.d.ts +61 -0
- package/dist/behaviour-overlay.d.ts.map +1 -0
- package/dist/behaviour.d.ts +178 -3
- package/dist/behaviour.d.ts.map +1 -1
- package/dist/cli/handlers/scenario.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +23 -5
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
- package/dist/lifecycle/scenario.d.ts +45 -3
- package/dist/lifecycle/scenario.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +17 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +42 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.d.ts +207 -0
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
- package/dist/op/activities/reconcile.d.ts +7 -0
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/composites/behaviour-op.d.ts +57 -0
- package/dist/op/composites/behaviour-op.d.ts.map +1 -0
- package/dist/op/composites/index.d.ts +2 -0
- package/dist/op/composites/index.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/behaviour-delta.test.ts +331 -0
- package/src/behaviour-delta.ts +564 -0
- package/src/behaviour-http.test.ts +456 -0
- package/src/behaviour-http.ts +252 -0
- package/src/behaviour-overlay.test.ts +149 -0
- package/src/behaviour-overlay.ts +76 -0
- package/src/behaviour.test.ts +50 -0
- package/src/behaviour.ts +255 -3
- package/src/cli/handlers/scenario.test.ts +108 -0
- package/src/cli/handlers/scenario.ts +63 -16
- package/src/index.ts +2 -0
- package/src/lifecycle/scenario-cost.test.ts +183 -0
- package/src/lifecycle/scenario-eval.ts +133 -6
- package/src/lifecycle/scenario.ts +72 -4
- package/src/lifecycle/types.ts +17 -0
- package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
- package/src/op/activities/activity-contracts.ts +49 -0
- package/src/op/activities/index.ts +24 -0
- package/src/op/activities/predict-behaviour.test.ts +255 -0
- package/src/op/activities/predict-behaviour.ts +468 -0
- package/src/op/activities/reconcile.ts +7 -2
- package/src/op/activity-contract-registry.test.ts +3 -0
- package/src/op/composites/behaviour-op.test.ts +56 -0
- package/src/op/composites/behaviour-op.ts +99 -0
- package/src/op/composites/index.ts +2 -0
- package/src/op/index.ts +2 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `predictBehaviour` activity and the pull-request finding built on it
|
|
3
|
+
* (#2358, epic #2355).
|
|
4
|
+
*
|
|
5
|
+
* Two activities, because they answer two questions:
|
|
6
|
+
*
|
|
7
|
+
* - {@link predictBehaviour} asks the project's predicting lexicon what the
|
|
8
|
+
* declared estate would do at a stated traffic level, and returns the
|
|
9
|
+
* {@link BehaviourResult} as the contract defines it — a report, or a named
|
|
10
|
+
* refusal. It builds the project in-process, assembles the request from the
|
|
11
|
+
* build the way `lifecycle plan` assembles a deep read's, and hands it to
|
|
12
|
+
* the one configured lexicon that implements the fourth observation method.
|
|
13
|
+
* `isBehaviourRefusalReport` is the branch every consumer of its result
|
|
14
|
+
* takes first.
|
|
15
|
+
* - {@link behaviourFinding} runs that prediction twice — once on the
|
|
16
|
+
* checkout the run is in (the pull request's head) and once on the base
|
|
17
|
+
* branch it targets — differences the two under the rules in
|
|
18
|
+
* `../../behaviour-delta.ts`, and posts the finding in `comment` mode
|
|
19
|
+
* through {@link reconcilePr}. Same sticky comment on GitHub and Forgejo,
|
|
20
|
+
* same merge-request note on GitLab, same marker recipe: nothing here
|
|
21
|
+
* posts anything itself.
|
|
22
|
+
*
|
|
23
|
+
* ## The base side is a checkout, not a snapshot
|
|
24
|
+
*
|
|
25
|
+
* The finding is declared-versus-declared: the graph the pull request
|
|
26
|
+
* proposes against the graph its base branch already holds. Both sides are
|
|
27
|
+
* built from source, so the base branch is checked out into a detached git
|
|
28
|
+
* worktree under the repository's own root (module resolution walks up from
|
|
29
|
+
* there to the same `node_modules` the head build uses) and removed again
|
|
30
|
+
* whether the prediction succeeded or not. A shallow CI clone does not carry
|
|
31
|
+
* the base branch, so it is fetched at depth one first — the same reason
|
|
32
|
+
* `converge.ts` fetches `chant/lifecycle` before reading it.
|
|
33
|
+
*
|
|
34
|
+
* The base branch name comes off the run's own event, the way the pull
|
|
35
|
+
* request itself does in `reconcile.ts`: `GITHUB_BASE_REF` on a GitHub
|
|
36
|
+
* Actions or Forgejo Actions `pull_request` job, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`
|
|
37
|
+
* on a GitLab `merge_request_event` pipeline. A run with neither and no
|
|
38
|
+
* explicit `base` fails by name rather than guessing `main`.
|
|
39
|
+
*
|
|
40
|
+
* ## What the marker names
|
|
41
|
+
*
|
|
42
|
+
* The comment's hidden marker names the Op and the env together
|
|
43
|
+
* ({@link behaviourFindingMarker}), for #2319's reason: a `comment`-mode
|
|
44
|
+
* `reconcilePr` step on the same pull request keys its marker on the env
|
|
45
|
+
* alone, and a behaviour finding over `prod` sharing a marker with a plan
|
|
46
|
+
* finding over `prod` would edit the wrong comment on every push.
|
|
47
|
+
*
|
|
48
|
+
* ## Edge coverage on the declared path
|
|
49
|
+
*
|
|
50
|
+
* `buildGraphIr` produces reference edges and is exhaustive for them. It
|
|
51
|
+
* produces no containment — a subnet's membership of a VPC is not a reference
|
|
52
|
+
* — and #2360's third comment records that nothing on the declared path does
|
|
53
|
+
* yet. So the request never claims `complete`: that would be a true claim
|
|
54
|
+
* about references and a false one about the graph. It cannot honestly claim
|
|
55
|
+
* `partial` either, because `partial` has to name a gap as `dangling` or
|
|
56
|
+
* `unresolvedKinds`, and the gap here is neither — every reference resolved,
|
|
57
|
+
* and the generic builder has no vocabulary for "this kind is a boundary
|
|
58
|
+
* whose containment is missing" (augur's own fixture names its boundary
|
|
59
|
+
* kinds by hand, which is lexicon knowledge core does not have). An earlier
|
|
60
|
+
* draft named every kind no reference touched, and named a queue nobody
|
|
61
|
+
* references as "unresolved", which it is not. So the claim is `unknown`,
|
|
62
|
+
* which the contract tells a consumer to treat exactly as it treats
|
|
63
|
+
* `partial`, and the finding shows each side's coverage and does not compare
|
|
64
|
+
* resilience verdicts across it — see `renderBehaviourFinding`.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { exec } from "node:child_process";
|
|
68
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
69
|
+
import { join, resolve } from "node:path";
|
|
70
|
+
import { promisify } from "node:util";
|
|
71
|
+
import {
|
|
72
|
+
isBehaviourRefusalReport,
|
|
73
|
+
type BehaviourEdgeCoverage,
|
|
74
|
+
type BehaviourResult,
|
|
75
|
+
type PredictBehaviourOptions,
|
|
76
|
+
} from "../../behaviour";
|
|
77
|
+
import {
|
|
78
|
+
behaviourDelta,
|
|
79
|
+
renderBehaviourFinding,
|
|
80
|
+
validateBehaviourResult,
|
|
81
|
+
type BehaviourDelta,
|
|
82
|
+
} from "../../behaviour-delta";
|
|
83
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
84
|
+
import type { SerializerResult } from "../../serializer";
|
|
85
|
+
import { markerSlug, reconcilePr, suppliedMarker, type ReconcileResult } from "./reconcile";
|
|
86
|
+
|
|
87
|
+
const execAsync = promisify(exec);
|
|
88
|
+
|
|
89
|
+
function shellQuote(s: string): string {
|
|
90
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* -------------------------------------------------------------------------- */
|
|
94
|
+
/* predictBehaviour */
|
|
95
|
+
/* -------------------------------------------------------------------------- */
|
|
96
|
+
|
|
97
|
+
export interface PredictBehaviourArgs {
|
|
98
|
+
/** The environment the prediction is for, mirroring the deep read's `environment`. */
|
|
99
|
+
environment: string;
|
|
100
|
+
/** The traffic level to predict at, verbatim: `100 rps, p50`. Never parsed here. */
|
|
101
|
+
traffic: string;
|
|
102
|
+
/** Deployed stack, for a multi-stack project. */
|
|
103
|
+
stack?: string;
|
|
104
|
+
/** Region the stack is deployed in. */
|
|
105
|
+
region?: string;
|
|
106
|
+
/** Restrict to chant-owned resources; a withheld entity is `filtered`, not absent. */
|
|
107
|
+
owned?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What the activity says when no configured lexicon implements the fourth method. */
|
|
111
|
+
export function noPredictingLexiconMessage(lexicons: readonly string[]): string {
|
|
112
|
+
const configured = lexicons.length > 0 ? lexicons.join(", ") : "(none)";
|
|
113
|
+
return (
|
|
114
|
+
"predictBehaviour has the estate to predict and no lexicon to predict it with: none of the configured " +
|
|
115
|
+
`lexicons (${configured}) implements predictBehaviour(). Add augur to \`lexicons\` in chant.config.ts — it ` +
|
|
116
|
+
"reads whatever the other lexicons declare — and set CHANT_BEHAVIOUR_ENGINE to the engine's address."
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What the activity says when more than one configured lexicon predicts. */
|
|
121
|
+
export function ambiguousPredictorMessage(names: readonly string[]): string {
|
|
122
|
+
return (
|
|
123
|
+
`predictBehaviour found ${names.length} configured lexicons that implement predictBehaviour() (${names.join(", ")}) ` +
|
|
124
|
+
"and predicts with one. Two engines pricing one estate is a merge this activity does not do, because an entity " +
|
|
125
|
+
"priced twice has two provenances and one row; configure one predicting lexicon."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The declared path's honest coverage claim. See the module doc: reference
|
|
131
|
+
* edges are exhaustive and containment is absent, and the contract has a
|
|
132
|
+
* field for a missing reference and none for missing containment, so the
|
|
133
|
+
* claim is `unknown`. Never `complete`, and not `partial` naming a kind the
|
|
134
|
+
* generic builder has no basis to name. A function rather than a constant so
|
|
135
|
+
* the day the declared path produces containment edges (#2360), the claim
|
|
136
|
+
* changes here and nowhere else.
|
|
137
|
+
*/
|
|
138
|
+
export function declaredEdgeCoverage(): BehaviourEdgeCoverage {
|
|
139
|
+
return { verdict: "unknown" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Predict the estate declared under `projectPath`. The in-process half of
|
|
144
|
+
* {@link predictBehaviour}, taking the project root explicitly so the
|
|
145
|
+
* finding can run it on the base checkout too.
|
|
146
|
+
*
|
|
147
|
+
* Imports are dynamic so this module carries no load-time edge into the CLI
|
|
148
|
+
* or the build: `cli/plugins` reaches back into the root index, and the
|
|
149
|
+
* activity registry imports this module statically.
|
|
150
|
+
*/
|
|
151
|
+
export async function predictDeclared(projectPath: string, args: PredictBehaviourArgs): Promise<BehaviourResult> {
|
|
152
|
+
const { loadChantConfigUpward } = await import("../../config");
|
|
153
|
+
const { loadPlugins, resolveProjectLexicons } = await import("../../cli/plugins");
|
|
154
|
+
const { build } = await import("../../build");
|
|
155
|
+
const { buildGraphIr } = await import("../../graph-ir");
|
|
156
|
+
|
|
157
|
+
const { config } = await loadChantConfigUpward(projectPath);
|
|
158
|
+
const lexicons = await resolveProjectLexicons(projectPath);
|
|
159
|
+
const plugins = (await loadPlugins(lexicons)) as LexiconPlugin[];
|
|
160
|
+
const predictors = plugins.filter((p) => typeof p.predictBehaviour === "function");
|
|
161
|
+
if (predictors.length === 0) throw new Error(noPredictingLexiconMessage(lexicons));
|
|
162
|
+
if (predictors.length > 1) throw new Error(ambiguousPredictorMessage(predictors.map((p) => p.name)));
|
|
163
|
+
const [predictor] = predictors;
|
|
164
|
+
|
|
165
|
+
const sourceDir = resolve(projectPath, config.sourceDir ?? ".");
|
|
166
|
+
const result = await build(sourceDir, plugins.map((p) => p.serializer));
|
|
167
|
+
if (result.errors.length > 0) {
|
|
168
|
+
const messages = result.errors.map((e) => (typeof e === "string" ? e : (e as { message?: string }).message ?? String(e)));
|
|
169
|
+
throw new Error(`predictBehaviour: the project under ${projectPath} did not build: ${messages.join("; ")}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Every declared entity with a type, the way augur's own request fixture
|
|
173
|
+
// assembles it: the predicting lexicon decides what reaches the engine and
|
|
174
|
+
// what is declared unmapped, and it can only decide about what it is
|
|
175
|
+
// handed. Filtering here to "resources" would silently drop the kinds the
|
|
176
|
+
// coverage table exists to name.
|
|
177
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
178
|
+
for (const [name, entity] of result.entities) {
|
|
179
|
+
const declarable = entity as { entityType?: unknown; props?: unknown };
|
|
180
|
+
if (typeof declarable.entityType !== "string") continue;
|
|
181
|
+
entities.set(name, {
|
|
182
|
+
entityType: declarable.entityType,
|
|
183
|
+
props: (declarable.props != null ? declarable.props : {}) as Record<string, unknown>,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const edges = buildGraphIr(result.entities, sourceDir).edges;
|
|
187
|
+
|
|
188
|
+
// The predicting lexicon's own serialized output, as the deep read is handed
|
|
189
|
+
// its lexicon's — a string, not a path (cli/handlers/lifecycle.ts). augur
|
|
190
|
+
// reads nothing off it; the mirror is kept so a lexicon that does gets what
|
|
191
|
+
// the other three methods get.
|
|
192
|
+
const raw = result.outputs.get(predictor.name);
|
|
193
|
+
const buildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
|
|
194
|
+
|
|
195
|
+
const options: PredictBehaviourOptions = {
|
|
196
|
+
environment: args.environment,
|
|
197
|
+
buildOutput,
|
|
198
|
+
entityNames: [...entities.keys()],
|
|
199
|
+
entities,
|
|
200
|
+
...(args.stack ? { stack: args.stack } : {}),
|
|
201
|
+
...(args.region ? { region: args.region } : {}),
|
|
202
|
+
...(args.owned !== undefined ? { owned: args.owned } : {}),
|
|
203
|
+
traffic: args.traffic,
|
|
204
|
+
edges,
|
|
205
|
+
edgeCoverage: declaredEdgeCoverage(),
|
|
206
|
+
};
|
|
207
|
+
const answer = await predictor.predictBehaviour!(options);
|
|
208
|
+
// On arrival, with the names that were asked — `behaviourReport` checks the
|
|
209
|
+
// ordinary route and says a consumer that needs the guarantee checks again.
|
|
210
|
+
return validateBehaviourResult(answer, options.entityNames);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Predict the declared estate in the working directory at `args.traffic`.
|
|
215
|
+
* Returns the contract's result as it is; a refusal is a result, not a
|
|
216
|
+
* thrown error, and `isBehaviourRefusalReport` is the branch to take first.
|
|
217
|
+
* Uses the `fastIdempotent` profile: the build is offline and the engine call
|
|
218
|
+
* is read-only.
|
|
219
|
+
*/
|
|
220
|
+
export async function predictBehaviour(args: PredictBehaviourArgs): Promise<BehaviourResult> {
|
|
221
|
+
return predictDeclared(resolve("."), args);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* -------------------------------------------------------------------------- */
|
|
225
|
+
/* behaviourFinding */
|
|
226
|
+
/* -------------------------------------------------------------------------- */
|
|
227
|
+
|
|
228
|
+
/** How the finding leaves the run. Only the two modes a pull request can carry. */
|
|
229
|
+
export type BehaviourFindingMode = "comment" | "report";
|
|
230
|
+
|
|
231
|
+
export interface BehaviourFindingArgs extends PredictBehaviourArgs {
|
|
232
|
+
/**
|
|
233
|
+
* The name of the Op this step belongs to — it keys the sticky comment's
|
|
234
|
+
* marker together with `environment` ({@link behaviourFindingMarker}), so
|
|
235
|
+
* two Ops over one env own two comments. Required for the same reason
|
|
236
|
+
* `reconcilePr`'s `issue` mode requires it (#2319).
|
|
237
|
+
*/
|
|
238
|
+
op: string;
|
|
239
|
+
/** `comment` posts on the triggering pull or merge request; `report` returns the body only. Default: `comment`. */
|
|
240
|
+
mode?: BehaviourFindingMode;
|
|
241
|
+
/**
|
|
242
|
+
* The base branch to predict the other side from. Read off the run's own
|
|
243
|
+
* event when omitted — `GITHUB_BASE_REF`, then `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`
|
|
244
|
+
* — and refused by name when neither is set.
|
|
245
|
+
*/
|
|
246
|
+
base?: string;
|
|
247
|
+
/** Comment title, unused on a sticky comment but carried for `reconcilePr`'s result. */
|
|
248
|
+
title?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface BehaviourFindingResult {
|
|
252
|
+
mode: BehaviourFindingMode;
|
|
253
|
+
/** The base branch the other side was predicted from. */
|
|
254
|
+
base: string;
|
|
255
|
+
/** What the head side was called in the finding. */
|
|
256
|
+
head: string;
|
|
257
|
+
/** The structured delta, before rendering. */
|
|
258
|
+
finding: BehaviourDelta;
|
|
259
|
+
/** True when either side refused, so the finding says "no prediction". */
|
|
260
|
+
refused: boolean;
|
|
261
|
+
/** The rendered Markdown, whether or not it was posted. */
|
|
262
|
+
summary: string;
|
|
263
|
+
/** The posted or updated comment / note URL (comment mode). */
|
|
264
|
+
commentUrl?: string;
|
|
265
|
+
/** `owner/repo#number` (comment mode, GitHub and Forgejo). */
|
|
266
|
+
pullRequest?: string;
|
|
267
|
+
/** `group/project!iid` (comment mode, GitLab). */
|
|
268
|
+
mergeRequest?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The hidden marker a behaviour finding is found by across re-runs. Keyed on
|
|
273
|
+
* the Op *and* the env, both slugified the same way `reconcilePr`'s markers
|
|
274
|
+
* are, and distinct from both of those by its prefix: a `comment`-mode
|
|
275
|
+
* `reconcilePr` step and this step on one pull request must never match each
|
|
276
|
+
* other's comment.
|
|
277
|
+
*/
|
|
278
|
+
export function behaviourFindingMarker(op: string, env: string): string {
|
|
279
|
+
return `<!-- chant-behaviour:${markerSlug(op)}/${markerSlug(env)} -->`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What the finding says when the run names no base branch to predict against. */
|
|
283
|
+
export function noBaseRefMessage(): string {
|
|
284
|
+
return (
|
|
285
|
+
"behaviourFinding predicts the pull request's declared estate against its base branch's, and this run " +
|
|
286
|
+
"names no base branch. On GitHub Actions and Forgejo Actions a pull_request event sets GITHUB_BASE_REF; on " +
|
|
287
|
+
"GitLab CI a merge_request_event pipeline sets CI_MERGE_REQUEST_TARGET_BRANCH_NAME. Trigger the Op from " +
|
|
288
|
+
"one of those, or pass `base` with the branch name."
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Where the base branch name comes from, most specific first. Pure — exported for testing. */
|
|
293
|
+
export function baseRefFrom(
|
|
294
|
+
env: Record<string, string | undefined>,
|
|
295
|
+
explicit?: string,
|
|
296
|
+
): string | undefined {
|
|
297
|
+
const fromArgs = explicit?.trim();
|
|
298
|
+
if (fromArgs) return fromArgs;
|
|
299
|
+
for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
|
|
300
|
+
const value = env[source]?.trim();
|
|
301
|
+
if (value) return value;
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
|
|
307
|
+
export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
|
|
308
|
+
for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
|
|
309
|
+
const value = env[source]?.trim();
|
|
310
|
+
if (value) return value;
|
|
311
|
+
}
|
|
312
|
+
return shortSha?.trim() || "head";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A checked-out base branch: where its project root is, and how to remove it. */
|
|
316
|
+
export interface BaseCheckout {
|
|
317
|
+
/** The base branch's counterpart of the working directory. */
|
|
318
|
+
projectPath: string;
|
|
319
|
+
cleanup(): Promise<void>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Check `base` out into a detached worktree beside the repository. Fetches it
|
|
324
|
+
* at depth one first, because a CI clone carries only the pull request's own
|
|
325
|
+
* ref; a branch already present locally (a full clone, a developer's own
|
|
326
|
+
* checkout) is used as it stands after the fetch fails.
|
|
327
|
+
*/
|
|
328
|
+
export async function checkoutBase(base: string, signal?: AbortSignal): Promise<BaseCheckout> {
|
|
329
|
+
const { stdout: rootOut } = await execAsync("git rev-parse --show-toplevel", { signal });
|
|
330
|
+
const { stdout: prefixOut } = await execAsync("git rev-parse --show-prefix", { signal });
|
|
331
|
+
const root = rootOut.trim();
|
|
332
|
+
const prefix = prefixOut.trim();
|
|
333
|
+
|
|
334
|
+
let commitish = "FETCH_HEAD";
|
|
335
|
+
try {
|
|
336
|
+
await execAsync(`git fetch --depth=1 origin ${shellQuote(base)}`, { signal });
|
|
337
|
+
} catch {
|
|
338
|
+
// No remote, or no such branch there: a local branch of that name is
|
|
339
|
+
// the only other thing `base` can honestly mean.
|
|
340
|
+
commitish = base;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const dir = await mkdtemp(join(root, ".chant-behaviour-base-"));
|
|
344
|
+
try {
|
|
345
|
+
await execAsync(`git worktree add --detach ${shellQuote(dir)} ${shellQuote(commitish)}`, { signal });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
await rm(dir, { recursive: true, force: true });
|
|
348
|
+
throw new Error(
|
|
349
|
+
`behaviourFinding could not check out base branch ${JSON.stringify(base)}: ${(err as Error).message}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
projectPath: prefix ? join(dir, prefix) : dir,
|
|
354
|
+
async cleanup() {
|
|
355
|
+
try {
|
|
356
|
+
await execAsync(`git worktree remove --force ${shellQuote(dir)}`);
|
|
357
|
+
} catch {
|
|
358
|
+
await rm(dir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** What {@link createBehaviourFinding} takes instead of reaching for the process. */
|
|
365
|
+
export interface BehaviourFindingDeps {
|
|
366
|
+
/** Predict one project root. Default: {@link predictDeclared}. */
|
|
367
|
+
predict?: (projectPath: string, args: PredictBehaviourArgs) => Promise<BehaviourResult>;
|
|
368
|
+
/** Check the base branch out. Default: {@link checkoutBase}. */
|
|
369
|
+
checkout?: (base: string, signal?: AbortSignal) => Promise<BaseCheckout>;
|
|
370
|
+
/** Post the finding. Default: {@link reconcilePr}, whose `comment` mode does the forge split. */
|
|
371
|
+
post?: typeof reconcilePr;
|
|
372
|
+
/** The environment the base and head refs are read from. Default: the process's. */
|
|
373
|
+
env?: Record<string, string | undefined>;
|
|
374
|
+
/** The short commit the head side is named by when the run names no branch. Default: `git rev-parse --short HEAD`. */
|
|
375
|
+
shortSha?: () => Promise<string | undefined>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function defaultShortSha(): Promise<string | undefined> {
|
|
379
|
+
try {
|
|
380
|
+
const { stdout } = await execAsync("git rev-parse --short HEAD");
|
|
381
|
+
return stdout.trim();
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the finding activity, with its four side effects injected so a test
|
|
389
|
+
* can drive the whole thing — predict twice, difference, render, post —
|
|
390
|
+
* against fixtures and a stubbed poster.
|
|
391
|
+
*/
|
|
392
|
+
export function createBehaviourFinding(
|
|
393
|
+
deps: BehaviourFindingDeps = {},
|
|
394
|
+
): (args: BehaviourFindingArgs, signal?: AbortSignal) => Promise<BehaviourFindingResult> {
|
|
395
|
+
const predict = deps.predict ?? predictDeclared;
|
|
396
|
+
const checkout = deps.checkout ?? checkoutBase;
|
|
397
|
+
const post = deps.post ?? reconcilePr;
|
|
398
|
+
const env = deps.env ?? process.env;
|
|
399
|
+
const shortSha = deps.shortSha ?? defaultShortSha;
|
|
400
|
+
|
|
401
|
+
return async function behaviourFinding(args, signal) {
|
|
402
|
+
const mode: BehaviourFindingMode = args.mode ?? "comment";
|
|
403
|
+
if (typeof args.op !== "string" || args.op.trim() === "") {
|
|
404
|
+
throw new Error(
|
|
405
|
+
"behaviourFinding needs `op`, the name of the Op this step belongs to: it keys the comment's marker " +
|
|
406
|
+
"together with `environment`, and an env alone is not unique across Ops (#2319).",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const base = baseRefFrom(env, args.base);
|
|
410
|
+
if (!base) throw new Error(noBaseRefMessage());
|
|
411
|
+
const head = headRefFrom(env, await shortSha());
|
|
412
|
+
const predictArgs: PredictBehaviourArgs = {
|
|
413
|
+
environment: args.environment,
|
|
414
|
+
traffic: args.traffic,
|
|
415
|
+
...(args.stack ? { stack: args.stack } : {}),
|
|
416
|
+
...(args.region ? { region: args.region } : {}),
|
|
417
|
+
...(args.owned !== undefined ? { owned: args.owned } : {}),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// Head first: it is the checkout the run is already in, and a build error
|
|
421
|
+
// there is the pull request's own and should surface before any worktree
|
|
422
|
+
// is created.
|
|
423
|
+
const headResult = await predict(resolve("."), predictArgs);
|
|
424
|
+
const baseCheckout = await checkout(base, signal);
|
|
425
|
+
let baseResult: BehaviourResult;
|
|
426
|
+
try {
|
|
427
|
+
baseResult = await predict(baseCheckout.projectPath, predictArgs);
|
|
428
|
+
} finally {
|
|
429
|
+
await baseCheckout.cleanup();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const finding = behaviourDelta(
|
|
433
|
+
{ label: "base", ref: base, result: baseResult },
|
|
434
|
+
{ label: "head", ref: head, result: headResult },
|
|
435
|
+
);
|
|
436
|
+
const summary = renderBehaviourFinding(finding, { env: args.environment, op: args.op });
|
|
437
|
+
const refused = isBehaviourRefusalReport(baseResult) || isBehaviourRefusalReport(headResult);
|
|
438
|
+
const result: BehaviourFindingResult = { mode, base, head, finding, refused, summary };
|
|
439
|
+
if (mode === "report") return result;
|
|
440
|
+
|
|
441
|
+
const posted: ReconcileResult = await post(
|
|
442
|
+
{
|
|
443
|
+
env: args.environment,
|
|
444
|
+
op: args.op,
|
|
445
|
+
mode: "comment",
|
|
446
|
+
marker: suppliedMarker(behaviourFindingMarker(args.op, args.environment)),
|
|
447
|
+
body: summary,
|
|
448
|
+
title: args.title ?? `Predicted behaviour for ${args.environment} at ${args.traffic}`,
|
|
449
|
+
},
|
|
450
|
+
signal,
|
|
451
|
+
);
|
|
452
|
+
return {
|
|
453
|
+
...result,
|
|
454
|
+
...(posted.commentUrl ? { commentUrl: posted.commentUrl } : {}),
|
|
455
|
+
...(posted.pullRequest ? { pullRequest: posted.pullRequest } : {}),
|
|
456
|
+
...(posted.mergeRequest ? { mergeRequest: posted.mergeRequest } : {}),
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Predict the pull request's declared estate and its base branch's, and post
|
|
463
|
+
* the delta as one sticky comment on the pull request — or one note on the
|
|
464
|
+
* merge request — that triggered the run. Needs a pull-request-triggered
|
|
465
|
+
* run; fails by name without one, the same way `reconcilePr`'s `comment`
|
|
466
|
+
* mode does, because that is the function that posts.
|
|
467
|
+
*/
|
|
468
|
+
export const behaviourFinding = createBehaviourFinding();
|
|
@@ -281,8 +281,13 @@ export function issueMarker(op: string, env: string): string {
|
|
|
281
281
|
return `<!-- chant-reconcile-issue:${markerSlug(op)}/${markerSlug(env)} -->`;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
284
|
+
/**
|
|
285
|
+
* The slugify every marker shares: everything outside `[A-Za-z0-9._-]`
|
|
286
|
+
* collapses to `-`. Exported for the behaviour finding's own marker
|
|
287
|
+
* (`./predict-behaviour.ts`), so a third marker cannot slugify differently
|
|
288
|
+
* from the two here.
|
|
289
|
+
*/
|
|
290
|
+
export function markerSlug(s: string): string {
|
|
286
291
|
return s.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
287
292
|
}
|
|
288
293
|
|
|
@@ -8,6 +8,9 @@ describe("loadActivityContracts", () => {
|
|
|
8
8
|
const contracts = await loadActivityContracts();
|
|
9
9
|
expect(contracts.get("shellCmd")).toBeDefined();
|
|
10
10
|
expect(contracts.get("httpCheck")?.returns).toBeDefined();
|
|
11
|
+
// The behaviour activities (#2358) ship their contracts the same way.
|
|
12
|
+
expect(contracts.get("predictBehaviour")?.name).toBe("predictBehaviour");
|
|
13
|
+
expect(contracts.get("behaviourFinding")?.returns).toBeDefined();
|
|
11
14
|
expect(contracts.get("lifecycleDiff")?.name).toBe("lifecycleDiff");
|
|
12
15
|
});
|
|
13
16
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { BehaviourOp } from "./behaviour-op";
|
|
3
|
+
|
|
4
|
+
/** Reach into the generated Op's phases → the behaviourFinding step. */
|
|
5
|
+
function findingStep(op: unknown): Record<string, unknown> {
|
|
6
|
+
const config = (op as { props: Record<string, unknown> }).props;
|
|
7
|
+
const phases = config.phases as Array<{ name: string; steps: Array<Record<string, unknown>> }>;
|
|
8
|
+
const predict = phases.find((p) => p.name === "Predict");
|
|
9
|
+
if (!predict) throw new Error("no Predict phase");
|
|
10
|
+
return predict.steps[0];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("BehaviourOp composite (#2358)", () => {
|
|
14
|
+
test("one phase, one step, calling behaviourFinding in comment mode with the Op's own name as `op`", () => {
|
|
15
|
+
const { op } = BehaviourOp({ name: "pr-behaviour", env: "prod", traffic: "1000 rps, p99" });
|
|
16
|
+
const step = findingStep(op);
|
|
17
|
+
expect(step.fn).toBe("behaviourFinding");
|
|
18
|
+
expect(step.args).toEqual({ environment: "prod", traffic: "1000 rps, p99", op: "pr-behaviour", mode: "comment" });
|
|
19
|
+
expect(step.outcomeAttribute).toEqual([
|
|
20
|
+
{ name: "Refused", from: "refused" },
|
|
21
|
+
{ name: "Comment", from: "commentUrl" },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("report mode carries no comment outcome, and an explicit base reaches the step", () => {
|
|
26
|
+
const { op } = BehaviourOp({ name: "pr-behaviour", env: "prod", traffic: "1000 rps, p99", findingMode: "report", base: "main" });
|
|
27
|
+
const step = findingStep(op);
|
|
28
|
+
expect((step.args as { mode: string; base: string }).mode).toBe("report");
|
|
29
|
+
expect((step.args as { base: string }).base).toBe("main");
|
|
30
|
+
expect(step.outcomeAttribute).toEqual([{ name: "Refused", from: "refused" }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("scope, stack and region flow into the step; no schedule ever lands on the Op", () => {
|
|
34
|
+
const { op } = BehaviourOp({
|
|
35
|
+
name: "pr-behaviour",
|
|
36
|
+
env: "prod",
|
|
37
|
+
traffic: "100 rps, p50",
|
|
38
|
+
stack: "checkout",
|
|
39
|
+
region: "us-east-1",
|
|
40
|
+
scope: { owned: true },
|
|
41
|
+
});
|
|
42
|
+
const args = findingStep(op).args as Record<string, unknown>;
|
|
43
|
+
expect(args.stack).toBe("checkout");
|
|
44
|
+
expect(args.region).toBe("us-east-1");
|
|
45
|
+
expect(args.owned).toBe(true);
|
|
46
|
+
const props = (op as unknown as { props: Record<string, unknown> }).props;
|
|
47
|
+
expect(props.schedule).toBeUndefined();
|
|
48
|
+
expect(props.labels).toEqual({ Behaviour: "true", Env: "prod" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("two Ops over one env carry two identities", () => {
|
|
52
|
+
const a = BehaviourOp({ name: "pr-behaviour", env: "prod", traffic: "100 rps, p50" });
|
|
53
|
+
const b = BehaviourOp({ name: "pr-behaviour-peak", env: "prod", traffic: "1000 rps, p99" });
|
|
54
|
+
expect((findingStep(a.op).args as { op: string }).op).not.toBe((findingStep(b.op).args as { op: string }).op);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BehaviourOp composite — the predicted delta of a pull request as a review
|
|
3
|
+
* finding (#2358, epic #2355).
|
|
4
|
+
*
|
|
5
|
+
* One phase, one step: `behaviourFinding` predicts the declared estate on the
|
|
6
|
+
* pull request's head and on its base branch, differences the two under the
|
|
7
|
+
* contract's rules, and posts the finding in `comment` mode on the pull
|
|
8
|
+
* request (GitHub, Forgejo) or as a note on the merge request (GitLab) that
|
|
9
|
+
* triggered the run. There is no `schedule` here and there never will be: the
|
|
10
|
+
* cadence is the pull request, and the trigger lives on the `ScheduledOpSpec`
|
|
11
|
+
* handed to `generateOpsPipeline` (`{ kind: "pull_request" }`), the way
|
|
12
|
+
* `TerraformWatchOp`'s does.
|
|
13
|
+
*
|
|
14
|
+
* `findingMode: "report"` returns the body without posting, for a `chant run`
|
|
15
|
+
* on a developer's machine with `base` named by hand.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* export const { op } = BehaviourOp({
|
|
20
|
+
* name: "pr-behaviour",
|
|
21
|
+
* env: "prod",
|
|
22
|
+
* traffic: "1000 rps, p99",
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Op, phase } from "../builders";
|
|
28
|
+
import type { OpResource } from "../resource";
|
|
29
|
+
import type { BehaviourFindingArgs, BehaviourFindingMode } from "../activities/predict-behaviour";
|
|
30
|
+
|
|
31
|
+
export interface BehaviourOpConfig {
|
|
32
|
+
/** Op name (kebab-case). Names the Op's output directory, is what `chant run` takes, and keys the comment's marker. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** Environment the prediction is for. */
|
|
35
|
+
env: string;
|
|
36
|
+
/** The traffic level to predict at, verbatim: `1000 rps, p99`. */
|
|
37
|
+
traffic: string;
|
|
38
|
+
/**
|
|
39
|
+
* What to do with the finding. `comment` posts it on the triggering pull or
|
|
40
|
+
* merge request; `report` returns the body only.
|
|
41
|
+
* @default "comment"
|
|
42
|
+
*/
|
|
43
|
+
findingMode?: BehaviourFindingMode;
|
|
44
|
+
/** The base branch, when the run cannot read it off its own event (a local `chant run`). */
|
|
45
|
+
base?: string;
|
|
46
|
+
/** Deployed stack, for a multi-stack project. */
|
|
47
|
+
stack?: string;
|
|
48
|
+
/** Region the stack is deployed in. */
|
|
49
|
+
region?: string;
|
|
50
|
+
/** Restrict to chant-owned resources. */
|
|
51
|
+
scope?: { owned?: boolean };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BehaviourOpResources {
|
|
55
|
+
/** Op resource — the predict-both-sides-and-post Op. */
|
|
56
|
+
op: InstanceType<typeof OpResource>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function BehaviourOp(config: BehaviourOpConfig): BehaviourOpResources {
|
|
60
|
+
const mode = config.findingMode ?? "comment";
|
|
61
|
+
const args: BehaviourFindingArgs = {
|
|
62
|
+
environment: config.env,
|
|
63
|
+
traffic: config.traffic,
|
|
64
|
+
// `op` names this Op in the marker the sticky comment is found by (#2319).
|
|
65
|
+
op: config.name,
|
|
66
|
+
mode,
|
|
67
|
+
...(config.base ? { base: config.base } : {}),
|
|
68
|
+
...(config.stack ? { stack: config.stack } : {}),
|
|
69
|
+
...(config.region ? { region: config.region } : {}),
|
|
70
|
+
...(config.scope?.owned !== undefined ? { owned: config.scope.owned } : {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const op = Op({
|
|
74
|
+
name: config.name,
|
|
75
|
+
overview: `Predict the ${config.env} estate's behaviour at "${config.traffic}" against the base branch`,
|
|
76
|
+
labels: {
|
|
77
|
+
Behaviour: "true",
|
|
78
|
+
Env: config.env,
|
|
79
|
+
},
|
|
80
|
+
phases: [
|
|
81
|
+
phase("Predict", [
|
|
82
|
+
{
|
|
83
|
+
kind: "activity" as const,
|
|
84
|
+
fn: "behaviourFinding",
|
|
85
|
+
args: { ...args },
|
|
86
|
+
// The finding's headline is the posted comment, and whether either
|
|
87
|
+
// side refused: a refusal is a finding that says "no prediction",
|
|
88
|
+
// and a reader of the run ledger should see that without opening it.
|
|
89
|
+
outcomeAttribute: [
|
|
90
|
+
{ name: "Refused", from: "refused" },
|
|
91
|
+
...(mode === "comment" ? [{ name: "Comment", from: "commentUrl" }] : []),
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { op };
|
|
99
|
+
}
|
|
@@ -24,3 +24,5 @@ export { PipelineAuditOp } from "./pipeline-audit-op";
|
|
|
24
24
|
export type { PipelineAuditOpConfig, PipelineAuditOpResources } from "./pipeline-audit-op";
|
|
25
25
|
export { LexiconUpgradeOp, IN_SCOPE_LEXICONS } from "./lexicon-upgrade-op";
|
|
26
26
|
export type { LexiconUpgradeOpConfig, LexiconUpgradeOpResources } from "./lexicon-upgrade-op";
|
|
27
|
+
export { BehaviourOp } from "./behaviour-op";
|
|
28
|
+
export type { BehaviourOpConfig, BehaviourOpResources } from "./behaviour-op";
|
package/src/op/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { isValidCronExpression, cronSyntaxMessage, cronMatches, cronDueBetween }
|
|
|
21
21
|
export {
|
|
22
22
|
WatchOp, ReconcileOp, ApplyOp, ConvergeOp,
|
|
23
23
|
WorkflowAuditOp, PipelineAuditOp, LexiconUpgradeOp, IN_SCOPE_LEXICONS,
|
|
24
|
+
BehaviourOp,
|
|
24
25
|
} from "./composites";
|
|
25
26
|
export type {
|
|
26
27
|
WatchOpConfig, WatchOpResources,
|
|
@@ -30,6 +31,7 @@ export type {
|
|
|
30
31
|
WorkflowAuditOpConfig, WorkflowAuditOpResources,
|
|
31
32
|
PipelineAuditOpConfig, PipelineAuditOpResources,
|
|
32
33
|
LexiconUpgradeOpConfig, LexiconUpgradeOpResources,
|
|
34
|
+
BehaviourOpConfig, BehaviourOpResources,
|
|
33
35
|
} from "./composites";
|
|
34
36
|
export { receiptActivities, receiptCheckInput } from "./receipt-store";
|
|
35
37
|
export type {
|