@intentius/chant 0.60.0 → 0.62.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 (54) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/operator.d.ts +0 -18
  3. package/dist/cli/handlers/operator.d.ts.map +1 -1
  4. package/dist/codegen/json-schema.d.ts +5 -2
  5. package/dist/codegen/json-schema.d.ts.map +1 -1
  6. package/dist/graph-ir.d.ts +70 -2
  7. package/dist/graph-ir.d.ts.map +1 -1
  8. package/dist/lexicon.d.ts +51 -0
  9. package/dist/lexicon.d.ts.map +1 -1
  10. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  11. package/dist/lifecycle/git.d.ts +117 -0
  12. package/dist/lifecycle/git.d.ts.map +1 -1
  13. package/dist/lifecycle/observe.d.ts.map +1 -1
  14. package/dist/observation.d.ts +21 -1
  15. package/dist/observation.d.ts.map +1 -1
  16. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  17. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  18. package/dist/op/activities/reconcile.d.ts +225 -14
  19. package/dist/op/activities/reconcile.d.ts.map +1 -1
  20. package/dist/op/gate.d.ts.map +1 -1
  21. package/dist/op/local-executor.d.ts +17 -2
  22. package/dist/op/local-executor.d.ts.map +1 -1
  23. package/dist/op/operator.d.ts.map +1 -1
  24. package/dist/op/runtimes/local.d.ts.map +1 -1
  25. package/dist/runtime-adapter.d.ts +8 -0
  26. package/dist/runtime-adapter.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/handlers/components.ts +1 -1
  29. package/src/cli/handlers/lifecycle.ts +1 -0
  30. package/src/cli/handlers/operator.test.ts +102 -1
  31. package/src/cli/handlers/operator.ts +75 -5
  32. package/src/codegen/json-schema.test.ts +159 -0
  33. package/src/codegen/json-schema.ts +106 -8
  34. package/src/graph-ir.test.ts +63 -0
  35. package/src/graph-ir.ts +125 -8
  36. package/src/lexicon.ts +51 -0
  37. package/src/lifecycle/assert-live.ts +1 -0
  38. package/src/lifecycle/git.test.ts +49 -5
  39. package/src/lifecycle/git.ts +312 -11
  40. package/src/lifecycle/observe.ts +3 -0
  41. package/src/observation.test.ts +21 -9
  42. package/src/observation.ts +31 -2
  43. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  44. package/src/op/activities/lexicon-upgrade.ts +55 -9
  45. package/src/op/activities/reconcile.test.ts +527 -1
  46. package/src/op/activities/reconcile.ts +446 -23
  47. package/src/op/gate.test.ts +504 -0
  48. package/src/op/gate.ts +9 -1
  49. package/src/op/local-executor.test.ts +115 -0
  50. package/src/op/local-executor.ts +130 -21
  51. package/src/op/operator.test.ts +20 -0
  52. package/src/op/operator.ts +39 -1
  53. package/src/op/runtimes/local.ts +11 -0
  54. package/src/runtime-adapter.ts +17 -3
@@ -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,93 @@ 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: string[] = [];
261
+ const gh = vi.fn(async (cmd: string) => {
262
+ calls.push(cmd);
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
+ try {
271
+ const r = await lexiconUpgrade({
272
+ lexicon: "gcp",
273
+ mode: "issue",
274
+ _checkPinned: checkPinned,
275
+ _gh: gh,
276
+ });
277
+
278
+ expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
279
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
280
+ const post = calls.find((c) => c.includes("--method POST"));
281
+ expect(post).toContain("https://api.github.com/repos/acme/infra/issues");
282
+ expect(post).toContain("<!-- chant-lexicon-upgrade:gcp -->");
283
+ } finally {
284
+ vi.unstubAllEnvs();
285
+ }
286
+ });
287
+
288
+ test("a re-run on a GitHub Actions job PATCHes the issue it already owns (#2297)", async () => {
289
+ const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
290
+ const calls: string[] = [];
291
+ const gh = vi.fn(async (cmd: string) => {
292
+ calls.push(cmd);
293
+ if (cmd.includes("--paginate")) return { stdout: "11\n", stderr: "" }; // marker search found issue 11
294
+ if (cmd.includes("--method PATCH")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
295
+ return { stdout: "", stderr: "" };
296
+ });
297
+
298
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
299
+ vi.stubEnv("GITHUB_API_URL", "");
300
+ try {
301
+ const r = await lexiconUpgrade({
302
+ lexicon: "gcp",
303
+ mode: "issue",
304
+ _checkPinned: checkPinned,
305
+ _gh: gh,
306
+ });
307
+
308
+ expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
309
+ expect(calls.some((c) => c.includes("--method POST"))).toBe(false);
310
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
311
+ } finally {
312
+ vi.unstubAllEnvs();
313
+ }
241
314
  });
242
315
  });
