@intentius/chant 0.124.2 → 0.124.3
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.
- package/dist/cli/handlers/override-approve.d.ts +3 -1
- package/dist/cli/handlers/override-approve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -33
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-pending.d.ts +41 -0
- package/dist/lifecycle/gate-pending.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +2 -0
- package/dist/op/activities/decide.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +5 -77
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +18 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate.d.ts +8 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +37 -6
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +38 -4
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +7 -0
- package/dist/op/steward-points.d.ts.map +1 -1
- package/dist/override-gate.d.ts +8 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/point-decision.d.ts +134 -0
- package/dist/point-decision.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +37 -2
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +3 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/override-approve.test.ts +16 -0
- package/src/cli/handlers/override-approve.ts +6 -2
- package/src/cli/main.test.ts +3 -0
- package/src/cli/main.ts +9 -2
- package/src/cli/registry.ts +2 -0
- package/src/lifecycle/gate-ledger.ts +3 -53
- package/src/lifecycle/gate-pending.ts +62 -0
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/decide.ts +3 -0
- package/src/op/gate-point-ci.e2e.test.ts +171 -0
- package/src/op/gate-point-mcp.e2e.test.ts +2 -0
- package/src/op/gate-point-run.ts +18 -149
- package/src/op/gate-point.e2e.test.ts +2 -0
- package/src/op/gate-point.test.ts +1 -1
- package/src/op/gate-point.ts +27 -3
- package/src/op/gate.ts +10 -0
- package/src/op/index.ts +10 -1
- package/src/op/op-waves-run.ts +84 -19
- package/src/op/op-waves.test.ts +96 -0
- package/src/op/op-waves.ts +60 -4
- package/src/op/steward-points.ts +8 -0
- package/src/override-gate.test.ts +5 -0
- package/src/override-gate.ts +8 -2
- package/src/point-decision-bundle.test.ts +62 -0
- package/src/point-decision.e2e.test.ts +95 -0
- package/src/point-decision.ts +280 -0
- package/src/pr-forge.test.ts +62 -0
- package/src/pr-forge.ts +60 -9
- package/src/workspace/decide.ts +7 -2
- package/src/workspace/points-cli.ts +1 -1
package/src/op/index.ts
CHANGED
|
@@ -54,23 +54,27 @@ export type { GenerateOpsPipelineResult, GenerateOpWavesPipelineResult } from ".
|
|
|
54
54
|
export {
|
|
55
55
|
OP_WAVE_APPLY_HOOKS_RUNNERS,
|
|
56
56
|
OP_WAVE_DEFAULT_APPLY,
|
|
57
|
+
OP_WAVE_DEFAULT_GATE_NAME,
|
|
57
58
|
OP_WAVE_GATE_POLICIES,
|
|
58
59
|
OP_WAVE_HOOK_MOMENTS,
|
|
59
60
|
assertOpWaveHooks,
|
|
60
61
|
assertOpWavesSpec,
|
|
61
62
|
opWaveApplyHooksRunBy,
|
|
63
|
+
opWaveGateName,
|
|
62
64
|
opWaveHooksAt,
|
|
63
65
|
opWaveJobs,
|
|
64
66
|
opWaveRecordPath,
|
|
65
67
|
opWaveShare,
|
|
66
68
|
} from "./op-waves";
|
|
67
|
-
export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesSpec } from "./op-waves";
|
|
69
|
+
export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesDigest, OpWavesSpec } from "./op-waves";
|
|
68
70
|
export {
|
|
69
71
|
opWaveHooksAtBase,
|
|
70
72
|
opWaveNeedsApproval,
|
|
71
73
|
opWavePlansPath,
|
|
72
74
|
opWaveRunsPath,
|
|
75
|
+
opWaveSetDigest,
|
|
73
76
|
parseOpWavesSpec,
|
|
77
|
+
recordOpWaveHeadPlans,
|
|
74
78
|
readOpWaveHooksFile,
|
|
75
79
|
readOpWavePolicy,
|
|
76
80
|
runOpWave,
|
|
@@ -80,6 +84,7 @@ export type {
|
|
|
80
84
|
OpWaveBaseRules,
|
|
81
85
|
OpWaveDecision,
|
|
82
86
|
OpWaveApplyHooksRun,
|
|
87
|
+
OpWaveExec,
|
|
83
88
|
OpWaveHookOutcome,
|
|
84
89
|
OpWaveHookRun,
|
|
85
90
|
OpWaveHooksFile,
|
|
@@ -87,7 +92,11 @@ export type {
|
|
|
87
92
|
OpWaveMember,
|
|
88
93
|
OpWavePlanFile,
|
|
89
94
|
OpWavePlansFile,
|
|
95
|
+
OpWavePolicySource,
|
|
90
96
|
OpWaveRunsFile,
|
|
97
|
+
OpWaveShowAtBase,
|
|
98
|
+
RecordHeadPlansOptions,
|
|
99
|
+
RunOpWaveApplyHooksOptions,
|
|
91
100
|
RunOpWaveOptions,
|
|
92
101
|
RunOpWaveResult,
|
|
93
102
|
} from "./op-waves-run";
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
43
43
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
44
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
45
|
-
import {
|
|
45
|
+
import { waveSetDigest, type WaveMember } from "../gated-waves";
|
|
46
46
|
import type { ReviewApproval } from "../gate-decision";
|
|
47
47
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
48
48
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
assertOpWaveHooks,
|
|
57
57
|
assertOpWavesSpec,
|
|
58
58
|
opWaveApplyHooksRunBy,
|
|
59
|
+
opWaveGateName,
|
|
59
60
|
opWaveHooksAt,
|
|
60
61
|
opWaveRecordPath,
|
|
61
62
|
opWaveShare,
|
|
@@ -81,8 +82,21 @@ export interface OpWavePlanFile {
|
|
|
81
82
|
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
82
83
|
export interface OpWavePlansFile {
|
|
83
84
|
runs: Array<OpWavePlanFile & { target: string }>;
|
|
85
|
+
/**
|
|
86
|
+
* Fields the product adds to the wave's pending fact (#3821), such as the
|
|
87
|
+
* commit it planned or the members its own readers expect. chant's own
|
|
88
|
+
* fields win: a key chant writes (`op`, `gate`, `planDigest`, `timestamp`,
|
|
89
|
+
* ...) is refused, except `description`.
|
|
90
|
+
*/
|
|
91
|
+
pending?: Record<string, unknown>;
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
95
|
+
export const OP_WAVE_PENDING_RESERVED = [
|
|
96
|
+
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
97
|
+
"environment", "origin", "neverOverMcp", "approval", "answer",
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
86
100
|
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
87
101
|
export interface OpWaveRunsFile {
|
|
88
102
|
/** The wave's name. */
|
|
@@ -148,6 +162,12 @@ export interface OpWaveDecision {
|
|
|
148
162
|
applyHooksRunBy?: "apply";
|
|
149
163
|
/** Every hook's outcome so far in the job that wrote the record (#3804). */
|
|
150
164
|
outcomes?: OpWaveHookOutcome[];
|
|
165
|
+
/** `changing`: the digest covers only the runs whose plan is not empty, as the spec's `digest.over` says (#3821). Absent: every run. */
|
|
166
|
+
digestOver?: "changing";
|
|
167
|
+
/** The fields the plans file added to the pending fact (#3821). */
|
|
168
|
+
pending?: Record<string, unknown>;
|
|
169
|
+
/** The pending fact carries `neverOverMcp`, set by the spec here or at base (#3821). */
|
|
170
|
+
neverOverMcp?: true;
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
/** One hook's run (#3762). */
|
|
@@ -216,6 +236,18 @@ export interface OpWaveHooksResult {
|
|
|
216
236
|
outcomes: OpWaveHookOutcome[];
|
|
217
237
|
}
|
|
218
238
|
|
|
239
|
+
/** The options of {@link runOpWaveApplyHooks} (#3825). */
|
|
240
|
+
export interface RunOpWaveApplyHooksOptions {
|
|
241
|
+
/** Where the hooks run. Default `process.cwd()`. */
|
|
242
|
+
cwd?: string;
|
|
243
|
+
/** Runs one hook. Default: spawn it with the job's environment. */
|
|
244
|
+
exec?: OpWaveExec;
|
|
245
|
+
/** Default: stderr. */
|
|
246
|
+
log?: (line: string) => void;
|
|
247
|
+
/** Run nothing and record the moment as skipped, with this reason (#3811). */
|
|
248
|
+
skip?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
219
251
|
/** One call of {@link runOpWaveApplyHooks}. */
|
|
220
252
|
export interface OpWaveApplyHooksRun {
|
|
221
253
|
moment: OpWaveHookMoment;
|
|
@@ -293,6 +325,8 @@ export interface OpWaveBaseRules {
|
|
|
293
325
|
spend: boolean;
|
|
294
326
|
/** Who runs the apply-stage hooks, as read at base (#3804). `chant` when the file, the wave or the hooks are missing there. */
|
|
295
327
|
applyHooksRunBy: OpWaveApplyHooksRunner;
|
|
328
|
+
/** The spec at base sets `neverOverMcp` (#3821). */
|
|
329
|
+
neverOverMcp: boolean;
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -313,7 +347,7 @@ export function readOpWavePolicy(
|
|
|
313
347
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
314
348
|
): OpWaveBaseRules {
|
|
315
349
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
316
|
-
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const };
|
|
350
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha, hooks: noHooks(), spend: false, applyHooksRunBy: "chant" as const, neverOverMcp: false };
|
|
317
351
|
if (text === null) return absent;
|
|
318
352
|
let spec: OpWavesSpec;
|
|
319
353
|
try {
|
|
@@ -323,12 +357,13 @@ export function readOpWavePolicy(
|
|
|
323
357
|
}
|
|
324
358
|
// The spend rule is the spec's, so it holds for a wave the base does not have yet.
|
|
325
359
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
360
|
+
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
326
361
|
const wave = spec.waves.find((w) => w.name === name);
|
|
327
|
-
if (!wave) return { ...absent, spend };
|
|
362
|
+
if (!wave) return { ...absent, spend, neverOverMcp };
|
|
328
363
|
const policy = wave.gate ?? "always";
|
|
329
364
|
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
330
365
|
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
331
|
-
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant" };
|
|
366
|
+
const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
|
|
332
367
|
try {
|
|
333
368
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
|
|
334
369
|
assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
|
|
@@ -481,9 +516,9 @@ function writeRunsFile(
|
|
|
481
516
|
* for all of them (#3762). A wave plan is written out as each run's plan file
|
|
482
517
|
* too, so a per-run `apply` or hook reads `{plan}` either way.
|
|
483
518
|
*/
|
|
484
|
-
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]):
|
|
519
|
+
function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string; exec: OpWaveExec }, runs: readonly OpWaveRun[]): PlannedRuns {
|
|
485
520
|
const { spec, k, wave, cwd, exec } = at;
|
|
486
|
-
if (!spec.planWave) return runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run));
|
|
521
|
+
if (!spec.planWave) return { members: runs.map((run) => planRun({ spec, wave: k, cwd, exec }, wave.name, run)) };
|
|
487
522
|
const runsPath = writeRunsFile(at, runs);
|
|
488
523
|
const plans = opWavePlansPath(spec, k);
|
|
489
524
|
rmSync(join(cwd, plans), { force: true });
|
|
@@ -510,12 +545,40 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
510
545
|
}
|
|
511
546
|
const missing = runs.filter((r) => !byTarget.has(r.target)).map((r) => r.target);
|
|
512
547
|
if (missing.length > 0) throw new Error(`the plans file of wave ${k} (${plans}) has no plan for ${missing.join(", ")}`);
|
|
513
|
-
|
|
548
|
+
const pending = Array.isArray(parsed) ? undefined : pendingFields((parsed as { pending?: unknown }).pending, `the plans file of wave ${k} (${plans})`);
|
|
549
|
+
const members = runs.map((run) => {
|
|
514
550
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
515
551
|
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
516
552
|
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}) }) + "\n");
|
|
517
553
|
return m;
|
|
518
554
|
});
|
|
555
|
+
return { members, ...(pending ? { pending } : {}) };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** What planning a wave's runs gave: each run's plan, and the product's pending-fact fields (#3821). */
|
|
559
|
+
interface PlannedRuns {
|
|
560
|
+
members: OpWaveMember[];
|
|
561
|
+
pending?: Record<string, unknown>;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
565
|
+
function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
|
|
566
|
+
if (value === undefined) return undefined;
|
|
567
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
|
|
568
|
+
const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
|
|
569
|
+
if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
|
|
570
|
+
if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
|
|
571
|
+
return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
|
|
576
|
+
* or under `digest.over: "changing"` over the runs whose plan is not empty,
|
|
577
|
+
* falling back to every run when all of them are empty.
|
|
578
|
+
*/
|
|
579
|
+
export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>): string {
|
|
580
|
+
const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
|
|
581
|
+
return waveSetDigest(changing.length > 0 ? changing : members);
|
|
519
582
|
}
|
|
520
583
|
|
|
521
584
|
/** What one moment's hooks came to. */
|
|
@@ -647,11 +710,7 @@ export function readOpWaveHooksFile(path: string | undefined = process.env.CHANT
|
|
|
647
710
|
* chant counts the moment as handled. A skip without a reason throws. `exec` runs one hook; inject it to run a hook
|
|
648
711
|
* in process or to keep its output.
|
|
649
712
|
*/
|
|
650
|
-
export function runOpWaveApplyHooks(
|
|
651
|
-
file: OpWaveHooksFile,
|
|
652
|
-
moment: OpWaveHookMoment,
|
|
653
|
-
options: { cwd?: string; exec?: OpWaveExec; log?: (line: string) => void; skip?: string } = {},
|
|
654
|
-
): OpWaveApplyHooksRun {
|
|
713
|
+
export function runOpWaveApplyHooks(file: OpWaveHooksFile, moment: OpWaveHookMoment, options: RunOpWaveApplyHooksOptions = {}): OpWaveApplyHooksRun {
|
|
655
714
|
const cwd = options.cwd ?? process.cwd();
|
|
656
715
|
const log = options.log ?? ((line: string) => console.error(line));
|
|
657
716
|
let run: OpWaveApplyHooksRun;
|
|
@@ -786,7 +845,7 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
786
845
|
const slice = opWaveShare(wave.runs, options.share, shares);
|
|
787
846
|
const before = runHooks(ctx, "before-plan", decision.hooks?.["before-plan"], slice);
|
|
788
847
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in share ${options.share} of wave ${k} was planned or applied`);
|
|
789
|
-
const members = planRuns(ctx, slice);
|
|
848
|
+
const { members } = planRuns(ctx, slice);
|
|
790
849
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
791
850
|
const after = runHooks(ctx, "after-plan", decision.hooks?.["after-plan"], slice, planned);
|
|
792
851
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in share ${options.share} of wave ${k} was applied`);
|
|
@@ -820,13 +879,14 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
820
879
|
}
|
|
821
880
|
const before = runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
|
|
822
881
|
if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
|
|
823
|
-
const members = planRuns(ctx, wave.runs);
|
|
882
|
+
const { members, pending } = planRuns(ctx, wave.runs);
|
|
824
883
|
const planned = new Map(members.map((m) => [m.member, m]));
|
|
825
|
-
const digest =
|
|
884
|
+
const digest = opWaveSetDigest(spec, members);
|
|
885
|
+
const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
|
|
826
886
|
const after = runHooks(ctx, "after-plan", rules.hooks["after-plan"], wave.runs, planned, digest);
|
|
827
887
|
if (after.failed) return failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`);
|
|
828
888
|
holds.push(...before.holds, ...after.holds);
|
|
829
|
-
const gate =
|
|
889
|
+
const gate = opWaveGateName(spec, k);
|
|
830
890
|
const hooks = Object.fromEntries(Object.entries(rules.hooks).filter(([, list]) => list.length > 0));
|
|
831
891
|
const decision: OpWaveDecision = {
|
|
832
892
|
wave: k,
|
|
@@ -844,6 +904,9 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
844
904
|
...(holds.length > 0 ? { heldBy: holds } : {}),
|
|
845
905
|
...(rules.spend ? { spend: "base" as const } : spec.approvals?.spend === true ? { spend: "change" as const } : {}),
|
|
846
906
|
...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
|
|
907
|
+
...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
|
|
908
|
+
...(pending ? { pending } : {}),
|
|
909
|
+
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
847
910
|
};
|
|
848
911
|
if (opWaveNeedsApproval(policy, members) || holds.length > 0) await settleGate(options, ctx, decision);
|
|
849
912
|
// A wide wave's shares apply under the deciding job's approval, so it is spent here, once.
|
|
@@ -905,9 +968,11 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
905
968
|
op: spec.name,
|
|
906
969
|
gate,
|
|
907
970
|
planDigest: digest,
|
|
908
|
-
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
971
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
909
972
|
now,
|
|
910
973
|
...(spent ? { spent } : {}),
|
|
974
|
+
...(decision.pending ? { fields: decision.pending } : {}),
|
|
975
|
+
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
911
976
|
});
|
|
912
977
|
if (check.satisfied) {
|
|
913
978
|
decision.status = "approved";
|
|
@@ -1279,8 +1344,8 @@ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Pr
|
|
|
1279
1344
|
head,
|
|
1280
1345
|
timestamp: options.now ?? new Date().toISOString(),
|
|
1281
1346
|
waves: spec.waves.map((wave, i) => {
|
|
1282
|
-
const members = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1283
|
-
return { wave: i + 1, name: wave.name, digest:
|
|
1347
|
+
const { members } = planRuns({ spec, k: i + 1, wave, cwd, exec }, wave.runs);
|
|
1348
|
+
return { wave: i + 1, name: wave.name, digest: opWaveSetDigest(spec, members) };
|
|
1284
1349
|
}),
|
|
1285
1350
|
};
|
|
1286
1351
|
await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
|
package/src/op/op-waves.test.ts
CHANGED
|
@@ -663,3 +663,99 @@ describe("apply-stage hooks run by the apply, under its lock (#3804)", () => {
|
|
|
663
663
|
h.done();
|
|
664
664
|
});
|
|
665
665
|
});
|
|
666
|
+
|
|
667
|
+
describe("a product's wave gate moved onto the waves (#3821)", () => {
|
|
668
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
669
|
+
const tg: OpWavesSpec = {
|
|
670
|
+
name: "tf-apply",
|
|
671
|
+
op: "tf-apply",
|
|
672
|
+
gateName: "wave-{k}",
|
|
673
|
+
digest: { over: "changing" },
|
|
674
|
+
planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
|
|
675
|
+
applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
|
|
676
|
+
waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }, { target: "nat" }] }],
|
|
677
|
+
};
|
|
678
|
+
function harness(plans: Record<string, { planDigest: string; empty?: boolean }>, extra: Record<string, unknown> = {}, atBase: OpWavesSpec = tg) {
|
|
679
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821-"));
|
|
680
|
+
const calls: string[] = [];
|
|
681
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
682
|
+
calls.push(argv.join(" "));
|
|
683
|
+
const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
|
|
684
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })), ...extra }));
|
|
685
|
+
return 0;
|
|
686
|
+
};
|
|
687
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
|
|
688
|
+
return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
test("the spec checks gateName, digest and neverOverMcp", () => {
|
|
692
|
+
expect(() => assertOpWavesSpec(tg)).not.toThrow();
|
|
693
|
+
expect(() => assertOpWavesSpec({ ...tg, gateName: "wave" })).toThrow(/pattern with \{k\}/);
|
|
694
|
+
expect(() => assertOpWavesSpec({ ...tg, gateName: "{op}-{k}" })).toThrow(/names \{op\}/);
|
|
695
|
+
expect(() => assertOpWavesSpec({ ...tg, digest: { over: "some" as "all" } })).toThrow(/digest.over/);
|
|
696
|
+
expect(() => assertOpWavesSpec({ ...tg, neverOverMcp: "yes" as unknown as boolean })).toThrow(/neverOverMcp/);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("the gate keeps the product's name, and the digest covers only the runs that change something", async () => {
|
|
700
|
+
const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
|
|
701
|
+
const h = harness(plans);
|
|
702
|
+
const gates = memoryGateLedgerPort();
|
|
703
|
+
const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
704
|
+
const changing = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("3") }]);
|
|
705
|
+
expect(result).toMatchObject({ exitCode: 3, decision: { gate: "wave-1", digest: changing, digestOver: "changing", status: "waiting" } });
|
|
706
|
+
expect(gates.appended[0]).toMatchObject({ op: "tf-apply", gate: "wave-1", planDigest: changing });
|
|
707
|
+
h.done();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("an approval a product recorded for its gate and digest before the move lets the wave apply", async () => {
|
|
711
|
+
const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
|
|
712
|
+
const h = harness(plans);
|
|
713
|
+
const changing = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("3") }]);
|
|
714
|
+
const gates = memoryGateLedgerPort({
|
|
715
|
+
pending: [{ version: 1, kind: "pending", op: "tf-apply", gate: "wave-1", timestamp: "2026-10-10T00:00:00Z", expiresAt: "2026-10-12T00:00:00Z", planDigest: changing, neverOverMcp: true }],
|
|
716
|
+
resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T01:00:00Z", planDigest: changing }],
|
|
717
|
+
});
|
|
718
|
+
const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-10T02:00:00Z" });
|
|
719
|
+
expect(result).toMatchObject({ exitCode: 0, decision: { status: "approved", approvedBy: "alice" } });
|
|
720
|
+
expect(gates.appended).toEqual([]);
|
|
721
|
+
h.done();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("a wave whose runs are all empty digests every run, as before", async () => {
|
|
725
|
+
const plans = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3"), empty: true } };
|
|
726
|
+
const h = harness(plans);
|
|
727
|
+
const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
728
|
+
const all = waveSetDigest(["vpc", "dns", "nat"].map((t, i) => ({ member: t, planDigest: digest(String(i + 1)) })));
|
|
729
|
+
expect(result).toMatchObject({ exitCode: 0, decision: { digest: all, status: "not-required" } });
|
|
730
|
+
h.done();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("the plans file's pending fields and the spec's neverOverMcp land on the pending fact; chant's own fields are refused", async () => {
|
|
734
|
+
const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") }, nat: { planDigest: digest("3") } };
|
|
735
|
+
const members = [{ member: "vpc", planDigest: digest("1") }];
|
|
736
|
+
const h = harness(plans, { pending: { commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat" } });
|
|
737
|
+
// neverOverMcp at base counts even when the change drops it.
|
|
738
|
+
const gates = memoryGateLedgerPort();
|
|
739
|
+
const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify({ ...tg, neverOverMcp: true }) }), gates });
|
|
740
|
+
expect(result.exitCode).toBe(3);
|
|
741
|
+
expect(gates.appended[0]).toMatchObject({ commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat", neverOverMcp: true, gate: "wave-1", op: "tf-apply" });
|
|
742
|
+
h.done();
|
|
743
|
+
|
|
744
|
+
const bad = harness(plans, { pending: { gate: "other" } });
|
|
745
|
+
await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: bad.cwd, exec: bad.exec, show: bad.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets gate, which chant writes itself/);
|
|
746
|
+
bad.done();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("a spec without the new fields names its gates and digests as before", async () => {
|
|
750
|
+
const plain: OpWavesSpec = { ...tg, gateName: undefined, digest: undefined, name: "roots" };
|
|
751
|
+
const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
|
|
752
|
+
const h = harness(plans, {}, plain);
|
|
753
|
+
const gates = memoryGateLedgerPort();
|
|
754
|
+
const result = await runOpWave({ spec: plain, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
755
|
+
const all = waveSetDigest(["vpc", "dns", "nat"].map((t, i) => ({ member: t, planDigest: digest(String(i + 1)) })));
|
|
756
|
+
expect(result.decision).toMatchObject({ gate: "roots-wave-1", digest: all });
|
|
757
|
+
expect(result.decision).not.toHaveProperty("digestOver");
|
|
758
|
+
expect(gates.appended[0]).not.toHaveProperty("neverOverMcp");
|
|
759
|
+
h.done();
|
|
760
|
+
});
|
|
761
|
+
});
|
package/src/op/op-waves.ts
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
*
|
|
23
23
|
* ## The gate
|
|
24
24
|
*
|
|
25
|
-
* Wave k waits on `waveGateName(<gate>, k)`,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Wave k waits on `waveGateName(<gate>, k)`, or the spec's `gateName` pattern
|
|
26
|
+
* ({@link opWaveGateName}, #3821), recorded under the spec's name, bound to
|
|
27
|
+
* {@link waveSetDigest} over its runs' plan digests (`../gated-waves.ts`, the
|
|
28
|
+
* same digest a component fan-out's wave binds), or with `digest.over:
|
|
29
|
+
* "changing"` over the runs whose plan changes something. Whether it waits at all
|
|
28
30
|
* is the wave's gate policy, read from the base commit's copy of the spec
|
|
29
31
|
* file, so a change cannot loosen the rule that gates its own merge.
|
|
30
32
|
*
|
|
@@ -170,12 +172,28 @@ export interface OpWave {
|
|
|
170
172
|
* `{plan}` is replaced per run.
|
|
171
173
|
*/
|
|
172
174
|
export interface OpWavesSpec {
|
|
173
|
-
/** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k
|
|
175
|
+
/** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`, or the `gateName` pattern). */
|
|
174
176
|
name: string;
|
|
175
177
|
/** The Op a run runs when it names none. */
|
|
176
178
|
op: string;
|
|
177
179
|
/** The base gate name. Default: `name`. */
|
|
178
180
|
gate?: string;
|
|
181
|
+
/**
|
|
182
|
+
* The name of wave k's gate (#3821): `{gate}` is the base gate name and
|
|
183
|
+
* `{k}` the wave's number. Default `"{gate}-wave-{k}"`. A product moving
|
|
184
|
+
* its own wave gates onto these waves names its old pattern here (such as
|
|
185
|
+
* `"wave-{k}"`), so the approvals already on the ledger keep their gate.
|
|
186
|
+
*/
|
|
187
|
+
gateName?: string;
|
|
188
|
+
/** Which runs the set digest covers (#3821). Default: every run. */
|
|
189
|
+
digest?: OpWavesDigest;
|
|
190
|
+
/**
|
|
191
|
+
* The waves' pending facts carry `neverOverMcp` (#3821, chant#3485): their
|
|
192
|
+
* gates are never resolved over MCP or ACP, whichever channel reached
|
|
193
|
+
* them. Read at the base commit too, and either one setting it is enough,
|
|
194
|
+
* so a change cannot drop the rule for its own gates.
|
|
195
|
+
*/
|
|
196
|
+
neverOverMcp?: boolean;
|
|
179
197
|
/** Branches whose pushes run the waves. Default `["main"]`. */
|
|
180
198
|
branches?: string[];
|
|
181
199
|
/**
|
|
@@ -230,6 +248,28 @@ export interface OpWavesSpec {
|
|
|
230
248
|
notify?: NotifyConfig;
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
/** Which runs a wave's set digest covers (#3821). */
|
|
252
|
+
export interface OpWavesDigest {
|
|
253
|
+
/**
|
|
254
|
+
* `all` (the default): every run's plan. `changing`: only the runs whose
|
|
255
|
+
* plan is not `empty`, or every run when all of them are empty. A product
|
|
256
|
+
* whose plan note shows a digest over the plans that change something
|
|
257
|
+
* names `changing`, so the digest the wave asks approval for is the one
|
|
258
|
+
* the note showed.
|
|
259
|
+
*/
|
|
260
|
+
over?: "all" | "changing";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
|
|
264
|
+
|
|
265
|
+
/** The default gate name pattern. */
|
|
266
|
+
export const OP_WAVE_DEFAULT_GATE_NAME = "{gate}-wave-{k}";
|
|
267
|
+
|
|
268
|
+
/** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
|
|
269
|
+
export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
|
|
270
|
+
return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
|
|
271
|
+
}
|
|
272
|
+
|
|
233
273
|
/** What happens to an approval once a wave applies under it (#3784). */
|
|
234
274
|
export interface OpWavesApprovals {
|
|
235
275
|
/**
|
|
@@ -331,6 +371,22 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
331
371
|
if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
|
|
332
372
|
}
|
|
333
373
|
}
|
|
374
|
+
if (spec.gateName !== undefined) {
|
|
375
|
+
if (typeof spec.gateName !== "string" || !spec.gateName.includes("{k}") || /\s/.test(spec.gateName)) {
|
|
376
|
+
throw new Error(`${where}: gateName must be a pattern with {k}, the wave's number, and no spaces, such as "${OP_WAVE_DEFAULT_GATE_NAME}" or "wave-{k}".`);
|
|
377
|
+
}
|
|
378
|
+
const left = spec.gateName.replaceAll("{gate}", "").replaceAll("{k}", "").match(/\{[^}]*\}/);
|
|
379
|
+
if (left) throw new Error(`${where}: gateName names ${left[0]}; the pattern fills in {gate} and {k} only.`);
|
|
380
|
+
}
|
|
381
|
+
if (spec.digest !== undefined) {
|
|
382
|
+
const d = spec.digest as unknown;
|
|
383
|
+
if (d === null || typeof d !== "object" || Array.isArray(d)) throw new Error(`${where}: \`digest\` must be { "over"?: "all" | "changing" }.`);
|
|
384
|
+
for (const [key, value] of Object.entries(d as Record<string, unknown>)) {
|
|
385
|
+
if (key !== "over") throw new Error(`${where}: digest.${key} is not a setting; the one there is digest.over.`);
|
|
386
|
+
if (!OP_WAVES_DIGEST_OVER.includes(value as "all")) throw new Error(`${where}: digest.over is ${JSON.stringify(value)}; use "all" or "changing".`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
|
|
334
390
|
if (spec.notify !== undefined) {
|
|
335
391
|
const problem = notifyConfigProblem(spec.notify, "notify");
|
|
336
392
|
if (problem) throw new Error(`${where}: ${problem}`);
|
package/src/op/steward-points.ts
CHANGED
|
@@ -121,6 +121,13 @@ export interface AskPointInRunOptions {
|
|
|
121
121
|
on?: string;
|
|
122
122
|
/** The steward turn, when the caller has it. Default: this process's (`currentStewardTurn`). */
|
|
123
123
|
turn?: StewardTurn | null;
|
|
124
|
+
/**
|
|
125
|
+
* Where a new question goes (#3815): the working tree, or `chant/lifecycle`.
|
|
126
|
+
* Default: the ledger in a steward's turn and the tree otherwise
|
|
127
|
+
* (`askPoint`'s rule). A run whose checkout does not outlive it, such as a
|
|
128
|
+
* CI job, passes `"ledger"` so a person can answer after the job ends.
|
|
129
|
+
*/
|
|
130
|
+
store?: "tree" | "ledger";
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
/** What a steward reaches through its broker, read from its declaration when the turn named only the steward. */
|
|
@@ -178,6 +185,7 @@ export async function askPointInRun(opts: AskPointInRunOptions): Promise<{ answe
|
|
|
178
185
|
...(opts.kind !== undefined ? { kind: opts.kind } : {}),
|
|
179
186
|
...(ask ? { ask } : {}),
|
|
180
187
|
...(opts.on !== undefined ? { on: opts.on } : {}),
|
|
188
|
+
...(opts.store !== undefined ? { store: opts.store } : {}),
|
|
181
189
|
...(turn ? { steward: { name: turn.steward, ...(turn.run ? { run: turn.run } : {}) } } : {}),
|
|
182
190
|
});
|
|
183
191
|
if ("error" in doc) throw new Error(`decision point ${opts.point}: ${doc.error.code}: ${doc.error.message}`);
|
|
@@ -148,6 +148,11 @@ describe("decideOverride", () => {
|
|
|
148
148
|
expect(parseAppliedOverrides(`${JSON.stringify(applied)}\nnot json\n{"version":1,"kind":"finished"}\n`)).toEqual([applied]);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test("takes just the subject, digest, time and approver (#3823)", () => {
|
|
152
|
+
const slim = appliedOverride({ subject: ROOT, digest: DIGEST, at: first.timestamp, by: "github:alice" }, { timestamp: "2026-10-11T11:05:00.000Z", runId: "42", commit: "c".repeat(40) });
|
|
153
|
+
expect(slim).toEqual(applied);
|
|
154
|
+
});
|
|
155
|
+
|
|
151
156
|
test("a spent override does not count for the same plan again", () => {
|
|
152
157
|
const d = decideOverride(input([first], { applied: [applied] }));
|
|
153
158
|
expect(d.status === "none" && d.refusals).toEqual([expect.stringMatching(/is spent: a run applied under it at 2026-10-11T11:05:00/)]);
|
package/src/override-gate.ts
CHANGED
|
@@ -359,8 +359,14 @@ export function decideOverride(input: OverrideInput): OverrideDecision {
|
|
|
359
359
|
};
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
/**
|
|
363
|
-
|
|
362
|
+
/**
|
|
363
|
+
* The applied record a run appends before it applies under `override`, which
|
|
364
|
+
* spends it. Appending it is the caller's. It reads only the subject, the
|
|
365
|
+
* override digest and the override's time and approver, so a product that
|
|
366
|
+
* carries those through its own record can pass them without the ledger line
|
|
367
|
+
* (#3823); a whole {@link StandingOverride} still works.
|
|
368
|
+
*/
|
|
369
|
+
export function appliedOverride(override: Pick<StandingOverride, "subject" | "digest" | "at" | "by">, at: { timestamp: string; runId?: string; commit?: string }): AppliedOverrideRecord {
|
|
364
370
|
return {
|
|
365
371
|
version: 1,
|
|
366
372
|
kind: "applied",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate-point decision bundles small (#3816).
|
|
3
|
+
*
|
|
4
|
+
* terragucci's wave gate asks a decision point, and terragucci ships as one
|
|
5
|
+
* bundled file with no TypeScript compiler, chant CLI, lint or codegen code
|
|
6
|
+
* in it. `@intentius/chant/op/gate-point-run` reached all of those through
|
|
7
|
+
* the workspace asker and the decide activity. `@intentius/chant/point-decision`
|
|
8
|
+
* holds the decision with the asker and the ledger port injected. This
|
|
9
|
+
* bundles it the way terragucci would and reads esbuild's own list of what
|
|
10
|
+
* went in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { build } from "esbuild";
|
|
15
|
+
import { describe, expect, test } from "vitest";
|
|
16
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
17
|
+
|
|
18
|
+
const FORBIDDEN = [
|
|
19
|
+
/node_modules\//,
|
|
20
|
+
/\/src\/cli\//,
|
|
21
|
+
/\/src\/workspace\//,
|
|
22
|
+
/\/src\/fold\//,
|
|
23
|
+
/\/src\/lint\//,
|
|
24
|
+
/\/src\/codegen\//,
|
|
25
|
+
/\/src\/op\/activities\//,
|
|
26
|
+
/\/src\/lifecycle\/git\.ts$/,
|
|
27
|
+
/\/src\/lifecycle\/gate-ledger\.ts$/,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe("the point-decision bundle", () => {
|
|
31
|
+
test("holds the gate-point decision and its pure helpers, and no workspace, CLI, package or git code", async () => {
|
|
32
|
+
const result = await build({
|
|
33
|
+
entryPoints: [join(REPO, "packages", "core", "src", "point-decision.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
|
+
// The plan digest is the one chant computes, which brings its canonical JSON with it, as in gate-decision.
|
|
47
|
+
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
|
|
48
|
+
"packages/core/src/content-digest.ts",
|
|
49
|
+
"packages/core/src/declarable.ts",
|
|
50
|
+
"packages/core/src/effect-receipt.ts",
|
|
51
|
+
"packages/core/src/intrinsic.ts",
|
|
52
|
+
"packages/core/src/lifecycle/gate-ledger-select.ts",
|
|
53
|
+
"packages/core/src/lifecycle/gate-pending.ts",
|
|
54
|
+
"packages/core/src/lifecycle/plan-digest.ts",
|
|
55
|
+
"packages/core/src/op/duration.ts",
|
|
56
|
+
"packages/core/src/op/gate-point.ts",
|
|
57
|
+
"packages/core/src/point-decision.ts",
|
|
58
|
+
]);
|
|
59
|
+
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
60
|
+
expect(bytes).toBeLessThan(24 * 1024);
|
|
61
|
+
});
|
|
62
|
+
});
|