@intentius/chant 0.64.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 +1174 -0
- package/dist/behaviour.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -1
- package/dist/identity.d.ts +28 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +45 -0
- package/dist/lexicon.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 +86 -16
- 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 +2011 -0
- package/src/behaviour.ts +2127 -0
- package/src/cli/handlers/scenario.test.ts +108 -0
- package/src/cli/handlers/scenario.ts +63 -16
- package/src/fold/subset-doc-parity.test.ts +60 -0
- package/src/identity.ts +31 -2
- package/src/index.ts +3 -0
- package/src/lexicon.ts +46 -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.test.ts +84 -0
- package/src/op/activities/reconcile.ts +97 -24
- 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
|
@@ -8,6 +8,8 @@ import { Scenario, snapshot } from "../../lifecycle/scenario";
|
|
|
8
8
|
import { EffectReceipt, receiptExpectation, type EffectReceiptDeclaration } from "../../effect-receipt";
|
|
9
9
|
import type { ResourceMetadata } from "../../lexicon";
|
|
10
10
|
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
11
|
+
import { behaviourReport, noBehaviourEngineRefusal, predictedRate } from "../../behaviour";
|
|
12
|
+
import type { PredictedBehaviour } from "../../behaviour";
|
|
11
13
|
|
|
12
14
|
const buildMock = vi.fn();
|
|
13
15
|
const fetchLifecycleMock = vi.fn();
|
|
@@ -359,6 +361,112 @@ describe("runScenarioCheck", () => {
|
|
|
359
361
|
expect(out).toContain("seeded");
|
|
360
362
|
});
|
|
361
363
|
|
|
364
|
+
// ── The cost clause end to end (#2358) ────────────────────────────────
|
|
365
|
+
//
|
|
366
|
+
// `../../lifecycle/scenario-cost.test.ts` drives the evaluator directly.
|
|
367
|
+
// These four go through the handler instead, because the half the evaluator
|
|
368
|
+
// never sees is the half that reads the block off a fixture on disk: a
|
|
369
|
+
// recorded prediction is a `behaviour` key on the `LifecycleSnapshot`, and
|
|
370
|
+
// the clause is only offline and credential-free if `chant scenario check`
|
|
371
|
+
// can bound a change from that file alone.
|
|
372
|
+
|
|
373
|
+
const TRAFFIC = "1000 rps, p99";
|
|
374
|
+
|
|
375
|
+
/** One entity's figure at {@link TRAFFIC}, at `perHour` USD. */
|
|
376
|
+
function figure(perHour: number): PredictedBehaviour {
|
|
377
|
+
return {
|
|
378
|
+
at: { traffic: TRAFFIC },
|
|
379
|
+
cost: predictedRate(perHour, "USD"),
|
|
380
|
+
headroom: { cpu: 0.4 },
|
|
381
|
+
errorRate: 0.002,
|
|
382
|
+
resilience: { failure: "one zone lost", verdict: "survives" },
|
|
383
|
+
provenance: { engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled" },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A snapshot carrying a recorded prediction over one entity, as `chant lifecycle snapshot` would write it. */
|
|
388
|
+
function pricedSnap(name: string, perHour: number): LifecycleSnapshot {
|
|
389
|
+
return snap({
|
|
390
|
+
resources: { [name]: meta() },
|
|
391
|
+
behaviour: behaviourReport(
|
|
392
|
+
{ entityNames: [name], traffic: TRAFFIC, edgeCoverage: { verdict: "unknown" } },
|
|
393
|
+
{ engine: "acme-sim", version: "1.4.2" },
|
|
394
|
+
{ [name]: figure(perHour) },
|
|
395
|
+
),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
test("a cost bound turns red when the fixture's prediction exceeds it, naming both rates and the level", async () => {
|
|
400
|
+
const fixturePath = await writeFixture(pricedSnap("bucket", 0.9));
|
|
401
|
+
const scenario = Scenario("stays under a dollar an hour", {
|
|
402
|
+
given: snapshot(fixturePath),
|
|
403
|
+
expect: { noop: true, cost: { maxPerHour: 0.5, currency: "USD" } },
|
|
404
|
+
});
|
|
405
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
|
|
406
|
+
|
|
407
|
+
const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
|
|
408
|
+
|
|
409
|
+
expect(exit).toBe(1);
|
|
410
|
+
const out = combined();
|
|
411
|
+
expect(out).toContain("FAIL");
|
|
412
|
+
expect(out).toContain("cost:");
|
|
413
|
+
expect(out).toContain("exceeded");
|
|
414
|
+
expect(out).toContain("0.9 USD/hour");
|
|
415
|
+
expect(out).toContain("0.5 USD/hour");
|
|
416
|
+
expect(out).toContain(TRAFFIC);
|
|
417
|
+
// The other clause is unaffected: the plan itself is still neutral.
|
|
418
|
+
expect(out).not.toContain("noop:");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the same bound passes under the rate, and the pass says which figure it read", async () => {
|
|
422
|
+
const fixturePath = await writeFixture(pricedSnap("bucket", 0.2));
|
|
423
|
+
const scenario = Scenario("stays under a dollar an hour", {
|
|
424
|
+
given: snapshot(fixturePath),
|
|
425
|
+
expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
|
|
426
|
+
});
|
|
427
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
|
|
428
|
+
|
|
429
|
+
const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
|
|
430
|
+
|
|
431
|
+
expect(exit).toBe(0);
|
|
432
|
+
expect(combined()).toContain("PASS");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("a fixture with no behaviour block fails the bound by name, never passes on nothing", async () => {
|
|
436
|
+
const fixturePath = await writeFixture(snap({ resources: { bucket: meta() } }));
|
|
437
|
+
const scenario = Scenario("stays under a dollar an hour", {
|
|
438
|
+
given: snapshot(fixturePath),
|
|
439
|
+
expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
|
|
440
|
+
});
|
|
441
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
|
|
442
|
+
|
|
443
|
+
const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
|
|
444
|
+
|
|
445
|
+
expect(exit).toBe(1);
|
|
446
|
+
const out = combined();
|
|
447
|
+
expect(out).toContain("FAIL");
|
|
448
|
+
expect(out).toContain("carries no `behaviour` block");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a fixture whose recorded prediction is a refusal fails with the refusal's own cause", async () => {
|
|
452
|
+
const fixturePath = await writeFixture(
|
|
453
|
+
snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("augur") }),
|
|
454
|
+
);
|
|
455
|
+
const scenario = Scenario("stays under a dollar an hour", {
|
|
456
|
+
given: snapshot(fixturePath),
|
|
457
|
+
expect: { cost: { maxPerHour: 0.5, currency: "USD" } },
|
|
458
|
+
});
|
|
459
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { budget: scenario }));
|
|
460
|
+
|
|
461
|
+
const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
|
|
462
|
+
|
|
463
|
+
expect(exit).toBe(1);
|
|
464
|
+
const out = combined();
|
|
465
|
+
expect(out).toContain("FAIL");
|
|
466
|
+
expect(out).toContain("no-engine");
|
|
467
|
+
expect(out).toContain("CHANT_BEHAVIOUR_ENGINE");
|
|
468
|
+
});
|
|
469
|
+
|
|
362
470
|
test("a receipt whose live value matches its expectation is a genuine noop pass", async () => {
|
|
363
471
|
// The positive control for the two reproductions above: a receipt that
|
|
364
472
|
// HAS fired, with the right value, is still a clean noop — the pipeline
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
type ReceiptReading,
|
|
13
13
|
} from "../../lifecycle/receipt-plan";
|
|
14
14
|
import { collectEffectReceipts, isEffectReceipt, type EffectReceiptDeclaration } from "../../effect-receipt";
|
|
15
|
-
import { evaluateScenario, type ScenarioVerdict } from "../../lifecycle/scenario-eval";
|
|
15
|
+
import { evaluateScenario, type ScenarioBehaviourFixture, type ScenarioVerdict } from "../../lifecycle/scenario-eval";
|
|
16
|
+
import { validateBehaviourResult } from "../../behaviour-delta";
|
|
16
17
|
import { collectScenarios, type ScenarioDeclaration, type ScenarioGiven } from "../../lifecycle/scenario";
|
|
17
18
|
import { isResourceDeclarable } from "../../declarable";
|
|
18
19
|
import { loadChantConfig } from "../../config";
|
|
@@ -153,6 +154,46 @@ interface GivenResolution {
|
|
|
153
154
|
perLexicon: Map<string, LifecycleSnapshot>;
|
|
154
155
|
/** Set when the fixture itself could not be resolved — the scenario fails on this alone. */
|
|
155
156
|
error?: string;
|
|
157
|
+
/**
|
|
158
|
+
* The fixture's recorded prediction, for a `cost` clause (#2358): the one
|
|
159
|
+
* `behaviour` block the fixture carries, held to the contract on the way
|
|
160
|
+
* in, or the reason there is none. Always set once the fixture resolved,
|
|
161
|
+
* so a `cost` clause against a fixture with no block fails by name rather
|
|
162
|
+
* than on `undefined`.
|
|
163
|
+
*/
|
|
164
|
+
behaviour: ScenarioBehaviourFixture;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read the `behaviour` block off the fixture's snapshots. One block, not one
|
|
169
|
+
* per lexicon: a prediction is over the whole estate (augur reads every
|
|
170
|
+
* lexicon's entities and holds none of its own), so it belongs to no single
|
|
171
|
+
* lexicon's file, and two files carrying two different blocks is a fixture
|
|
172
|
+
* that answers twice. Validated on arrival — a hand-edited fixture with a
|
|
173
|
+
* negative rate or a bare `{ behaviour: "v1" }` is refused as not a
|
|
174
|
+
* prediction, never read as one.
|
|
175
|
+
*/
|
|
176
|
+
function behaviourFrom(snapshots: Iterable<LifecycleSnapshot>, where: string): ScenarioBehaviourFixture {
|
|
177
|
+
const found: unknown[] = [];
|
|
178
|
+
for (const snap of snapshots) {
|
|
179
|
+
if (snap.behaviour !== undefined) found.push(snap.behaviour);
|
|
180
|
+
}
|
|
181
|
+
if (found.length === 0) return { missing: `given ${where} carries no \`behaviour\` block` };
|
|
182
|
+
const distinct = new Set(found.map((b) => JSON.stringify(b)));
|
|
183
|
+
if (distinct.size > 1) {
|
|
184
|
+
return {
|
|
185
|
+
missing: `given ${where} carries ${found.length} different \`behaviour\` blocks across its lexicon snapshots; a prediction is one answer over the whole estate`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const block = found[0] as { entities?: Record<string, unknown>; unpredicted?: Record<string, unknown> };
|
|
189
|
+
const names = [...Object.keys(block?.entities ?? {}), ...Object.keys(block?.unpredicted ?? {})];
|
|
190
|
+
try {
|
|
191
|
+
return { result: validateBehaviourResult(block, names) };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return {
|
|
194
|
+
missing: `given ${where} carries a \`behaviour\` block that is not a valid prediction: ${(err as Error).message}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
156
197
|
}
|
|
157
198
|
|
|
158
199
|
/** Read `given`'s fixture data. Offline: a file read for `snapshot(path)`, a
|
|
@@ -162,41 +203,47 @@ async function resolveGiven(given: ScenarioGiven): Promise<GivenResolution> {
|
|
|
162
203
|
if (given.kind === "file") {
|
|
163
204
|
const abs = resolve(given.path);
|
|
164
205
|
let raw: string;
|
|
206
|
+
const unresolved = (error: string, env = ""): GivenResolution => ({
|
|
207
|
+
env,
|
|
208
|
+
perLexicon: new Map(),
|
|
209
|
+
error,
|
|
210
|
+
behaviour: { missing: error },
|
|
211
|
+
});
|
|
165
212
|
try {
|
|
166
213
|
raw = await readFile(abs, "utf8");
|
|
167
214
|
} catch {
|
|
168
|
-
return
|
|
215
|
+
return unresolved(`fixture not found: ${given.path}`);
|
|
169
216
|
}
|
|
170
217
|
let snap: LifecycleSnapshot;
|
|
171
218
|
try {
|
|
172
219
|
snap = JSON.parse(raw) as LifecycleSnapshot;
|
|
173
220
|
} catch {
|
|
174
|
-
return
|
|
221
|
+
return unresolved(`fixture is not valid JSON: ${given.path}`);
|
|
175
222
|
}
|
|
176
223
|
if (typeof snap.lexicon !== "string" || typeof snap.environment !== "string" || typeof snap.resources !== "object") {
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
};
|
|
224
|
+
return unresolved(
|
|
225
|
+
`${given.path} is not a LifecycleSnapshot — missing lexicon/environment/resources`,
|
|
226
|
+
typeof snap.environment === "string" ? snap.environment : "",
|
|
227
|
+
);
|
|
182
228
|
}
|
|
183
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
env: snap.environment,
|
|
231
|
+
perLexicon: new Map([[snap.lexicon, snap]]),
|
|
232
|
+
behaviour: behaviourFrom([snap], given.path),
|
|
233
|
+
};
|
|
184
234
|
}
|
|
185
235
|
|
|
186
236
|
const stored = await readEnvironmentSnapshots(given.env);
|
|
187
237
|
if (stored.size === 0) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
perLexicon: new Map(),
|
|
191
|
-
error: `no recorded snapshot for environment "${given.env}" on chant/lifecycle — record one with \`chant lifecycle snapshot ${given.env}\``,
|
|
192
|
-
};
|
|
238
|
+
const error = `no recorded snapshot for environment "${given.env}" on chant/lifecycle — record one with \`chant lifecycle snapshot ${given.env}\``;
|
|
239
|
+
return { env: given.env, perLexicon: new Map(), error, behaviour: { missing: error } };
|
|
193
240
|
}
|
|
194
241
|
const perLexicon = new Map<string, LifecycleSnapshot>();
|
|
195
242
|
for (const [key, content] of stored) {
|
|
196
243
|
const snap = JSON.parse(content) as LifecycleSnapshot;
|
|
197
244
|
perLexicon.set(snap.lexicon ?? key, snap);
|
|
198
245
|
}
|
|
199
|
-
return { env: given.env, perLexicon };
|
|
246
|
+
return { env: given.env, perLexicon, behaviour: behaviourFrom(perLexicon.values(), `env "${given.env}"`) };
|
|
200
247
|
}
|
|
201
248
|
|
|
202
249
|
/**
|
|
@@ -317,7 +364,7 @@ async function evaluateOneScenario(
|
|
|
317
364
|
mergeReceiptEntries(merged, receipts, receiptEntries);
|
|
318
365
|
}
|
|
319
366
|
|
|
320
|
-
return { env: resolved.env, verdict: evaluateScenario(merged, scenario.expect) };
|
|
367
|
+
return { env: resolved.env, verdict: evaluateScenario(merged, scenario.expect, resolved.behaviour) };
|
|
321
368
|
}
|
|
322
369
|
|
|
323
370
|
/** Fallback for `chant scenario <unknown subcommand>` — mirrors `runLifecycleUnknown`. */
|
|
@@ -64,6 +64,16 @@ import { findSubsetViolation } from "./subset";
|
|
|
64
64
|
* comment): moving it under a `###` heading with a fenced block, the shape
|
|
65
65
|
* every other case here uses, was necessary but not sufficient — the
|
|
66
66
|
* fixture still has to be run through the right function.
|
|
67
|
+
*
|
|
68
|
+
* Two more claims are checked against `fold()` the same way — chant #2348.
|
|
69
|
+
* "typescript-as-data.mdx" said a method call never folds; #1966 made that
|
|
70
|
+
* false for a method call whose receiver folds to a real value. Both the
|
|
71
|
+
* corrected supported claim ("Method calls on folded values") and the
|
|
72
|
+
* corrected unsupported one ("An array method whose callback is a function
|
|
73
|
+
* value" — right outcome, `list.map(...)` still falls back, but because the
|
|
74
|
+
* callback is a function used as a value, not because `.map` is a method
|
|
75
|
+
* call) were previously unfenced prose sentences too, invisible to this file
|
|
76
|
+
* for the same reason #2306's was.
|
|
67
77
|
*/
|
|
68
78
|
|
|
69
79
|
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
@@ -193,6 +203,17 @@ describe("subset-doc-parity — supported patterns in typescript-as-data.mdx cla
|
|
|
193
203
|
if (!wrapped) throw new Error("subset-doc-parity: nullish-coalescing fragment failed to parse");
|
|
194
204
|
expect(findSubsetViolation(wrapped)).toBeUndefined();
|
|
195
205
|
});
|
|
206
|
+
|
|
207
|
+
test("Method calls on folded values", () => {
|
|
208
|
+
// chant #1966/#2348 — findSubsetViolation accepts this shape
|
|
209
|
+
// unconditionally regardless of whether the receiver actually resolves
|
|
210
|
+
// (module doc, point above the CallExpression case in ./subset.ts):
|
|
211
|
+
// shape-valid here is necessary but not sufficient. The real
|
|
212
|
+
// accept/reject decision is fold()'s, checked below in the
|
|
213
|
+
// fold()-decided describe block.
|
|
214
|
+
const consts = parseConsts(extractFencedBlock("Method calls on folded values"));
|
|
215
|
+
expect(findSubsetViolation(resourceArg(consts, "store"))).toBeUndefined();
|
|
216
|
+
});
|
|
196
217
|
});
|
|
197
218
|
|
|
198
219
|
describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx classify as rejected", () => {
|
|
@@ -221,6 +242,14 @@ describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx c
|
|
|
221
242
|
const consts = parseConsts(extractFencedBlock("Spread from dynamic sources"));
|
|
222
243
|
expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
|
|
223
244
|
});
|
|
245
|
+
|
|
246
|
+
test("An array method whose callback is a function value", () => {
|
|
247
|
+
// chant #2348 — the callback argument, an ArrowFunction, is what
|
|
248
|
+
// findSubsetViolation rejects here (falls through to the catch-all
|
|
249
|
+
// unsupportedExpressionMessage), not the `.map(...)` method call itself.
|
|
250
|
+
const consts = parseConsts(extractFencedBlock("An array method whose callback is a function value"));
|
|
251
|
+
expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
|
|
252
|
+
});
|
|
224
253
|
});
|
|
225
254
|
|
|
226
255
|
describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (#2306)", () => {
|
|
@@ -242,3 +271,34 @@ describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (
|
|
|
242
271
|
expect((error as FoldError).message).toContain("unknownTag");
|
|
243
272
|
});
|
|
244
273
|
});
|
|
274
|
+
|
|
275
|
+
describe("subset-doc-parity — fold()-decided claims in typescript-as-data.mdx (#2348)", () => {
|
|
276
|
+
test("Method calls on folded values", () => {
|
|
277
|
+
// findSubsetViolation only proves the shape is admissible (above); prove
|
|
278
|
+
// the doc's own example actually folds, and to the value the doc claims,
|
|
279
|
+
// by running it through fold() with no externals at all — the receiver
|
|
280
|
+
// (`[prefix, "data"]`) is a plain array literal, so nothing needs to
|
|
281
|
+
// resolve across a file boundary for this one.
|
|
282
|
+
const consts = parseConsts(extractFencedBlock("Method calls on folded values"));
|
|
283
|
+
const arg = resourceArg(consts, "store");
|
|
284
|
+
expect(fold(arg, consts, [])).toEqual({ name: "acct-data" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("An array method whose callback is a function value — real reason", () => {
|
|
288
|
+
// The doc's corrected claim: this falls back because the ARROW FUNCTION
|
|
289
|
+
// is a value fold refuses, not because `.map` is a method call. Assert
|
|
290
|
+
// the actual FoldError says so, rather than something naming ".map" or
|
|
291
|
+
// "method call".
|
|
292
|
+
const consts = parseConsts(extractFencedBlock("An array method whose callback is a function value"));
|
|
293
|
+
const arg = resourceArg(consts, "store");
|
|
294
|
+
let error: unknown;
|
|
295
|
+
try {
|
|
296
|
+
fold(arg, consts, []);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
error = e;
|
|
299
|
+
}
|
|
300
|
+
expect(error).toBeInstanceOf(FoldError);
|
|
301
|
+
expect((error as FoldError).message).toContain("a function used as a value is not foldable");
|
|
302
|
+
expect((error as FoldError).message).not.toContain("method call");
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/identity.ts
CHANGED
|
@@ -161,7 +161,7 @@ export const REDACTED = "[redacted]";
|
|
|
161
161
|
* name, so a lexicon that echoes one of these into an identity string has the
|
|
162
162
|
* value removed before it is printed or serialized.
|
|
163
163
|
*/
|
|
164
|
-
const CREDENTIAL_ENV_NAME =
|
|
164
|
+
export const CREDENTIAL_ENV_NAME =
|
|
165
165
|
/(SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|APIKEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)/i;
|
|
166
166
|
|
|
167
167
|
/** Shortest env value worth redacting. Below this a "secret" is a false positive. */
|
|
@@ -171,7 +171,7 @@ const MIN_CREDENTIAL_LENGTH = 8;
|
|
|
171
171
|
* Literal credential shapes. Each is something a principal string cannot be,
|
|
172
172
|
* so matching one is proof rather than a guess.
|
|
173
173
|
*/
|
|
174
|
-
const CREDENTIAL_SHAPES: RegExp[] = [
|
|
174
|
+
export const CREDENTIAL_SHAPES: RegExp[] = [
|
|
175
175
|
// A PEM block of any key type.
|
|
176
176
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
177
177
|
// A JWT: three base64url segments, the first starting with the `{"` header.
|
|
@@ -180,6 +180,32 @@ const CREDENTIAL_SHAPES: RegExp[] = [
|
|
|
180
180
|
/\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/]{16,}={0,2}/g,
|
|
181
181
|
];
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Provider-prefixed access tokens, by their issuer's own prefix (#2356).
|
|
185
|
+
*
|
|
186
|
+
* Separate from {@link CREDENTIAL_SHAPES} because these are a **denylist of
|
|
187
|
+
* known formats** rather than proof-by-shape. A prefix nobody has added here —
|
|
188
|
+
* a new provider, an internal issuer, a bare random string — passes every one
|
|
189
|
+
* of them, and any caller relying on this must say so rather than claim
|
|
190
|
+
* coverage. What it does buy is that the tokens people actually paste are
|
|
191
|
+
* caught wherever they appear, whatever the field is called.
|
|
192
|
+
*
|
|
193
|
+
* The suffix bound is deliberately short. The prefix is the signal; a truncated
|
|
194
|
+
* or example token is still somebody having written a credential down.
|
|
195
|
+
*/
|
|
196
|
+
export const CREDENTIAL_TOKEN_SHAPES: { name: string; re: RegExp }[] = [
|
|
197
|
+
{ name: "a GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{6,}\b/g },
|
|
198
|
+
{ name: "a GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{6,}\b/g },
|
|
199
|
+
{ name: "a GitLab personal access token", re: /\bglpat-[A-Za-z0-9_-]{3,}\b/g },
|
|
200
|
+
{ name: "an OpenAI-style secret key", re: /\bsk-(?:live-|proj-|test-)?[A-Za-z0-9]{6,}\b/g },
|
|
201
|
+
{ name: "a Stripe key", re: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{6,}\b/g },
|
|
202
|
+
{ name: "a Slack token", re: /\bxox[abposr]-[A-Za-z0-9-]{6,}\b/g },
|
|
203
|
+
{ name: "an AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
204
|
+
{ name: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{20,}\b/g },
|
|
205
|
+
{ name: "a Google OAuth token", re: /\bya29\.[0-9A-Za-z_-]{10,}/g },
|
|
206
|
+
{ name: "an npm token", re: /\bnpm_[A-Za-z0-9]{10,}\b/g },
|
|
207
|
+
];
|
|
208
|
+
|
|
183
209
|
/**
|
|
184
210
|
* Strip credential material from one reported field.
|
|
185
211
|
*
|
|
@@ -205,6 +231,9 @@ export function redactCredentialMaterial(
|
|
|
205
231
|
out = out.split(secret).join(REDACTED);
|
|
206
232
|
}
|
|
207
233
|
for (const shape of CREDENTIAL_SHAPES) out = out.replace(shape, REDACTED);
|
|
234
|
+
// Provider-prefixed tokens too (#2356): these are the ones people paste, and
|
|
235
|
+
// the three shapes above match none of them.
|
|
236
|
+
for (const { re } of CREDENTIAL_TOKEN_SHAPES) out = out.replace(re, REDACTED);
|
|
208
237
|
return out;
|
|
209
238
|
}
|
|
210
239
|
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,9 @@ export * from "./observation";
|
|
|
62
62
|
export * from "./identity";
|
|
63
63
|
export * from "./apply";
|
|
64
64
|
export * from "./deep-observation";
|
|
65
|
+
export * from "./behaviour";
|
|
66
|
+
export * from "./behaviour-http";
|
|
67
|
+
export * from "./behaviour-delta";
|
|
65
68
|
export * from "./claimed-fields";
|
|
66
69
|
export * from "./fold-provenance";
|
|
67
70
|
export * from "./owner-chain";
|
package/src/lexicon.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { IREdge } from "./graph-ir";
|
|
|
23
23
|
import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
24
24
|
import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
|
|
25
25
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
26
|
+
import type { BehaviourResult, PredictBehaviourOptions } from "./behaviour";
|
|
26
27
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
27
28
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
28
29
|
import type { CommandGroup } from "./cli/command-group";
|
|
@@ -1413,6 +1414,51 @@ export interface LexiconPlugin {
|
|
|
1413
1414
|
*/
|
|
1414
1415
|
deepNormalizationHooks?: DeepNormalizationHooks;
|
|
1415
1416
|
|
|
1417
|
+
/**
|
|
1418
|
+
* Predict what the declared estate would *do* at a stated traffic level
|
|
1419
|
+
* (#2356) — cost per hour, headroom, an error-rate expectation, a resilience
|
|
1420
|
+
* verdict under a named failure, and a right-size hint, per entity, every
|
|
1421
|
+
* figure carrying its provenance. Opt-in, and the fourth member of the
|
|
1422
|
+
* observation family beside {@link describeResources}, {@link
|
|
1423
|
+
* observeResourcesDeep} and {@link listArtifacts}.
|
|
1424
|
+
*
|
|
1425
|
+
* It is the odd one out in that family, and the type says so. The other three
|
|
1426
|
+
* report what a substrate was asked and answered. This one reports what an
|
|
1427
|
+
* engine believes would happen at a level nobody has run yet, so it is
|
|
1428
|
+
* `predict`, not `observe`, and its result can never be read as a
|
|
1429
|
+
* measurement or a bill: money exists only as a `PredictedRate` for one
|
|
1430
|
+
* imagined hour, every entity states the `at` its figures answer, and
|
|
1431
|
+
* `provenance.basis` says `modeled` or `validated` on every one of them. See
|
|
1432
|
+
* `../behaviour.ts` for the full argument.
|
|
1433
|
+
*
|
|
1434
|
+
* Options mirror {@link observeResourcesDeep}'s field for field, plus
|
|
1435
|
+
* `traffic` — a caller already driving the deep read drives this with the
|
|
1436
|
+
* same object. What the options deliberately cannot carry is a credential:
|
|
1437
|
+
* every name for one is declared `?: never`, because the engine is handed the
|
|
1438
|
+
* resource graph and nothing else, reaches no account, and writes nothing.
|
|
1439
|
+
*
|
|
1440
|
+
* Three verdicts per entity, on the tri-state discipline #1089 established
|
|
1441
|
+
* and a stricter total: PREDICTED (a key in `entities`),
|
|
1442
|
+
* NOT-PREDICTABLE-FOR-THIS-KIND (`unpredicted` with `unsupported-kind` — an
|
|
1443
|
+
* engine with no model for a kind says so and never returns zero), and
|
|
1444
|
+
* NOT-PREDICTED (`unpredicted` with another reason). Every name the caller
|
|
1445
|
+
* asked about lands in one map or the other; unlike the thin read there is no
|
|
1446
|
+
* third position, because a prediction has no equivalent of "the provider
|
|
1447
|
+
* says it is not there".
|
|
1448
|
+
*
|
|
1449
|
+
* A missing or unreachable engine is a `BehaviourRefusalReport` — the other
|
|
1450
|
+
* arm of the result union, with no `entities` map to be empty and no total to
|
|
1451
|
+
* be zero — carrying a named cause and a message that names the variable it
|
|
1452
|
+
* wanted, in the style of `noGitlabNoteTokenMessage`
|
|
1453
|
+
* (`./op/activities/reconcile.ts`). Build one with
|
|
1454
|
+
* `noBehaviourEngineRefusal` / `unreachableBehaviourEngineRefusal` rather
|
|
1455
|
+
* than by hand.
|
|
1456
|
+
*
|
|
1457
|
+
* Throwing is the whole-lexicon failure, same as the other reads. Prefer the
|
|
1458
|
+
* refusal: it says which variable, and a stack trace does not.
|
|
1459
|
+
*/
|
|
1460
|
+
predictBehaviour?(options: PredictBehaviourOptions): Promise<BehaviourResult>;
|
|
1461
|
+
|
|
1416
1462
|
/**
|
|
1417
1463
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
1418
1464
|
*
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `cost` clause on a scenario (#2358): its shape at declaration, and its
|
|
3
|
+
* evaluation against the fixture's recorded prediction.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { EXPECT_KEYS, Scenario, snapshot } from "./scenario";
|
|
8
|
+
import { evaluateScenario, type ScenarioBehaviourFixture } from "./scenario-eval";
|
|
9
|
+
import type { ChangeSet } from "./change-set";
|
|
10
|
+
import {
|
|
11
|
+
behaviourReport,
|
|
12
|
+
noBehaviourEngineRefusal,
|
|
13
|
+
outOfCreditBehaviourEngineRefusal,
|
|
14
|
+
predictedRate,
|
|
15
|
+
type PredictedBehaviour,
|
|
16
|
+
type UnpredictedEntity,
|
|
17
|
+
} from "../behaviour";
|
|
18
|
+
|
|
19
|
+
const EMPTY: ChangeSet = { env: "prod", entries: [] };
|
|
20
|
+
const TRAFFIC = "100 rps, p50";
|
|
21
|
+
|
|
22
|
+
function figure(perHour: number, currency = "USD"): PredictedBehaviour {
|
|
23
|
+
return {
|
|
24
|
+
at: { traffic: TRAFFIC },
|
|
25
|
+
cost: predictedRate(perHour, currency),
|
|
26
|
+
headroom: { cpu: 0.5 },
|
|
27
|
+
errorRate: 0.001,
|
|
28
|
+
resilience: { failure: "one zone lost", verdict: "survives" },
|
|
29
|
+
provenance: { engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled" },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fixture(
|
|
34
|
+
entities: Record<string, PredictedBehaviour>,
|
|
35
|
+
unpredicted: Record<string, UnpredictedEntity> = {},
|
|
36
|
+
total?: { perHour: number; currency: string },
|
|
37
|
+
): ScenarioBehaviourFixture {
|
|
38
|
+
return {
|
|
39
|
+
result: behaviourReport(
|
|
40
|
+
{ entityNames: [...Object.keys(entities), ...Object.keys(unpredicted)], traffic: TRAFFIC, edgeCoverage: { verdict: "unknown" } },
|
|
41
|
+
{ engine: "acme-sim", version: "1.4.2", ...(total ? { total: predictedRate(total.perHour, total.currency) } : {}) },
|
|
42
|
+
entities,
|
|
43
|
+
unpredicted,
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cost(verdict: ReturnType<typeof evaluateScenario>) {
|
|
49
|
+
return verdict.checks.find((c) => c.clause === "cost")!;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("Scenario — the cost clause's shape", () => {
|
|
53
|
+
test("cost is a recognized expect key", () => {
|
|
54
|
+
expect(EXPECT_KEYS).toContain("cost");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("accepts a bound with a currency, and an optional entity", () => {
|
|
58
|
+
const s = Scenario("s", { given: snapshot("fixtures/prod.json"), expect: { cost: { maxPerHour: 12.5, currency: "USD" } } });
|
|
59
|
+
expect(s.expect.cost).toEqual({ maxPerHour: 12.5, currency: "USD" });
|
|
60
|
+
const e = Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: 1, currency: " EUR ", entity: "db" } } });
|
|
61
|
+
expect(e.expect.cost).toEqual({ maxPerHour: 1, currency: "EUR", entity: "db" });
|
|
62
|
+
expect(Object.isFrozen(e.expect.cost)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("composes with the other clauses", () => {
|
|
66
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { noop: true, cost: { maxPerHour: 1, currency: "USD" } } });
|
|
67
|
+
expect(Object.keys(s.expect).sort()).toEqual(["cost", "noop"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects a bound with no currency — chant converts nothing", () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
// @ts-expect-error deliberately omitting currency
|
|
73
|
+
Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: 1 } } }),
|
|
74
|
+
).toThrow(/expect.cost.currency/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects a negative, non-finite or non-numeric bound", () => {
|
|
78
|
+
expect(() => Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: -1, currency: "USD" } } })).toThrow(/non-negative finite/);
|
|
79
|
+
expect(() => Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: Number.NaN, currency: "USD" } } })).toThrow(/non-negative finite/);
|
|
80
|
+
expect(() =>
|
|
81
|
+
// @ts-expect-error deliberately passing a string
|
|
82
|
+
Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: "12", currency: "USD" } } }),
|
|
83
|
+
).toThrow(/non-negative finite/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects an unknown field inside cost, and a non-object cost", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
// @ts-expect-error deliberately passing an unrecognized field
|
|
89
|
+
Scenario("s", { given: snapshot("prod"), expect: { cost: { maxPerHour: 1, currency: "USD", maxPerMonth: 700 } } }),
|
|
90
|
+
).toThrow(/unknown `expect.cost` field "maxPerMonth"/);
|
|
91
|
+
expect(() =>
|
|
92
|
+
// @ts-expect-error deliberately passing a number
|
|
93
|
+
Scenario("s", { given: snapshot("prod"), expect: { cost: 12 } }),
|
|
94
|
+
).toThrow(/must be \{ maxPerHour, currency, entity\? \}/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("evaluateScenario — cost against the fixture's recorded prediction", () => {
|
|
99
|
+
test("a fixture with no behaviour block fails the clause by name — a bound cannot be checked against no figure", () => {
|
|
100
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD" } }, { missing: "given fixtures/prod.json carries no `behaviour` block" });
|
|
101
|
+
expect(verdict.pass).toBe(false);
|
|
102
|
+
expect(cost(verdict).detail).toMatch(/carries no `behaviour` block.*Record one/);
|
|
103
|
+
// And with nothing handed over at all.
|
|
104
|
+
expect(evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD" } }).pass).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a fixture whose prediction is a refusal fails with the refusal's reason, never a pass on nothing", () => {
|
|
108
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1e9, currency: "USD" } }, { result: noBehaviourEngineRefusal("augur") });
|
|
109
|
+
expect(verdict.pass).toBe(false);
|
|
110
|
+
expect(cost(verdict).detail).toContain("the fixture's prediction is a refusal (no-engine)");
|
|
111
|
+
expect(cost(verdict).detail).toContain("Set CHANT_BEHAVIOUR_ENGINE to the engine's address.");
|
|
112
|
+
|
|
113
|
+
const broke = outOfCreditBehaviourEngineRefusal("augur", { value: "engine", source: "CHANT_BEHAVIOUR_ENGINE" }, "balance 0");
|
|
114
|
+
expect(cost(evaluateScenario(EMPTY, { cost: { maxPerHour: 1e9, currency: "USD" } }, { result: broke })).detail).toContain("engine-out-of-credit");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("turns red when the fixture's figure exceeds the bound, naming both rates and the level", () => {
|
|
118
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 0.25, currency: "USD" } }, fixture({ db: figure(0.272) }));
|
|
119
|
+
expect(verdict.pass).toBe(false);
|
|
120
|
+
expect(cost(verdict).detail).toBe(
|
|
121
|
+
'exceeded: 0.272 USD/hour at "100 rps, p50" against a bound of 0.25 USD/hour; read from chant\'s own sum over 1 predicted entity (the engine states no total)',
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("passes at or under the bound, and says on the pass which figure it read", () => {
|
|
126
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 0.272, currency: "USD" } }, fixture({ db: figure(0.272) }));
|
|
127
|
+
expect(verdict.pass).toBe(true);
|
|
128
|
+
expect(cost(verdict).detail).toContain("read from chant's own sum");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("reads the engine's own total when it states one, rather than summing", () => {
|
|
132
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD" } }, fixture({ db: figure(0.272), web: figure(0.04) }, {}, { perHour: 1.5, currency: "USD" }));
|
|
133
|
+
expect(verdict.pass).toBe(false);
|
|
134
|
+
expect(cost(verdict).detail).toContain("1.5 USD/hour");
|
|
135
|
+
expect(cost(verdict).detail).toContain("read from the engine's own estate total (acme-sim 1.4.2)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("chant's sum names every declined entity, because those are in the estate and not in the sum", () => {
|
|
139
|
+
const verdict = evaluateScenario(
|
|
140
|
+
EMPTY,
|
|
141
|
+
{ cost: { maxPerHour: 1, currency: "USD" } },
|
|
142
|
+
fixture({ db: figure(0.272) }, { role: { type: "AWS::IAM::Role", reason: "unsupported-kind", detail: "a role is a grant" } }),
|
|
143
|
+
);
|
|
144
|
+
expect(verdict.pass).toBe(true);
|
|
145
|
+
expect(cost(verdict).detail).toContain("1 declined and not in the figure: role (unsupported-kind: a role is a grant)");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("bounds one entity's own rate when named, with its provenance", () => {
|
|
149
|
+
const fx = fixture({ db: figure(0.272), web: figure(5) });
|
|
150
|
+
expect(evaluateScenario(EMPTY, { cost: { maxPerHour: 0.3, currency: "USD", entity: "db" } }, fx).pass).toBe(true);
|
|
151
|
+
const red = evaluateScenario(EMPTY, { cost: { maxPerHour: 0.3, currency: "USD", entity: "web" } }, fx);
|
|
152
|
+
expect(red.pass).toBe(false);
|
|
153
|
+
expect(cost(red).detail).toContain("read from web's own rate (acme-sim 1.4.2, ±15%, modeled)");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a named entity the engine declined, or never asked about, fails with the reason", () => {
|
|
157
|
+
const fx = fixture({ db: figure(0.272) }, { role: { reason: "unsupported-kind", detail: "a role is a grant" } });
|
|
158
|
+
expect(cost(evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD", entity: "role" } }, fx)).detail).toContain(
|
|
159
|
+
'"role" was declined by the engine (unsupported-kind: a role is a grant)',
|
|
160
|
+
);
|
|
161
|
+
expect(cost(evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD", entity: "ghost" } }, fx)).detail).toContain(
|
|
162
|
+
'"ghost" is in neither the fixture\'s figures nor its declined entities',
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a bound in another currency fails — chant converts nothing", () => {
|
|
167
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "EUR" } }, fixture({ db: figure(0.272) }));
|
|
168
|
+
expect(verdict.pass).toBe(false);
|
|
169
|
+
expect(cost(verdict).detail).toContain("the bound is in EUR and the figure is 0.272 USD/hour");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("entities priced in two currencies with no engine total cannot be summed", () => {
|
|
173
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1, currency: "USD" } }, fixture({ db: figure(0.272), eu: figure(0.1, "EUR") }));
|
|
174
|
+
expect(verdict.pass).toBe(false);
|
|
175
|
+
expect(cost(verdict).detail).toContain("priced in EUR, USD");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the cost clause sits beside the others in declaration order and does not touch them", () => {
|
|
179
|
+
const verdict = evaluateScenario(EMPTY, { noop: true, cost: { maxPerHour: 1, currency: "USD" } }, fixture({ db: figure(0.272) }));
|
|
180
|
+
expect(verdict.checks.map((c) => c.clause)).toEqual(["noop", "cost"]);
|
|
181
|
+
expect(verdict.pass).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|