243
316
 
@@ -260,21 +333,26 @@ describe("lexiconUpgrade breakage handling", () => {
260
333
  );
261
334
  const { gh, calls } = recordingGh();
262
335
 
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");
336
+ vi.stubEnv("GITHUB_REPOSITORY", "");
337
+ try {
338
+ const r = await lexiconUpgrade({
339
+ lexicon: "k8s",
340
+ mode: "pull-request",
341
+ _checkPinned: checkPinned,
342
+ _gh: gh,
343
+ });
344
+
345
+ expect(r.validationOk).toBe(false);
346
+ expect(r.issueUrl).toBe("https://gh/issue/1");
347
+ expect(r.prUrl).toBeUndefined();
348
+ // must NOT open a PR, must NOT push
349
+ expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
350
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
351
+ // the issue body carries the failure diff
352
+ expect(r.summary).toContain("type error");
353
+ } finally {
354
+ vi.unstubAllEnvs();
355
+ }
278
356
  });
279
357
 
280
358
  test("upstream fetch error surfaces as a non-upgrade report, never a PR", async () => {
@@ -473,20 +551,25 @@ describe("lexiconUpgrade pull-request mode: package.json version bump", () => {
473
551
  return { stdout: "", stderr: "" };
474
552
  });
475
553
 
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();
554
+ vi.stubEnv("GITHUB_REPOSITORY", "");
555
+ try {
556
+ const r = await lexiconUpgrade({
557
+ lexicon: "aws",
558
+ mode: "pull-request",
559
+ _checkRolling: checkRolling,
560
+ _bumpPackageVersion: bumpFn,
561
+ _gh: gh,
562
+ });
563
+
564
+ // Rolling drift → issue, never a baseline-only PR, a branch push, or a bump.
565
+ expect(r.mode).toBe("issue");
566
+ expect(r.issueUrl).toBe("https://gh/issue/9");
567
+ expect(calls.some((c) => c.includes("gh issue create"))).toBe(true);
568
+ expect(calls.some((c) => c.includes("gh pr create"))).toBe(false);
569
+ expect(calls.some((c) => c.startsWith("git "))).toBe(false);
570
+ expect(bumpFn).not.toHaveBeenCalled();
571
+ } finally {
572
+ vi.unstubAllEnvs();
573
+ }
491
574
  });
492
575
  });
