@intentius/chant 0.121.1 → 0.123.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 (161) hide show
  1. package/dist/cli/commands/build.d.ts +12 -0
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +16 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  6. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  7. package/dist/cli/handlers/operator.d.ts +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/override-approve.d.ts +17 -0
  10. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  11. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +4 -0
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/gate-decision.d.ts +157 -0
  16. package/dist/gate-decision.d.ts.map +1 -0
  17. package/dist/lifecycle/gate-applied.d.ts +75 -0
  18. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  19. package/dist/lifecycle/gate-audit.d.ts +145 -0
  20. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  22. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  23. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/lifecycle/override-ledger.d.ts +34 -0
  26. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  27. package/dist/lifecycle/rollback.d.ts.map +1 -1
  28. package/dist/lifecycle/work-item-id.d.ts +22 -0
  29. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  30. package/dist/lifecycle/work-lease.d.ts +2 -2
  31. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify.d.ts +202 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/op/activities/activity-contracts.d.ts +4 -4
  39. package/dist/op/activities/gh-host.d.ts +37 -0
  40. package/dist/op/activities/gh-host.d.ts.map +1 -0
  41. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  42. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  43. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  44. package/dist/op/activities/reconcile.d.ts +74 -76
  45. package/dist/op/activities/reconcile.d.ts.map +1 -1
  46. package/dist/op/gate-approval.d.ts +17 -1
  47. package/dist/op/gate-approval.d.ts.map +1 -1
  48. package/dist/op/gate-name.d.ts +8 -1
  49. package/dist/op/gate-name.d.ts.map +1 -1
  50. package/dist/op/gate-resume.d.ts +4 -2
  51. package/dist/op/gate-resume.d.ts.map +1 -1
  52. package/dist/op/gate-review.d.ts +2 -1
  53. package/dist/op/gate-review.d.ts.map +1 -1
  54. package/dist/op/gate.d.ts +43 -6
  55. package/dist/op/gate.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +8 -6
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/op/op-waves-notify.d.ts +31 -0
  59. package/dist/op/op-waves-notify.d.ts.map +1 -0
  60. package/dist/op/op-waves-run.d.ts +91 -16
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves.d.ts +99 -2
  63. package/dist/op/op-waves.d.ts.map +1 -1
  64. package/dist/op/operator.d.ts +15 -0
  65. package/dist/op/operator.d.ts.map +1 -1
  66. package/dist/op/work-lease-decl.d.ts.map +1 -1
  67. package/dist/op/work-lease-run.d.ts.map +1 -1
  68. package/dist/override-gate.d.ts +224 -0
  69. package/dist/override-gate.d.ts.map +1 -0
  70. package/dist/pr-forge.d.ts +198 -19
  71. package/dist/pr-forge.d.ts.map +1 -1
  72. package/dist/workspace/status-gates.d.ts +1 -0
  73. package/dist/workspace/status-gates.d.ts.map +1 -1
  74. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  75. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  76. package/dist/workspace/trust/policy.d.ts +2 -41
  77. package/dist/workspace/trust/policy.d.ts.map +1 -1
  78. package/dist/workspace/trust/seal.d.ts +2 -27
  79. package/dist/workspace/trust/seal.d.ts.map +1 -1
  80. package/dist/workspace/trust/trust-files.d.ts +57 -0
  81. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  82. package/dist/workspace/work-cli.d.ts.map +1 -1
  83. package/package.json +2 -1
  84. package/src/cli/commands/build.ts +37 -18
  85. package/src/cli/commands/carve-emit-state.test.ts +8 -2
  86. package/src/cli/commands/lint.test.ts +109 -1
  87. package/src/cli/commands/lint.ts +165 -1
  88. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  89. package/src/cli/handlers/gate-resume.ts +3 -2
  90. package/src/cli/handlers/operator.test.ts +12 -1
  91. package/src/cli/handlers/operator.ts +25 -13
  92. package/src/cli/handlers/override-approve.test.ts +87 -0
  93. package/src/cli/handlers/override-approve.ts +103 -0
  94. package/src/cli/handlers/run-wave.ts +10 -2
  95. package/src/cli/main.test.ts +8 -0
  96. package/src/cli/main.ts +15 -0
  97. package/src/cli/registry.ts +4 -0
  98. package/src/gate-decision-bundle.test.ts +56 -0
  99. package/src/gate-decision.test.ts +237 -0
  100. package/src/gate-decision.ts +321 -0
  101. package/src/lifecycle/gate-applied.ts +115 -0
  102. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  103. package/src/lifecycle/gate-audit.test.ts +134 -0
  104. package/src/lifecycle/gate-audit.ts +438 -0
  105. package/src/lifecycle/gate-ledger-select.ts +113 -0
  106. package/src/lifecycle/gate-ledger.test.ts +22 -0
  107. package/src/lifecycle/gate-ledger.ts +9 -104
  108. package/src/lifecycle/override-ledger.test.ts +63 -0
  109. package/src/lifecycle/override-ledger.ts +69 -0
  110. package/src/lifecycle/rollback.ts +5 -1
  111. package/src/lifecycle/work-item-id.test.ts +71 -0
  112. package/src/lifecycle/work-item-id.ts +35 -0
  113. package/src/lifecycle/work-lease.ts +4 -5
  114. package/src/lint/post-synth.test.ts +29 -0
  115. package/src/lint/post-synth.ts +21 -2
  116. package/src/notify-bundle.test.ts +50 -0
  117. package/src/notify-verify.test.ts +59 -0
  118. package/src/notify-verify.ts +93 -0
  119. package/src/notify.schema.json +72 -0
  120. package/src/notify.test.ts +279 -0
  121. package/src/notify.ts +398 -0
  122. package/src/op/activities/gh-host.test.ts +130 -0
  123. package/src/op/activities/gh-host.ts +79 -0
  124. package/src/op/activities/lexicon-upgrade.ts +22 -1
  125. package/src/op/activities/propose-upgrade.ts +5 -1
  126. package/src/op/activities/reconcile.test.ts +284 -280
  127. package/src/op/activities/reconcile.ts +128 -131
  128. package/src/op/gate-approval.test.ts +175 -7
  129. package/src/op/gate-approval.ts +27 -1
  130. package/src/op/gate-name.ts +10 -2
  131. package/src/op/gate-resume.ts +6 -3
  132. package/src/op/gate-review.ts +4 -7
  133. package/src/op/gate.ts +117 -16
  134. package/src/op/index.ts +20 -5
  135. package/src/op/op-waves-notify.test.ts +127 -0
  136. package/src/op/op-waves-notify.ts +101 -0
  137. package/src/op/op-waves-run.ts +527 -104
  138. package/src/op/op-waves.test.ts +383 -3
  139. package/src/op/op-waves.ts +187 -6
  140. package/src/op/operator.test.ts +29 -0
  141. package/src/op/operator.ts +27 -2
  142. package/src/op/run-context.test.ts +2 -2
  143. package/src/op/work-lease-decl.ts +4 -4
  144. package/src/op/work-lease-run.test.ts +5 -0
  145. package/src/op/work-lease-run.ts +4 -3
  146. package/src/override-gate-bundle.test.ts +47 -0
  147. package/src/override-gate.test.ts +239 -0
  148. package/src/override-gate.ts +395 -0
  149. package/src/pr-forge-bundle.test.ts +40 -0
  150. package/src/pr-forge.test.ts +303 -0
  151. package/src/pr-forge.ts +502 -47
  152. package/src/workspace/records-write-contract.test.ts +3 -2
  153. package/src/workspace/status-gates.ts +7 -4
  154. package/src/workspace/trust/gate-seal.test.ts +156 -0
  155. package/src/workspace/trust/gate-seal.ts +254 -0
  156. package/src/workspace/trust/policy.ts +10 -165
  157. package/src/workspace/trust/seal.ts +19 -34
  158. package/src/workspace/trust/trust-files.test.ts +50 -0
  159. package/src/workspace/trust/trust-files.ts +245 -0
  160. package/src/workspace/work-cli.ts +5 -3
  161. package/src/workspace/work-history.test.ts +7 -0
