@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.
@@ -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, summarize, type ChangeSet } from "../../lifecycle/change-set";
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
- const unobservedCount = summarize(merged).unobserved;
1307
- if (unobservedCount > 0) {
1308
- console.error(formatWarning({
1309
- message: `${unobservedCount} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
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],
@@ -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 { buildChangeSet, renderChangeSet, summarize, gitlabMrReport } from "./change-set";
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;