@intentius/chant 0.75.0 → 0.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAiHlD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAkDnE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,cAAc,EAAc,MAAM,aAAa,CAAC;AAkH9D;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AA0F9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA8WpD;AAocD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EAmGvC,CAAC;AAqIF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AA0F9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA8WpD;AAqcD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EAmGvC,CAAC;AAqIF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.75.0",
3
+ "version": "0.75.1",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -259,6 +259,66 @@ describe("runGraph", () => {
259
259
  expect(ir.nodes.find((n: { id: string }) => n.id === "deploy").attrs.phases).toEqual([]);
260
260
  });
261
261
 
262
+ // #2377 / epic #2355: "the live path predicts the account as it stands,
263
+ // drift included. The declared path predicts the file. The two are shown
264
+ // as a delta." An earlier draft of #2377 required --live, which forbade
265
+ // the declared half and so forbade the delta.
266
+ test("--traffic predicts the declared graph, without --live", async () => {
267
+ lintClean(); discovered();
268
+ const seen: Array<{ traffic: string; environment: string }> = [];
269
+ loadPluginsMock.mockResolvedValue([{
270
+ name: "gcp",
271
+ serializer: {},
272
+ predictBehaviour: (o: { traffic: string; environment: string }) => {
273
+ seen.push({ traffic: o.traffic, environment: o.environment });
274
+ return Promise.resolve(behaviourReport(
275
+ { entityNames: ["vpc"], traffic: o.traffic, edgeCoverage: { verdict: "unknown" } },
276
+ { engine: "fixture", version: "0.0.1", total: predictedRate(1, "USD") },
277
+ { vpc: {
278
+ at: { traffic: o.traffic },
279
+ cost: predictedRate(1, "USD"),
280
+ headroom: { cpu: 0.5 },
281
+ errorRate: 0,
282
+ resilience: { failure: "one zone lost", verdict: "survives" },
283
+ provenance: { engine: "fixture", version: "0.0.1", tolerance: "±20%", basis: "modeled" },
284
+ } },
285
+ {},
286
+ ));
287
+ },
288
+ }]);
289
+ resolveLexMock.mockResolvedValue(["gcp"]);
290
+
291
+ const exit = await runGraph({
292
+ args: makeArgs({ format: "ir", traffic: "100 rps, p50" }),
293
+ plugins: [], serializers: [],
294
+ });
295
+
296
+ expect(exit).toBe(0);
297
+ expect(seen[0]?.traffic).toBe("100 rps, p50");
298
+ const ir = JSON.parse(stdoutBuf.join("\n"));
299
+ expect(ir.meta?._behaviour?.engine).toBe("fixture");
300
+ expect(ir.nodes.find((n: { id: string }) => n.id === "vpc").attrs._behaviour.cost.perHour).toBe(1);
301
+ });
302
+
303
+ test("the declared path names no environment, because the file is deployed nowhere", async () => {
304
+ lintClean(); discovered();
305
+ const seen: string[] = [];
306
+ loadPluginsMock.mockResolvedValue([{
307
+ name: "gcp",
308
+ serializer: {},
309
+ predictBehaviour: (o: { environment: string }) => {
310
+ seen.push(o.environment);
311
+ return Promise.resolve(noBehaviourEngineRefusal("gcp"));
312
+ },
313
+ }]);
314
+ resolveLexMock.mockResolvedValue(["gcp"]);
315
+
316
+ await runGraph({ args: makeArgs({ format: "ir", traffic: "100 rps, p50" }), plugins: [], serializers: [] });
317
+
318
+ // Naming one would claim the declared estate was deployed there.
319
+ expect(seen[0]).toBe("");
320
+ });
321
+
262
322
  test("op-discovery errors are warnings, not a refusal", async () => {
263
323
  lintClean(); discovered();
264
324
  discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: ["ops/bad.op.ts: boom"] });
@@ -790,18 +850,6 @@ describe("runGraph", () => {
790
850
  expect(ir.meta?._behaviour).toBeUndefined();
791
851
  });
792
852
 