@@ -42,13 +42,21 @@
42
42
  * `"resolution"` is the default reading).
43
43
  */
44
44
  import { isNeverOverMcpGateOp } from "../op/gate-name";
45
- import { samePlanDigest } from "./plan-digest";
46
45
  import { sortedJsonReplacer } from "../utils";
47
46
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
48
47
  import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
49
48
  import type { GateRunLocator } from "../op/gate-resume";
50
49
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
51
50
 
51
+ // The selection rules a gate decides by live apart, so a bundle can import them without git (#3763).
52
+ export {
53
+ isPendingGateExpired,
54
+ latestPendingGate,
55
+ latestResolutionForPlan,
56
+ latestResolutionSince,
57
+ type PlanBoundResolution,
58
+ } from "./gate-ledger-select";
59
+
52
60
  const DIR = "_gates";
53
61
 
54
62
  /** The directory on the `chant/lifecycle` branch that holds gate ledgers, under a member's prefix when it has one. */
@@ -337,11 +345,6 @@ export type GateLedgerRecord = GateResolutionRecord | PendingGateRecord;
337
345
  /** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
338
346
  export const DEFAULT_GATE_EXPIRY = "48h";
339
347
 
340
- /** Whether a pending fact has aged out of relevance at `nowIso`. */
341
- export function isPendingGateExpired(record: PendingGateRecord, nowIso: string): boolean {
342
- return new Date(record.expiresAt).getTime() <= new Date(nowIso).getTime();
343
- }
344
-
345
348
  function filename(op: string): string {
346
349
  return `${op}.jsonl`;
347
350
  }
@@ -504,101 +507,3 @@ export async function readGateResolutions(
504
507
  const { resolutions, malformed } = await readGateLedger(op, opts);
505
508
  return { records: resolutions, malformed };
506
509
  }
