@intentius/chant 0.124.2 → 0.124.4
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/operator.d.ts.map +1 -1
- 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 +5 -4
- 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-notify.d.ts +2 -1
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +123 -10
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +71 -6
- 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/discovery-skip.e2e.test.ts +4 -1
- package/src/cli/handlers/operator.ts +25 -11
- 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/notify-bundle.test.ts +20 -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 +12 -1
- package/src/op/op-waves-notify.ts +19 -2
- package/src/op/op-waves-run.ts +541 -76
- package/src/op/op-waves.test.ts +229 -0
- package/src/op/op-waves.ts +110 -10
- 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/op-waves.test.ts
CHANGED
|
@@ -663,3 +663,232 @@ 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
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("gates and holds a product's wave needs (#3821)", () => {
|
|
764
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
765
|
+
const tg: OpWavesSpec = {
|
|
766
|
+
name: "tf-apply",
|
|
767
|
+
op: "tf-apply",
|
|
768
|
+
gateName: "wave-{k}",
|
|
769
|
+
planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
|
|
770
|
+
applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
|
|
771
|
+
hooks: { after: { gate: [{ name: "report", command: ["tg", "report", "{runs}"] }] } },
|
|
772
|
+
waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }], gate: "always" }],
|
|
773
|
+
};
|
|
774
|
+
type Plan = { planDigest: string; empty?: boolean; denied?: { rules: string[] } };
|
|
775
|
+
function harness(plans: Record<string, Plan>, opts: { extra?: Record<string, unknown>; atBase?: OpWavesSpec; hook?: (argv: string[], env: Record<string, string>, dir: string) => number } = {}) {
|
|
776
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821b-"));
|
|
777
|
+
const calls: string[] = [];
|
|
778
|
+
const envs: Array<Record<string, string>> = [];
|
|
779
|
+
const exec: OpWaveExec = (argv, dir, env = {}) => {
|
|
780
|
+
calls.push(argv.join(" "));
|
|
781
|
+
envs.push(env);
|
|
782
|
+
if (argv[0] !== "tg") return opts.hook?.(argv, env, dir) ?? 0;
|
|
783
|
+
if (argv[1] === "report") return opts.hook?.(argv, env, dir) ?? 0;
|
|
784
|
+
const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
|
|
785
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })), ...(opts.extra ?? {}) }));
|
|
786
|
+
return 0;
|
|
787
|
+
};
|
|
788
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
|
|
789
|
+
return { cwd, calls, envs, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
790
|
+
}
|
|
791
|
+
const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
|
|
792
|
+
runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
|
|
793
|
+
|
|
794
|
+
test("the spec checks after.gate hooks and override", () => {
|
|
795
|
+
expect(() => assertOpWavesSpec(tg)).not.toThrow();
|
|
796
|
+
expect(() => assertOpWavesSpec({ ...tg, hooks: { before: { gate: [{ command: ["x"] }] } } as OpWavesSpec["hooks"] })).toThrow(/runs after the gate/);
|
|
797
|
+
expect(() => assertOpWavesSpec({ ...tg, hooks: { after: { gate: [{ command: ["x"], onFailure: "approve" }] } } })).toThrow(/already decided/);
|
|
798
|
+
expect(() => assertOpWavesSpec({ ...tg, override: { overriders: "" } })).toThrow(/override.overriders/);
|
|
799
|
+
expect(() => assertOpWavesSpec({ ...tg, override: { who: "x" } as OpWavesSpec["override"] })).toThrow(/override.who/);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("the after-gate hook runs when the wave waits, with the decision in its environment", async () => {
|
|
803
|
+
const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } });
|
|
804
|
+
const result = await run(h);
|
|
805
|
+
expect(result.exitCode).toBe(3);
|
|
806
|
+
const at = h.calls.indexOf(`tg report .chant/op-waves/tf-apply/wave-1/runs.json`);
|
|
807
|
+
expect(at).toBeGreaterThan(0);
|
|
808
|
+
const env = h.envs[at]!;
|
|
809
|
+
expect(env).toMatchObject({ CHANT_HOOK: "after-gate", CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-apply", CHANT_GATE: "wave-1", CHANT_GATE_DIGEST: result.decision!.digest, CHANT_GATE_RECORDED: "true", CHANT_GATE_WAITING_SINCE: "2026-10-11T00:00:00.000Z" });
|
|
810
|
+
expect(env.CHANT_GATE_APPROVE).toBe(`chant approve tf-apply wave-1 --plan ${result.decision!.digest}`);
|
|
811
|
+
const record = JSON.parse(readFileSync(join(h.cwd, env.CHANT_DECISION!), "utf-8")) as OpWaveDecision;
|
|
812
|
+
expect(record).toMatchObject({ status: "waiting", recorded: true, pendingFact: { gate: "wave-1", planDigest: result.decision!.digest } });
|
|
813
|
+
expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
|
|
814
|
+
h.done();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("the after-gate hook names the approval of another digest; a failing one stops a wave that would apply, and not a waiting one", async () => {
|
|
818
|
+
const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook: () => 1 });
|
|
819
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("9") }] });
|
|
820
|
+
const waiting = await run(h, gates);
|
|
821
|
+
expect(waiting.exitCode).toBe(3);
|
|
822
|
+
expect(waiting.decision).toMatchObject({ mismatch: { approved: digest("9"), resolvedBy: "alice" } });
|
|
823
|
+
h.done();
|
|
824
|
+
|
|
825
|
+
const open = harness({ vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } }, { hook: () => 1 });
|
|
826
|
+
const stopped = await run(open);
|
|
827
|
+
expect(stopped).toMatchObject({ exitCode: 1, decision: { status: "not-required" } });
|
|
828
|
+
expect(open.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
|
|
829
|
+
open.done();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("a run the policy denied refuses the wave (exit 7) and records a denial an override answers", async () => {
|
|
833
|
+
const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } });
|
|
834
|
+
const gates = memoryGateLedgerPort();
|
|
835
|
+
const result = await run(h, gates);
|
|
836
|
+
expect(result).toMatchObject({ exitCode: 7, decision: { status: "refused", refused: { reason: "policy", runs: ["vpc"] } } });
|
|
837
|
+
expect(gates.appended).toHaveLength(1);
|
|
838
|
+
expect(gates.appended[0]).toMatchObject({ op: "policy-override", gate: "vpc", plan: digest("1"), rules: ["no-public-s3"], members: [{ member: "vpc", planDigest: digest("1") }] });
|
|
839
|
+
expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "refused" });
|
|
840
|
+
expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
|
|
841
|
+
const { opWaveNotifyFields } = await import("./op-waves-notify");
|
|
842
|
+
expect(opWaveNotifyFields(tg, 1, { result })).toMatchObject({ event: "refused", status: "denied", targets: ["vpc"] });
|
|
843
|
+
h.done();
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("an override by a listed overrider lets the denied run through, and is spent before the apply", async () => {
|
|
847
|
+
const { overrideDigest } = await import("../override-gate");
|
|
848
|
+
const od = overrideDigest("vpc", digest("1"), ["no-public-s3"]);
|
|
849
|
+
const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2"), empty: true } }, { extra: { overriders: ["github:carol"] }, atBase: { ...tg, waves: [{ ...tg.waves[0]!, gate: "never" }] } });
|
|
850
|
+
const gates = memoryGateLedgerPort({
|
|
851
|
+
resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "the bucket is the public docs site" }],
|
|
852
|
+
});
|
|
853
|
+
const result = await run(h, gates);
|
|
854
|
+
expect(result).toMatchObject({ exitCode: 0, applied: ["vpc", "dns"] });
|
|
855
|
+
expect(result.decision!.members.find((m) => m.member === "vpc")).toMatchObject({ override: { by: "github:carol", digest: od } });
|
|
856
|
+
expect(gates.applied).toEqual([expect.objectContaining({ op: "policy-override", gate: "vpc", planDigest: od, approvedBy: "github:carol" })]);
|
|
857
|
+
h.done();
|
|
858
|
+
|
|
859
|
+
// Somebody the list does not name does not count.
|
|
860
|
+
const other = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra: { overriders: ["github:dave"] } });
|
|
861
|
+
expect((await run(other, memoryGateLedgerPort({ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "ok" }] }))).exitCode).toBe(7);
|
|
862
|
+
other.done();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("a hook holds the wave on another gate: it waits on that gate's digest, then runs again once approved", async () => {
|
|
866
|
+
const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
|
|
867
|
+
const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
|
|
868
|
+
const hook = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
869
|
+
if (argv[0] !== "migrate") return 0;
|
|
870
|
+
if (env.CHANT_HOLD_APPROVED) return 0;
|
|
871
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), pending: { members: [] } }));
|
|
872
|
+
return 3;
|
|
873
|
+
};
|
|
874
|
+
const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook, atBase: spec });
|
|
875
|
+
const gates = memoryGateLedgerPort();
|
|
876
|
+
const waiting = await run(h, gates, spec);
|
|
877
|
+
expect(waiting).toMatchObject({ exitCode: 3, decision: { status: "waiting", heldOn: { op: "tf-migrate", gate: "split-queues", digest: digest("m"), status: "waiting" } } });
|
|
878
|
+
expect(waiting.decision!.approve).toBe(`chant approve tf-migrate split-queues --plan ${digest("m")}`);
|
|
879
|
+
expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
|
|
880
|
+
expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
|
|
881
|
+
expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
|
|
882
|
+
h.done();
|
|
883
|
+
|
|
884
|
+
const again = harness({ vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } }, { hook, atBase: spec });
|
|
885
|
+
const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") }] });
|
|
886
|
+
const result = await run(again, approved, spec);
|
|
887
|
+
expect(result.exitCode).toBe(0);
|
|
888
|
+
const reruns = again.envs.filter((e, i) => again.calls[i]!.startsWith("migrate"));
|
|
889
|
+
expect(reruns).toHaveLength(2);
|
|
890
|
+
expect(JSON.parse(reruns[1]!.CHANT_HOLD_APPROVED!)).toMatchObject({ op: "tf-migrate", gate: "split-queues", approvedBy: "alice" });
|
|
891
|
+
expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "passed"]);
|
|
892
|
+
again.done();
|
|
893
|
+
});
|
|
894
|
+
});
|
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
|
*
|
|
@@ -56,6 +58,15 @@
|
|
|
56
58
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
57
59
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The variables a wave job reads its notify secrets from, for the lexicons
|
|
63
|
+
* that generate wave pipelines. Re-exported here because a lexicon's source
|
|
64
|
+
* in this repository cannot import `@intentius/chant/notify`: under any
|
|
65
|
+
* loader that entry resolves its `import` condition to the compiled
|
|
66
|
+
* `dist/notify.js` (#3803), which only a packed release carries.
|
|
67
|
+
*/
|
|
68
|
+
export { notifyVariables } from "../notify";
|
|
69
|
+
|
|
59
70
|
/** When a wave waits for an approval. */
|
|
60
71
|
export type OpWaveGatePolicy =
|
|
61
72
|
/** Whenever the wave changes something. */
|
|
@@ -100,12 +111,17 @@ export interface OpWaveHook {
|
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/** The stages a hook runs around. */
|
|
103
|
-
export type OpWaveHookStage = "plan" | "apply";
|
|
114
|
+
export type OpWaveHookStage = "plan" | "apply" | "gate";
|
|
104
115
|
|
|
105
116
|
/** When a hook runs. */
|
|
106
|
-
export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "after-apply";
|
|
117
|
+
export type OpWaveHookMoment = "before-plan" | "after-plan" | "after-gate" | "before-apply" | "after-apply";
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Every moment, in the order a wave reaches them. `after-gate` (#3821) runs
|
|
121
|
+
* once the gate decided, whatever it decided: approved, let through,
|
|
122
|
+
* waiting, or refused by a policy denial.
|
|
123
|
+
*/
|
|
124
|
+
export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "after-gate", "before-apply", "after-apply"];
|
|
109
125
|
|
|
110
126
|
/**
|
|
111
127
|
* Who runs the `before.apply` and `after.apply` hooks (#3804). `chant`: the
|
|
@@ -120,7 +136,8 @@ export const OP_WAVE_APPLY_HOOKS_RUNNERS: readonly OpWaveApplyHooksRunner[] = ["
|
|
|
120
136
|
/** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
|
|
121
137
|
export interface OpWaveHooks {
|
|
122
138
|
before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
123
|
-
after
|
|
139
|
+
/** `after.gate` (#3821) runs once the gate decided, whatever it decided, with the decision in its environment. */
|
|
140
|
+
after?: { plan?: OpWaveHook[]; gate?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
124
141
|
/** Who runs the apply-stage hooks (#3804). Default `{ runBy: "chant" }`. A wave's own setting wins over the spec's. */
|
|
125
142
|
apply?: { runBy?: OpWaveApplyHooksRunner };
|
|
126
143
|
}
|
|
@@ -170,12 +187,28 @@ export interface OpWave {
|
|
|
170
187
|
* `{plan}` is replaced per run.
|
|
171
188
|
*/
|
|
172
189
|
export interface OpWavesSpec {
|
|
173
|
-
/** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k
|
|
190
|
+
/** The pipeline's name, and the op the wave gates are recorded under (`chant approve <name> <gate>-wave-<k>`, or the `gateName` pattern). */
|
|
174
191
|
name: string;
|
|
175
192
|
/** The Op a run runs when it names none. */
|
|
176
193
|
op: string;
|
|
177
194
|
/** The base gate name. Default: `name`. */
|
|
178
195
|
gate?: string;
|
|
196
|
+
/**
|
|
197
|
+
* The name of wave k's gate (#3821): `{gate}` is the base gate name and
|
|
198
|
+
* `{k}` the wave's number. Default `"{gate}-wave-{k}"`. A product moving
|
|
199
|
+
* its own wave gates onto these waves names its old pattern here (such as
|
|
200
|
+
* `"wave-{k}"`), so the approvals already on the ledger keep their gate.
|
|
201
|
+
*/
|
|
202
|
+
gateName?: string;
|
|
203
|
+
/** Which runs the set digest covers (#3821). Default: every run. */
|
|
204
|
+
digest?: OpWavesDigest;
|
|
205
|
+
/**
|
|
206
|
+
* The waves' pending facts carry `neverOverMcp` (#3821, chant#3485): their
|
|
207
|
+
* gates are never resolved over MCP or ACP, whichever channel reached
|
|
208
|
+
* them. Read at the base commit too, and either one setting it is enough,
|
|
209
|
+
* so a change cannot drop the rule for its own gates.
|
|
210
|
+
*/
|
|
211
|
+
neverOverMcp?: boolean;
|
|
179
212
|
/** Branches whose pushes run the waves. Default `["main"]`. */
|
|
180
213
|
branches?: string[];
|
|
181
214
|
/**
|
|
@@ -210,6 +243,13 @@ export interface OpWavesSpec {
|
|
|
210
243
|
hooks?: OpWaveHooks;
|
|
211
244
|
/** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
|
|
212
245
|
approvals?: OpWavesApprovals;
|
|
246
|
+
/**
|
|
247
|
+
* Who may override a run the product's policy denied at plan time (#3821).
|
|
248
|
+
* A plan entry with `denied` refuses the wave unless an override on
|
|
249
|
+
* `_gates/policy-override.jsonl` counts for that run, its plan digest and
|
|
250
|
+
* the rules (`@intentius/chant/override-gate`).
|
|
251
|
+
*/
|
|
252
|
+
override?: OpWavesOverride;
|
|
213
253
|
waves: OpWave[];
|
|
214
254
|
/**
|
|
215
255
|
* A scheduled job that starts again a wave job whose approval has arrived
|
|
@@ -230,6 +270,38 @@ export interface OpWavesSpec {
|
|
|
230
270
|
notify?: NotifyConfig;
|
|
231
271
|
}
|
|
232
272
|
|
|
273
|
+
/** Which runs a wave's set digest covers (#3821). */
|
|
274
|
+
export interface OpWavesDigest {
|
|
275
|
+
/**
|
|
276
|
+
* `all` (the default): every run's plan. `changing`: only the runs whose
|
|
277
|
+
* plan is not `empty`, or every run when all of them are empty. A product
|
|
278
|
+
* whose plan note shows a digest over the plans that change something
|
|
279
|
+
* names `changing`, so the digest the wave asks approval for is the one
|
|
280
|
+
* the note showed.
|
|
281
|
+
*/
|
|
282
|
+
over?: "all" | "changing";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
|
|
286
|
+
|
|
287
|
+
/** The default gate name pattern. */
|
|
288
|
+
export const OP_WAVE_DEFAULT_GATE_NAME = "{gate}-wave-{k}";
|
|
289
|
+
|
|
290
|
+
/** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
|
|
291
|
+
export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
|
|
292
|
+
return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Where a wave reads who may override a policy denial (#3821). */
|
|
296
|
+
export interface OpWavesOverride {
|
|
297
|
+
/**
|
|
298
|
+
* The overriders file, read at the base commit. Default
|
|
299
|
+
* `.chant/overriders`. A plans file that names `overriders` (a product
|
|
300
|
+
* that keeps its list in its own config at base) wins over it.
|
|
301
|
+
*/
|
|
302
|
+
overriders?: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
233
305
|
/** What happens to an approval once a wave applies under it (#3784). */
|
|
234
306
|
export interface OpWavesApprovals {
|
|
235
307
|
/**
|
|
@@ -331,6 +403,30 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
331
403
|
if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
|
|
332
404
|
}
|
|
333
405
|
}
|
|
406
|
+
if (spec.gateName !== undefined) {
|
|
407
|
+
if (typeof spec.gateName !== "string" || !spec.gateName.includes("{k}") || /\s/.test(spec.gateName)) {
|
|
408
|
+
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}".`);
|
|
409
|
+
}
|
|
410
|
+
const left = spec.gateName.replaceAll("{gate}", "").replaceAll("{k}", "").match(/\{[^}]*\}/);
|
|
411
|
+
if (left) throw new Error(`${where}: gateName names ${left[0]}; the pattern fills in {gate} and {k} only.`);
|
|
412
|
+
}
|
|
413
|
+
if (spec.digest !== undefined) {
|
|
414
|
+
const d = spec.digest as unknown;
|
|
415
|
+
if (d === null || typeof d !== "object" || Array.isArray(d)) throw new Error(`${where}: \`digest\` must be { "over"?: "all" | "changing" }.`);
|
|
416
|
+
for (const [key, value] of Object.entries(d as Record<string, unknown>)) {
|
|
417
|
+
if (key !== "over") throw new Error(`${where}: digest.${key} is not a setting; the one there is digest.over.`);
|
|
418
|
+
if (!OP_WAVES_DIGEST_OVER.includes(value as "all")) throw new Error(`${where}: digest.over is ${JSON.stringify(value)}; use "all" or "changing".`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (spec.override !== undefined) {
|
|
422
|
+
const o = spec.override as unknown;
|
|
423
|
+
if (o === null || typeof o !== "object" || Array.isArray(o)) throw new Error(`${where}: \`override\` must be { "overriders"?: "<file at base>" }.`);
|
|
424
|
+
for (const [key, value] of Object.entries(o as Record<string, unknown>)) {
|
|
425
|
+
if (key !== "overriders") throw new Error(`${where}: override.${key} is not a setting; the one there is override.overriders.`);
|
|
426
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}: override.overriders must be a path, such as ".chant/overriders".`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
|
|
334
430
|
if (spec.notify !== undefined) {
|
|
335
431
|
const problem = notifyConfigProblem(spec.notify, "notify");
|
|
336
432
|
if (problem) throw new Error(`${where}: ${problem}`);
|
|
@@ -393,7 +489,8 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
|
|
|
393
489
|
if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
|
|
394
490
|
for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
|
|
395
491
|
const at = `hooks.${side}.${stage}`;
|
|
396
|
-
if (stage
|
|
492
|
+
if (stage === "gate" && side === "before") throw new Error(`${where}: ${at} is not a moment; a hook runs after the gate (hooks.after.gate), or before the plan or the apply.`);
|
|
493
|
+
if (stage !== "plan" && stage !== "apply" && stage !== "gate") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply, or after the gate.`);
|
|
397
494
|
if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
|
|
398
495
|
list.forEach((hook: OpWaveHook, i: number) => {
|
|
399
496
|
const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
|
|
@@ -405,6 +502,9 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
|
|
|
405
502
|
if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
|
|
406
503
|
throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
|
|
407
504
|
}
|
|
505
|
+
if (hook.onFailure === "approve" && stage === "gate") {
|
|
506
|
+
throw new Error(`${where}: ${which} has onFailure "approve", but the gate has already decided when it runs. Use "fail", or hold the wave from an after.plan hook.`);
|
|
507
|
+
}
|
|
408
508
|
if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
|
|
409
509
|
if (!hook.perRun) {
|
|
410
510
|
const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
|
|
@@ -422,7 +522,7 @@ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name:
|
|
|
422
522
|
const wave = spec.waves.find((w) => w.name === name);
|
|
423
523
|
const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
|
|
424
524
|
const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
|
|
425
|
-
return hooks?.[side]?.[stage] ?? [];
|
|
525
|
+
return (hooks?.[side] as Partial<Record<OpWaveHookStage, OpWaveHook[]>> | undefined)?.[stage] ?? [];
|
|
426
526
|
};
|
|
427
527
|
return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
|
|
428
528
|
}
|
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
|
+
});
|