@@ -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;
@@ -284,6 +285,49 @@ function shellQuote(s: string): string {
284
285
 
285
286
  const defaultGh: GhRunner = async (cmd) => execAsync(cmd);
286
287
 
288
+ /**
289
+ * The hidden marker that makes this lexicon's upgrade-status issue findable
290
+ * across re-runs (#2297): written as the issue body's first line, matched by
291
+ * `startswith` — the same recipe reconcile.ts's `issueMarker` names for a
292
+ * reconcile finding, kept as its own function because the two activities own
293
+ * different issues. Keyed on `lexicon`, not on the validation outcome: a
294
+ * lexicon has one upgrade-status issue that flips between "validation
295
+ * failed" and "upgrade ready" as reality changes, rather than a second issue
296
+ * for the other outcome. `lexicon` is a closed set of known identifiers
297
+ * (see `SupportedLexicon`), so unlike `issueMarker` this needs no slugifying.
298
+ */
299
+ export function lexiconUpgradeIssueMarker(lexicon: SupportedLexicon): string {
300
+ return `<!-- chant-lexicon-upgrade:${lexicon} -->`;
301
+ }
302
+
303
+ /**
304
+ * Open or update this lexicon's upgrade-status issue (#2297). On a GitHub
305
+ * Actions or Forgejo Actions job (`GITHUB_REPOSITORY` set) this is
306
+ * `postOrUpdateGithubIssue`'s sticky recipe from reconcile.ts: find the OPEN
307
+ * issue already carrying this lexicon's marker, edit it, open one only when
308
+ * there is none — see that function's own doc comment for the search
309
+ * mechanism and the close decision (never closed here either). Outside any
310
+ * known CI job, falls back to `gh`'s own ambient `gh issue create`, still
311
+ * marker-prefixed so a later CI run finds and edits it instead of opening a
312
+ * second one.
313
+ */
314
+ async function postLexiconIssue(
315
+ lexicon: SupportedLexicon,
316
+ title: string,
317
+ body: string,
318
+ gh: GhRunner,
319
+ ): Promise<string> {
320
+ const marker = lexiconUpgradeIssueMarker(lexicon);
321
+ const repo = process.env.GITHUB_REPOSITORY;
322
+ if (repo) {
323
+ return postOrUpdateGithubIssue(repo, marker, title, body, gh);
324
+ }
325
+ const { stdout } = await gh(
326
+ `gh issue create --title ${shellQuote(title)} --body ${shellQuote(`${marker}\n\n${body}`)}`,
327
+ );
328
+ return stdout.trim();
329
+ }
330
+
287
331
  // ── PR idempotency helpers ────────────────────────────────────────────
288
332
 
289
333
  /**
@@ -384,6 +428,12 @@ async function getRealApplyBump(): Promise<ApplyBumpFn> {
384
428
  * - Semver label: "minor" (additive) or "breaking" (changed/removed).
385
429
  * - Validation failure falls back to issue mode.
386
430
  *
431
+ * In issue mode (#2297): one OPEN issue per lexicon, found by a hidden
432
+ * marker and edited in place on every re-run — the same sticky recipe
433
+ * reconcile.ts's `issue` mode uses, via the shared `postOrUpdateGithubIssue`.
434
+ * Never closed by this activity; see that function's doc comment for the
435
+ * close decision, which applies here unchanged.
436
+ *
387
437
  * Never auto-merges or auto-publishes.
388
438
  */
389
439
  export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconUpgradeResult> {
@@ -493,16 +543,14 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
493
543
  const effectiveMode: LexiconUpgradeMode = mode === "pull-request" ? "issue" : mode;
494
544
  if (effectiveMode === "issue") {
495
545
  const title = `chore(${lexicon}): spec upgrade validation failed`;
496
- const { stdout } = await gh(
497
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
498
- );
546
+ const issueUrl = await postLexiconIssue(lexicon, title, summary, gh);
499
547
  return {
500
548
  lexicon,
501
549
  mode,
502
550
  hasUpgrade: true,
503
551
  deltaText,
504
552
  semverLabel,
505
- issueUrl: stdout.trim(),
553
+ issueUrl,
506
554
  summary,
507
555
  validationOk: false,
508
556
  };
@@ -543,16 +591,14 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
543
591
  }
544
592
 
545
593
  if (effectiveMode === "issue") {
546
- const { stdout } = await gh(
547
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
548
- );
594
+ const issueUrl = await postLexiconIssue(lexicon, title, summary, gh);
549
595
  return {
550
596
  lexicon,
551
597
  mode: effectiveMode,
552
598
  hasUpgrade: true,
553
599
  deltaText,
554
600
  semverLabel,
555
- issueUrl: stdout.trim(),
601
+ issueUrl,
556
602
  summary,
557
603
  validationOk: true,
558
604
  };