@intentius/chant 0.124.10 → 0.124.12

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 (67) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +85 -10
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/gate-resume-run.d.ts +87 -0
  5. package/dist/gate-resume-run.d.ts.map +1 -0
  6. package/dist/lifecycle/gate-applied.d.ts +44 -1
  7. package/dist/lifecycle/gate-applied.d.ts.map +1 -1
  8. package/dist/lifecycle/gate-ledger.d.ts +1 -1
  9. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  10. package/dist/lifecycle/git.d.ts +30 -14
  11. package/dist/lifecycle/git.d.ts.map +1 -1
  12. package/dist/lifecycle/ledger-port.d.ts +18 -0
  13. package/dist/lifecycle/ledger-port.d.ts.map +1 -0
  14. package/dist/lifecycle/release-ledger.d.ts +10 -9
  15. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  16. package/dist/lifecycle/release-read.d.ts +23 -0
  17. package/dist/lifecycle/release-read.d.ts.map +1 -0
  18. package/dist/lifecycle/wave-plans.d.ts +51 -0
  19. package/dist/lifecycle/wave-plans.d.ts.map +1 -0
  20. package/dist/lint/typescript.d.ts +18 -0
  21. package/dist/lint/typescript.d.ts.map +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts +4 -4
  23. package/dist/op/gate-resume-plan.d.ts +113 -0
  24. package/dist/op/gate-resume-plan.d.ts.map +1 -0
  25. package/dist/op/gate-resume.d.ts +8 -64
  26. package/dist/op/gate-resume.d.ts.map +1 -1
  27. package/dist/op/gate-review.d.ts +1 -20
  28. package/dist/op/gate-review.d.ts.map +1 -1
  29. package/dist/op/gate-run-locator.d.ts +1 -1
  30. package/dist/op/index.d.ts +2 -0
  31. package/dist/op/index.d.ts.map +1 -1
  32. package/dist/op/op-waves-run.d.ts.map +1 -1
  33. package/dist/point-decision.d.ts +11 -0
  34. package/dist/point-decision.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/gate-resume.test.ts +140 -2
  37. package/src/cli/handlers/gate-resume.ts +174 -65
  38. package/src/cli/main.ts +8 -3
  39. package/src/codegen/pinned-upgrade.test.ts +8 -1
  40. package/src/gate-decision-bundle.test.ts +1 -0
  41. package/src/gate-resume-run-bundle.test.ts +49 -0
  42. package/src/gate-resume-run.ts +171 -0
  43. package/src/lifecycle/gate-applied.ts +63 -1
  44. package/src/lifecycle/gate-ledger.test.ts +7 -1
  45. package/src/lifecycle/gate-ledger.ts +1 -1
  46. package/src/lifecycle/git.ts +61 -18
  47. package/src/lifecycle/ledger-port.ts +17 -0
  48. package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
  49. package/src/lifecycle/ledger-readers.test.ts +139 -0
  50. package/src/lifecycle/release-ledger.ts +15 -42
  51. package/src/lifecycle/release-read.ts +69 -0
  52. package/src/lifecycle/wave-plans.ts +93 -0
  53. package/src/lint/typescript.ts +18 -0
  54. package/src/meta/lexicon-typescript-import.test.ts +42 -0
  55. package/src/op/gate-resume-plan-bundle.test.ts +49 -0
  56. package/src/op/gate-resume-plan.test.ts +107 -0
  57. package/src/op/gate-resume-plan.ts +228 -0
  58. package/src/op/gate-resume.test.ts +18 -0
  59. package/src/op/gate-resume.ts +16 -150
  60. package/src/op/gate-review.ts +2 -16
  61. package/src/op/gate-run-locator.ts +1 -1
  62. package/src/op/gate.ts +1 -1
  63. package/src/op/index.ts +2 -0
  64. package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
  65. package/src/op/op-waves-point.test.ts +60 -2
  66. package/src/op/op-waves-run.ts +6 -10
  67. package/src/point-decision.ts +19 -2
@@ -29,8 +29,13 @@
29
29
  */
30
30
 
31
31
  import { sortedJsonReplacer } from "../utils";
