@intentius/chant 0.50.0 → 0.52.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/cli/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +40 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +22 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +109 -0
- package/src/cli/handlers/lifecycle.ts +32 -8
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +240 -0
- package/src/cli/handlers/run.ts +76 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +11 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +165 -11
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +35 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place chant assembles `build()`'s options for a project directory.
|
|
3
|
+
*
|
|
4
|
+
* chant #2002 — `chant build` (./commands/build.ts) and the `policyGate` Op
|
|
5
|
+
* step (../lint/policy.ts's `evaluateProjectPolicies`) both build the same
|
|
6
|
+
* directory, and each used to assemble that option object itself. The two lists
|
|
7
|
+
* drifted to nine options against two: the gate built with fold off (the path
|
|
8
|
+
* `chant build` retired as its default in #1134), without the project's config
|
|
9
|
+
* (so serializers lost their lexicon-scoped dialect settings and a policy
|
|
10
|
+
* reading `ctx.docs` inspected different documents), and without `buildRoots`
|
|
11
|
+
* (so config-declared roots contributed no entities at all). A gate can only
|
|
12
|
+
* decide on a build `chant build` would produce if the two are assembled from
|
|
13
|
+
* one place, so they are.
|
|
14
|
+
*
|
|
15
|
+
* Two functions rather than one because the *modes* are needed earlier than the
|
|
16
|
+
* rest: `chant build` arms sandboxed policy execution from the resolved
|
|
17
|
+
* `sandbox` before it loads any policy module, which happens before build-time
|
|
18
|
+
* parameters and the ownership marker exist. {@link resolveBuildModes} is that
|
|
19
|
+
* early half; {@link resolveProjectBuildOptions} takes its result and assembles
|
|
20
|
+
* everything `build()` is given.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveFoldEnabled, resolveSandboxEnabled, type ChantConfig } from "../config";
|
|
23
|
+
import { collectBuildRootContributors, resolveLexiconVersions } from "./plugins";
|
|
24
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
25
|
+
import type { BuildOptions } from "../build";
|
|
26
|
+
import type { OwnershipMarker } from "../ownership";
|
|
27
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
28
|
+
|
|
29
|
+
/** The resolved fold/sandbox execution modes for one build. */
|
|
30
|
+
export interface BuildModes {
|
|
31
|
+
/** #1022/#1134 — fold source modules statically; the default build path. */
|
|
32
|
+
fold: boolean;
|
|
33
|
+
/** #1045 Phase 2 — execute run-fallback files in a sandboxed child. */
|
|
34
|
+
sandbox: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve this build's execution modes: an explicit CLI flag wins over
|
|
39
|
+
* `chant.config.ts`'s `build.fold`/`build.sandbox`, which wins over the default
|
|
40
|
+
* (fold on, sandbox off). Callers with no flags to offer (the `policyGate` step)
|
|
41
|
+
* pass no overrides and get the project's own answer.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveBuildModes(
|
|
44
|
+
config: ChantConfig,
|
|
45
|
+
overrides?: { fold?: boolean; sandbox?: boolean },
|
|
46
|
+
): BuildModes {
|
|
47
|
+
return {
|
|
48
|
+
fold: resolveFoldEnabled(config, overrides?.fold),
|
|
49
|
+
sandbox: resolveSandboxEnabled(config, overrides?.sandbox),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inputs {@link resolveProjectBuildOptions} needs to assemble a build. */
|
|
54
|
+
export interface ProjectBuildOptionsInput {
|
|
55
|
+
/** The project's resolved config, from `loadChantConfigUpward`. */
|
|
56
|
+
config: ChantConfig;
|
|
57
|
+
/** Directory the config was found in — build roots are rooted here, not at a (possibly sourceDir-scoped) build path. */
|
|
58
|
+
configDir: string;
|
|
59
|
+
/** The lexicon plugins loaded for this build. */
|
|
60
|
+
plugins?: readonly LexiconPlugin[];
|
|
61
|
+
/** Resolved execution modes, from {@link resolveBuildModes}. */
|
|
62
|
+
modes: BuildModes;
|
|
63
|
+
/** The resolved ownership marker, when marking is on. */
|
|
64
|
+
ownership?: OwnershipMarker;
|
|
65
|
+
/** This build's resolved build-time parameters (#1064). */
|
|
66
|
+
buildParams?: BuildParamProvenance[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Assemble the option object `build()` takes for a project directory.
|
|
71
|
+
*
|
|
72
|
+
* Every option here is derived from the project (its config, its plugins, its
|
|
73
|
+
* resolved parameters) rather than from the command that asked, so two callers
|
|
74
|
+
* handing in the same project get byte-identical builds. A new option belongs
|
|
75
|
+
* in this function, never at a call site.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveProjectBuildOptions(input: ProjectBuildOptionsInput): BuildOptions {
|
|
78
|
+
const { config, configDir, plugins, modes, ownership, buildParams } = input;
|
|
79
|
+
|
|
80
|
+
// #1039 — each loaded plugin's registered intrinsics (e.g. AWS's `Sub`), so
|
|
81
|
+
// a file using a registered intrinsic tagged template folds instead of
|
|
82
|
+
// unconditionally falling back to run. `intrinsics` is an optional plugin
|
|
83
|
+
// extension, hence the guard.
|
|
84
|
+
const intrinsics = plugins?.flatMap((plugin) => plugin.intrinsics?.() ?? []) ?? [];
|
|
85
|
+
|
|
86
|
+
// #1063 — the same loaded plugins, by NAME, are this build's allowlist for
|
|
87
|
+
// following a bare import specifier into a lexicon package (so `Azure`,
|
|
88
|
+
// `GCP`, `S3Actions`, `CI` fold as values).
|
|
89
|
+
const lexicons = plugins?.map((plugin) => plugin.name) ?? [];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ownership,
|
|
93
|
+
config: config as unknown as Record<string, unknown>,
|
|
94
|
+
fold: modes.fold,
|
|
95
|
+
sandbox: modes.sandbox,
|
|
96
|
+
intrinsics,
|
|
97
|
+
lexicons,
|
|
98
|
+
// chant #1442 — which lexicon VERSION interpreted each declaration.
|
|
99
|
+
lexiconVersions: resolveLexiconVersions(lexicons),
|
|
100
|
+
buildParams,
|
|
101
|
+
// #1548 piece 3 — config-declared build roots (kustomize dirs, committed
|
|
102
|
+
// ciphertext), rendered into entities by the owning lexicon's hook.
|
|
103
|
+
buildRoots: collectBuildRootContributors(
|
|
104
|
+
plugins,
|
|
105
|
+
config as unknown as Record<string, unknown>,
|
|
106
|
+
configDir,
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -5,17 +5,15 @@ import {
|
|
|
5
5
|
resolveOwnershipEnv,
|
|
6
6
|
ownershipEnvDisagreement,
|
|
7
7
|
isOwnershipParamRef,
|
|
8
|
-
resolveFoldEnabled,
|
|
9
|
-
resolveSandboxEnabled,
|
|
10
8
|
resolveKnowledgeDir,
|
|
11
9
|
} from "../../config";
|
|
10
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
|
|
12
11
|
import { loadOkfBundle } from "../../okf-read";
|
|
13
12
|
import { unknownEnvError } from "../../env";
|
|
14
13
|
import type { OwnershipMarker } from "../../ownership";
|
|
15
14
|
import { resolveCliBuildParams } from "../build-params-cli";
|
|
16
15
|
import type { Serializer, SerializerResult } from "../../serializer";
|
|
17
16
|
import type { LexiconPlugin } from "../../lexicon";
|
|
18
|
-
import { resolveLexiconVersions, collectBuildRootContributors } from "../plugins";
|
|
19
17
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
20
18
|
import { coreReceiptChecks } from "../../lint/receipt-checks";
|
|
21
19
|
import { coreOutputChecks } from "../../lint/output-checks";
|
|
@@ -182,15 +180,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
182
180
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
183
181
|
const policies = config.lint?.policies ?? [];
|
|
184
182
|
|
|
185
|
-
// #1022/#1134
|
|
186
|
-
// (--fold/--no-fold) wins over `chant.config.ts`'s
|
|
187
|
-
// wins over the
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const sandbox = resolveSandboxEnabled(config, options.sandbox);
|
|
183
|
+
// #1022/#1134 (fold) and #1045 Phase 2 (sandbox) — an explicit CLI flag
|
|
184
|
+
// (--fold/--no-fold, --sandbox) wins over `chant.config.ts`'s
|
|
185
|
+
// `build.fold`/`build.sandbox`, which wins over the defaults (fold on,
|
|
186
|
+
// sandbox off). Resolved here rather than with the rest of the build
|
|
187
|
+
// options below because arming sandboxed policy execution has to happen
|
|
188
|
+
// before any policy module could be loaded — see #2002 in ../build-options.ts.
|
|
189
|
+
const modes = resolveBuildModes(config, { fold: options.fold, sandbox: options.sandbox });
|
|
190
|
+
const { fold, sandbox } = modes;
|
|
194
191
|
|
|
195
192
|
// #1131 — arm sandboxed policy execution from the RESOLVED value, before any
|
|
196
193
|
// policy module could be loaded. Resolved, not `options.sandbox`, because
|
|
@@ -301,48 +298,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
301
298
|
}
|
|
302
299
|
}
|
|
303
300
|
|
|
304
|
-
// #
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// `GCP`, `S3Actions`, `CI` fold as values). A plugin's `name` is the
|
|
314
|
-
// lexicon name `loadPlugin()` was called with, which is exactly what
|
|
315
|
-
// `@intentius/chant-lexicon-<name>` was imported from — see
|
|
316
|
-
// ../plugins.ts and fold-import.ts's `lexiconPackageName`.
|
|
317
|
-
const lexicons = options.plugins?.map((plugin) => plugin.name) ?? [];
|
|
318
|
-
|
|
319
|
-
// chant #1442 — which lexicon VERSION interpreted each declaration. The
|
|
320
|
-
// declaration is fingerprinted by `hashProps`; the thing that turned it into
|
|
321
|
-
// output was not recorded at all, so a lexicon bump that changed emitted
|
|
322
|
-
// output left the build digest identical. Captured here because this is the
|
|
323
|
-
// layer that knows which lexicons were loaded — `build()` is given only
|
|
324
|
-
// their names.
|
|
325
|
-
const lexiconVersions = resolveLexiconVersions(lexicons);
|
|
326
|
-
|
|
327
|
-
// Run the build
|
|
328
|
-
const result = await build(infraPath, options.serializers, undefined, {
|
|
329
|
-
ownership,
|
|
330
|
-
config: config as unknown as Record<string, unknown>,
|
|
331
|
-
fold,
|
|
332
|
-
sandbox,
|
|
333
|
-
intrinsics,
|
|
334
|
-
lexicons,
|
|
335
|
-
lexiconVersions,
|
|
336
|
-
buildParams: paramsResolution.provenance,
|
|
337
|
-
// #1548 piece 3 — config-declared build roots (kustomize dirs), rendered
|
|
338
|
-
// into entities by the owning lexicon's hook. Rooted at the config dir,
|
|
339
|
-
// not the (possibly sourceDir-scoped) infra path.
|
|
340
|
-
buildRoots: collectBuildRootContributors(
|
|
341
|
-
options.plugins,
|
|
342
|
-
config as unknown as Record<string, unknown>,
|
|
301
|
+
// Run the build. #2002 — every option comes from the shared assembler
|
|
302
|
+
// (../build-options.ts), which `evaluateProjectPolicies` calls too, so the
|
|
303
|
+
// `policyGate` step decides on the same project this command builds.
|
|
304
|
+
const result = await build(
|
|
305
|
+
infraPath,
|
|
306
|
+
options.serializers,
|
|
307
|
+
undefined,
|
|
308
|
+
resolveProjectBuildOptions({
|
|
309
|
+
config,
|
|
343
310
|
configDir,
|
|
344
|
-
|
|
345
|
-
|
|
311
|
+
plugins: options.plugins,
|
|
312
|
+
modes,
|
|
313
|
+
ownership,
|
|
314
|
+
buildParams: paramsResolution.provenance,
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
346
317
|
|
|
347
318
|
// #1022 — report per-file fold vs run so it's visible what still runs.
|
|
348
319
|
// #1424 — one line by default; the per-file lines and their reasons are
|
|
@@ -753,6 +753,25 @@ describe("runLifecyclePlan", () => {
|
|
|
753
753
|
expect(stdoutBuf.join("\n")).toContain("bucket");
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
+
// #1983 — `--report markdown` emits the reviewer-facing projection instead
|
|
757
|
+
// of the human render, same as `--report gitlab-mr` swaps in the widget JSON.
|
|
758
|
+
test("--report markdown emits the markdown projection instead of the human render", async () => {
|
|
759
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
760
|
+
const plugins: LexiconPlugin[] = [
|
|
761
|
+
createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
|
|
762
|
+
];
|
|
763
|
+
const exit = await runLifecyclePlan({
|
|
764
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", reportFile: "markdown" }),
|
|
765
|
+
plugins,
|
|
766
|
+
serializers: plugins.map((p) => p.serializer),
|
|
767
|
+
});
|
|
768
|
+
expect(exit).toBe(0);
|
|
769
|
+
const out = stdoutBuf.join("\n");
|
|
770
|
+
expect(out).toContain("## Plan for `prod`");
|
|
771
|
+
expect(out).toContain("### CREATE");
|
|
772
|
+
expect(out).toContain("- `bucket`");
|
|
773
|
+
});
|
|
774
|
+
|
|
756
775
|
// #1620 — the resolved read address rides plan entries the same way it rides
|
|
757
776
|
// the live diff. Regression: the plan path dropped the observation's queried
|
|
758
777
|
// map, so only unobserved rows ever carried an address while the docs
|
|
@@ -821,6 +840,96 @@ describe("runLifecyclePlan", () => {
|
|
|
821
840
|
expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
|
|
822
841
|
});
|
|
823
842
|
|
|
843
|
+
// #1665 — per-change disruption. Core defines the contract and reports it;
|
|
844
|
+
// the lexicon that owns the spec answers, and every degradation is `unknown`.
|
|
845
|
+
describe("disruption classification (#1665)", () => {
|
|
846
|
+
const drifted = (lexicon: string, type: string) => {
|
|
847
|
+
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
848
|
+
lexicon,
|
|
849
|
+
environment: "prod",
|
|
850
|
+
commit: "abc",
|
|
851
|
+
timestamp: "2026-04-01T00:00:00Z",
|
|
852
|
+
resources: { db: meta({ type, attributes: { Engine: "postgres" } }) },
|
|
853
|
+
}));
|
|
854
|
+
return staticDescribeResources({ db: meta({ type, attributes: { Engine: "mysql" } }) });
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
test("a lexicon's verdict reaches --json and the stderr warning", async () => {
|
|
858
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
859
|
+
const plugins: LexiconPlugin[] = [
|
|
860
|
+
createMockPlugin({
|
|
861
|
+
name: "aws",
|
|
862
|
+
emulator: awsEmulatorStub,
|
|
863
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
864
|
+
classifyDisruption: ({ changes }) =>
|
|
865
|
+
Object.fromEntries(
|
|
866
|
+
changes.map((c) => [
|
|
867
|
+
c.name,
|
|
868
|
+
{ disruption: "destroy" as const, because: c.deltas.map((d) => d.path), detail: "Engine is create-only" },
|
|
869
|
+
]),
|
|
870
|
+
),
|
|
871
|
+
}),
|
|
872
|
+
];
|
|
873
|
+
const exit = await runLifecyclePlan({
|
|
874
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
875
|
+
plugins,
|
|
876
|
+
serializers: plugins.map((p) => p.serializer),
|
|
877
|
+
});
|
|
878
|
+
expect(exit).toBe(0);
|
|
879
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
880
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
881
|
+
expect(db).toMatchObject({
|
|
882
|
+
action: "update",
|
|
883
|
+
disruption: "destroy",
|
|
884
|
+
disruptionDetail: "Engine is create-only",
|
|
885
|
+
});
|
|
886
|
+
expect(db.disruptionBecause).toContain("attributes.Engine");
|
|
887
|
+
// The --json shape has no column for it, so the warning is what a CI
|
|
888
|
+
// consumer hears.
|
|
889
|
+
expect(stderrBuf.join("\n")).toContain("replace the resource rather than mutating it in place");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("a lexicon with no classifier leaves every update unknown, never in-place", async () => {
|
|
893
|
+
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["db"] }));
|
|
894
|
+
const plugins: LexiconPlugin[] = [
|
|
895
|
+
createMockPlugin({ name: "k8s", describeResources: drifted("k8s", "K8s::Apps::Deployment") }),
|
|
896
|
+
];
|
|
897
|
+
const exit = await runLifecyclePlan({
|
|
898
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
899
|
+
plugins,
|
|
900
|
+
serializers: plugins.map((p) => p.serializer),
|
|
901
|
+
});
|
|
902
|
+
expect(exit).toBe(0);
|
|
903
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
904
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
905
|
+
expect(db.disruption).toBe("unknown");
|
|
906
|
+
expect(db.disruptionDetail).toContain("k8s lexicon does not classify disruption");
|
|
907
|
+
expect(stderrBuf.join("\n")).toContain("could not be classified");
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("the human render carries the verdict", async () => {
|
|
911
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
912
|
+
const plugins: LexiconPlugin[] = [
|
|
913
|
+
createMockPlugin({
|
|
914
|
+
name: "aws",
|
|
915
|
+
emulator: awsEmulatorStub,
|
|
916
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
917
|
+
classifyDisruption: () => ({ db: { disruption: "in-place" as const, detail: "no create-only property changed" } }),
|
|
918
|
+
}),
|
|
919
|
+
];
|
|
920
|
+
const exit = await runLifecyclePlan({
|
|
921
|
+
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
922
|
+
plugins,
|
|
923
|
+
serializers: plugins.map((p) => p.serializer),
|
|
924
|
+
});
|
|
925
|
+
expect(exit).toBe(0);
|
|
926
|
+
const out = stdoutBuf.join("\n");
|
|
927
|
+
expect(out).toContain("Disruption: 1 in-place");
|
|
928
|
+
expect(out).toContain("— in-place: no create-only property changed");
|
|
929
|
+
expect(stderrBuf.join("\n")).not.toContain("could not be classified");
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
824
933
|
// #1832 — effect receipts are declared, diffed, and observed like any
|
|
825
934
|
// resource, but observe-only to the generic apply path: the plan compares
|
|
826
935
|
// live value to resolved expectation and proposes the fire, never a create.
|
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
} from "../../lifecycle/observation-baseline";
|
|
19
19
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
20
20
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
21
|
-
import { buildChangeSet, renderChangeSet, gitlabMrReport,
|
|
21
|
+
import { buildChangeSet, renderChangeSet, renderChangeSetMarkdown, gitlabMrReport, unobservedPlanNotice, type ChangeSet } from "../../lifecycle/change-set";
|
|
22
22
|
import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
|
|
23
|
+
import { annotateDisruption, disruptionNotices } from "../../lifecycle/disruption";
|
|
23
24
|
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
24
25
|
import {
|
|
25
26
|
formatUnobserved,
|
|
@@ -1264,7 +1265,17 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1264
1265
|
// Attribution survives the flat merge below (#1674).
|
|
1265
1266
|
lexicon: lexiconName,
|
|
1266
1267
|
});
|
|
1267
|
-
|
|
1268
|
+
|
|
1269
|
+
// Disruption (#1665) is asked of THIS lexicon, before the merge: the
|
|
1270
|
+
// deltas are paths into its own observation shape, and the replacement
|
|
1271
|
+
// rules are in the spec it compiled. A lexicon that answers nothing
|
|
1272
|
+
// leaves every update `unknown` — never `in-place`.
|
|
1273
|
+
const classified = await annotateDisruption(
|
|
1274
|
+
cs,
|
|
1275
|
+
environment,
|
|
1276
|
+
plugin.classifyDisruption ? (o) => plugin.classifyDisruption!(o) : undefined,
|
|
1277
|
+
);
|
|
1278
|
+
merged.entries.push(...classified.entries);
|
|
1268
1279
|
checked++;
|
|
1269
1280
|
}
|
|
1270
1281
|
} finally {
|
|
@@ -1291,12 +1302,17 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1291
1302
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1292
1303
|
|
|
1293
1304
|
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
1294
|
-
// shapes have no room for it) still learn the plan has a hole (#1089).
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1305
|
+
// shapes have no room for it) still learn the plan has a hole (#1089). The
|
|
1306
|
+
// `markdown` report carries the same wording in its own body instead — a
|
|
1307
|
+
// reviewer reading a comment never sees this stderr.
|
|
1308
|
+
for (const notice of unobservedPlanNotice(merged)) {
|
|
1309
|
+
console.error(formatWarning({ message: notice }));
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Same reason as above (#1665): the `--json` and `--report gitlab-mr` shapes
|
|
1313
|
+
// have no column for how much an update hurts.
|
|
1314
|
+
for (const notice of disruptionNotices(merged)) {
|
|
1315
|
+
console.error(formatWarning({ message: notice }));
|
|
1300
1316
|
}
|
|
1301
1317
|
|
|
1302
1318
|
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
@@ -1307,6 +1323,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1307
1323
|
return 0;
|
|
1308
1324
|
}
|
|
1309
1325
|
|
|
1326
|
+
// `--report markdown` emits the reviewer-facing projection (#1983) — a
|
|
1327
|
+
// counts header, entries grouped and attributed to their lexicon, holes and
|
|
1328
|
+
// disruption both carried in the body rather than left to stderr.
|
|
1329
|
+
if (args.reportFile === "markdown") {
|
|
1330
|
+
console.log(renderChangeSetMarkdown(merged));
|
|
1331
|
+
return 0;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1310
1334
|
if (args.json) {
|
|
1311
1335
|
console.log(JSON.stringify(merged, null, 2));
|
|
1312
1336
|
} else {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
3
|
+
import type { OpConfig } from "../../op/types";
|
|
4
|
+
import type { EffectReceiptRef } from "../../op/receipt-store";
|
|
5
|
+
import type { WorkflowHistoryRaw, HistoryEvent } from "./run-client";
|
|
6
|
+
|
|
7
|
+
// Minimal history-event builders. Field shapes match what `fetchNormalizedHistory`
|
|
8
|
+
// produces: short PascalCase `eventType`, decimal-string `eventId`/`scheduledEventId`.
|
|
9
|
+
function scheduled(eventId: string, fn: string, at: Date): HistoryEvent {
|
|
10
|
+
return {
|
|
11
|
+
eventId,
|
|
12
|
+
eventType: "ActivityTaskScheduled",
|
|
13
|
+
eventTime: at,
|
|
14
|
+
activityTaskScheduledEventAttributes: { activityId: eventId, activityType: { name: fn } },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function completed(scheduledEventId: string, at: Date): HistoryEvent {
|
|
18
|
+
return {
|
|
19
|
+
eventType: "ActivityTaskCompleted",
|
|
20
|
+
eventTime: at,
|
|
21
|
+
activityTaskCompletedEventAttributes: { scheduledEventId },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function failed(scheduledEventId: string, at: Date, message = "boom"): HistoryEvent {
|
|
25
|
+
return {
|
|
26
|
+
eventType: "ActivityTaskFailed",
|
|
27
|
+
eventTime: at,
|
|
28
|
+
activityTaskFailedEventAttributes: { scheduledEventId, failure: { message } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const T0 = new Date("2026-05-01T00:00:00Z");
|
|
33
|
+
const sec = (n: number) => new Date(T0.getTime() + n * 1000);
|
|
34
|
+
|
|
35
|
+
describe("extractStepRecords", () => {
|
|
36
|
+
test("joins two phases' activities to their declared phase, in order", () => {
|
|
37
|
+
const config: OpConfig = {
|
|
38
|
+
name: "op", overview: "o",
|
|
39
|
+
phases: [
|
|
40
|
+
{ name: "Build", steps: [{ kind: "activity", fn: "build" }] },
|
|
41
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const history: WorkflowHistoryRaw = {
|
|
45
|
+
events: [
|
|
46
|
+
scheduled("1", "build", sec(0)),
|
|
47
|
+
completed("1", sec(1)),
|
|
48
|
+
scheduled("2", "deploy", sec(2)),
|
|
49
|
+
completed("2", sec(4)),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const records = extractStepRecords(config, history);
|
|
53
|
+
expect(records).toEqual([
|
|
54
|
+
{ phase: "Build", fn: "build", status: "ok", durationMs: 1000 },
|
|
55
|
+
{ phase: "Deploy", fn: "deploy", status: "ok", durationMs: 2000 },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("two steps calling the same activity each get their own scheduled event (no last-wins collision)", () => {
|
|
60
|
+
const config: OpConfig = {
|
|
61
|
+
name: "op", overview: "o",
|
|
62
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "dup" }, { kind: "activity", fn: "dup" }] }],
|
|
63
|
+
};
|
|
64
|
+
const history: WorkflowHistoryRaw = {
|
|
65
|
+
events: [
|
|
66
|
+
scheduled("1", "dup", sec(0)),
|
|
67
|
+
completed("1", sec(1)), // first "dup": 1000ms, ok
|
|
68
|
+
scheduled("2", "dup", sec(1)),
|
|
69
|
+
failed("2", sec(1.5), "second dup failed"), // second "dup": fails
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
const records = extractStepRecords(config, history);
|
|
73
|
+
expect(records).toEqual([
|
|
74
|
+
{ phase: "P1", fn: "dup", status: "ok", durationMs: 1000 },
|
|
75
|
+
{ phase: "P1", fn: "dup", status: "fail", durationMs: 500, error: "second dup failed" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a step not yet scheduled produces no record unless final", () => {
|
|
80
|
+
const config: OpConfig = {
|
|
81
|
+
name: "op", overview: "o",
|
|
82
|
+
phases: [
|
|
83
|
+
{ name: "P1", steps: [{ kind: "activity", fn: "a" }] },
|
|
84
|
+
{ name: "P2", steps: [{ kind: "activity", fn: "b" }] },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0)), completed("1", sec(1))] };
|
|
88
|
+
|
|
89
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
90
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
91
|
+
]);
|
|
92
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
93
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
94
|
+
{ phase: "P2", fn: "b", status: "skipped", durationMs: 0 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a scheduled-but-not-yet-settled step produces no record", () => {
|
|
99
|
+
const config: OpConfig = {
|
|
100
|
+
name: "op", overview: "o",
|
|
101
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "a" }] }],
|
|
102
|
+
};
|
|
103
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0))] };
|
|
104
|
+
expect(extractStepRecords(config, history)).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a retried activity resolves to its last outcome (Failed attempt then eventual Completed)", () => {
|
|
108
|
+
const config: OpConfig = {
|
|
109
|
+
name: "op", overview: "o",
|
|
110
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "flaky" }] }],
|
|
111
|
+
};
|
|
112
|
+
const history: WorkflowHistoryRaw = {
|
|
113
|
+
events: [
|
|
114
|
+
scheduled("1", "flaky", sec(0)),
|
|
115
|
+
failed("1", sec(1), "attempt 1 failed"),
|
|
116
|
+
completed("1", sec(3)), // eventual success
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
120
|
+
{ phase: "P1", fn: "flaky", status: "ok", durationMs: 3000 },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("effect step: matched receipt — only receiptRead settles; nested + receiptWrite are skipped only when final", () => {
|
|
125
|
+
const receipt: EffectReceiptRef = { name: "r", effect: "e", flavor: "existence", inputs: {} };
|
|
126
|
+
const config: OpConfig = {
|
|
127
|
+
name: "op", overview: "o",
|
|
128
|
+
phases: [{
|
|
129
|
+
name: "Effect",
|
|
130
|
+
steps: [{ kind: "effect", receipt, steps: [{ kind: "activity", fn: "applyIt" }] }],
|
|
131
|
+
}],
|
|
132
|
+
};
|
|
133
|
+
const history: WorkflowHistoryRaw = {
|
|
134
|
+
events: [scheduled("1", "receiptRead", sec(0)), completed("1", sec(1))],
|
|
135
|
+
};
|
|
136
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
137
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
138
|
+
]);
|
|
139
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
140
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
141
|
+
{ phase: "Effect", fn: "applyIt", status: "skipped", durationMs: 0 },
|
|
142
|
+
{ phase: "Effect", fn: "receiptWrite", status: "skipped", durationMs: 0 },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("onFailure phases are matched in reverse declared order, matching how the workflow actually runs them", () => {
|
|
147
|
+
const config: OpConfig = {
|
|
148
|
+
name: "op", overview: "o",
|
|
149
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] }],
|
|
150
|
+
onFailure: [
|
|
151
|
+
{ name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
|
|
152
|
+
{ name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const history: WorkflowHistoryRaw = {
|
|
156
|
+
events: [
|
|
157
|
+
scheduled("1", "boom", sec(0)),
|
|
158
|
+
failed("1", sec(1), "boom"),
|
|
159
|
+
// Compensation runs C2 then C1 (reverse of declared order).
|
|
160
|
+
scheduled("2", "comp2", sec(2)),
|
|
161
|
+
completed("2", sec(3)),
|
|
162
|
+
scheduled("3", "comp1", sec(3)),
|
|
163
|
+
completed("3", sec(4)),
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
167
|
+
{ phase: "Main", fn: "boom", status: "fail", durationMs: 1000, error: "boom" },
|
|
168
|
+
{ phase: "C2", fn: "comp2", status: "ok", durationMs: 1000 },
|
|
169
|
+
{ phase: "C1", fn: "comp1", status: "ok", durationMs: 1000 },
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("countActivities", () => {
|
|
175
|
+
test("counts scheduled and completed events", () => {
|
|
176
|
+
const history: WorkflowHistoryRaw = {
|
|
177
|
+
events: [scheduled("1", "a", sec(0)), scheduled("2", "b", sec(0)), completed("1", sec(1))],
|
|
178
|
+
};
|
|
179
|
+
expect(countActivities(history)).toEqual({ completed: 1, scheduled: 2 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("empty history", () => {
|
|
183
|
+
expect(countActivities({})).toEqual({ completed: 0, scheduled: 0 });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("queryGateState", () => {
|
|
188
|
+
test("returns the query result when the handle supports it", async () => {
|
|
189
|
+
const handle = { query: async () => ({ signalName: "g", since: "t" }) } as unknown as Parameters<typeof queryGateState>[0];
|
|
190
|
+
expect(await queryGateState(handle)).toEqual({ signalName: "g", since: "t" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("returns null when the workflow reports no pending gate", async () => {
|
|
194
|
+
const handle = { query: async () => null } as unknown as Parameters<typeof queryGateState>[0];
|
|
195
|
+
expect(await queryGateState(handle)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns undefined (not an error) when the query isn't registered — e.g. an Op with no gates", async () => {
|
|
199
|
+
const handle = { query: async () => { throw new Error("unregistered query"); } } as unknown as Parameters<typeof queryGateState>[0];
|
|
200
|
+
expect(await queryGateState(handle)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|