@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.
Files changed (66) hide show
  1. package/dist/behaviour-delta.d.ts +181 -0
  2. package/dist/behaviour-delta.d.ts.map +1 -0
  3. package/dist/behaviour-http.d.ts +106 -0
  4. package/dist/behaviour-http.d.ts.map +1 -0
  5. package/dist/behaviour-overlay.d.ts +61 -0
  6. package/dist/behaviour-overlay.d.ts.map +1 -0
  7. package/dist/behaviour.d.ts +1174 -0
  8. package/dist/behaviour.d.ts.map +1 -0
  9. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  10. package/dist/identity.d.ts +28 -0
  11. package/dist/identity.d.ts.map +1 -1
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +45 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/scenario-eval.d.ts +23 -5
  17. package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
  18. package/dist/lifecycle/scenario.d.ts +45 -3
  19. package/dist/lifecycle/scenario.d.ts.map +1 -1
  20. package/dist/lifecycle/types.d.ts +17 -0
  21. package/dist/lifecycle/types.d.ts.map +1 -1
  22. package/dist/op/activities/activity-contracts.d.ts +42 -0
  23. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  24. package/dist/op/activities/index.d.ts +2 -0
  25. package/dist/op/activities/index.d.ts.map +1 -1
  26. package/dist/op/activities/predict-behaviour.d.ts +207 -0
  27. package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
  28. package/dist/op/activities/reconcile.d.ts +86 -16
  29. package/dist/op/activities/reconcile.d.ts.map +1 -1
  30. package/dist/op/composites/behaviour-op.d.ts +57 -0
  31. package/dist/op/composites/behaviour-op.d.ts.map +1 -0
  32. package/dist/op/composites/index.d.ts +2 -0
  33. package/dist/op/composites/index.d.ts.map +1 -1
  34. package/dist/op/index.d.ts +2 -2
  35. package/dist/op/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/behaviour-delta.test.ts +331 -0
  38. package/src/behaviour-delta.ts +564 -0
  39. package/src/behaviour-http.test.ts +456 -0
  40. package/src/behaviour-http.ts +252 -0
  41. package/src/behaviour-overlay.test.ts +149 -0
  42. package/src/behaviour-overlay.ts +76 -0
  43. package/src/behaviour.test.ts +2011 -0
  44. package/src/behaviour.ts +2127 -0
  45. package/src/cli/handlers/scenario.test.ts +108 -0
  46. package/src/cli/handlers/scenario.ts +63 -16
  47. package/src/fold/subset-doc-parity.test.ts +60 -0
  48. package/src/identity.ts +31 -2
  49. package/src/index.ts +3 -0
  50. package/src/lexicon.ts +46 -0
  51. package/src/lifecycle/scenario-cost.test.ts +183 -0
  52. package/src/lifecycle/scenario-eval.ts +133 -6
  53. package/src/lifecycle/scenario.ts +72 -4
  54. package/src/lifecycle/types.ts +17 -0
  55. package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
  56. package/src/op/activities/activity-contracts.ts +49 -0
  57. package/src/op/activities/index.ts +24 -0
  58. package/src/op/activities/predict-behaviour.test.ts +255 -0
  59. package/src/op/activities/predict-behaviour.ts +468 -0
  60. package/src/op/activities/reconcile.test.ts +84 -0
  61. package/src/op/activities/reconcile.ts +97 -24
  62. package/src/op/activity-contract-registry.test.ts +3 -0
  63. package/src/op/composites/behaviour-op.test.ts +56 -0
  64. package/src/op/composites/behaviour-op.ts +99 -0
  65. package/src/op/composites/index.ts +2 -0
  66. 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 { env: "", perLexicon: new Map(), error: `fixture not found: ${given.path}` };
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 { env: "", perLexicon: new Map(), error: `fixture is not valid JSON: ${given.path}` };
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
- env: typeof snap.environment === "string" ? snap.environment : "",
179
- perLexicon: new Map(),
180
- error: `${given.path} is not a LifecycleSnapshot — missing lexicon/environment/resources`,
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 { env: snap.environment, perLexicon: new Map([[snap.lexicon, snap]]) };
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
- return {
189
- env: given.env,
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
+ });