32
- import { flagLegacyDigest, warnOnLegacyDigests, withoutReadFlags, type LegacyDigestFlag } from "./legacy-digest";
33
- import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
32
+ import { warnOnLegacyDigests, withoutReadFlags, type LegacyDigestFlag } from "./legacy-digest";
33
+ import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments, type LedgerLocation } from "./git";
34
+ import { parseReleaseLedger, validateReleaseRecord } from "./release-read";
35
+
36
+ // The pure readers live apart, so a bundle can import them without git (#3865).
37
+ export { validateReleaseRecord, parseReleaseLedger, releaseLedgerLines, readReleaseRecords, listReleaseEnvironmentsOn } from "./release-read";
38
+ export type { LedgerReadPort } from "./ledger-port";
34
39
 
35
40
  /**
36
41
  * The id space a `ReleaseRecord.runId` lives in, and how to reach the run
@@ -172,17 +177,6 @@ export interface ReleaseRecord {
172
177
  redeploys?: ReleaseRef;
173
178
  }
174
179
 
175
- /** Required, non-empty-string fields every `ReleaseRecord` must carry. */
176
- const REQUIRED_STRING_FIELDS: Array<keyof ReleaseRecord> = [
177
- "component",
178
- "env",
179
- "digest",
180
- "gitSha",
181
- "runId",
182
- "timestamp",
183
- "actor",
184
- ];
185
-
186
180
  /** Thrown by `appendReleaseRecord` when the record is missing a required field — a release record can never be recorded partially, since it is never editable afterward. */
