@intentius/chant 0.124.25 → 0.124.27

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 (113) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/gate-decision.d.ts +1 -1
  8. package/dist/gate-decision.d.ts.map +1 -1
  9. package/dist/gate-decision.js +1 -1
  10. package/dist/lifecycle/gate-audit.d.ts +7 -1
  11. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-audit.js +40 -3
  13. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  14. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  15. package/dist/lifecycle/gate-checkpoint.js +103 -0
  16. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/gate-ledger.js +18 -0
  19. package/dist/lifecycle/run-ledger.d.ts +2 -0
  20. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  21. package/dist/lifecycle/run-ledger.js +2 -0
  22. package/dist/lint/rules/op/index.d.ts +6 -2
  23. package/dist/lint/rules/op/index.d.ts.map +1 -1
  24. package/dist/lint/rules/op/index.js +7 -3
  25. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  26. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  27. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  28. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  29. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  30. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  31. package/dist/op/activity-contract.d.ts +14 -0
  32. package/dist/op/activity-contract.d.ts.map +1 -1
  33. package/dist/op/activity-contract.js +16 -0
  34. package/dist/op/activity-registry.d.ts +6 -3
  35. package/dist/op/activity-registry.d.ts.map +1 -1
  36. package/dist/op/activity-registry.js +7 -5
  37. package/dist/op/builders.d.ts +7 -0
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/builders.js +13 -0
  40. package/dist/op/discover.d.ts.map +1 -1
  41. package/dist/op/discover.js +14 -7
  42. package/dist/op/gate-alias.d.ts +57 -0
  43. package/dist/op/gate-alias.d.ts.map +1 -0
  44. package/dist/op/gate-alias.js +127 -0
  45. package/dist/op/gate-checkpoint.d.ts +123 -0
  46. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  47. package/dist/op/gate-checkpoint.js +280 -0
  48. package/dist/op/gate.d.ts +54 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +142 -1
  51. package/dist/op/index.d.ts +4 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +2 -1
  54. package/dist/op/local-executor.d.ts +57 -2
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +136 -6
  57. package/dist/op/local-output.d.ts.map +1 -1
  58. package/dist/op/local-output.js +20 -2
  59. package/dist/op/op-ir.d.ts +4 -0
  60. package/dist/op/op-ir.d.ts.map +1 -1
  61. package/dist/op/op-ir.js +4 -0
  62. package/dist/op/runtime.d.ts +15 -0
  63. package/dist/op/runtime.d.ts.map +1 -1
  64. package/dist/op/runtimes/local.d.ts.map +1 -1
  65. package/dist/op/runtimes/local.js +9 -2
  66. package/dist/op/types.d.ts +31 -0
  67. package/dist/op/types.d.ts.map +1 -1
  68. package/dist/wave-runtime.mjs +225 -245
  69. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  70. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.js +41 -2
  72. package/dist/workspace/trust/seal.d.ts +1 -1
  73. package/dist/workspace/trust/seal.d.ts.map +1 -1
  74. package/dist/workspace/trust/seal.js +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/commands/build.ts +9 -2
  77. package/src/cli/commands/lint.ts +4 -2
  78. package/src/cli/compiled-cli.e2e.test.ts +31 -6
  79. package/src/cli/handlers/approve.ts +46 -4
  80. package/src/cli/handlers/operator.test.ts +91 -0
  81. package/src/gate-decision.ts +2 -0
  82. package/src/lifecycle/gate-audit.ts +41 -4
  83. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  84. package/src/lifecycle/gate-checkpoint.ts +133 -0
  85. package/src/lifecycle/gate-ledger.ts +61 -0
  86. package/src/lifecycle/run-ledger.ts +4 -0
  87. package/src/lint/rules/op/index.ts +9 -3
  88. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  89. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  90. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  91. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  92. package/src/op/activity-contract.ts +22 -1
  93. package/src/op/activity-registry.ts +7 -5
  94. package/src/op/builders.ts +21 -1
  95. package/src/op/discover-root.test.ts +39 -0
  96. package/src/op/discover.ts +15 -8
  97. package/src/op/gate-alias.e2e.test.ts +78 -0
  98. package/src/op/gate-alias.test.ts +350 -0
  99. package/src/op/gate-alias.ts +137 -0
  100. package/src/op/gate-checkpoint.test.ts +284 -0
  101. package/src/op/gate-checkpoint.ts +322 -0
  102. package/src/op/gate.ts +198 -4
  103. package/src/op/index.ts +4 -1
  104. package/src/op/local-executor.ts +180 -7
  105. package/src/op/local-output.ts +18 -2
  106. package/src/op/op-ir.test.ts +25 -0
  107. package/src/op/op-ir.ts +8 -0
  108. package/src/op/runtime.ts +8 -0
  109. package/src/op/runtimes/local.ts +9 -2
  110. package/src/op/types.ts +32 -0
  111. package/src/workspace/trust/gate-seal.test.ts +29 -2
  112. package/src/workspace/trust/gate-seal.ts +46 -2
  113. package/src/workspace/trust/seal.ts +1 -1
