@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.
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/activity-registry.d.ts +6 -3
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.js +7 -5
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +14 -7
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +136 -6
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +9 -2
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +225 -245
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +31 -6
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/gate-decision.ts +2 -0
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/activity-registry.ts +7 -5
- package/src/op/builders.ts +21 -1
- package/src/op/discover-root.test.ts +39 -0
- package/src/op/discover.ts +15 -8
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.ts +180 -7
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +9 -2
- package/src/op/types.ts +32 -0
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- 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
|
|
8
|
-
*
|
|
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
|
|
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(
|
|
74
|
+
collectActivities((await import("./activities")) as unknown as Record<string, unknown>, activities);
|
|
73
75
|
|
|
74
76
|
for (const name of lexicons) {
|
|
75
77
|
try {
|
package/src/op/builders.ts
CHANGED
|
@@ -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?: {
|
|
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
|
+
});
|
package/src/op/discover.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|