187
181
  export class InvalidReleaseRecordError extends Error {
188
182
  constructor(public readonly missing: string[]) {
@@ -191,16 +185,6 @@ export class InvalidReleaseRecordError extends Error {
191
185
  }
192
186
  }
193
187
 
194
- /** Validate that a release record carries every required field as a non-empty string. */
195
- export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[] {
196
- const missing: string[] = [];
197
- for (const field of REQUIRED_STRING_FIELDS) {
198
- const value = record[field];
199
- if (typeof value !== "string" || value.length === 0) missing.push(field);
200
- }
201
- return missing;
202
- }
203
-
204
188
  /** Input a caller supplies to record one deploy — `version` is filled in, everything else is required (see `ReleaseRecord`). */
205
189
  export type ReleaseRecordInput = Omit<ReleaseRecord, "version" | "flags">;
206
190
 
@@ -284,24 +268,9 @@ export async function appendReleaseRecord(
284
268
  */
285
269
  export async function readReleaseLedger(
286
270
  environment: string,
287
- opts?: { cwd?: string },
271
+ opts?: LedgerLocation,
288
272
  ): Promise<{ records: ReleaseRecord[]; malformed: number }> {
289
- const lines = await readReleaseLedgerLines(environment, opts);
290
- const records: ReleaseRecord[] = [];
291
- let malformed = 0;
292
- for (const line of lines) {
293
- try {
294
- const parsed = JSON.parse(line) as ReleaseRecord;
295
- if (validateReleaseRecord(parsed).length > 0) {
296
- malformed++;
297
- continue;
298
- }
299
- // chant #2514 — a digest from before real SHA-256 is flagged, never re-keyed.
300
- records.push(flagLegacyDigest(parsed, [parsed.digest, parsed.manifestDigest, parsed.inputDigest]));
301
- } catch {
302
- malformed++;
303
- }
304
- }
273
+ const { records, malformed } = parseReleaseLedger(await readReleaseLedgerLines(environment, opts));
305
274
  // chant #2514 — say once that this ledger holds values in the old form.
306
275
  warnOnLegacyDigests(
307
276
  records.flatMap((r) => [r.digest, r.manifestDigest, r.inputDigest]),
@@ -311,8 +280,12 @@ export async function readReleaseLedger(
311
280
  return { records, malformed };
312
281
  }
313
282
 
314
- /** List every environment that has release records on the orphan branch. */
315
- export async function listReleaseEnvironments(opts?: { cwd?: string }): Promise<string[]> {
283
+ /**
284
+ * List every environment that has release records on the orphan branch. With
285
+ * `member` or `ledgerPrefix` (#3865) it lists that workspace member's, from
286
+ * wherever `cwd` is; without either, the member `cwd` resolves to.
287
+ */
288
+ export async function listReleaseEnvironments(opts?: LedgerLocation): Promise<string[]> {
316
289
  return gitListLedgerEnvironments(opts);
317
290
  }
318
291
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reading release records without git (#3865). The parsing and the
3
+ * environment listing of `release-ledger.ts`, over a {@link LedgerReadPort}
4
+ * instead of a checkout, so a product bundles them in one file
5
+ * (`release-read-bundle.test.ts` holds the import graph). Pass
6
+ * `gitLedgerReadPort({ member })` from `./git.ts` to read a workspace member's
7
+ * ledger from a checkout, or a port of your own.
8
+ *
9
+ * `release-ledger.ts` reads through these too; its `readReleaseLedger` and
10
+ * `listReleaseEnvironments` also take `member` or `ledgerPrefix`.
11
+ */
12
+ import { flagLegacyDigest } from "./legacy-digest";
13
+ import type { LedgerReadPort } from "./ledger-port";
14
+ import type { ReleaseRecord } from "./release-ledger";
15
+
16
+ /** Required, non-empty-string fields every `ReleaseRecord` must carry. */
17
+ const REQUIRED_STRING_FIELDS: Array<keyof ReleaseRecord> = ["component", "env", "digest", "gitSha", "runId", "timestamp", "actor"];
18
+
19
+ /** The required fields a release record lacks as non-empty strings. */
20
+ export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[] {
21
+ const missing: string[] = [];
22
+ for (const field of REQUIRED_STRING_FIELDS) {
23
+ const value = record[field];
24
+ if (typeof value !== "string" || value.length === 0) missing.push(field);
25
+ }
26
+ return missing;
27
+ }
28
+
29
+ /** The lines of a `releases.jsonl` file: trimmed, blanks dropped. */
30
+ export function releaseLedgerLines(text: string | null): string[] {
31
+ return text ? text.split("\n").map((line) => line.trim()).filter(Boolean) : [];
32
+ }
33
+
34
+ /**
35
+ * Parse release ledger lines, oldest first. A malformed line is counted and
36
+ * skipped; a digest recorded by the old 32-bit hash reads back flagged
37
+ * (#2514), never re-keyed.
38
+ */
39
+ export function parseReleaseLedger(lines: readonly string[]): { records: ReleaseRecord[]; malformed: number } {
40
+ const records: ReleaseRecord[] = [];
41
+ let malformed = 0;
42
+ for (const line of lines) {
43
+ try {
44
+ const parsed = JSON.parse(line) as ReleaseRecord;
45
+ if (parsed === null || typeof parsed !== "object" || validateReleaseRecord(parsed).length > 0) {
46
+ malformed++;
47
+ continue;
48
+ }
49
+ records.push(flagLegacyDigest(parsed, [parsed.digest, parsed.manifestDigest, parsed.inputDigest]));
50
+ } catch {
51
+ malformed++;
52
+ }
53
+ }
54
+ return { records, malformed };
55
+ }
56
+
57
+ /** Every release record of `environment` the port holds, oldest first. */
58
+ export async function readReleaseRecords(port: LedgerReadPort, environment: string): Promise<{ records: ReleaseRecord[]; malformed: number }> {
59
+ return parseReleaseLedger(releaseLedgerLines(await port.read(environment, "releases.jsonl")));
60
+ }
61
+
62
+ /** The environments with a `releases.jsonl`, sorted: the port's root directories that hold one. */
63
+ export async function listReleaseEnvironmentsOn(port: LedgerReadPort): Promise<string[]> {
64
+ const envs: string[] = [];
65
+ for (const name of await port.trees()) {
66
+ if (await port.read(name, "releases.jsonl")) envs.push(name);
67
+ }
68
+ return envs.sort();
69
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The plans an op-waves `pr-review` wave records for a pull request's head
3
+ * (#3684, #3865): `_wave-plans/<name>/<head>.json` on `chant/lifecycle`,
4
+ * written by `chant run wave --record-plans` (`../op/op-waves-run.ts`) and read
5
+ * after the merge, where the review of that head approves the digests it
6
+ * planned and only those (`../op/gate-review.ts`).
7
+ *
8
+ * The parser and the readers here touch no git, no clock and no file: they
9
+ * take a {@link LedgerReadPort}, so a product that shows or checks the
10
+ * records bundles them in one file (`wave-plans-bundle.test.ts` holds the
11
+ * import graph). `gitLedgerReadPort({ member })` from `./git.ts` reads a
12
+ * workspace member's records (`_members/<member>/_wave-plans/...`) from a
13
+ * checkout.
14
+ */
15
+ import type { LedgerReadPort } from "./ledger-port";
16
+
17
+ /** The digests each wave planned at one commit, recorded for a review of it to approve. */
18
+ export interface HeadPlans {
19
+ version: 1;
20
+ /** The Op waves spec's name. */
21
+ name: string;
22
+ /** The commit the waves were planned at: a pull request's head. */
23
+ head: string;
24
+ timestamp: string;
25
+ waves: Array<{ wave: number; name: string; digest: string }>;
26
+ }
27
+
28
+ /** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
29
+ export const HEAD_PLANS_DIR = "_wave-plans";
30
+
31
+ export function headPlansPath(name: string, head: string): { dir: string; file: string } {
32
+ return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
33
+ }
34
+
35
+ /**
36
+ * Parse one record. Null when the text is not JSON or not a version 1 plans
37
+ * record: a record that cannot be read approves nothing.
38
+ */
39
+ export function parseHeadPlans(text: string | null): HeadPlans | null {
40
+ if (!text) return null;
41
+ let value: unknown;
42
+ try {
43
+ value = JSON.parse(text);
44
+ } catch {
45
+ return null;
46
+ }
47
+ if (value === null || typeof value !== "object") return null;
48
+ const r = value as Record<string, unknown>;
49
+ if (r.version !== 1 || typeof r.name !== "string" || typeof r.head !== "string" || typeof r.timestamp !== "string" || !Array.isArray(r.waves)) return null;
50
+ const waves: HeadPlans["waves"] = [];
51
+ for (const w of r.waves) {
52
+ if (w === null || typeof w !== "object") return null;
53
+ const { wave, name, digest } = w as Record<string, unknown>;
54
+ if (typeof wave !== "number" || typeof name !== "string" || typeof digest !== "string") return null;
55
+ waves.push({ wave, name, digest });
56
+ }
57
+ return { version: 1, name: r.name, head: r.head, timestamp: r.timestamp, waves };
58
+ }
59
+
60
+ /** The record for `head` of the spec `name`, or null when none was recorded or it cannot be read. */
61
+ export async function readHeadPlans(port: LedgerReadPort, name: string, head: string): Promise<HeadPlans | null> {
62
+ const { dir, file } = headPlansPath(name, head);
63
+ return parseHeadPlans(await port.read(dir, file));
64
+ }
65
+
66
+ /** Every record of the spec `name` by head. A file that is not a record is left out. */
67
+ export async function readAllHeadPlans(port: LedgerReadPort, name: string): Promise<Map<string, HeadPlans>> {
68
+ const out = new Map<string, HeadPlans>();
69
+ const { dir } = headPlansPath(name, "");
70
+ for (const file of (await port.list(dir)).sort()) {
71
+ if (!file.endsWith(".json")) continue;
72
+ const plans = parseHeadPlans(await port.read(dir, file));
73
+ if (plans) out.set(file.slice(0, -".json".length), plans);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** The digests a record holds by wave number. */
79
+ export function waveDigests(plans: HeadPlans): Map<number, string> {
80
+ return new Map(plans.waves.map((w) => [w.wave, w.digest]));
81
+ }
82
+
83
+ /** The digests recorded for each head of the spec `name`: head, then wave number, to digest. */
84
+ export async function readHeadPlanDigests(port: LedgerReadPort, name: string): Promise<Map<string, Map<number, string>>> {
85
+ const out = new Map<string, Map<number, string>>();
86
+ for (const [head, plans] of await readAllHeadPlans(port, name)) out.set(head, waveDigests(plans));
87
+ return out;
88
+ }
89
+
90
+ /** The names of the specs that have records: the directories under `_wave-plans/`. */
91
+ export async function listWavePlanSpecs(port: LedgerReadPort): Promise<string[]> {
92
+ return (await port.list(HEAD_PLANS_DIR)).sort();
93
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The TypeScript compiler API chant parses with, for lexicon lint rules and
3
+ * language services (#3864).
4
+ *
5
+ * Core parses every source file with its own `typescript` dependency, which
6
+ * stays on 5.x. A lexicon that imported `typescript` itself got the project's
7
+ * copy instead, and a project on TypeScript 7 has no JS compiler API there:
8
+ * `ts.isImportDeclaration` is undefined and every rule threw. Even when two
9
+ * 5.x copies load, their `SyntaxKind` numbers can differ between minors, so a
10
+ * rule must test nodes with the same module that built them.
11
+ *
12
+ * Lexicons import `ts` from here, never from "typescript":
13
+ *
14
+ * import { ts } from "@intentius/chant/lint/typescript";
15
+ */
16
+ import * as ts from "typescript";
17
+
18
+ export { ts };
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Lexicon source takes the TypeScript compiler API from
8
+ * `@intentius/chant/lint/typescript`, never from `typescript` (chant #3864).
9
+ *
10
+ * A bare `typescript` import in a published lexicon resolves to the project's
11
+ * copy. On TypeScript 7 that copy has no JS compiler API, so every sql lint
12
+ * rule threw `ts.isImportDeclaration is not a function` and `chant graph`
13
+ * refused the project. Core parses with its own 5.x, and a rule has to test
14
+ * nodes with that same module. Tests are exempt: they run in this repo, where
15
+ * `typescript` is 5.x.
16
+ */
17
+ const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
18
+ const lexiconsDir = join(repoRoot, "lexicons");
19
+
20
+ function sourceFiles(dir: string): string[] {
21
+ const out: string[] = [];
22
+ for (const name of readdirSync(dir)) {
23
+ const path = join(dir, name);
24
+ if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
25
+ else if (/\.(ts|mts|js|mjs)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(path);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const BARE_TYPESCRIPT = /^\s*(import|export)\b[^;]*?\bfrom\s+["']typescript["']|\b(require|import)\(\s*["']typescript["']\s*\)/m;
31
+
32
+ describe("lexicon source imports TypeScript through chant (#3864)", () => {
33
+ test("no lexicon src file imports `typescript` directly", () => {
34
+ const offenders = readdirSync(lexiconsDir)
35
+ .map((name) => join(lexiconsDir, name, "src"))
36
+ .filter((dir) => statSync(dir, { throwIfNoEntry: false })?.isDirectory())
37
+ .flatMap(sourceFiles)
38
+ .filter((file) => BARE_TYPESCRIPT.test(readFileSync(file, "utf-8")))
39
+ .map((file) => relative(repoRoot, file));
40
+ expect(offenders, 'import { ts } from "@intentius/chant/lint/typescript" instead').toEqual([]);
41
+ });
42
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The resume plan bundles small (#3858).
3
+ *
4
+ * A product that resumes in its own job, such as terragucci's, decides what
5
+ * to resume with `@intentius/chant/op/gate-resume-plan` and ships as one
6
+ * bundled file. This bundles it the way such a product would and reads
7
+ * esbuild's own list of what went in: no git ledger, no gate evaluation, no
8
+ * workspace, CLI or Ajv code.
9
+ */
10
+
11
+ import { join } from "node:path";
12
+ import { build } from "esbuild";
13
+ import { describe, expect, test } from "vitest";
14
+ import { REPO } from "../workspace/__fixtures__/contract-repo";
15
+
16
+ const FORBIDDEN = [
17
+ /node_modules\//,
18
+ /\/src\/cli\//,
19
+ /\/src\/workspace\//,
20
+ /\/src\/fold\//,
21
+ /\/src\/lint\//,
22
+ /\/src\/codegen\//,
23
+ /\/src\/op\/activities\//,
24
+ /\/src\/op\/gate\.ts$/,
25
+ /\/src\/op\/gate-resume\.ts$/,
26
+ /\/src\/lifecycle\/git\.ts$/,
27
+ /\/src\/lifecycle\/gate-ledger\.ts$/,
28
+ ];
29
+
30
+ describe("the gate-resume-plan bundle", () => {
31
+ test("holds the resume decision and its pure helpers only", async () => {
32
+ const result = await build({
33
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "gate-resume-plan.ts")],
34
+ bundle: true,
35
+ platform: "node",
36
+ format: "esm",
37
+ write: false,
38
+ metafile: true,
39
+ outdir: "out",
40
+ logLevel: "silent",
41
+ });
42
+ const inputs = Object.keys(result.metafile.inputs);
43
+ for (const pattern of FORBIDDEN) {
44
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
45
+ }
46
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
47
+ expect(bytes).toBeLessThan(32 * 1024);
48
+ });
49
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
3
+ import { parseAppliedGateFile } from "../lifecycle/gate-applied";
4
+ import { planGateResumes } from "./gate-resume-plan";
5
+ import type { GateRunLocator } from "./gate-run-locator";
6
+
7
+ const DIGEST = `sha256:${"a".repeat(64)}`;
8
+ const OTHER = `sha256:${"b".repeat(64)}`;
9
+ const NOW = "2026-10-01T06:00:00Z";
10
+ const waited: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/infra", run: "42", attempt: 1 };
11
+ const applying: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/infra", run: "57", attempt: 1 };
12
+
13
+ const pending = (gate: string, timestamp: string, extra: Partial<PendingGateRecord> = {}): PendingGateRecord => ({
14
+ version: 1,
15
+ kind: "pending",
16
+ op: "tf-apply",
17
+ gate,
18
+ timestamp,
19
+ expiresAt: "2026-10-03T00:00:00Z",
20
+ planDigest: DIGEST,
21
+ resume: waited,
22
+ ...extra,
23
+ });
24
+ const approval = (gate: string, timestamp: string, by = "alice"): GateResolutionRecord => ({ version: 1, op: "tf-apply", gate, resolvedBy: by, timestamp, planDigest: DIGEST });
25
+
26
+ const file = (...lines: object[]) => parseAppliedGateFile(lines.map((l) => JSON.stringify(l)).join("\n"));
27
+ const appliedLine = (gate: string, approvedAt: string, timestamp: string, extra: Record<string, unknown> = {}) => ({
28
+ version: 1, kind: "applied", op: "tf-apply", gate, planDigest: DIGEST, approvedAt, approvedBy: "alice", timestamp, ...extra,
29
+ });
30
+
31
+ describe("planGateResumes (#3858)", () => {
32
+ test("an approval that answers a standing fact and is not spent: resumable, approved", () => {
33
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], now: NOW });
34
+ expect(c).toMatchObject({ source: "pending", gate: "wave-1", resumable: true, reason: "approved", approvedBy: "alice", approvedAt: "2026-10-01T01:00:00Z", run: waited });
35
+ });
36
+
37
+ test("nothing answers it: not resumable", () => {
38
+ expect(planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [], now: NOW })).toMatchObject([
39
+ { resumable: false, reason: "not-approved" },
40
+ ]);
41
+ });
42
+
43
+ test("an approval a wave already applied under is spent: not resumable, and the report names the apply", () => {
44
+ const lines = file(appliedLine("wave-1", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z", { wave: { number: 1, name: "network" } }));
45
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
46
+ expect(c).toMatchObject({ resumable: false, reason: "spent", spent: { appliedAt: "2026-10-01T02:00:00Z", approvedAt: "2026-10-01T01:00:00Z", wave: { number: 1 } } });
47
+ expect(c!.message).toContain("spent");
48
+ });
49
+
50
+ test("a spent approval of another digest, or an older one, spends nothing here", () => {
51
+ const lines = file(appliedLine("wave-1", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z", { planDigest: OTHER }), appliedLine("wave-1", "2026-09-30T00:00:00Z", "2026-09-30T01:00:00Z"));
52
+ const [c] = planGateResumes({ op: "tf-apply", pending: [pending("wave-1", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
53
+ expect(c).toMatchObject({ resumable: true, reason: "approved" });
54
+ });
55
+
56
+ test("a decision point's answer the gate passes on: answered, unless the resolution citing it is spent", () => {
57
+ const answer = { point: "prod-apply", id: "q-1", path: "points/q-1.json" };
58
+ const fact = pending("wave-1", "2026-10-01T00:00:00Z", { answer });
59
+ const questions = new Map([["q-1", { id: "q-1", state: "answered" as const, answer: true, answeredBy: ["alice"], answeredOn: "2026-10-01T01:00:00Z" }]]);
60
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [], questions, now: NOW })).toMatchObject([{ resumable: true, reason: "answered", approvedBy: "alice" }]);
61
+
62
+ const cited: GateResolutionRecord = { ...approval("wave-1", "2026-10-01T03:00:00Z", "alice"), answer };
63
+ const lines = file(appliedLine("wave-1", "2026-10-01T03:00:00Z", "2026-10-01T03:00:01Z"));
64
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [cited], ...lines, questions, now: NOW })).toMatchObject([{ resumable: false, reason: "spent" }]);
65
+ });
66
+
67
+ test("an apply that recorded its run and never finished is a stopped apply", () => {
68
+ // terragucci's shape: chant's `applied` line, then the product's `changes` line with the run.
69
+ const lines = file(
70
+ appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z"),
71
+ appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:01Z", { kind: "changes", resume: applying, changes: [{ address: "aws_s3_bucket.a" }] }),
72
+ );
73
+ const out = planGateResumes({ op: "tf-apply", pending: [pending("wave-2", "2026-10-01T00:00:00Z")], resolutions: [approval("wave-2", "2026-10-01T01:00:00Z")], ...lines, now: NOW });
74
+ expect(out).toMatchObject([
75
+ { source: "pending", reason: "spent", resumable: false },
76
+ { source: "stopped", gate: "wave-2", reason: "stopped", resumable: true, run: applying, approvedBy: "alice", stopped: { appliedAt: "2026-10-01T02:00:01Z", approvedAt: "2026-10-01T01:00:00Z", kind: "changes" } },
77
+ ]);
78
+ });
79
+
80
+ test("a finished line, a newer pending fact, or no run locator: not a stopped apply", () => {
81
+ const changes = appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:01Z", { kind: "changes", resume: applying });
82
+ const finished = { version: 1, kind: "finished", op: "tf-apply", gate: "wave-2", planDigest: DIGEST, applied: "2026-10-01T02:00:01Z", result: "failed", timestamp: "2026-10-01T02:30:00Z" };
83
+ const stopped = (lines: ReturnType<typeof file>, p: PendingGateRecord[] = []) =>
84
+ planGateResumes({ op: "tf-apply", pending: p, resolutions: [], ...lines, now: NOW }).filter((c) => c.source === "stopped");
85
+ expect(stopped(file(changes))).toHaveLength(1);
86
+ expect(stopped(file(changes, finished))).toEqual([]);
87
+ expect(stopped(file(changes), [pending("wave-2", "2026-10-01T03:00:00Z")])).toEqual([]);
88
+ expect(stopped(file(appliedLine("wave-2", "2026-10-01T01:00:00Z", "2026-10-01T02:00:00Z")))).toEqual([]);
89
+ // A newer apply under another approval, with no run, supersedes the stopped one.
90
+ expect(stopped(file(changes, appliedLine("wave-2", "2026-10-01T04:00:00Z", "2026-10-01T05:00:00Z")))).toEqual([]);
91
+ });
92
+
93
+ test("a gate filter, and an expired fact, leave facts out", () => {
94
+ const facts = [pending("wave-1", "2026-10-01T00:00:00Z"), pending("wave-2", "2026-10-01T00:00:00Z", { expiresAt: "2026-10-01T05:00:00Z" })];
95
+ expect(planGateResumes({ op: "tf-apply", pending: facts, resolutions: [], now: NOW }).map((c) => c.gate)).toEqual(["wave-1"]);
96
+ expect(planGateResumes({ op: "tf-apply", pending: facts, resolutions: [], now: NOW, gate: "wave-2" })).toEqual([]);
97
+ });
98
+
99
+ test("a fact with an approval block needs the tally to be answered", () => {
100
+ const fact = pending("wave-1", "2026-10-01T00:00:00Z", { approval: { mode: "off" } as unknown as PendingGateRecord["approval"] });
101
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [approval("wave-1", "2026-10-01T01:00:00Z")], now: NOW })).toMatchObject([
102
+ { resumable: false, reason: "not-approved", message: expect.stringContaining("tally") },
103
+ ]);
104
+ const answering = () => approval("wave-1", "2026-10-01T01:00:00Z");
105
+ expect(planGateResumes({ op: "tf-apply", pending: [fact], resolutions: [], now: NOW, answering })).toMatchObject([{ resumable: true, reason: "approved" }]);
106
+ });
107
+ });