@@ -0,0 +1,89 @@
1
+ /**
2
+ * OPS017: a gate's aliases (#3949) can stand. An alias admits approvals of
3
+ * the plan a run planned, so the gate must bind a `plan`, and a gate that
4
+ * asks a decision point takes none. An alias must not name a gate that still
5
+ * exists in the build, or one approval would answer two gates, and two gates
6
+ * must not claim one alias. When the workspace's `identity.gates` names an
7
+ * alias, the new gate must be named too, with the alias's class, or
8
+ * approvals that needed a seal would count without one.
9
+ *
10
+ * The identity rules come from the caller, as SYS010's decide backends do:
11
+ * `chant build` and `chant lint` read them with
12
+ * {@link workspaceIdentityGates}, which loads workspace code only when an Op
13
+ * has aliases and the project sits in a workspace. Without them that part
14
+ * is skipped.
15
+ */
16
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
17
+ import type { OpConfig } from "../../../op";
18
+ import { gateStepsOf, validateGateAliases } from "../../../op/gate-alias";
19
+ import { findWorkspaceRoot } from "../../../project-root";
20
+ import { isOpEntity } from "./support";
21
+
22
+ /** The Op configs among a build's entities, each with its entity key and lexicon. */
23
+ function opsOf(entities: Iterable<[string, unknown]>): Array<{ entityKey: string; lexicon?: string; config: OpConfig }> {
24
+ const ops: Array<{ entityKey: string; lexicon?: string; config: OpConfig }> = [];
25
+ for (const [entityKey, entity] of entities) {
26
+ if (!isOpEntity(entity as never)) continue;
27
+ const rec = entity as Record<string, unknown>;
28
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
29
+ if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
30
+ ops.push({ entityKey, ...(typeof rec.lexicon === "string" ? { lexicon: rec.lexicon } : {}), config: props });
31
+ }
32
+ return ops;
33
+ }
34
+
35
+ const hasAliases = (ops: ReadonlyArray<{ config: OpConfig }>): boolean =>
36
+ ops.some(({ config }) => gateStepsOf(config).some(({ step }) => step.aliases !== undefined));
37
+
38
+ /**
39
+ * The gates the workspace around `projectDir` names under `identity.gates`,
40
+ * each with its class (null for any signer), for OPS017. Undefined when no
41
+ * Op among `entities` has aliases, outside a workspace, or when the
42
+ * declaration can't be read, which `chant workspace check` reports. The
43
+ * workspace modules load only past the first two checks, so a level-0
44
+ * build loads none.
45
+ */
46
+ export async function workspaceIdentityGates(
47
+ projectDir: string,
48
+ entities: Iterable<[string, unknown]>,
49
+ ): Promise<Map<string, string | null> | undefined> {
50
+ if (!hasAliases(opsOf(entities))) return undefined;
51
+ const found = findWorkspaceRoot(projectDir);
52
+ if (!found) return undefined;
53
+ try {
54
+ const { readDeclaration } = await import("../../../workspace/declaration");
55
+ const { workingTree } = await import("../../../workspace/tree");
56
+ const gates = readDeclaration(workingTree(found.dir)).identity?.gates ?? {};
57
+ return new Map(Object.values(gates).map((g) => [g.gate, g.class]));
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /** OPS017, judging `identity.gates` by `identityGates` when given. */
64
+ export function ops017Check(identityGates?: ReadonlyMap<string, string | null>): PostSynthCheck {
65
+ return {
66
+ id: "OPS017",
67
+ description:
68
+ "A gate's aliases need a plan and no decision point, must not name a gate that still exists or another gate's alias, and keep any identity.gates rule an alias had",
69
+
70
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
71
+ const ops = opsOf(ctx.entities);
72
+ if (!hasAliases(ops)) return [];
73
+ const byName = new Map(ops.map((o) => [o.config.name, o]));
74
+ return validateGateAliases(ops.map((o) => o.config), identityGates).map((issue) => {
75
+ const op = byName.get(issue.opName);
76
+ return {
77
+ checkId: "OPS017",
78
+ severity: "error",
79
+ message: `Op "${issue.opName}", phase "${issue.phase}", gate:${issue.gate}: ${issue.message}`,
80
+ ...(op ? { entity: op.entityKey } : {}),
81
+ ...(op?.lexicon ? { lexicon: op.lexicon } : {}),
82
+ };
83
+ });
84
+ },
85
+ };
86
+ }
87
+
88
+ /** OPS017 without the workspace's identity rules. */
89
+ export const ops017: PostSynthCheck = ops017Check();
@@ -89,6 +89,26 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
89
89
  * stack members internally) simply declares none.
