@intentius/chant 0.61.0 → 0.63.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.
Files changed (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -9,6 +9,7 @@ import {
9
9
  buildUpgradeSummary,
10
10
  computeBumpedVersion,
11
11
  bumpPackageJsonVersion,
12
+ lexiconUpgradeIssueMarker,
12
13
  type CheckPinnedFn,
13
14
  type CheckRollingFn,
14
15
  type BumpPackageVersionFn,
@@ -223,21 +224,105 @@ describe("lexiconUpgrade report mode", () => {
223
224
 
224
225
  // ── issue mode ────────────────────────────────────────────────────────
225
226
 
227
+ describe("lexiconUpgradeIssueMarker (#2297)", () => {
228
+ test("deterministic per lexicon, and distinct across lexicons", () => {
229
+ expect(lexiconUpgradeIssueMarker("k8s")).toBe("<!-- chant-lexicon-upgrade:k8s -->");
230
+ expect(lexiconUpgradeIssueMarker("k8s")).toBe(lexiconUpgradeIssueMarker("k8s"));
231
+ expect(lexiconUpgradeIssueMarker("k8s")).not.toBe(lexiconUpgradeIssueMarker("aws"));
232
+ });
233
+ });
234
+
226
235
  describe("lexiconUpgrade issue mode", () => {
227
- test("opens an issue for a ready upgrade", async () => {
236
+ test("outside any known CI job, falls back to gh's own ambient gh issue create, marker-prefixed", async () => {
228
237
  const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
229
238
  const { gh, calls } = recordingGh();
230
239
 
231
- const r = await lexiconUpgrade({
232
- lexicon: "gcp",
233
- mode: "issue",
234
- _checkPinned: checkPinned,
235
- _gh: gh,
240
+ vi.stubEnv("GITHUB_REPOSITORY", "");
241
+ try {
242
+ const r = await lexiconUpgrade({
243
+ lexicon: "gcp",
244
+ mode: "issue",
245
+ _checkPinned: checkPinned,
246
+ _gh: gh,
247
+ });
248
+
249
+ expect(r.issueUrl).toBe("https://gh/issue/1");
250
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
251
+ expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
252
+ expect(calls.some((c) => c.includes("<!-- chant-lexicon-upgrade:gcp -->"))).toBe(true);
253
+ } finally {
254
+ vi.unstubAllEnvs();
255
+ }
256
+ });
257
+
258
+ test("on a GitHub Actions job (GITHUB_REPOSITORY set), opens the sticky issue via gh api (#2297)", async () => {
259
+ const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
260
+ const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
261
+ const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
262
+ calls.push({ cmd, env: opts?.env });
263
+ if (cmd.includes("--paginate")) return { stdout: "", stderr: "" }; // no owned issue yet
264
+ if (cmd.includes("--method POST")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
265
+ return { stdout: "", stderr: "" };
236
266
  });
237
267
 
238
- expect(r.issueUrl).toBe("https://gh/issue/1");
239
- expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
240
- expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
268
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
269
+ vi.stubEnv("GITHUB_API_URL", "");
270
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
271
+ vi.stubEnv("GH_TOKEN", "ghs-workflow");
272
+ try {
273
+ const r = await lexiconUpgrade({
274
+ lexicon: "gcp",
275
+ mode: "issue",
276
+ _checkPinned: checkPinned,
277
+ _gh: gh,
278
+ });
279
+
280
+ expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
281
+ expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
282
+ const post = calls.find((c) => c.cmd.includes("--method POST"));
283
+ expect(post?.cmd).toContain("https://api.github.com/repos/acme/infra/issues");
284
+ expect(post?.cmd).toContain("<!-- chant-lexicon-upgrade:gcp -->");
285
+ // #2320: the shared `postOrUpdateGithubIssue` resolves the credential
286
+ // and hands it to the runner, so this caller gets the same forwarding
287
+ // reconcilePr's issue mode does rather than leaving `gh` to guess.
288
+ for (const call of calls) expect(call.env?.GH_TOKEN).toBe("ghs-workflow");
289
+ } finally {
290
+ vi.unstubAllEnvs();
291
+ }
292
+ });
293
+
294
+ test("a re-run on a GitHub Actions job PATCHes the issue it already owns (#2297)", async () => {
295
+ const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
296
+ const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
297
+ const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
298
+ calls.push({ cmd, env: opts?.env });
299
+ if (cmd.includes("--paginate")) return { stdout: "11\n", stderr: "" }; // marker search found issue 11
300
+ if (cmd.includes("--method PATCH")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
301
+ return { stdout: "", stderr: "" };
302
+ });
303
+
304
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
305
+ vi.stubEnv("GITHUB_API_URL", "");
306
+ // The cross-instance case (#2320): a Forgejo token that must outrank the
307
+ // job's own `github.token`, on the path that used to forward neither.
308
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
309
+ vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
310
+ try {
311
+ const r = await lexiconUpgrade({
312
+ lexicon: "gcp",
313
+ mode: "issue",
314
+ _checkPinned: checkPinned,
315
+ _gh: gh,
316
+ });
317
+
318
+ expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
319
+ expect(calls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
320
+ expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
321
+ const patch = calls.find((c) => c.cmd.includes("--method PATCH"));
322
+ expect(patch?.env?.GH_TOKEN).toBe("forgejo-cross-instance");
323
+ } finally {
324
+ vi.unstubAllEnvs();
325
+ }
241
326
  });
242
327
  });
243
328
 
@@ -260,21 +345,26 @@ describe("lexiconUpgrade breakage handling", () => {
260
345
  );
261
346
  const { gh, calls } = recordingGh();
262
347
 
263
- const r = await lexiconUpgrade({
264
- lexicon: "k8s",
265
- mode: "pull-request",
266
- _checkPinned: checkPinned,
267
- _gh: gh,
268
- });
269
-
270
- expect(r.validationOk).toBe(false);
271
- expect(r.issueUrl).toBe("https://gh/issue/1");
272
- expect(r.prUrl).toBeUndefined();
273
- // must NOT open a PR, must NOT push
274
- expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
275
- expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
276
- // the issue body carries the failure diff
277
- expect(r.summary).toContain("type error");
348
+ vi.stubEnv("GITHUB_REPOSITORY", "");
349
+ try {
350
+ const r = await lexiconUpgrade({
351
+ lexicon: "k8s",
352
+ mode: "pull-request",
353
+ _checkPinned: checkPinned,
354
+ _gh: gh,
355
+ });
356
+
357
+ expect(r.validationOk).toBe(false);
358
+ expect(r.issueUrl).toBe("https://gh/issue/1");
359
+ expect(r.prUrl).toBeUndefined();
360
+ // must NOT open a PR, must NOT push
361
+ expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
362
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
363
+ // the issue body carries the failure diff
364
+ expect(r.summary).toContain("type error");
365
+ } finally {
366
+ vi.unstubAllEnvs();
367
+ }
278
368
  });
279
369
 
280
370
  test("upstream fetch error surfaces as a non-upgrade report, never a PR", async () => {
@@ -473,20 +563,25 @@ describe("lexiconUpgrade pull-request mode: package.json version bump", () => {
473
563
  return { stdout: "", stderr: "" };
474
564
  });
475
565
 
476
- const r = await lexiconUpgrade({
477
- lexicon: "aws",
478
- mode: "pull-request",
479
- _checkRolling: checkRolling,
480
- _bumpPackageVersion: bumpFn,
481
- _gh: gh,
482
- });
483
-
484
- // Rolling drift → issue, never a baseline-only PR, a branch push, or a bump.
485
- expect(r.mode).toBe("issue");
486
- expect(r.issueUrl).toBe("https://gh/issue/9");
487
- expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
488
- expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
489
- expect(calls.some((c) => c.startsWith("git "))).toBe(false);
490
- expect(bumpFn).not.toHaveBeenCalled();
566
+ vi.stubEnv("GITHUB_REPOSITORY", "");
567
+ try {
568
+ const r = await lexiconUpgrade({
569
+ lexicon: "aws",
570
+ mode: "pull-request",
571
+ _checkRolling: checkRolling,
572
+ _bumpPackageVersion: bumpFn,
573
+ _gh: gh,
574
+ });
575
+
576
+ // Rolling drift → issue, never a baseline-only PR, a branch push, or a bump.
577
+ expect(r.mode).toBe("issue");
578
+ expect(r.issueUrl).toBe("https://gh/issue/9");
579
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
580
+ expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
581
+ expect(calls.some((c) => c.startsWith("git "))).toBe(false);
582
+ expect(bumpFn).not.toHaveBeenCalled();
583
+ } finally {
584
+ vi.unstubAllEnvs();
585
+ }
491
586
  });
492
587
  });
@@ -28,6 +28,7 @@ import { join } from "node:path";
28
28
  import type { UpgradeCheckResult, LexiconId } from "../../codegen/pinned-upgrade";
29
29
  import type { RollingUpgradeResult, RollingLexicon } from "../../codegen/rolling-upgrade";
30
30
  import { bumpForSeverity, bumpPackageJsonVersion } from "../../codegen/version-bump";
31
+ import { postOrUpdateGithubIssue } from "./reconcile";
31
32
 
32
33
  export { bumpPackageJsonVersion };
33
34
 
@@ -94,7 +95,7 @@ export interface LexiconUpgradeResult {
94
95
  semverLabel: SemverLabel | null;
95
96
  /** URL of the opened/updated PR (pull-request mode). */
96
97
  prUrl?: string;
97
- /** URL of the opened issue (issue mode). */
98
+ /** URL of the opened or edited issue (issue mode; sticky per lexicon, #2297). */
98
99
  issueUrl?: string;
99
100
  /** Full markdown summary, used as PR/issue body or printed in report mode. */
100
101
  summary: string;
@@ -119,8 +120,21 @@ export type CheckRollingFn = (opts: {
119
120
  verbose?: boolean;
120
121
  }) => Promise<RollingUpgradeResult>;
121
122
 
122
- /** Minimal shell-exec interface for gh/git invocations. */
123
- export type GhRunner = (cmd: string) => Promise<{ stdout: string; stderr: string }>;
123
+ /**
124
+ * Minimal shell-exec interface for gh/git invocations.
125
+ *
126
+ * `opts` carries the environment `postOrUpdateGithubIssue` resolves the issue
127
+ * credential into (chant #2320) — the same `GhExec` shape reconcile.ts
128
+ * declares, so the two stay one signature and this can keep being passed
129
+ * straight through. Every other call site here forwards nothing and passes
130
+ * nothing. A mock runner is free to ignore the argument; the default one
131
+ * hands it to `execAsync`, which is what makes CHANT_FORGEJO_TOKEN actually
132
+ * reach `gh` on a cross-instance Forgejo run.
133
+ */
134
+ export type GhRunner = (
135
+ cmd: string,
136
+ opts?: { env?: NodeJS.ProcessEnv },
137
+ ) => Promise<{ stdout: string; stderr: string }>;
124
138
 
125
139
  /** Applies a pinned version bump permanently (no revert). Async (core loads the lexicon's pin descriptor); a sync mock is also accepted. */
126
140
  export type ApplyBumpFn = (
@@ -282,7 +296,53 @@ function shellQuote(s: string): string {
282
296
  return `'${s.replace(/'/g, "'\\''")}'`;
283
297
  }
284
298
 
285
- const defaultGh: GhRunner = async (cmd) => execAsync(cmd);
299
+ const defaultGh: GhRunner = async (cmd, opts) => execAsync(cmd, { ...opts });
300
+
301
+ /**
302
+ * The hidden marker that makes this lexicon's upgrade-status issue findable
303
+ * across re-runs (#2297): written as the issue body's first line, matched by
304
+ * `startswith` — the same recipe reconcile.ts's `issueMarker` names for a
305
+ * reconcile finding, kept as its own function because the two activities own
306
+ * different issues. Keyed on `lexicon`, not on the validation outcome: a
307
+ * lexicon has one upgrade-status issue that flips between "validation
308
+ * failed" and "upgrade ready" as reality changes, rather than a second issue
309
+ * for the other outcome. `lexicon` is a closed set of known identifiers
310
+ * (see `SupportedLexicon`), so unlike `issueMarker` this needs no slugifying.
311
+ */
312
+ export function lexiconUpgradeIssueMarker(lexicon: SupportedLexicon): string {
313
+ return `<!-- chant-lexicon-upgrade:${lexicon} -->`;
314
+ }
315
+
316
+ /**
317
+ * Open or update this lexicon's upgrade-status issue (#2297). On a GitHub
318
+ * Actions or Forgejo Actions job (`GITHUB_REPOSITORY` set) this is
319
+ * `postOrUpdateGithubIssue`'s sticky recipe from reconcile.ts: find the OPEN
320
+ * issue already carrying this lexicon's marker, edit it, open one only when
321
+ * there is none — see that function's own doc comment for the search
322
+ * mechanism and the close decision (never closed here either). Outside any
323
+ * known CI job, falls back to `gh`'s own ambient `gh issue create`, still
324
+ * marker-prefixed so a later CI run finds and edits it instead of opening a
325
+ * second one.
326
+ */
327
+ async function postLexiconIssue(
328
+ lexicon: SupportedLexicon,
329
+ title: string,
330
+ body: string,
331
+ gh: GhRunner,
332
+ ): Promise<string> {
333
+ const marker = lexiconUpgradeIssueMarker(lexicon);
334
+ const repo = process.env.GITHUB_REPOSITORY;
335
+ if (repo) {
336
+ // `postOrUpdateGithubIssue` resolves the token itself and hands it back
337
+ // through `gh`'s second argument (#2320), so this runner has to forward
338
+ // what it is given rather than dropping it — see `GhRunner`.
339
+ return postOrUpdateGithubIssue(repo, marker, title, body, gh);
340
+ }
341
+ const { stdout } = await gh(
342
+ `gh issue create --title ${shellQuote(title)} --body ${shellQuote(`${marker}\n\n${body}`)}`,
343
+ );
344
+ return stdout.trim();
345
+ }
286
346
 
287
347
  // ── PR idempotency helpers ────────────────────────────────────────────
288
348
 
@@ -384,6 +444,12 @@ async function getRealApplyBump(): Promise<ApplyBumpFn> {
384
444
  * - Semver label: "minor" (additive) or "breaking" (changed/removed).
385
445
  * - Validation failure falls back to issue mode.
386
446
  *
447
+ * In issue mode (#2297): one OPEN issue per lexicon, found by a hidden
448
+ * marker and edited in place on every re-run — the same sticky recipe
449
+ * reconcile.ts's `issue` mode uses, via the shared `postOrUpdateGithubIssue`.
450
+ * Never closed by this activity; see that function's doc comment for the
451
+ * close decision, which applies here unchanged.
452
+ *
387
453
  * Never auto-merges or auto-publishes.
388
454
  */
389
455
  export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconUpgradeResult> {
@@ -493,16 +559,14 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
493
559
  const effectiveMode: LexiconUpgradeMode = mode === "pull-request" ? "issue" : mode;
494
560
  if (effectiveMode === "issue") {
495
561
  const title = `chore(${lexicon}): spec upgrade validation failed`;
496
- const { stdout } = await gh(
497
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
498
- );
562
+ const issueUrl = await postLexiconIssue(lexicon, title, summary, gh);
499
563
  return {
500
564
  lexicon,
501
565
  mode,
502
566
  hasUpgrade: true,
503
567
  deltaText,
504
568
  semverLabel,
505
- issueUrl: stdout.trim(),
569
+ issueUrl,
506
570
  summary,
507
571
  validationOk: false,
508
572
  };
@@ -543,16 +607,14 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
543
607
  }
544
608
 
545
609
  if (effectiveMode === "issue") {
546
- const { stdout } = await gh(
547
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
548
- );
610
+ const issueUrl = await postLexiconIssue(lexicon, title, summary, gh);
549
611
  return {
550
612
  lexicon,
551
613
  mode: effectiveMode,
552
614
  hasUpgrade: true,
553
615
  deltaText,
554
616
  semverLabel,
555
- issueUrl: stdout.trim(),
617
+ issueUrl,
556
618
  summary,
557
619
  validationOk: true,
558
620
  };
@@ -1,5 +1,6 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { computePlanDigest } from "../../lifecycle/plan-digest";
3
4
 
4
5
  const execAsync = promisify(exec);
5
6
 
@@ -39,6 +40,40 @@ export interface LifecycleDiffResult {
39
40
  * `chant lifecycle diff --live`).
40
41
  */
41
42
  drifted: boolean;
43
+ /**
44
+ * This diff's identity (#2300) — the change set `chant lifecycle diff`
45
+ * reported, hashed. `ApplyOp` hands it to its gate step's `plan`, so an
46
+ * approval binds to the change set the approver read rather than to the
47
+ * next run of the Op.
48
+ *
49
+ * Computed over {@link normalizeDiffChangeSet}'s canonical form of
50
+ * `output`, so incidental whitespace does not read as a changed plan, while
51
+ * any added, removed or reworded row does.
52
+ */
53
+ planDigest: string;
54
+ }
55
+
56
+ /**
57
+ * The change set half of a `chant lifecycle diff` render, canonicalised for
58
+ * digesting (#2300).
59
+ *
60
+ * The diff render is line-oriented: section headers and the resource rows
61
+ * under them. Two runs over an unchanged environment print the same lines, so
62
+ * the lines are the change set. Normalising is deliberately minimal — CRLF to
63
+ * LF, trailing whitespace off each line, blank lines dropped — because
64
+ * anything more aggressive would start discarding rows, and a digest that
65
+ * discards rows is a digest that approves changes nobody saw.
66
+ *
67
+ * Unlike a terraform plan there is no timestamp to strip: the diff render
68
+ * carries none. If one is ever added it has to be dropped here, or every
69
+ * approval would be stale the moment it was written.
70
+ */
71
+ export function normalizeDiffChangeSet(output: string): string[] {
72
+ return output
73
+ .replace(/\r\n/g, "\n")
74
+ .split("\n")
75
+ .map((line) => line.replace(/\s+$/, ""))
76
+ .filter((line) => line !== "");
42
77
  }
43
78
 
44
79
  /**
@@ -59,6 +94,20 @@ function detectDrift(output: string): boolean {
59
94
  return DRIFT_HEADERS.some((h) => output.includes(`${h} (`) || output.includes(`\n${h}`));
60
95
  }
61
96
 
97
+ /**
98
+ * The identity of one `chant lifecycle diff` result (#2300). The environment
99
+ * and the `--live` flag are hashed alongside the change set because they say
100
+ * what the change set is *of*: the same rows against `staging` are not an
101
+ * approval to apply against `prod`.
102
+ */
103
+ function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): string {
104
+ return computePlanDigest("lifecycle-diff", {
105
+ env: args.env,
106
+ live: args.live === true,
107
+ changeSet: normalizeDiffChangeSet(output),
108
+ });
109
+ }
110
+
62
111
  /**
63
112
  * Run `chant lifecycle diff <env>` and return the output + structured drift
64
113
  * flag. Read-only; intended for use inside watch/observation Ops.
@@ -75,11 +124,11 @@ export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSigna
75
124
  const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
76
125
  const output = `${stdout}${stderr}`.trim();
77
126
  if (output) console.log(output);
78
- return { output, exitCode: 0, drifted: detectDrift(output) };
127
+ return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
79
128
  } catch (err) {
80
129
  const e = err as { code?: number; stdout?: string; stderr?: string };
81
130
  const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
82
131
  if (output) console.error(output);
83
- return { output, exitCode: e.code ?? 1, drifted: detectDrift(output) };
132
+ return { output, exitCode: e.code ?? 1, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
84
133
  }
85
134
  }