793
- test("--traffic without --live is refused rather than silently ignored", async () => {
794
- // Accepted-and-ignored would read as "predicted, and everything came
795
- // back empty", which is the opposite of what happened.
796
- const exit = await runGraph({
797
- args: makeArgs({ format: "ir", traffic: "100 rps, p50" }),
798
- plugins: [], serializers: [],
799
- });
800
-
801
- expect(exit).toBe(1);
802
- expect(stderrBuf.join("\n")).toContain("--traffic needs --live");
803
- });
804
-
805
853
  test("two predicting lexicons is refused rather than attributed to one", async () => {
806
854
  liveEstate();
807
855
  loadPluginsMock.mockResolvedValue([
@@ -22,7 +22,8 @@ import { lintCommand } from "../commands/lint";
22
22
  import { loadPlugins, resolveProjectLexicons, collectBuildRootContributors } from "../plugins";
23
23
  import { readFileSync } from "node:fs";
24
24
  import { formatError, formatWarning, formatBold } from "../format";
25
- import type { CommandContext } from "../registry";
25
+ import type { CommandContext, ParsedArgs } from "../registry";
26
+ import type { LexiconPlugin } from "../../lexicon";
26
27
  import { computeComponentGraph, generateComponentsPipeline } from "../../components/cli-support";
27
28
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
28
29
  import type { BuildParamProvenance } from "../../provenance";
@@ -160,16 +161,6 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
160
161
  }));
161
162
  return 1;
162
163
  }
163
- // A prediction is about an estate, and only the live path has one. Without
164
- // `--live` the flag would be accepted and do nothing at all, which reads as
165
- // "predicted, and everything came back empty" rather than "never ran".
166
- if (ctx.args.traffic && !ctx.args.live) {
167
- console.error(formatError({
168
- message: "--traffic needs --live: a prediction is made about the observed estate, and there is nothing to predict on the declared graph alone.",
169
- hint: 'chant graph --format ir --live --env <name> --overlay --traffic "100 rps, p50"',
170
- }));
171
- return 1;
172
- }
173
164
  // `--at` graphs a recorded observation instead of reading the estate (#1279).
174
165
  // Two different observations with no rule for which wins, so not both.