90
90
  */
91
91
  entities?: string[];
92
+ /**
93
+ * What the activity returns that is secret (#3947): `true` for the whole
94
+ * return value, or the names of its top-level fields that are. A gate that
95
+ * resumes never stores a secret output in its checkpoint, so a gate whose
96
+ * later steps reference one doesn't resume, and OPS016 says so at build.
97
+ */
98
+ secret?: true | string[];
99
+ }
100
+
101
+ /**
102
+ * Whether a reference into `contract`'s return value at `path` reads a value
103
+ * the contract marks secret (#3947). A whole-value reference reads every
104
+ * field, so it is secret when any field is.
105
+ */
106
+ export function readsSecretOutput(contract: Pick<ActivityContract, "secret"> | undefined, path?: string): boolean {
107
+ const secret = contract?.secret;
108
+ if (secret === undefined) return false;
109
+ if (secret === true) return true;
110
+ if (!path) return secret.length > 0;
111
+ return secret.includes(path.split(".")[0]!);
92
112
  }
93
113
 
94
114
  /** Declare an activity contract. */
@@ -96,7 +116,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
96
116
  name: string,
97
117
  args: ArgsSchema,
98
118
  returns?: ReturnSchema,
99
- opts?: { entities?: string[] },
119
+ opts?: { entities?: string[]; secret?: true | string[] },
100
120
  ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
101
121
  return {
102
122
  [CONTRACT_BRAND]: true,
@@ -104,6 +124,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
104
124
  args,
105
125
  ...(returns ? { returns } : {}),
106
126
  ...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
127
+ ...(opts?.secret === true || (Array.isArray(opts?.secret) && opts.secret.length > 0) ? { secret: opts!.secret } : {}),
107
128
  } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
108
129
  }
109
130
 
@@ -4,13 +4,15 @@
4
4
  *
5
5
  * The base activities are core's own (`./activities`): plain async functions
6
6
  * taking a single args object, with no workflow engine anywhere in the call
