@intentius/chant 0.65.0 → 0.66.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/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,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour activities (#2358): the marker, the base-ref resolution, the
|
|
3
|
+
* declared path's coverage claim, and the finding activity driven end to end
|
|
4
|
+
* against fixtures with its poster stubbed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
behaviourReport,
|
|
10
|
+
noBehaviourEngineRefusal,
|
|
11
|
+
predictedRate,
|
|
12
|
+
validateEdgeCoverage,
|
|
13
|
+
type BehaviourResult,
|
|
14
|
+
type PredictedBehaviour,
|
|
15
|
+
} from "../../behaviour";
|
|
16
|
+
import { commentMarker, issueMarker, type ReconcilePrArgs, type ReconcileResult } from "./reconcile";
|
|
17
|
+
import {
|
|
18
|
+
ambiguousPredictorMessage,
|
|
19
|
+
baseRefFrom,
|
|
20
|
+
behaviourFindingMarker,
|
|
21
|
+
createBehaviourFinding,
|
|
22
|
+
declaredEdgeCoverage,
|
|
23
|
+
headRefFrom,
|
|
24
|
+
noBaseRefMessage,
|
|
25
|
+
noPredictingLexiconMessage,
|
|
26
|
+
type BaseCheckout,
|
|
27
|
+
} from "./predict-behaviour";
|
|
28
|
+
|
|
29
|
+
const TRAFFIC = "1000 rps, p99";
|
|
30
|
+
|
|
31
|
+
function figure(perHour: number, basis: "modeled" | "validated" = "modeled"): PredictedBehaviour {
|
|
32
|
+
return {
|
|
33
|
+
at: { traffic: TRAFFIC },
|
|
34
|
+
cost: predictedRate(perHour, "USD"),
|
|
35
|
+
headroom: { cpu: 0.5 },
|
|
36
|
+
errorRate: 0.001,
|
|
37
|
+
resilience: { failure: "one zone lost", verdict: "survives" },
|
|
38
|
+
provenance: { engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function report(entities: Record<string, PredictedBehaviour>, unpredicted: Record<string, { type?: string; reason: "unsupported-kind"; detail?: string }> = {}): BehaviourResult {
|
|
43
|
+
return behaviourReport(
|
|
44
|
+
{ entityNames: [...Object.keys(entities), ...Object.keys(unpredicted)], traffic: TRAFFIC, edgeCoverage: { verdict: "partial", unresolvedKinds: ["AWS::EC2::VPC"] } },
|
|
45
|
+
{ engine: "acme-sim", version: "1.4.2" },
|
|
46
|
+
entities,
|
|
47
|
+
unpredicted,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("behaviourFindingMarker — names the Op and the env (#2319)", () => {
|
|
52
|
+
test("keys on both, slugified, and is distinct from both reconcilePr markers for the same env", () => {
|
|
53
|
+
expect(behaviourFindingMarker("pr-behaviour", "prod")).toBe("<!-- chant-behaviour:pr-behaviour/prod -->");
|
|
54
|
+
expect(behaviourFindingMarker("pr-behaviour", "prod")).not.toBe(commentMarker("prod"));
|
|
55
|
+
expect(behaviourFindingMarker("pr-behaviour", "prod")).not.toBe(issueMarker("pr-behaviour", "prod"));
|
|
56
|
+
expect(behaviourFindingMarker("a", "prod")).not.toBe(behaviourFindingMarker("b", "prod"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("slugifies the way the other markers do, so nothing it sits next to can be escaped out of", () => {
|
|
60
|
+
const marker = behaviourFindingMarker('op" or true', "us-east/1");
|
|
61
|
+
expect(marker).toBe("<!-- chant-behaviour:op-or-true/us-east-1 -->");
|
|
62
|
+
expect(marker).not.toMatch(/["'\\]/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("baseRefFrom / headRefFrom — read off the run's own event", () => {
|
|
67
|
+
test("GITHUB_BASE_REF on GitHub and Forgejo, CI_MERGE_REQUEST_TARGET_BRANCH_NAME on GitLab, an explicit base first", () => {
|
|
68
|
+
expect(baseRefFrom({ GITHUB_BASE_REF: "main" })).toBe("main");
|
|
69
|
+
expect(baseRefFrom({ CI_MERGE_REQUEST_TARGET_BRANCH_NAME: "develop" })).toBe("develop");
|
|
70
|
+
expect(baseRefFrom({ GITHUB_BASE_REF: "main" }, "release")).toBe("release");
|
|
71
|
+
expect(baseRefFrom({ GITHUB_BASE_REF: " " })).toBeUndefined();
|
|
72
|
+
expect(baseRefFrom({})).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the head side is the source branch when the run names one, else the short commit, else `head`", () => {
|
|
76
|
+
expect(headRefFrom({ GITHUB_HEAD_REF: "feature/x" }, "abc1234")).toBe("feature/x");
|
|
77
|
+
expect(headRefFrom({ CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: "mr-branch" })).toBe("mr-branch");
|
|
78
|
+
expect(headRefFrom({}, "abc1234")).toBe("abc1234");
|
|
79
|
+
expect(headRefFrom({})).toBe("head");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the refusal names both variables and the explicit arg", () => {
|
|
83
|
+
expect(noBaseRefMessage()).toMatch(/GITHUB_BASE_REF.*CI_MERGE_REQUEST_TARGET_BRANCH_NAME.*`base`/s);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("declaredEdgeCoverage — never complete on the declared path", () => {
|
|
88
|
+
test("unknown: references are exhaustive, containment is absent, and the contract cannot name that gap", () => {
|
|
89
|
+
const coverage = declaredEdgeCoverage();
|
|
90
|
+
expect(coverage.verdict).toBe("unknown");
|
|
91
|
+
expect(coverage.unresolvedKinds).toBeUndefined();
|
|
92
|
+
expect(coverage.dangling).toBeUndefined();
|
|
93
|
+
// And it satisfies the contract's own validator as it stands.
|
|
94
|
+
expect(() => validateEdgeCoverage(coverage)).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("the refusals for a project that cannot predict", () => {
|
|
99
|
+
test("no predicting lexicon names the configured ones and what to add", () => {
|
|
100
|
+
expect(noPredictingLexiconMessage(["aws", "k8s"])).toMatch(/\(aws, k8s\).*augur.*CHANT_BEHAVIOUR_ENGINE/s);
|
|
101
|
+
expect(noPredictingLexiconMessage([])).toContain("(none)");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("two predicting lexicons is refused rather than merged", () => {
|
|
105
|
+
expect(ambiguousPredictorMessage(["augur", "other"])).toMatch(/2 configured lexicons.*\(augur, other\)/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** A finding activity over fixtures: predict by project path, check out nothing, post into `posted`. */
|
|
110
|
+
function harness(opts: {
|
|
111
|
+
head: BehaviourResult;
|
|
112
|
+
base: BehaviourResult;
|
|
113
|
+
env?: Record<string, string | undefined>;
|
|
114
|
+
baseThrows?: Error;
|
|
115
|
+
}) {
|
|
116
|
+
const calls: string[] = [];
|
|
117
|
+
const posted: ReconcilePrArgs[] = [];
|
|
118
|
+
const checkout: BaseCheckout = {
|
|
119
|
+
projectPath: "/tmp/base-checkout",
|
|
120
|
+
cleanup: async () => {
|
|
121
|
+
calls.push("cleanup");
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const run = createBehaviourFinding({
|
|
125
|
+
env: opts.env ?? { GITHUB_BASE_REF: "main", GITHUB_HEAD_REF: "feature" },
|
|
126
|
+
shortSha: async () => "abc1234",
|
|
127
|
+
checkout: async (base) => {
|
|
128
|
+
calls.push(`checkout:${base}`);
|
|
129
|
+
return checkout;
|
|
130
|
+
},
|
|
131
|
+
predict: async (projectPath) => {
|
|
132
|
+
calls.push(`predict:${projectPath === checkout.projectPath ? "base" : "head"}`);
|
|
133
|
+
if (projectPath === checkout.projectPath) {
|
|
134
|
+
if (opts.baseThrows) throw opts.baseThrows;
|
|
135
|
+
return opts.base;
|
|
136
|
+
}
|
|
137
|
+
return opts.head;
|
|
138
|
+
},
|
|
139
|
+
post: async (args): Promise<ReconcileResult> => {
|
|
140
|
+
posted.push(args);
|
|
141
|
+
return {
|
|
142
|
+
mode: "comment",
|
|
143
|
+
summary: args.body ?? "",
|
|
144
|
+
entries: [],
|
|
145
|
+
commentUrl: "https://forge.example/pr/5#c1",
|
|
146
|
+
pullRequest: "acme/infra#5",
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return { run, calls, posted };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("behaviourFinding — predicts both sides, differences them, and posts through reconcilePr", () => {
|
|
154
|
+
test("head is predicted first, the base checkout is cleaned up, and the delta is posted with the Op-keyed marker", async () => {
|
|
155
|
+
const { run, calls, posted } = harness({
|
|
156
|
+
base: report({ db: figure(0.272), orders: figure(0.004) }),
|
|
157
|
+
head: report({ db: figure(0.4) }, { orders: { type: "AWS::SQS::Queue", reason: "unsupported-kind", detail: "declared unmapped" } }),
|
|
158
|
+
});
|
|
159
|
+
const result = await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" });
|
|
160
|
+
|
|
161
|
+
expect(calls).toEqual(["predict:head", "checkout:main", "predict:base", "cleanup"]);
|
|
162
|
+
expect(result.mode).toBe("comment");
|
|
163
|
+
expect(result.base).toBe("main");
|
|
164
|
+
expect(result.head).toBe("feature");
|
|
165
|
+
expect(result.refused).toBe(false);
|
|
166
|
+
expect(result.commentUrl).toBe("https://forge.example/pr/5#c1");
|
|
167
|
+
expect(result.pullRequest).toBe("acme/infra#5");
|
|
168
|
+
|
|
169
|
+
expect(posted).toHaveLength(1);
|
|
170
|
+
expect(posted[0].mode).toBe("comment");
|
|
171
|
+
expect(posted[0].env).toBe("prod");
|
|
172
|
+
expect(posted[0].op).toBe("pr-behaviour");
|
|
173
|
+
expect(posted[0].marker).toBe(behaviourFindingMarker("pr-behaviour", "prod"));
|
|
174
|
+
expect(posted[0].body).toBe(result.summary);
|
|
175
|
+
// The delta rules survive the trip: a declined entity is a row, not a zero.
|
|
176
|
+
expect(result.summary).toMatch(/\| orders \| AWS::SQS::Queue \| 0\.004 USD\/hour \| declined: unsupported-kind \|/);
|
|
177
|
+
expect(result.summary).toContain("| +0.128 USD/hour |");
|
|
178
|
+
expect(result.finding.kind).toBe("delta");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a mixed-basis pair reaches the comment marked, not subtracted", async () => {
|
|
182
|
+
const { run } = harness({
|
|
183
|
+
base: report({ db: figure(0.272, "modeled") }),
|
|
184
|
+
head: report({ db: figure(0.272, "validated") }),
|
|
185
|
+
});
|
|
186
|
+
const result = await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" });
|
|
187
|
+
expect(result.summary).toContain("| marked: mixed-basis |");
|
|
188
|
+
expect(result.summary).not.toContain("| 0 USD/hour |");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("report mode posts nothing and returns the body", async () => {
|
|
192
|
+
const { run, posted } = harness({ base: report({ db: figure(0.1) }), head: report({ db: figure(0.1) }) });
|
|
193
|
+
const result = await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour", mode: "report" });
|
|
194
|
+
expect(posted).toHaveLength(0);
|
|
195
|
+
expect(result.mode).toBe("report");
|
|
196
|
+
expect(result.commentUrl).toBeUndefined();
|
|
197
|
+
expect(result.summary).toContain("## Predicted behaviour for `prod`");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a refusal on the base side posts a finding that says no prediction, and flags the run", async () => {
|
|
201
|
+
const { run, posted } = harness({ base: noBehaviourEngineRefusal("augur"), head: report({ db: figure(0.1) }) });
|
|
202
|
+
const result = await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" });
|
|
203
|
+
expect(result.refused).toBe(true);
|
|
204
|
+
expect(result.finding.kind).toBe("no-prediction");
|
|
205
|
+
expect(posted[0].body).toContain("no prediction");
|
|
206
|
+
expect(posted[0].body).toContain("Set CHANT_BEHAVIOUR_ENGINE to the engine's address.");
|
|
207
|
+
expect(posted[0].body).not.toContain("/hour");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("the base checkout is removed even when its prediction throws", async () => {
|
|
211
|
+
const { run, calls } = harness({
|
|
212
|
+
base: report({ db: figure(0.1) }),
|
|
213
|
+
head: report({ db: figure(0.1) }),
|
|
214
|
+
baseThrows: new Error("the base did not build"),
|
|
215
|
+
});
|
|
216
|
+
await expect(run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" })).rejects.toThrow(/did not build/);
|
|
217
|
+
expect(calls).toContain("cleanup");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("refuses by name with no base branch to predict against, before predicting anything", async () => {
|
|
221
|
+
const { run, calls } = harness({ base: report({}), head: report({}), env: {} });
|
|
222
|
+
await expect(run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" })).rejects.toThrow(/GITHUB_BASE_REF/);
|
|
223
|
+
expect(calls).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an explicit base wins over the run's own, for a local `chant run`", async () => {
|
|
227
|
+
const { run, calls } = harness({ base: report({}), head: report({}), env: { GITHUB_BASE_REF: "main" } });
|
|
228
|
+
await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour", base: "release", mode: "report" });
|
|
229
|
+
expect(calls).toContain("checkout:release");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("refuses a step with no `op`, which is what keys the marker", async () => {
|
|
233
|
+
const { run } = harness({ base: report({}), head: report({}) });
|
|
234
|
+
await expect(run({ environment: "prod", traffic: TRAFFIC, op: " " })).rejects.toThrow(/needs `op`/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("the posted title never reads as a bill", async () => {
|
|
238
|
+
const { run, posted } = harness({ base: report({}), head: report({}) });
|
|
239
|
+
await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" });
|
|
240
|
+
expect(posted[0].title).toBe("Predicted behaviour for prod at 1000 rps, p99");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("the activity registry carries both activities", () => {
|
|
245
|
+
test("predictBehaviour and behaviourFinding resolve by name with nothing but chant installed", async () => {
|
|
246
|
+
const { loadActivities } = await import("../activity-registry");
|
|
247
|
+
const activities = await loadActivities([]);
|
|
248
|
+
expect(activities.has("predictBehaviour")).toBe(true);
|
|
249
|
+
expect(activities.has("behaviourFinding")).toBe(true);
|
|
250
|
+
// The builder is not an activity; the registry collects every exported
|
|
251
|
+
// function, so it stays off the index on purpose.
|
|
252
|
+
expect(activities.has("createBehaviourFinding")).toBe(false);
|
|
253
|
+
vi.restoreAllMocks();
|
|
254
|
+
});
|
|
255
|
+
});
|