@intentius/chant 0.51.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/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -0
- package/dist/lifecycle/change-set.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 +15 -1
- package/dist/op/local-executor.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 +19 -0
- package/src/cli/handlers/lifecycle.ts +14 -7
- package/src/cli/handlers/run.test.ts +96 -0
- package/src/cli/handlers/run.ts +37 -1
- package/src/cli/main.ts +2 -0
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +133 -11
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +28 -0
|
@@ -18,7 +18,7 @@ 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
23
|
import { annotateDisruption, disruptionNotices } from "../../lifecycle/disruption";
|
|
24
24
|
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
@@ -1302,12 +1302,11 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1302
1302
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1303
1303
|
|
|
1304
1304
|
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
1305
|
-
// shapes have no room for it) still learn the plan has a hole (#1089).
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
}));
|
|
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 }));
|
|
1311
1310
|
}
|
|
1312
1311
|
|
|
1313
1312
|
// Same reason as above (#1665): the `--json` and `--report gitlab-mr` shapes
|
|
@@ -1324,6 +1323,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1324
1323
|
return 0;
|
|
1325
1324
|
}
|
|
1326
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
|
+
|
|
1327
1334
|
if (args.json) {
|
|
1328
1335
|
console.log(JSON.stringify(merged, null, 2));
|
|
1329
1336
|
} else {
|
|
@@ -987,6 +987,102 @@ describe("runOp dispatcher", () => {
|
|
|
987
987
|
});
|
|
988
988
|
});
|
|
989
989
|
|
|
990
|
+
/**
|
|
991
|
+
* chant #2003 — `--sandbox` is a global flag and `../main.ts` arms the
|
|
992
|
+
* process-wide policy latch off it for every command, so `chant run <op>
|
|
993
|
+
* --sandbox` on an Op with a `policyGate` step used to reach `loadPolicyChecks`
|
|
994
|
+
* mid-run and show the user a message written for a chant maintainer ("This is
|
|
995
|
+
* a chant bug"). At the CLI level, not on `loadPolicyChecks`: the defect was
|
|
996
|
+
* that the combination got that far, not what the refusal says.
|
|
997
|
+
*/
|
|
998
|
+
describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
|
|
999
|
+
const policyGateOp = (name: string) =>
|
|
1000
|
+
localOp(name, [{ kind: "activity", fn: "policyGate", args: { path: "." } }]);
|
|
1001
|
+
|
|
1002
|
+
beforeEach(() => {
|
|
1003
|
+
discoverOpsMock.mockReset();
|
|
1004
|
+
loadTemporalClientMock.mockReset();
|
|
1005
|
+
loadChantConfigMock.mockReset();
|
|
1006
|
+
resolveProfileMock.mockReset();
|
|
1007
|
+
existsSyncMock.mockReset();
|
|
1008
|
+
spawnChildMock.mockReset();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test("local mode → refuses before any phase runs, naming the combination", async () => {
|
|
1012
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1013
|
+
const stderr = makeStderrSpy();
|
|
1014
|
+
|
|
1015
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1016
|
+
|
|
1017
|
+
expect(exit).toBe(1);
|
|
1018
|
+
const out = stderr.join("\n");
|
|
1019
|
+
expect(out).toContain("policyGate");
|
|
1020
|
+
expect(out).toContain("--sandbox");
|
|
1021
|
+
// The message the user used to get instead.
|
|
1022
|
+
expect(out).not.toContain("This is a chant bug");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("--temporal → the same refusal, as a CLI error rather than an activity failure", async () => {
|
|
1026
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1027
|
+
const stderr = makeStderrSpy();
|
|
1028
|
+
|
|
1029
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true, sandbox: true }), plugins: [], serializers: [] });
|
|
1030
|
+
|
|
1031
|
+
expect(exit).toBe(1);
|
|
1032
|
+
expect(stderr.join("\n")).toContain("policyGate");
|
|
1033
|
+
// Refused before the project config is even loaded — nothing is scheduled.
|
|
1034
|
+
expect(loadChantConfigMock).not.toHaveBeenCalled();
|
|
1035
|
+
expect(loadTemporalClientMock).not.toHaveBeenCalled();
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("a policyGate nested in an effect step is found too", async () => {
|
|
1039
|
+
discoverOpsMock.mockResolvedValue({
|
|
1040
|
+
ops: new Map([
|
|
1041
|
+
localOp("gate", [
|
|
1042
|
+
{ kind: "effect", steps: [{ kind: "activity", fn: "policyGate", args: { path: "." } }] },
|
|
1043
|
+
]),
|
|
1044
|
+
]),
|
|
1045
|
+
errors: [],
|
|
1046
|
+
});
|
|
1047
|
+
const stderr = makeStderrSpy();
|
|
1048
|
+
|
|
1049
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1050
|
+
|
|
1051
|
+
expect(exit).toBe(1);
|
|
1052
|
+
expect(stderr.join("\n")).toContain("policyGate");
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("without --sandbox the same Op is not refused", async () => {
|
|
1056
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1057
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
1058
|
+
resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
|
|
1059
|
+
existsSyncMock.mockReturnValue(false);
|
|
1060
|
+
const stderr = makeStderrSpy();
|
|
1061
|
+
|
|
1062
|
+
// The Temporal path, so the run stops at the missing worker.ts rather than
|
|
1063
|
+
// actually building a project: reaching that point proves the pre-flight
|
|
1064
|
+
// let it through.
|
|
1065
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true }), plugins: [], serializers: [] });
|
|
1066
|
+
|
|
1067
|
+
expect(exit).toBe(1);
|
|
1068
|
+
expect(stderr.join("\n")).toContain("worker.ts not found");
|
|
1069
|
+
expect(loadChantConfigMock).toHaveBeenCalled();
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("--sandbox on an Op with no policyGate step is untouched", async () => {
|
|
1073
|
+
discoverOpsMock.mockResolvedValue({
|
|
1074
|
+
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
1075
|
+
errors: [],
|
|
1076
|
+
});
|
|
1077
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
1078
|
+
|
|
1079
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1080
|
+
|
|
1081
|
+
expect(exit).toBe(0);
|
|
1082
|
+
stderrWrite.mockRestore();
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
990
1086
|
describe("Temporal-only subcommand guards", () => {
|
|
991
1087
|
const cases: Array<[string, (ctx: { args: ParsedArgs; plugins: never[]; serializers: never[] }) => Promise<number>]> = [
|
|
992
1088
|
["list", runOpList],
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { createConnection } from "node:net";
|
|
|
4
4
|
import { spawn as spawnChild, type ChildProcess } from "node:child_process";
|
|
5
5
|
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
6
6
|
import { discoverOps } from "../../op/discover";
|
|
7
|
+
import type { OpConfig } from "../../op/types";
|
|
7
8
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
8
|
-
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
9
|
+
import { runOpLocally, findGate, findPolicyGateStep, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
9
10
|
import { renderHuman, renderJson } from "../../op/local-output";
|
|
10
11
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
11
12
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
@@ -561,6 +562,32 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
|
|
|
561
562
|
* live reaching an actual cloud shell-out. Erroring here is the safe minimum
|
|
562
563
|
* called out on the issue; a real preview is future work.
|
|
563
564
|
*/
|
|
565
|
+
/**
|
|
566
|
+
* Pre-flight for `chant run <op> --sandbox` on an Op containing a `policyGate`
|
|
567
|
+
* step (chant #2003). `--sandbox` is a global flag, and `../main.ts` arms the
|
|
568
|
+
* process-wide policy latch off it for every command — so the gate's
|
|
569
|
+
* `loadPolicyChecks` refuses mid-run with a message addressed to a chant
|
|
570
|
+
* maintainer ("This is a chant bug"), or, under `--temporal`, as a
|
|
571
|
+
* non-retryable activity failure inside the workflow. Neither is an answer a
|
|
572
|
+
* user can act on. Refuse here instead, before anything runs, naming the
|
|
573
|
+
* combination and what to do about it.
|
|
574
|
+
*
|
|
575
|
+
* Keyed on the flag rather than on whether the project declares
|
|
576
|
+
* `lint.policies`: the flag is what arms the latch, and a gate that builds the
|
|
577
|
+
* project in this process is a divergence from what `--sandbox` promises
|
|
578
|
+
* whether or not a policy module happens to be declared. Returns true when the
|
|
579
|
+
* caller should stop.
|
|
580
|
+
*/
|
|
581
|
+
function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, opName: string): boolean {
|
|
582
|
+
if (!ctx.args.sandbox) return false;
|
|
583
|
+
if (!findPolicyGateStep(config)) return false;
|
|
584
|
+
console.error(formatError({
|
|
585
|
+
message: `Op "${opName}" has a policyGate step, which cannot run under --sandbox: the gate builds this project and imports its lint.policies in the chant process, which --sandbox forbids.`,
|
|
586
|
+
hint: "Re-run without --sandbox, or drop the policyGate step from this Op. Running the gate itself inside the sandbox boundary is tracked on chant#1157.",
|
|
587
|
+
}));
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
564
591
|
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
565
592
|
if (ctx.args.components && ctx.args.report) {
|
|
566
593
|
console.error(formatError({
|
|
@@ -1061,6 +1088,9 @@ export async function runOpLocal(ctx: CommandContext): Promise<number> {
|
|
|
1061
1088
|
|
|
1062
1089
|
const { config } = discovered;
|
|
1063
1090
|
|
|
1091
|
+
// Pre-flight: --sandbox cannot cover a policyGate step (#2003).
|
|
1092
|
+
if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
|
|
1093
|
+
|
|
1064
1094
|
// Pre-flight: gates/schedules need a durable runtime — fail before any step.
|
|
1065
1095
|
const gate = findGate(config);
|
|
1066
1096
|
if (gate) {
|
|
@@ -1145,6 +1175,12 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1145
1175
|
}
|
|
1146
1176
|
|
|
1147
1177
|
const { config } = discovered;
|
|
1178
|
+
|
|
1179
|
+
// Pre-flight: --sandbox cannot cover a policyGate step (#2003). Refused here
|
|
1180
|
+
// too, so the durable path fails as a CLI error rather than as a
|
|
1181
|
+
// non-retryable activity failure buried in workflow history.
|
|
1182
|
+
if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
|
|
1183
|
+
|
|
1148
1184
|
const projectPath = resolve(".");
|
|
1149
1185
|
|
|
1150
1186
|
// Load config + profile
|
package/src/cli/main.ts
CHANGED
|
@@ -624,6 +624,8 @@ Options:
|
|
|
624
624
|
OR with a path arg: SARIF report destination (migrate)
|
|
625
625
|
OR '--report gitlab-mr': emit the GitLab MR plan-widget
|
|
626
626
|
JSON (lifecycle plan)
|
|
627
|
+
OR '--report markdown': emit a reviewer-facing markdown
|
|
628
|
+
render, holes and disruption included (lifecycle plan)
|
|
627
629
|
--from <name> Source lexicon for migrate (default: github)
|
|
628
630
|
--to <name> Target lexicon for migrate (default: gitlab)
|
|
629
631
|
--emit <fmt> Migration output format: yaml (default) or ts
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildChangeSet,
|
|
4
|
+
renderChangeSet,
|
|
5
|
+
renderChangeSetMarkdown,
|
|
6
|
+
summarize,
|
|
7
|
+
gitlabMrReport,
|
|
8
|
+
unobservedPlanNotice,
|
|
9
|
+
type ChangeSet,
|
|
10
|
+
type ChangeSetEntry,
|
|
11
|
+
} from "./change-set";
|
|
3
12
|
import type { ResourceMetadata } from "../lexicon";
|
|
4
13
|
|
|
5
14
|
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
@@ -446,3 +455,137 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
|
446
455
|
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
447
456
|
});
|
|
448
457
|
});
|
|
458
|
+
|
|
459
|
+
// ── Markdown report (#1983) ──────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
|
|
462
|
+
return {
|
|
463
|
+
name: "db",
|
|
464
|
+
type: "AWS::RDS::DBInstance",
|
|
465
|
+
lexicon: "aws",
|
|
466
|
+
action: "update",
|
|
467
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
468
|
+
ownership: "unknown",
|
|
469
|
+
...overrides,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function set(entries: ChangeSetEntry[]): ChangeSet {
|
|
474
|
+
return { env: "prod", entries };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
describe("unobservedPlanNotice (#1983)", () => {
|
|
478
|
+
test("empty when nothing is unobserved", () => {
|
|
479
|
+
expect(unobservedPlanNotice(set([entry({ action: "noop" })]))).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("one notice naming the count, matching the CLI's stderr wording", () => {
|
|
483
|
+
const cs = set([
|
|
484
|
+
entry({ name: "a", action: "unobserved", unobservedReason: "no-binding" }),
|
|
485
|
+
entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
|
|
486
|
+
entry({ name: "c", action: "noop" }),
|
|
487
|
+
]);
|
|
488
|
+
expect(unobservedPlanNotice(cs)).toEqual([
|
|
489
|
+
"2 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.",
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("renderChangeSetMarkdown (#1983)", () => {
|
|
495
|
+
test("empty plan: header only, no ANSI, deterministic", () => {
|
|
496
|
+
const out = renderChangeSetMarkdown(set([]));
|
|
497
|
+
expect(out).toBe(
|
|
498
|
+
"## Plan for `prod`\n\n0 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 0 noop, 0 unobserved\n",
|
|
499
|
+
);
|
|
500
|
+
expect(out).not.toMatch(/\x1b\[/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("counts header and grouped sections, attributed to lexicon", () => {
|
|
504
|
+
const cs = set([
|
|
505
|
+
entry({ name: "new-bucket", type: "S3::Bucket", lexicon: "aws", action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true } }),
|
|
506
|
+
entry({ name: "web", type: "K8s::Apps::Deployment", lexicon: "k8s", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true } }),
|
|
507
|
+
]);
|
|
508
|
+
const out = renderChangeSetMarkdown(cs);
|
|
509
|
+
expect(out).toContain("## Plan for `prod`");
|
|
510
|
+
expect(out).toContain("1 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 1 noop, 0 unobserved");
|
|
511
|
+
expect(out).toContain("### CREATE");
|
|
512
|
+
expect(out).toContain("- `new-bucket` (S3::Bucket) `aws`");
|
|
513
|
+
expect(out).toContain("### NOOP");
|
|
514
|
+
expect(out).toContain("- `web` (K8s::Apps::Deployment) `k8s`");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("deltas render in a fenced block, forced path marked", () => {
|
|
518
|
+
const cs = set([
|
|
519
|
+
entry({
|
|
520
|
+
deltas: [
|
|
521
|
+
{ path: "attributes.DBInstanceIdentifier", oldValue: "app-db", newValue: "app-db-2" },
|
|
522
|
+
{ path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
|
|
523
|
+
],
|
|
524
|
+
disruption: "destroy",
|
|
525
|
+
disruptionBecause: ["attributes.DBInstanceIdentifier"],
|
|
526
|
+
disruptionDetail: "DBInstanceIdentifier is create-only",
|
|
527
|
+
}),
|
|
528
|
+
]);
|
|
529
|
+
const out = renderChangeSetMarkdown(cs);
|
|
530
|
+
expect(out).toContain("**destroy**: DBInstanceIdentifier is create-only");
|
|
531
|
+
expect(out).toContain("```");
|
|
532
|
+
expect(out).toContain("! attributes.DBInstanceIdentifier: app-db → app-db-2");
|
|
533
|
+
expect(out).toContain("attributes.AllocatedStorage: 20 → 40");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("disruption count rides the header, same as the human render", () => {
|
|
537
|
+
const cs = set([entry({ disruption: "in-place" }), entry({ name: "web", disruption: "destroy" })]);
|
|
538
|
+
const out = renderChangeSetMarkdown(cs);
|
|
539
|
+
expect(out).toContain("**Disruption:** 1 in-place, 1 destroy");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("unobserved renders both the prominent notice and its own section, same wording as the human render", () => {
|
|
543
|
+
const cs = set([
|
|
544
|
+
entry({ name: "crd-widget", action: "unobserved", unobservedReason: "no-binding", unobservedDetail: "no kubectl context for prod" }),
|
|
545
|
+
]);
|
|
546
|
+
const out = renderChangeSetMarkdown(cs);
|
|
547
|
+
expect(out).toContain(
|
|
548
|
+
"> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**",
|
|
549
|
+
);
|
|
550
|
+
expect(out).toContain("### UNOBSERVED (declared; chant could not read live state — no action proposed)");
|
|
551
|
+
expect(out).toContain("- `crd-widget`");
|
|
552
|
+
expect(out).toContain("no binding for this environment");
|
|
553
|
+
expect(out).toContain("no kubectl context for prod");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("a clean plan (no holes) carries no unobserved notice at all", () => {
|
|
557
|
+
const out = renderChangeSetMarkdown(set([entry({ action: "noop" })]));
|
|
558
|
+
expect(out).not.toContain("could not be observed");
|
|
559
|
+
expect(out).not.toContain("UNOBSERVED");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("effect entries render as their own line, no deltas", () => {
|
|
563
|
+
const cs = set([
|
|
564
|
+
entry({
|
|
565
|
+
name: "receipt-x",
|
|
566
|
+
action: "effect",
|
|
567
|
+
effect: "seed-admin",
|
|
568
|
+
effectReason: "receipt-absent",
|
|
569
|
+
effectDetail: "no receipt recorded yet",
|
|
570
|
+
deltas: undefined,
|
|
571
|
+
}),
|
|
572
|
+
]);
|
|
573
|
+
const out = renderChangeSetMarkdown(cs);
|
|
574
|
+
expect(out).toContain("- effect will fire: `seed-admin` — receipt `receipt-x`");
|
|
575
|
+
expect(out).toContain("no receipt recorded yet");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("a group past the fold threshold collapses into <details>, a small one does not", () => {
|
|
579
|
+
const many = Array.from({ length: 25 }, (_, i) =>
|
|
580
|
+
entry({ name: `bucket-${i}`, action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true }, deltas: undefined }),
|
|
581
|
+
);
|
|
582
|
+
const out = renderChangeSetMarkdown(set(many));
|
|
583
|
+
expect(out).toContain("<details><summary>25 entries — click to expand</summary>");
|
|
584
|
+
expect(out).toContain("</details>");
|
|
585
|
+
expect(out).toContain("- `bucket-0`");
|
|
586
|
+
expect(out).toContain("- `bucket-24`");
|
|
587
|
+
|
|
588
|
+
const few = renderChangeSetMarkdown(set(many.slice(0, 3)));
|
|
589
|
+
expect(few).not.toContain("<details>");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
@@ -325,6 +325,45 @@ export function gitlabMrReport(cs: ChangeSet): GitlabMrReport {
|
|
|
325
325
|
return { create: counts.create, update: counts.update, delete: counts.delete };
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Warning a plan should print when it carries a hole — a declared entity the
|
|
330
|
+
* lexicon could not observe (#1089). The `--json` and `--report gitlab-mr`
|
|
331
|
+
* shapes have no column for `unobserved`, so both the CLI (on stderr) and the
|
|
332
|
+
* `markdown` report (in the body, since a reviewer never sees a job's stderr)
|
|
333
|
+
* read this same wording rather than drifting apart. Empty when the plan has
|
|
334
|
+
* no hole. Same discipline as {@link disruptionNotices} in `./disruption`.
|
|
335
|
+
*/
|
|
336
|
+
export function unobservedPlanNotice(cs: ChangeSet): string[] {
|
|
337
|
+
const count = summarize(cs).unobserved;
|
|
338
|
+
return count > 0
|
|
339
|
+
? [
|
|
340
|
+
`${count} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
|
|
341
|
+
]
|
|
342
|
+
: [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Section heading text for one action group — shared between {@link renderChangeSet}
|
|
347
|
+
* and {@link renderChangeSetMarkdown} so the wording never drifts between the
|
|
348
|
+
* terminal render and the reviewer-facing one.
|
|
349
|
+
*/
|
|
350
|
+
function actionSectionLabel(action: ChangeAction, hasDisruption: boolean): string {
|
|
351
|
+
switch (action) {
|
|
352
|
+
case "unobserved":
|
|
353
|
+
return "UNOBSERVED (declared; chant could not read live state — no action proposed)";
|
|
354
|
+
case "runtime":
|
|
355
|
+
return "RUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate)";
|
|
356
|
+
case "effect":
|
|
357
|
+
return "EFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt)";
|
|
358
|
+
case "update":
|
|
359
|
+
return hasDisruption
|
|
360
|
+
? "UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe)"
|
|
361
|
+
: "UPDATE";
|
|
362
|
+
default:
|
|
363
|
+
return action.toUpperCase();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
328
367
|
/** Human-readable render of a change set. Pure — returns a string. */
|
|
329
368
|
export function renderChangeSet(cs: ChangeSet): string {
|
|
330
369
|
const counts = summarize(cs);
|
|
@@ -344,17 +383,7 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
344
383
|
for (const action of ACTION_ORDER) {
|
|
345
384
|
const group = cs.entries.filter((e) => e.action === action);
|
|
346
385
|
if (group.length === 0) continue;
|
|
347
|
-
lines.push(
|
|
348
|
-
action === "unobserved"
|
|
349
|
-
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
350
|
-
: action === "runtime"
|
|
351
|
-
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
352
|
-
: action === "effect"
|
|
353
|
-
? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
|
|
354
|
-
: action === "update" && disruptionParts.length > 0
|
|
355
|
-
? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
|
|
356
|
-
: `\n${action.toUpperCase()}:`,
|
|
357
|
-
);
|
|
386
|
+
lines.push(`\n${actionSectionLabel(action, disruptionParts.length > 0)}:`);
|
|
358
387
|
for (const e of group) {
|
|
359
388
|
if (e.action === "effect") {
|
|
360
389
|
lines.push(
|
|
@@ -381,6 +410,99 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
381
410
|
return lines.join("\n");
|
|
382
411
|
}
|
|
383
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Entries beyond this in one action group are collapsed into a `<details>`
|
|
415
|
+
* block in {@link renderChangeSetMarkdown} — a group's worth of rows is fine
|
|
416
|
+
* to read inline, a hundred-entry plan is not (#1983).
|
|
417
|
+
*/
|
|
418
|
+
const MARKDOWN_FOLD_THRESHOLD = 20;
|
|
419
|
+
|
|
420
|
+
/** One entry's markdown, mirroring the rows {@link renderChangeSet} prints. */
|
|
421
|
+
function renderMarkdownEntry(e: ChangeSetEntry): string[] {
|
|
422
|
+
if (e.action === "effect") {
|
|
423
|
+
return [
|
|
424
|
+
`- effect will fire: \`${e.effect ?? e.name}\` — receipt \`${e.name}\`${e.type ? ` (${e.type})` : ""}` +
|
|
425
|
+
`${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
const lexicon = e.lexicon ? ` \`${e.lexicon}\`` : "";
|
|
429
|
+
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
430
|
+
const why = e.unobservedReason
|
|
431
|
+
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
432
|
+
: "";
|
|
433
|
+
const owner = e.runtimeOwner ? ` — owned by \`${e.runtimeOwner}\`` : "";
|
|
434
|
+
// Bolded rather than the plain-text render's bare " — destroy: ..." — a
|
|
435
|
+
// reviewer scanning a wall of "update" rows should see disruption without
|
|
436
|
+
// reading every one (#1665).
|
|
437
|
+
const disruption = e.disruption
|
|
438
|
+
? ` — **${e.disruption}**${e.disruptionDetail ? `: ${e.disruptionDetail}` : ""}`
|
|
439
|
+
: "";
|
|
440
|
+
const lines = [`- \`${e.name}\`${e.type ? ` (${e.type})` : ""}${lexicon}${own}${why}${owner}${disruption}`];
|
|
441
|
+
|
|
442
|
+
if (e.deltas && e.deltas.length > 0) {
|
|
443
|
+
const forced = new Set(e.disruptionBecause ?? []);
|
|
444
|
+
lines.push(" ```");
|
|
445
|
+
for (const d of e.deltas) {
|
|
446
|
+
lines.push(` ${forced.has(d.path) ? "! " : " "}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
447
|
+
}
|
|
448
|
+
lines.push(" ```");
|
|
449
|
+
}
|
|
450
|
+
return lines;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Markdown projection of a change set (#1983) — sized for a PR/MR comment
|
|
455
|
+
* rather than a terminal. A scannable counts header, entries grouped by
|
|
456
|
+
* action and attributed to their lexicon, deltas in a fenced block, and any
|
|
457
|
+
* group past {@link MARKDOWN_FOLD_THRESHOLD} folded into a `<details>` so a
|
|
458
|
+
* large plan doesn't bury the comment. Pure — no ANSI, no I/O — and
|
|
459
|
+
* deterministic: same entry ordering as {@link renderChangeSet}.
|
|
460
|
+
*
|
|
461
|
+
* Both a hole (#1089) and an expensive verdict (#1665) are surfaced IN the
|
|
462
|
+
* body, not left to stderr the way `--json` and `--report gitlab-mr` leave
|
|
463
|
+
* them: a reviewer reading a comment never sees the job's log, so a plan
|
|
464
|
+
* carrying either must not read as clean.
|
|
465
|
+
*/
|
|
466
|
+
export function renderChangeSetMarkdown(cs: ChangeSet): string {
|
|
467
|
+
const counts = summarize(cs);
|
|
468
|
+
const lines: string[] = [`## Plan for \`${cs.env}\``, "", ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ")];
|
|
469
|
+
|
|
470
|
+
const disruption = summarizeDisruption(cs);
|
|
471
|
+
const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
|
|
472
|
+
.filter(([, n]) => n > 0)
|
|
473
|
+
.map(([level, n]) => `${n} ${level}`);
|
|
474
|
+
if (disruptionParts.length > 0) {
|
|
475
|
+
lines.push("", `**Disruption:** ${disruptionParts.join(", ")}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const notice of unobservedPlanNotice(cs)) {
|
|
479
|
+
lines.push("", `> **${notice}**`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const action of ACTION_ORDER) {
|
|
483
|
+
const group = cs.entries.filter((e) => e.action === action);
|
|
484
|
+
if (group.length === 0) continue;
|
|
485
|
+
|
|
486
|
+
lines.push("", `### ${actionSectionLabel(action, disruptionParts.length > 0)}`);
|
|
487
|
+
|
|
488
|
+
const body = group.flatMap((e) => renderMarkdownEntry(e));
|
|
489
|
+
if (group.length > MARKDOWN_FOLD_THRESHOLD) {
|
|
490
|
+
lines.push(
|
|
491
|
+
"",
|
|
492
|
+
`<details><summary>${group.length} entries — click to expand</summary>`,
|
|
493
|
+
"",
|
|
494
|
+
...body,
|
|
495
|
+
"",
|
|
496
|
+
"</details>",
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
lines.push("", ...body);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
504
|
+
}
|
|
505
|
+
|
|
384
506
|
function fmt(v: unknown): string {
|
|
385
507
|
if (v === undefined) return "<unset>";
|
|
386
508
|
if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
|