7
- * path. They are imported statically, so `loadActivities([])` works in a
8
- * project that has installed nothing but chant. Product-specific appliers stay
7
+ * path. They ship with core, so `loadActivities([])` works in a project that
8
+ * has installed nothing but chant. They are imported when activities are
9
+ * loaded, not when this module is: their implementations reach lint, codegen
10
+ * and `typescript`, and every `*.op.ts` imports `@intentius/chant/op`, which
11
+ * re-exports this module (#3982). Product-specific appliers stay
9
12
  * in their owning lexicon (#809) and are pulled in dynamically per the
10
13
  * project's configured lexicon list.
11
14
  */
12
15
 
13
- import * as baseActivities from "./activities";
14
16
  import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
15
17
  import { importLexiconModule, importLexiconPackage } from "../lexicon-module";
16
18
 
@@ -63,13 +65,13 @@ function collectActivities(mod: Record<string, unknown>, into: Map<string, Activ
63
65
  * lexicon's activity of the same name; otherwise a later lexicon's export
64
66
  * replaces an earlier one's.
65
67
  *
66
- * Never throws: the base library is a static import, so there is no "no
68
+ * Never throws: the base library ships with core, so there is no "no
67
69
  * activities registered" state to report.
68
70
  */
69
71
  export async function loadActivities(lexicons: string[] = []): Promise<Map<string, ActivityFn>> {
70
72
  const activities = new Map<string, ActivityFn>();
71
73
 
72
- collectActivities(baseActivities as unknown as Record<string, unknown>, activities);
74
+ collectActivities((await import("./activities")) as unknown as Record<string, unknown>, activities);
73
75
 
74
76
  for (const name of lexicons) {
75
77
  try {
@@ -5,6 +5,7 @@ import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receip
5
5
  import { receiptCheckInput } from "./receipt-store";
6
6
  import { gateApprovalProblems } from "./gate-approval";
7
7
  import { gatePointProblems } from "./gate-point";
8
+ import { gateAliasProblems } from "./gate-alias";
8
9
  import { makeOutProxy, stepOutput, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
9
10
  import type { ChantBuildArgs } from "./activities/build";
10
11
  import type { ShellCmdArgs } from "./activities/shell";
@@ -135,10 +136,23 @@ export function activity(
135
136
  * The point's deciders and quorum decide, `chant workspace points answer`
136
137
  * answers an open question, and the run passes on the answer. See
137
138
  * {@link GateStep.point}.
139
+ *
140
+ * Pass `aliases` to keep the approvals recorded under the gate's old names
141
+ * after an Op is restructured (#3949): `gate("approve-tier-1", { plan:
142
+ * plan.out.planDigest, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] })`.
143
+ * A gate with aliases must bind a plan. See {@link GateStep.aliases}.
138
144
  */
139
145
  export function gate(
140
146
  name: string,
141
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"]; point?: GateStep["point"] },
147
+ opts?: {
148
+ timeout?: string;
149
+ description?: string;
150
+ plan?: GateStep["plan"];
151
+ approval?: GateStep["approval"];
152
+ point?: GateStep["point"];
153
+ resume?: boolean;
154
+ aliases?: GateStep["aliases"];
155
+ },
142
156
  ): GateStep {
143
157
  if (opts?.approval !== undefined) {
144
158
  const problems = gateApprovalProblems(opts.approval);
@@ -148,6 +162,10 @@ export function gate(
148
162
  const problems = gatePointProblems(opts.point, opts.approval !== undefined);
149
163
  if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
150
164
  }
165
+ if (opts?.aliases !== undefined) {
166
+ const problems = gateAliasProblems(opts.aliases, { plan: opts.plan, point: opts.point });
167
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
168
+ }
151
169
  return {
152
170
  kind: "gate",
153
171
  gate: name,
@@ -156,6 +174,8 @@ export function gate(
156
174
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
157
175
  ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
158
176
  ...(opts?.point !== undefined ? { point: opts.point } : {}),
177
+ ...(opts?.resume ? { resume: true } : {}),
178
+ ...(opts?.aliases !== undefined && opts.aliases.length > 0 ? { aliases: opts.aliases.map((a) => ({ ...(a.op !== undefined ? { op: a.op } : {}), gate: a.gate })) } : {}),
159
179
  };
160
180
  }
161
181
 
@@ -170,3 +170,42 @@ describe("discoverOps: an Op may be exported by name (#2171)", () => {
170
170
  expect(errors[0]).toContain(`Duplicate Op name "twice" declared twice in`);
171
171
  });
172
172
  });
173
+
174
+ /**
175
+ * #3976. Reading the project's `exclude`/`include` evaluates its config,
176
+ * which registers tsx, so discovery reads them only when an Op file could be
177
+ * found. An `exclude` that is not a list is an error whenever the config is
178
+ * read, which is how these tests tell whether it was.
179
+ */
180
+ describe("discoverOps reads the config's globs only when there is an Op file (#3976)", () => {
181
+ let checkout: string;
182
+ beforeEach(() => {
183
+ checkout = realpathSync(mkdtempSync(join(tmpdir(), "chant-op-globs-")));
184
+ fakeGitRoot = checkout;
185
+ mkdirSync(join(checkout, "src"), { recursive: true });
186
+ writeFileSync(join(checkout, "src", "infra.ts"), "export const x = 1;\n");
187
+ });
188
+ afterEach(() => rmSync(checkout, { recursive: true, force: true }));
189
+
190
+ test("a project with no Op file has its config left unread", async () => {
191
+ writeFileSync(join(checkout, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], exclude: 42 }));
192
+ const { ops, errors } = await discoverOps({ cwd: checkout });
193
+ expect(errors).toEqual([]);
194
+ expect([...ops.keys()]).toEqual([]);
195
+ });
196
+
197
+ test("with an Op file the config is read", async () => {
198
+ writeFileSync(join(checkout, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], exclude: 42 }));
199
+ writeFileSync(join(checkout, "src", "run.op.ts"), OP_FILE("run"));
200
+ await expect(discoverOps({ cwd: checkout })).rejects.toThrow(/exclude/);
201
+ });
202
+
203
+ test("an Op file only `include` re-admits is still found", async () => {
204
+ writeFileSync(join(checkout, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], include: [".ops"] }));
205
+ mkdirSync(join(checkout, ".ops"));
206
+ writeFileSync(join(checkout, ".ops", "hidden.op.ts"), OP_FILE("hidden"));
207
+ const { ops, errors } = await discoverOps({ cwd: checkout });
208
+ expect(errors).toEqual([]);
209
+ expect([...ops.keys()]).toEqual(["hidden"]);
210
+ });
211
+ });
@@ -57,22 +57,29 @@ async function findDiscoveryRoot(cwd?: string): Promise<string> {
57
57
  return gitRoot;
58
58
  }
59
59
 
60
+ const isOpFile = (name: string, full: string): boolean =>
61
+ name.endsWith(".op.ts") && !name.endsWith(".test.ts") && !name.endsWith(".spec.ts") && !hasDiscoveryMarkerSync(full);
62
+
60
63
  /**
61
64
  * Every `*.op.ts` file below the Op root, through the one discovery walk
62
65
  * (`../discovery/walk.ts`, #2527). The walk down stops at child projects
63
66
  * whatever the root is, so a configless layout whose Op root is the git root
64
67
  * no longer collects its sibling projects' Ops. The project's
65
68
  * `exclude`/`include` globs and the skip marker apply.
69
+ *
70
+ * The globs come from evaluating the project config, which registers tsx
71
+ * (#3976), so they are resolved only when an Op file could be found. A walk
72
+ * that includes `**` first reaches every path any `include` could re-admit,
73
+ * and stops at the first Op file. When it finds none, no globs could find
74
+ * one either.
66
75
  */
67
76
  async function collectOpFiles(root: string): Promise<string[]> {
68
- return walkDiscovery({
69
- walker: "ops",
70
- root,
71
- globs: await resolveDiscoveryGlobs(root),
72
- excludeDirs: await workspaceMemberDirs(root),
73
- accept: (name, full) =>
74
- name.endsWith(".op.ts") && !name.endsWith(".test.ts") && !name.endsWith(".spec.ts") && !hasDiscoveryMarkerSync(full),
75
- });
77
+ const excludeDirs = await workspaceMemberDirs(root);
78
+ // A limit of 0 ends the walk at the first file it accepts.
79
+ const any = { max: 0, truncated: false };
80
+ walkDiscovery({ walker: "ops", root, globs: { root, exclude: [], include: ["**"] }, excludeDirs, limit: any, accept: isOpFile });
81
+ if (!any.truncated) return [];
82
+ return walkDiscovery({ walker: "ops", root, globs: await resolveDiscoveryGlobs(root), excludeDirs, accept: isOpFile });
76
83
  }
77
84
 
78
85
  /** The `OpConfig` behind an exported value, or `undefined` when the value is not an Op. An Op entity carries its config on `.props` (`./resource.ts`); `name` and `phases` are what every consumer of a discovered Op reads. */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * #3949 against a real `chant/lifecycle` branch: an approval recorded under
3
+ * a per-tier Op's gate passes the merged Op's gate that lists it as an
4
+ * alias. The run reads the old Op's ledger file, writes the carried line to
5
+ * its own, and the next run passes on that line alone.
6
+ */
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, commitAll, git, repo } from "../workspace/__fixtures__/contract-repo";
10
+ import { appendGateResolution, appendPendingGate, readGateLedger } from "../lifecycle/gate-ledger";
11
+ import { runOpLocally } from "./local-executor";
12
+ import { gitGateLedgerPort } from "./gate";
13
+ import { stepOutput } from "./step-output-ref";
14
+ import type { ActivityFn } from "./activity-registry";
15
+ import type { OpConfig } from "./types";
16
+
17
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
18
+
19
+ let root: string;
20
+ beforeAll(() => {
21
+ root = repo({ "chant.config.json": "{}\n" }, false);
22
+ git(root, "config", "user.name", "Gate Test");
23
+ git(root, "config", "user.email", "gate@example.com");
24
+ commitAll(root, "c0");
25
+ });
26
+ afterAll(cleanScratch);
27
+
28
+ const applied: string[] = [];
29
+ const ACTIVITIES = new Map<string, ActivityFn>([
30
+ ["plan", async () => ({ planDigest: PLAN })],
31
+ ["apply", async (args) => (applied.push(String((args as { tier: string }).tier)), { ok: true })],
32
+ ]);
33
+
34
+ const merged: OpConfig = {
35
+ name: "upgrade",
36
+ overview: "upgrade every tier, one wave each",
37
+ phases: [
38
+ { name: "Plan", steps: [{ kind: "activity", id: "plan", fn: "plan" }] },
39
+ {
40
+ name: "Wave 1",
41
+ steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }],
42
+ },
43
+ { name: "Apply 1", steps: [{ kind: "activity", fn: "apply", args: { tier: "1" } }] },
44
+ ],
45
+ };
46
+
47
+ describe("a gate alias on a real ledger", () => {
48
+ test("the merged Op passes on the old Op's approval, and keeps passing on the carried line", async () => {
49
+ const now = Date.now();
50
+ const iso = (s: number) => new Date(now + s * 1000).toISOString();
51
+ await appendPendingGate(
52
+ { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-60), expiresAt: iso(3600), planDigest: PLAN, runId: "old-run" },
53
+ { cwd: root },
54
+ );
55
+ await appendGateResolution({ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: iso(-30), planDigest: PLAN }, { cwd: root });
56
+
57
+ const port = gitGateLedgerPort({ cwd: root });
58
+ const first = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-1" });
59
+ expect(first.status).toBe("ok");
60
+ expect(first.records.find((r) => r.fn === "gate:approve-tier-1")?.approval).toMatchObject({ resolvedBy: "alice", via: "alias" });
61
+
62
+ const ledger = await readGateLedger("upgrade", { cwd: root });
63
+ expect(ledger.resolutions).toHaveLength(1);
64
+ expect(ledger.resolutions[0]).toMatchObject({
65
+ op: "upgrade",
66
+ gate: "approve-tier-1",
67
+ resolvedBy: "alice",
68
+ planDigest: PLAN,
69
+ carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-30) },
70
+ });
71
+
72
+ const second = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-2" });
73
+ expect(second.status).toBe("ok");
74
+ expect(second.records.find((r) => r.fn === "gate:approve-tier-1")?.approval?.via).toBeUndefined();
75
+ expect((await readGateLedger("upgrade", { cwd: root })).resolutions).toHaveLength(1);
76
+ expect(applied).toEqual(["1", "1"]);
77
+ });
78
+ });