@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.
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +60 -12
- package/src/cli/handlers/graph.ts +106 -73
- package/src/cli/main.ts +6 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,cAAc,
|
|
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"}
|
package/dist/cli/main.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
@@ -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
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
--
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
the
|
|
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
|