507
-
508
- /** The most recent pending fact for `gate`, expired or not — the anchor {@link latestResolutionSince} measures a resolution against. `undefined` when the gate has never been recorded pending. */
509
- export function latestPendingGate(
510
- records: PendingGateRecord[],
511
- gate: string,
512
- ): PendingGateRecord | undefined {
513
- let latest: PendingGateRecord | undefined;
514
- for (const r of records) {
515
- if (r.gate !== gate) continue;
516
- if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
517
- }
518
- return latest;
519
- }
520
-
521
- /** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
522
- export function latestResolutionSince(
523
- records: GateResolutionRecord[],
524
- gate: string,
525
- sinceIso: string,
526
- ): GateResolutionRecord | undefined {
527
- const since = new Date(sinceIso).getTime();
528
- let latest: GateResolutionRecord | undefined;
529
- for (const r of records) {
530
- if (r.gate !== gate) continue;
531
- if (new Date(r.timestamp).getTime() < since) continue;
532
- if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
533
- }
534
- return latest;
535
- }
536
-
537
- /** What {@link latestResolutionForPlan} found for the plan a run is holding. */
538
- export interface PlanBoundResolution {
539
- /** The resolution that answers this gate for this plan. Absent when nothing does. */
540
- resolution?: GateResolutionRecord;
541
- /**
542
- * Present instead of {@link PlanBoundResolution.resolution} when a
543
- * resolution stands for this gate but for a different plan — the newest
544
- * such record, so a refusal can name who approved what and when. Its
545
- * `planDigest` is `undefined` for a record written before #2300.
546
- */
547
- mismatched?: GateResolutionRecord;
548
- }
549
-
550
- /**
551
- * The resolution that answers `gate` for the plan `planDigest` identifies
552
- * (#2300).
553
- *
554
- * The criterion is the digest; recency is only the tiebreak between several
555
- * resolutions that all match it. That inversion is the whole point of the
556
- * issue: {@link latestResolutionSince} asks "is there a newer approval",
557
- * which a run answers yes to no matter what has changed since, and this asks
558
- * "is there an approval of *this*".
559
- *
560
- * - `planDigest` `undefined` — the gate binds no plan (an Op `gate` step
561
- * authored with no `plan`). Falls straight through to
562
- * {@link latestResolutionSince}: gates that never claimed to bind a plan
563
- * behave exactly as they did before #2300.
564
- * - A resolution whose `planDigest` equals `planDigest` answers the gate.
565
- * - A resolution for a different plan does not, and comes back as
566
- * `mismatched` so the caller can name both digests.
567
- * - A resolution with no `planDigest` at all — every record written before
568
- * #2300 — does not either, and comes back as `mismatched` with an absent
569
- * `planDigest`. This is the safe reading of the migration: such a record
570
- * proves someone approved something, and nothing about what. Accepting it
571
- * once would silently apply the very change this check exists to catch, on
572
- * exactly the estates that have been running longest. The cost is one
573
- * further `chant approve` per gate after the upgrade, which the refusal
574
- * says out loud.
575
- */
576
- export function latestResolutionForPlan(
577
- records: GateResolutionRecord[],
578
- gate: string,
579
- sinceIso: string,
580
- planDigest: string | undefined,
581
- ): PlanBoundResolution {
582
- if (planDigest === undefined) {
583
- const resolution = latestResolutionSince(records, gate, sinceIso);
584
- return resolution ? { resolution } : {};
585
- }
586
-
587
- const since = new Date(sinceIso).getTime();
588
- let matched: GateResolutionRecord | undefined;
589
- let mismatched: GateResolutionRecord | undefined;
590
- for (const r of records) {
591
- if (r.gate !== gate) continue;
592
- if (new Date(r.timestamp).getTime() < since) continue;
593
- const newest = (best: GateResolutionRecord | undefined) =>
594
- !best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
595
- if (samePlanDigest(r.planDigest, planDigest)) {
596
- if (newest(matched)) matched = r;
597
- } else if (newest(mismatched)) {
598
- mismatched = r;
599
- }
600
- }
601
-
602
- if (matched) return { resolution: matched };
603
- return mismatched ? { mismatched } : {};
604
- }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * #3760: the override gate's lines on chant/lifecycle. A denial is an
3
+ * ordinary pending fact on `_gates/policy-override.jsonl`, and applied
4
+ * records sit beside it where no gate reader lists them.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { withTestDir } from "@intentius/chant-test-utils";
8
+ import { spawnSync } from "node:child_process";
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { appendGateResolution, readGateLedger } from "./gate-ledger";
12
+ import { readOverrideLedger, recordOverrideDenial, recordOverridesApplied } from "./override-ledger";
13
+ import { appliedOverride, decideOverride, overrideDigest, OVERRIDE_OP } from "../override-gate";
14
+ import { readBlobFromPath } from "./git";
15
+
16
+ function initRepo(dir: string): void {
17
+ const git = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
18
+ git(["init", "-q", "-b", "main"]);
19
+ git(["config", "user.email", "test@chant.dev"]);
20
+ git(["config", "user.name", "Test"]);
21
+ writeFileSync(join(dir, "README.md"), "fixture\n");
22
+ git(["add", "README.md"]);
23
+ git(["commit", "-q", "-m", "init"]);
24
+ }
25
+
26
+ const PLAN = `sha256:${"a".repeat(64)}`;
27
+
28
+ describe("lifecycle/override-ledger", () => {
29
+ test("a denial, its override and the applied record round-trip, and the override is spent", async () => {
30
+ await withTestDir(async (dir) => {
31
+ initRepo(dir);
32
+ const opts = { cwd: dir };
33
+ const { record: denial } = await recordOverrideDenial(
34
+ { subject: "prod", planDigest: PLAN, rules: ["no-drop"], timestamp: "2026-10-11T10:00:00.000Z", expiresAt: "2026-10-13T00:00:00.000Z" },
35
+ opts,
36
+ );
37
+ expect(denial).toMatchObject({ op: OVERRIDE_OP, gate: "prod", plan: PLAN, rules: ["no-drop"], planDigest: overrideDigest("prod", PLAN, ["no-drop"]), neverOverMcp: true });
38
+ await appendGateResolution(
39
+ { op: OVERRIDE_OP, gate: "prod", resolvedBy: "github:alice", timestamp: "2026-10-11T11:00:00.000Z", planDigest: denial.planDigest, note: "the column is unused since 0.6" },
40
+ opts,
41
+ );
42
+
43
+ const ledger = await readOverrideLedger(opts);
44
+ const input = { ledger, applied: ledger.applied, subject: "prod", planDigest: PLAN, rules: ["no-drop"], overriders: ["github:alice"], now: "2026-10-11T12:00:00.000Z" };
45
+ const decided = decideOverride(input);
46
+ expect(decided.status).toBe("overridden");
47
+ if (decided.status !== "overridden") return;
48
+
49
+ await recordOverridesApplied([appliedOverride(decided.override, { timestamp: "2026-10-11T12:00:00.000Z" })], opts);
50
+ await recordOverridesApplied([], opts);
51
+ const after = await readOverrideLedger(opts);
52
+ expect(after.applied).toHaveLength(1);
53
+ expect(decideOverride({ ...input, ledger: after, applied: after.applied }).status).toBe("none");
54
+
55
+ // The gate ledger reads the denial and the override, and nothing in the applied file.
56
+ const gates = await readGateLedger(OVERRIDE_OP, opts);
57
+ expect(gates).toMatchObject({ malformed: 0 });
58
+ expect(gates.pending).toHaveLength(1);
59
+ expect(gates.resolutions).toHaveLength(1);
60
+ expect(await readBlobFromPath(`_gates/${OVERRIDE_OP}`, "applied.jsonl", opts)).toContain('"kind":"applied"');
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The override gate's lines on `chant/lifecycle` (#3760): read the denials,
3
+ * overrides and applied records, record a denial, and record that a run
4
+ * applied under an override, which spends it. The decision is
5
+ * `../override-gate.ts`'s, which bundles without this module's git code.
6
+ *
7
+ * Denials and overrides share `_gates/policy-override.jsonl` with the
8
+ * ordinary gate line shapes, so `chant approve`, `chant operator status` and
9
+ * the audit reader read them as they read any gate. Applied records live in
10
+ * `_gates/policy-override/applied.jsonl`, which no gate reader lists.
11
+ */
12
+
13
+ import { sortedJsonReplacer } from "../utils";
14
+ import { appendPendingGate, readGateLedger, type GateResolutionRecord, type PendingGateRecord } from "./gate-ledger";
15
+ import { readBlobBySha, readBlobFromPath, readPathSha, RefCASConflictError, writeBlobToPath } from "./git";
16
+ import {
17
+ OVERRIDE_OP,
18
+ parseAppliedOverrides,
19
+ overridePending,
20
+ type AppliedOverrideRecord,
21
+ type OverridePendingInput,
22
+ type OverridePendingRecord,
23
+ } from "../override-gate";
24
+
25
+ const APPLIED_DIR = `_gates/${OVERRIDE_OP}`;
26
+ const APPLIED_FILE = "applied.jsonl";
27
+ const APPEND_RETRY_ATTEMPTS = 5;
28
+
29
+ export interface OverrideLedger {
30
+ resolutions: GateResolutionRecord[];
31
+ pending: PendingGateRecord[];
32
+ applied: AppliedOverrideRecord[];
33
+ }
34
+
35
+ /** The override ledger and its applied records, oldest first. Empty when nothing is recorded. */
36
+ export async function readOverrideLedger(opts?: { cwd?: string }): Promise<OverrideLedger> {
37
+ const { resolutions, pending } = await readGateLedger(OVERRIDE_OP, opts);
38
+ const text = await readBlobFromPath(APPLIED_DIR, APPLIED_FILE, opts);
39
+ return { resolutions, pending, applied: text ? parseAppliedOverrides(text) : [] };
40
+ }
41
+
42
+ /** Append a denial's pending fact ({@link overridePending}). Does not push. */
43
+ export async function recordOverrideDenial(input: OverridePendingInput, opts?: { cwd?: string }): Promise<{ commit: string; record: OverridePendingRecord }> {
44
+ const { version: _v, kind: _k, ...rest } = overridePending(input);
45
+ const { commit, record } = await appendPendingGate(rest, opts);
46
+ return { commit, record: record as OverridePendingRecord };
47
+ }
48
+
49
+ /** Append applied records, spending the overrides they name. Write them before applying; a write that fails should stop the run. Does not push. */
50
+ export async function recordOverridesApplied(records: readonly AppliedOverrideRecord[], opts?: { cwd?: string }): Promise<string | undefined> {
51
+ if (records.length === 0) return undefined;
52
+ const lines = records.map((r) => JSON.stringify(r, sortedJsonReplacer)).join("\n");
53
+ let lastErr: unknown;
54
+ for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
55
+ try {
56
+ const priorSha = await readPathSha(APPLIED_DIR, APPLIED_FILE, opts);
57
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
58
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${lines}` : lines;
59
+ return await writeBlobToPath(APPLIED_DIR, APPLIED_FILE, content, `Applied under override: ${records.map((r) => r.gate).join(", ")}`, {
60
+ ...opts,
61
+ expectPriorPathSha: priorSha,
62
+ });
63
+ } catch (err) {
64
+ if (!(err instanceof RefCASConflictError)) throw err;
65
+ lastErr = err;
66
+ }
67
+ }
68
+ throw lastErr;
69
+ }
@@ -12,6 +12,7 @@ import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
+ import { ghHostEnv } from "../op/activities/gh-host";
15
16
 
16
17
  const execFileAsync = promisify(execFile);
17
18
 
@@ -106,10 +107,13 @@ export async function rollbackToRevision(opts: {
106
107
 
107
108
  await git(["commit", "-m", rollbackTitle(env, ref)], wt);
108
109
  await git(["push", "-u", "origin", branch], wt);
110
+ // `gh pr create` names no host: tell it the one `origin` is on, or the
111
+ // CI job's forge, so a GitHub Enterprise Server or Forgejo checkout is not
112
+ // sent to github.com (#3780).
109
113
  const { stdout } = await execFileAsync(
110
114
  "gh",
111
115
  ["pr", "create", "--title", rollbackTitle(env, ref), "--body", rollbackBody(env, ref, sourceDir), "--head", branch],
112
- { cwd: wt },
116
+ { cwd: wt, env: await ghHostEnv(process.env, wt) },
113
117
  );
114
118
  return { noop: false, branch, prUrl: stdout.trim() };
115
119
  } finally {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * #2815 — a work item id has to be a ref path segment git accepts. The cases
3
+ * are checked against `git check-ref-format` itself, in every ref the id lands in.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { spawnSync } from "node:child_process";
7
+ import { isWorkItemId, workItemIdProblem } from "./work-item-id";
8
+
9
+ function gitAccepts(ref: string): boolean {
10
+ return spawnSync("git", ["check-ref-format", ref]).status === 0;
11
+ }
12
+
13
+ const REF_SHAPES = [
14
+ (id: string) => `refs/chant/lease/work/${id}`,
15
+ (id: string) => `refs/chant/lease/_members/api/work/${id}`,
16
+ (id: string) => `refs/chant/kept/api/${id}/0123abcd`,
17
+ (id: string) => `refs/heads/chant/work/api/${id}`,
18
+ ];
19
+
20
+ const ACCEPTED = ["W-001", "upgrade-1", "a", "0", "a.b", "a_b", "v1.2.3", "x.lock2", "lock", "a.lockb", "A".repeat(100)];
21
+ const REFUSED = [
22
+ "upgrade-.",
23
+ ".",
24
+ "x.",
25
+ "x.lock",
26
+ "x.LOCK",
27
+ "x.Lock",
28
+ "a..b",
29
+ "..",
30
+ ".hidden",
31
+ "-lead",
32
+ "_lead",
33
+ "a/b",
34
+ "a b",
35
+ "a~b",
36
+ "a^b",
37
+ "a:b",
38
+ "a?b",
39
+ "a*b",
40
+ "a[b",
41
+ "a\\b",
42
+ "a@{b",
43
+ "@",
44
+ "",
45
+ "a\tb",
46
+ "A".repeat(101),
47
+ ];
48
+
49
+ describe("work item id (#2815)", () => {
50
+ test.each(ACCEPTED)("%s keys a lease, and git takes it in every ref it lands in", (id) => {
51
+ expect(workItemIdProblem(id)).toBeUndefined();
52
+ expect(isWorkItemId(id)).toBe(true);
53
+ for (const shape of REF_SHAPES) expect(gitAccepts(shape(id)), shape(id)).toBe(true);
54
+ });
55
+
56
+ test.each(REFUSED)("%j is refused", (id) => {
57
+ expect(workItemIdProblem(id)).toMatch(/can't key a work lease/);
58
+ expect(isWorkItemId(id)).toBe(false);
59
+ });
60
+
61
+ test("everything git refuses in the lease ref is refused here", () => {
62
+ for (const id of REFUSED.filter((i) => i !== "" && !i.includes("\t") && i.length <= 100)) {
63
+ if (!gitAccepts(`refs/chant/lease/work/${id}`)) expect(isWorkItemId(id), id).toBe(false);
64
+ }
65
+ });
66
+
67
+ test("a non-string is refused", () => {
68
+ expect(workItemIdProblem(undefined)).toMatch(/not a string/);
69
+ expect(isWorkItemId(42)).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What a work item id has to be to key a work lease (#2815). The id lands in
3
+ * refs (`refs/chant/lease/work/<id>`, `refs/chant/kept/.../<item>/<token>`,
4
+ * the work branch `chant/work/.../<id>`) and in a file name
5
+ * (`_leases/<id>.jsonl`), so it has to be a valid ref path segment under
6
+ * `git check-ref-format`, and git's check is stricter than a character class.
7
+ *
8
+ * Kept free of imports so `Op()`, `declareSteward` and the CLI can all call it
9
+ * without loading the lease machinery.
10
+ */
11
+
12
+ /** The characters an id may use: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 100. */
13
+ export const WORK_ITEM_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
14
+
15
+ /**
16
+ * Why `id` can't key a work lease, or `undefined` when it can. On top of
17
+ * {@link WORK_ITEM_ID_PATTERN}, git refuses a ref component that has `..`,
18
+ * ends in `.`, or ends in `.lock` (git's own lock file for the ref; matched
19
+ * case-insensitively here since a case-folding filesystem would collide).
20
+ */
21
+ export function workItemIdProblem(id: unknown): string | undefined {
22
+ if (typeof id !== "string") return `${JSON.stringify(id)} is not a string`;
23
+ if (!WORK_ITEM_ID_PATTERN.test(id)) {
24
+ return `${JSON.stringify(id)} can't key a work lease: use letters, digits, ".", "_" and "-", starting with a letter or digit, at most 100 characters`;
25
+ }
26
+ if (id.includes("..")) return `${JSON.stringify(id)} can't key a work lease: git refuses ".." in a ref name`;
27
+ if (id.endsWith(".")) return `${JSON.stringify(id)} can't key a work lease: git refuses a ref name ending in "."`;
28
+ if (id.toLowerCase().endsWith(".lock")) return `${JSON.stringify(id)} can't key a work lease: git refuses a ref name ending in ".lock"`;
29
+ return undefined;
30
+ }
31
+
32
+ /** Whether `id` can key a work lease. */
33
+ export function isWorkItemId(id: unknown): id is string {
34
+ return workItemIdProblem(id) === undefined;
35
+ }
@@ -50,6 +50,7 @@ import {
50
50
  import { dirname } from "node:path";
51
51
  import type { ReasonCode } from "../workspace/reason-codes";
52
52
  import { resolveMemberLedger } from "./member-ledger";
53
+ import { WORK_ITEM_ID_PATTERN, isWorkItemId, workItemIdProblem } from "./work-item-id";
53
54
 
54
55
  /** The key prefix of a work lease under the operator lease's ref namespace. */
55
56
  export const WORK_LEASE_KEY_PREFIX = "work/";
@@ -60,8 +61,7 @@ export const LEASES_DIR = "_leases";
60
61
  /** How long a claim lasts unless renewed: chud's default, which its dispatcher renews at a third of. */
61
62
  export const DEFAULT_WORK_LEASE_TTL_MS = 10 * 60_000;
62
63
 
63
- /** A work item id a lease can be keyed by: one ref path segment and one file name. */
64
- export const WORK_ITEM_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
64
+ export { WORK_ITEM_ID_PATTERN, isWorkItemId, workItemIdProblem };
65
65
 
66
66
  /** How long a claim waits out another process's lock on the ref before calling it stale. */
67
67
  const LOCK_WAIT_MS = 2_000;
@@ -245,9 +245,8 @@ function toWorkLease(item: string, r: LeaseRecord): WorkLease {
245
245
  }
246
246
 
247
247
  function checkId(id: string): void {
248
- if (!WORK_ITEM_ID_PATTERN.test(id) || id.includes("..")) {
249
- throw new Error(`${JSON.stringify(id)} can't key a work lease: use letters, digits, ".", "_" and "-", starting with a letter or digit`);
250
- }
248
+ const problem = workItemIdProblem(id);
249
+ if (problem) throw new Error(problem);
251
250
  }
252
251
 
253
252
  const REASONS: Record<string, WorkLeaseRefusal> = {
@@ -181,6 +181,35 @@ describe("telemetry attribution (#3375)", () => {
181
181
  });
182
182
  });
183
183
 
184
+ describe("project directory (#3751)", () => {
185
+ const readsDir: PostSynthCheck = {
186
+ id: "PROJECT_DIR",
187
+ description: "reports the project directory the context carries",
188
+ check(ctx) {
189
+ return [{ checkId: "PROJECT_DIR", severity: "warning", message: JSON.stringify(ctx.projectDir ?? null) }];
190
+ },
191
+ };
192
+
193
+ test("the build's config directory is threaded into the context", () => {
194
+ const diags = runPostSynthChecks([readsDir], createBuildResult(), undefined, { projectDir: "/work/app" });
195
+ expect(diags[0].message).toBe('"/work/app"');
196
+ });
197
+
198
+ test("the loaded config is threaded too", () => {
199
+ const readsConfig: PostSynthCheck = {
200
+ id: "PROJECT_CONFIG",
201
+ description: "reports the config the context carries",
202
+ check: (ctx) => [{ checkId: "PROJECT_CONFIG", severity: "warning", message: JSON.stringify(ctx.projectConfig ?? null) }],
203
+ };
204
+ expect(runPostSynthChecks([readsConfig], createBuildResult(), undefined, { projectConfig: { sourceDir: "src" } })[0].message).toBe('{"sourceDir":"src"}');
205
+ expect(runPostSynthChecks([readsConfig], createBuildResult())[0].message).toBe("null");
206
+ });
207
+
208
+ test("a hand-built run leaves the field undefined", () => {
209
+ expect(runPostSynthChecks([readsDir], createBuildResult())[0].message).toBe("null");
210
+ });
211
+ });
212
+
184
213
  describe("isPostSynthCheck", () => {
185
214
  test("accepts a well-formed check and rejects others", async () => {
186
215
  const { isPostSynthCheck } = await import("./post-synth");
@@ -63,6 +63,21 @@ export interface PostSynthContext {
63
63
  * hand-built context. OTEL118 runs only when it is set.
64
64
  */
65
65
  readonly telemetry?: TelemetryAttribution;
66
+ /**
67
+ * The directory of the project config the build loaded (#3751): where a
68
+ * relative `sourceDir` resolves, and so where a check looks for a file the
69
+ * project keeps beside its source (the sql lexicon's `sql-types.d.ts`).
70
+ * Set by `chant build` and `chant lint`; absent from a hand-built context,
71
+ * where a check that reads the disk stays silent.
72
+ */
73
+ readonly projectDir?: string;
74
+ /**
75
+ * The project config the build loaded (#3751), as source lint rules get it
76
+ * in `LintContext.projectConfig`. A check that renders output again, as the
77
+ * sql lexicon's does for `sql-types.d.ts`, needs the settings the build ran
78
+ * with. Absent from a hand-built context.
79
+ */
80
+ readonly projectConfig?: Record<string, unknown>;
66
81
  /** Raw build result object */
67
82
  buildResult: {
68
83
  outputs: Map<string, string | SerializerResult>;
@@ -217,13 +232,15 @@ export function isPostSynthCheck(value: unknown): value is PostSynthCheck {
217
232
  * into the context so a check can branch on the current environment/stack, and
218
233
  * `opts.activityContracts` (chant #2101) carries the build's cross-lexicon
219
234
  * activity-contract map to the checks that validate Op steps against it.
220
- * `opts.telemetry` is the build's resolved telemetry attribution.
235
+ * `opts.telemetry` is the build's resolved telemetry attribution, and
236
+ * `opts.projectDir` and `opts.projectConfig` the config the build loaded and
237
+ * its directory.
221
238
  */
222
239
  export function runPostSynthChecks(
223
240
  checks: PostSynthCheck[],
224
241
  buildResult: PostSynthContext["buildResult"],
225
242
  env?: string,
226
- opts?: { activityContracts?: ReadonlyMap<string, ActivityContract>; telemetry?: TelemetryAttribution },
243
+ opts?: { activityContracts?: ReadonlyMap<string, ActivityContract>; telemetry?: TelemetryAttribution; projectDir?: string; projectConfig?: Record<string, unknown> },
227
244
  ): PostSynthDiagnostic[] {
228
245
  const getDocs = createDocsAccessor(buildResult.outputs);
229
246
  const ctx: PostSynthContext = {
@@ -233,6 +250,8 @@ export function runPostSynthChecks(
233
250
  buildResult,
234
251
  ...(opts?.activityContracts ? { activityContracts: opts.activityContracts } : {}),
235
252
  ...(opts?.telemetry ? { telemetry: opts.telemetry } : {}),
253
+ ...(opts?.projectDir ? { projectDir: opts.projectDir } : {}),
254
+ ...(opts?.projectConfig ? { projectConfig: opts.projectConfig } : {}),
236
255
  get docs(): OutputDoc[] {
237
256
  return getDocs();
238
257
  },
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The notify modules bundle small (#3761, ruling #3421).
3
+ *
4
+ * terragucci runs its stages in customer CI as one bundled file with no
5
+ * TypeScript toolchain, and a webhook receiver imports the verifier alone.
6
+ * So `@intentius/chant/notify` must reach nothing but `node:crypto` and the
7
+ * verifier, and `@intentius/chant/notify-verify` nothing but `node:crypto`.
8
+ * This bundles both the way a product and a receiver would and reads
9
+ * esbuild's own list of what went in.
10
+ */
11
+
12
+ import { join } from "node:path";
13
+ import { build } from "esbuild";
14
+ import { describe, expect, test } from "vitest";
15
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
16
+
17
+ async function bundle(entry: string): Promise<{ inputs: string[]; bytes: number; imports: string[] }> {
18
+ const result = await build({
19
+ entryPoints: [join(REPO, "packages", "core", "src", entry)],
20
+ bundle: true,
21
+ platform: "node",
22
+ format: "esm",
23
+ write: false,
24
+ metafile: true,
25
+ outdir: "out",
26
+ logLevel: "silent",
27
+ });
28
+ const imports = Object.values(result.metafile.outputs).flatMap((o) => o.imports.map((i) => i.path));
29
+ return {
30
+ inputs: Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort(),
31
+ bytes: result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0),
32
+ imports: [...new Set(imports)].sort(),
33
+ };
34
+ }
35
+
36
+ describe("the notify bundle", () => {
37
+ test("the sender holds itself and the verifier, and imports only node:crypto", async () => {
38
+ const { inputs, bytes, imports } = await bundle("notify.ts");
39
+ expect(inputs).toEqual(["packages/core/src/notify-verify.ts", "packages/core/src/notify.ts"]);
40
+ expect(imports).toEqual(["node:crypto"]);
41
+ expect(bytes).toBeLessThan(16 * 1024);
42
+ });
43
+
44
+ test("the verifier holds itself alone, and imports only node:crypto", async () => {
45
+ const { inputs, bytes, imports } = await bundle("notify-verify.ts");
46
+ expect(inputs).toEqual(["packages/core/src/notify-verify.ts"]);
47
+ expect(imports).toEqual(["node:crypto"]);
48
+ expect(bytes).toBeLessThan(4 * 1024);
49
+ });
50
+ });
@@ -0,0 +1,59 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, test } from "vitest";
3
+ import { DEFAULT_TOLERANCE_SECONDS, signWebhook, verifyWebhook, webhookHeaderNames } from "./notify-verify";
4
+
5
+ const KEY = "s3cret";
6
+ const NOW = 1_760_000_000;
7
+ const BODY = JSON.stringify({ schema: "chant.notify/v1", event: "waiting" });
8
+
9
+ function headers(body = BODY, ts: number | string = NOW, key = KEY, prefix = "x-chant"): Record<string, string> {
10
+ return { [`${prefix}-timestamp`]: String(ts), [`${prefix}-signature`]: signWebhook(key, ts, body) };
11
+ }
12
+
13
+ describe("signWebhook", () => {
14
+ test("is HMAC-SHA256 over <timestamp>.<body>, as sha256=<hex>", () => {
15
+ const hex = createHmac("sha256", KEY).update(`${NOW}.${BODY}`).digest("hex");
16
+ expect(signWebhook(KEY, NOW, BODY)).toBe(`sha256=${hex}`);
17
+ });
18
+ });
19
+
20
+ describe("verifyWebhook", () => {
21
+ test("accepts a fresh delivery signed with the key, and returns the event", () => {
22
+ expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + 10 })).toEqual({ ok: true, event: JSON.parse(BODY) });
23
+ });
24
+
25
+ test("matches header names without regard to case, and reads a Headers object and a byte body", () => {
26
+ const upper = Object.fromEntries(Object.entries(headers()).map(([k, v]) => [k.toUpperCase(), v]));
27
+ expect(verifyWebhook({ body: BODY, headers: upper, key: KEY, now: NOW }).ok).toBe(true);
28
+ expect(verifyWebhook({ body: new TextEncoder().encode(BODY), headers: new Headers(headers()), key: KEY, now: NOW }).ok).toBe(true);
29
+ });
30
+
31
+ test("refuses another key, a changed body, a moved timestamp and a missing key", () => {
32
+ expect(verifyWebhook({ body: BODY, headers: headers(BODY, NOW, "other"), key: KEY, now: NOW })).toEqual({ ok: false, reason: "the signature does not match" });
33
+ expect(verifyWebhook({ body: BODY + " ", headers: headers(), key: KEY, now: NOW }).ok).toBe(false);
34
+ expect(verifyWebhook({ body: BODY, headers: { ...headers(), "x-chant-timestamp": String(NOW + 1) }, key: KEY, now: NOW }).ok).toBe(false);
35
+ expect(verifyWebhook({ body: BODY, headers: headers(), key: "", now: NOW })).toEqual({ ok: false, reason: "no key to verify with" });
36
+ expect(verifyWebhook({ body: BODY, headers: headers(), key: undefined, now: NOW }).ok).toBe(false);
37
+ });
38
+
39
+ test("refuses a replay older than the tolerance, and one from too far ahead", () => {
40
+ const late = verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + DEFAULT_TOLERANCE_SECONDS + 1 });
41
+ expect(late).toEqual({ ok: false, reason: `the timestamp is more than ${DEFAULT_TOLERANCE_SECONDS}s from now` });
42
+ expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW - 61, toleranceSeconds: 60 }).ok).toBe(false);
43
+ expect(verifyWebhook({ body: BODY, headers: headers(), key: KEY, now: NOW + DEFAULT_TOLERANCE_SECONDS }).ok).toBe(true);
44
+ });
45
+
46
+ test("refuses missing or malformed headers, and a body that is not JSON", () => {
47
+ expect(verifyWebhook({ body: BODY, headers: {}, key: KEY, now: NOW })).toEqual({ ok: false, reason: "no x-chant-signature header" });
48
+ expect(verifyWebhook({ body: BODY, headers: { "x-chant-signature": "sha256=00" }, key: KEY, now: NOW }).ok).toBe(false);
49
+ expect(verifyWebhook({ body: BODY, headers: headers(BODY, "soon"), key: KEY, now: NOW }).ok).toBe(false);
50
+ expect(verifyWebhook({ body: "not json", headers: headers("not json"), key: KEY, now: NOW })).toEqual({ ok: false, reason: "the body is not JSON" });
51
+ });
52
+
53
+ test("reads a product's own header prefix", () => {
54
+ expect(webhookHeaderNames("X-Yodel")).toEqual({ signature: "x-yodel-signature", timestamp: "x-yodel-timestamp", event: "x-yodel-event", delivery: "x-yodel-delivery" });
55
+ const h = headers(BODY, NOW, KEY, "x-yodel");
56
+ expect(verifyWebhook({ body: BODY, headers: h, key: KEY, now: NOW, prefix: "x-yodel" }).ok).toBe(true);
57
+ expect(verifyWebhook({ body: BODY, headers: h, key: KEY, now: NOW }).ok).toBe(false);
58
+ });
59
+ });