175
166
  if (ctx.args.at && ctx.args.live) {
@@ -448,68 +439,16 @@ async function runGraphLive(
448
439
  }
449
440
 
450
441
  // Behaviour overlay (#2377, epic #2355): carry a prediction on the graph.
451
- //
452
- // `--traffic` is the trigger, and it is the trigger because the contract
453
- // forbids defaulting the level (../../behaviour.ts): every figure an engine
454
- // returns is *at* a stated traffic, so inventing one here would silently
455
- // decide what every number on the graph means. No flag: nothing is asked,
456
- // neither key appears, and a consumer reads that absence as "not looked".
457
- //
458
- // Placed after the drift overlay so the prediction lands on the final node
459
- // set — the canvas is settled by here under either anchoring — and before
460
- // the lens below, so a lens filters predicted nodes like any other.
461
- if (args.traffic) {
462
- const predicting = plugins.filter((p) => p.predictBehaviour);
463
- if (predicting.length === 0) {
464
- console.error(formatWarning({
465
- message:
466
- "--traffic: no installed lexicon implements predictBehaviour, so nothing was predicted. The graph is unchanged.",
467
- }));
468
- } else if (predicting.length > 1) {
469
- // Two engines would mean two `meta._behaviour` values and one key. Rather
470
- // than pick, say so: a merged prediction across engines is not defined by
471
- // the contract, and silently taking the first would attribute one
472
- // engine's figures to the other's provenance.
473
- console.error(formatError({
474
- message: `--traffic: ${predicting.length} lexicons implement predictBehaviour (${predicting.map((p) => p.name).join(", ")}) — chant cannot merge two engines' figures onto one graph.`,
475
- hint: "Predict one lexicon at a time, or drop --traffic to graph without a prediction",
476
- }));
477
- return 1;
478
- } else {
479
- // The first output the build produced, or empty. Nothing on this path
480
- // reads it — the mirror is kept so the four observation methods take the
481
- // same shape (../../op/activities/predict-behaviour.ts) — so a build
482
- // result without outputs costs an empty string, never the graph.
483
- const raw = buildResult.outputs ? [...buildResult.outputs.values()][0] : undefined;
484
- const buildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
485
- const request = behaviourRequestFromIr(ir, {
486
- environment: environment ?? "",
487
- traffic: args.traffic,
488
- buildOutput,
489
- // `unknown`, for the reason the declared path claims it
490
- // (../../op/activities/predict-behaviour.ts): reference edges resolved,
491
- // containment absent, and no vocabulary here for "this kind is a
492
- // boundary whose containment is missing". Claiming `complete` would be
493
- // true about references and false about the graph; `partial` has to
494
- // name its gap as `dangling` or `unresolvedKinds`, and this gap is
495
- // neither.
496
- edgeCoverage: { verdict: "unknown" },
497
- ...(args.owned === undefined ? {} : { owned: args.owned }),
498
- });
499
- try {
500
- // A refusal is a value, not a throw: it comes back as a result and
501
- // rides on `meta._behaviour` whole, which is the one shape behold's
502
- // reader branches on (../../behaviour-overlay.ts).
503
- const result = await predicting[0].predictBehaviour!(request);
504
- ir = applyBehaviourOverlay(ir, behaviourOverlay(result));
505
- } catch (err) {
506
- // The estate graph is the thing asked for; a broken engine must not
507
- // take it away. Say what failed and serve the graph without the block.
508
- console.error(formatWarning({
509
- message: `--traffic: ${predicting[0].name}'s predictBehaviour failed (${err instanceof Error ? err.message : String(err)}) — showing the graph without a prediction`,
510
- }));
511
- }
512
- }
442
+ // After the drift overlay so it lands on the final node set beside `_status`,
443
+ // and before the lens so a lens filters a predicted node like any other.
444
+ {
445
+ const raw = buildResult.outputs ? [...buildResult.outputs.values()][0] : undefined;
446
+ const predicted = await predictOntoIr(ir, plugins, args, {
447
+ environment: environment ?? "",
448
+ buildOutput: raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary,
449
+ });
450
+ if (predicted === REFUSED) return 1;
451
+ ir = predicted;
513
452
  }
514
453
 
515
454
  if (args.lens) {
@@ -732,6 +671,82 @@ async function buildPipelineProjection(
732
671
  * Lint-gated: the IR represents valid infra, so we refuse to emit for source that
733
672
  * does not pass lint. Non-zero on discovery errors or a layout-engine failure.
734
673
  */
674
+ /** Returned instead of a graph when the prediction cannot be attempted honestly. */
675
+ const REFUSED = Symbol("behaviour-refused");
676
+
677
+ /**
678
+ * Carry a prediction on `ir`, if one was asked for (#2377, epic #2355).
679
+ *
680
+ * Shared by both graph paths, because the epic wants both: "the live path
681
+ * predicts the account as it stands, drift included. The declared path
682
+ * predicts the file. The two are shown as a delta" (#2355). A delta between
683
+ * two shapes that reached the engine by different routes is the worst possible
684
+ * input to a delta, so there is one route.
685
+ *
686
+ * `--traffic` is the trigger, and it is the trigger rather than the engine
687
+ * variable because the contract forbids defaulting the level
688
+ * (../../behaviour.ts): every figure an engine returns is *at* a stated
689
+ * traffic, so inventing one would silently decide what every number on the
690
+ * graph means. Without the flag nothing is asked, neither key appears, and a
691
+ * consumer reads that absence as "not looked" rather than as a refusal.
692
+ */
693
+ async function predictOntoIr(
694
+ ir: GraphIR,
695
+ plugins: LexiconPlugin[],
696
+ args: ParsedArgs,
697
+ ctx: { environment: string; buildOutput: string },
698
+ ): Promise<GraphIR | typeof REFUSED> {
699
+ if (!args.traffic) return ir;
700
+
701
+ const predicting = plugins.filter((p) => p.predictBehaviour);
702
+ if (predicting.length === 0) {
703
+ console.error(formatWarning({
704
+ message:
705
+ "--traffic: no installed lexicon implements predictBehaviour, so nothing was predicted. The graph is unchanged.",
706
+ }));
707
+ return ir;
708
+ }
709
+ if (predicting.length > 1) {
710
+ // Two engines would mean two `meta._behaviour` values and one key. Rather
711
+ // than pick, say so: a merged prediction across engines is not defined by
712
+ // the contract, and silently taking the first would attribute one engine's
713
+ // figures to the other's provenance.
714
+ console.error(formatError({
715
+ message: `--traffic: ${predicting.length} lexicons implement predictBehaviour (${predicting.map((p) => p.name).join(", ")}) — chant cannot merge two engines' figures onto one graph.`,
716
+ hint: "Predict one lexicon at a time, or drop --traffic to graph without a prediction",
717
+ }));
718
+ return REFUSED;
719
+ }
720
+
721
+ const request = behaviourRequestFromIr(ir, {
722
+ environment: ctx.environment,
723
+ traffic: args.traffic,
724
+ buildOutput: ctx.buildOutput,
725
+ // `unknown`, for the reason the declared path claims it
726
+ // (../../op/activities/predict-behaviour.ts): reference edges resolved,
727
+ // containment absent, and no vocabulary here for "this kind is a boundary
728
+ // whose containment is missing". Claiming `complete` would be true about
729
+ // references and false about the graph; `partial` has to name its gap as
730
+ // `dangling` or `unresolvedKinds`, and this gap is neither.
731
+ edgeCoverage: { verdict: "unknown" },
732
+ ...(args.owned === undefined ? {} : { owned: args.owned }),
733
+ });
734
+
735
+ try {
736
+ // A refusal is a value, not a throw: it comes back as a result and rides on
737
+ // `meta._behaviour` whole, which is the one shape behold's reader branches
738
+ // on (../../behaviour-overlay.ts).
739
+ return applyBehaviourOverlay(ir, behaviourOverlay(await predicting[0].predictBehaviour!(request)));
740
+ } catch (err) {
741
+ // The estate graph is the thing asked for; a broken engine must not take it
742
+ // away. Say what failed and serve the graph without the block.
743
+ console.error(formatWarning({
744
+ message: `--traffic: ${predicting[0].name}'s predictBehaviour failed (${err instanceof Error ? err.message : String(err)}) — showing the graph without a prediction`,
745
+ }));
746
+ return ir;
747
+ }
748
+ }
749
+
735
750
  async function runGraphView(
736
751
  ctx: CommandContext,
737
752
  format: "ir" | "mermaid" | "dot" | "layout",
@@ -788,6 +803,24 @@ async function runGraphView(
788
803
  // Build the base IR, focus with a lens (declarable-level, most precise), then
789
804
  // apply the detail tier — so e.g. blast:<resource> works before any collapse.
790
805
  let ir: GraphIR = buildGraphIr(result.entities, projectPath);
806
+
807
+ // The declared half of the epic's delta (#2355): this path predicts the
808
+ // file, the live path predicts the account. Same helper, so the two shapes
809
+ // reach the engine by one route and stay comparable.
810
+ {
811
+ const plugins = ctx.plugins.length > 0
812
+ ? ctx.plugins
813
+ : await loadPlugins(await resolveProjectLexicons(projectPath));
814
+ const predicted = await predictOntoIr(ir, plugins as LexiconPlugin[], ctx.args, {
815
+ // No environment on the declared path: the file is not deployed anywhere
816
+ // yet, and naming one would claim it was.
817
+ environment: ctx.args.env ?? "",
818
+ buildOutput: "",
819
+ });
820
+ if (predicted === REFUSED) return 1;
821
+ ir = predicted;
822
+ }
823
+
791
824
  if (ctx.args.lens) {
792
825
  try {
793
826
  ir = applyLens(ir, parseLens(ctx.args.lens, { up: ctx.args.up, down: ctx.args.down }));
package/src/cli/main.ts CHANGED
@@ -599,11 +599,12 @@ Ops:
599
599
  add the CI/pipeline projection (stages/jobs/needs) to
600
600
  the component-graph IR, from the same generator
601
601
  'build --components --generate' uses (#989)
602
- --live --overlay --traffic "<level>": ask the project's
603
- predicting lexicon what the overlaid estate does at that
604
- traffic level, and carry each entity's prediction on the
605
- IR. The level is passed to the engine verbatim; without
606
- the flag nothing is asked and nothing is carried (#2377)
602
+ --traffic "<level>": ask the project's predicting
603
+ lexicon what the estate does at that traffic level, and
604
+ carry each entity's prediction on the IR. On the
605
+ declared graph it predicts the file; with --live it
606
+ predicts the account. The level reaches the engine
607
+ verbatim; without the flag nothing is asked (#2377)
607
608
 
608
609
  Lifecycle (alias: lc):
609
610
  lifecycle snapshot <env> Query API, save metadata to orphan branch