@intentius/chant 0.79.0 → 0.80.0
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/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.ts +2 -1
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +69 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +14 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2520 — a lexicon declared by module path in `chant.config.ts`
|
|
3
|
+
* (`{ name, module }`) loads with no `@intentius/chant-lexicon-<name>`
|
|
4
|
+
* package, for build, lint, run and the capability registry.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { loadChantConfig } from "./config";
|
|
12
|
+
import { lexiconModulePath, lexiconNames, resetLexiconModules } from "./lexicon-module";
|
|
13
|
+
import { loadPlugins, resolveProjectLexicons, loadPlugin } from "./cli/plugins";
|
|
14
|
+
import { loadActivities } from "./op/activity-registry";
|
|
15
|
+
import { loadActivityContracts } from "./op/activity-contract-registry";
|
|
16
|
+
import { loadGatePolicyEvaluator } from "./op/gate-approval";
|
|
17
|
+
import { buildCapabilityRegistry } from "./components/capability-plugin-loader";
|
|
18
|
+
import { buildCommand } from "./cli/commands/build";
|
|
19
|
+
import { lintCommand } from "./cli/commands/lint";
|
|
20
|
+
|
|
21
|
+
const activityContractModule = resolve(import.meta.dirname, "op/activity-contract.ts");
|
|
22
|
+
const zodModule = createRequire(import.meta.url).resolve("zod");
|
|
23
|
+
|
|
24
|
+
/** The whole project-local lexicon, in one module, importing core by absolute path so it needs no package. */
|
|
25
|
+
function lexiconSource(pluginName = "site"): string {
|
|
26
|
+
return `
|
|
27
|
+
import { z } from ${JSON.stringify(zodModule)};
|
|
28
|
+
import { activityContract } from ${JSON.stringify(activityContractModule)};
|
|
29
|
+
|
|
30
|
+
export const sitePlugin = {
|
|
31
|
+
name: ${JSON.stringify(pluginName)},
|
|
32
|
+
serializer: {
|
|
33
|
+
name: "site",
|
|
34
|
+
rulePrefix: "SITE",
|
|
35
|
+
serialize: (entities) => JSON.stringify({ pages: [...entities.keys()].sort() }),
|
|
36
|
+
},
|
|
37
|
+
generate: async () => {},
|
|
38
|
+
validate: async () => {},
|
|
39
|
+
coverage: async () => {},
|
|
40
|
+
package: async () => {},
|
|
41
|
+
activities: () => ({ siteDeploy: async (args) => ({ deployed: args.target }) }),
|
|
42
|
+
activityContracts: () => [activityContract("siteDeploy", z.strictObject({ target: z.string() }))],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const siteCapabilities = {
|
|
46
|
+
name: "site-capabilities",
|
|
47
|
+
version: "0.0.0",
|
|
48
|
+
capabilities: () => [{ kind: "site-publish", run: async () => ({}) }],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function evaluateGatePolicy() {
|
|
52
|
+
return { decision: "allow", determiningPolicies: [], errors: [] };
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function opSource(args: Record<string, unknown>): string {
|
|
58
|
+
return `
|
|
59
|
+
export default {
|
|
60
|
+
[Symbol.for("chant.declarable")]: true,
|
|
61
|
+
entityType: "Chant::Op",
|
|
62
|
+
lexicon: "chant",
|
|
63
|
+
kind: "resource",
|
|
64
|
+
props: {
|
|
65
|
+
name: "publish",
|
|
66
|
+
overview: "test",
|
|
67
|
+
phases: [
|
|
68
|
+
{ name: "Publish", steps: [{ kind: "activity", fn: "siteDeploy", args: ${JSON.stringify(args)} }] },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("lexicons declared by module path (#2520)", () => {
|
|
76
|
+
let dir: string;
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
resetLexiconModules();
|
|
80
|
+
dir = await mkdtemp(join(tmpdir(), "chant-lexicon-module-"));
|
|
81
|
+
await mkdir(join(dir, "lexicon"));
|
|
82
|
+
await writeFile(join(dir, "lexicon", "index.ts"), lexiconSource());
|
|
83
|
+
await writeFile(
|
|
84
|
+
join(dir, "chant.config.ts"),
|
|
85
|
+
`export default { lexicons: [{ name: "site", module: "./lexicon/index.ts" }] };\n`,
|
|
86
|
+
);
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(dir, "main.ts"),
|
|
89
|
+
`export const home = { lexicon: "site", entityType: "Site::Page", kind: "resource", props: {}, [Symbol.for("chant.declarable")]: true };\n`,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
resetLexiconModules();
|
|
95
|
+
await rm(dir, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the loaded config holds the name, and the module path is recorded against the config's directory", async () => {
|
|
99
|
+
const { config } = await loadChantConfig(dir);
|
|
100
|
+
expect(config.lexicons).toEqual(["site"]);
|
|
101
|
+
expect(config.lexiconModules).toEqual({ site: join(dir, "lexicon", "index.ts") });
|
|
102
|
+
expect(lexiconModulePath("site")).toBe(join(dir, "lexicon", "index.ts"));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a config of plain names comes back as written, with nothing recorded", async () => {
|
|
106
|
+
const plain = join(dir, "plain");
|
|
107
|
+
await mkdir(plain);
|
|
108
|
+
await writeFile(join(plain, "chant.config.ts"), `export default { lexicons: ["aws"] };\n`);
|
|
109
|
+
const { config } = await loadChantConfig(plain);
|
|
110
|
+
expect(config).toEqual({ lexicons: ["aws"] });
|
|
111
|
+
expect(lexiconModulePath("aws")).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a later config naming the lexicon as a package drops the recorded path", async () => {
|
|
115
|
+
await loadChantConfig(dir);
|
|
116
|
+
expect(lexiconModulePath("site")).toBeDefined();
|
|
117
|
+
// A second project; a config file is a module, cached by path.
|
|
118
|
+
const other = join(dir, "other");
|
|
119
|
+
await mkdir(other);
|
|
120
|
+
await writeFile(join(other, "chant.config.ts"), `export default { lexicons: ["site"] };\n`);
|
|
121
|
+
await loadChantConfig(other);
|
|
122
|
+
expect(lexiconModulePath("site")).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("an entry with an unknown key is rejected", async () => {
|
|
126
|
+
const pinned = join(dir, "pinned");
|
|
127
|
+
await mkdir(pinned);
|
|
128
|
+
await writeFile(
|
|
129
|
+
join(pinned, "chant.config.ts"),
|
|
130
|
+
`export default { lexicons: [{ name: "site", module: "./lexicon/index.ts", pin: "x" }] };\n`,
|
|
131
|
+
);
|
|
132
|
+
await expect(loadChantConfig(pinned)).rejects.toThrow(/Invalid chant config/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("lexiconNames reads both entry forms", () => {
|
|
136
|
+
expect(lexiconNames(["aws", { name: "site", module: "./x.ts" }])).toEqual(["aws", "site"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("build: the plugin loads from the module and the project builds with it", async () => {
|
|
140
|
+
const names = await resolveProjectLexicons(dir);
|
|
141
|
+
expect(names).toEqual(["site"]);
|
|
142
|
+
const plugins = await loadPlugins(names);
|
|
143
|
+
expect(plugins.map((p) => p.name)).toEqual(["site"]);
|
|
144
|
+
|
|
145
|
+
await writeFile(join(dir, "publish.op.ts"), opSource({ target: "prod" }));
|
|
146
|
+
const result = await buildCommand({
|
|
147
|
+
path: dir,
|
|
148
|
+
format: "json",
|
|
149
|
+
serializers: plugins.map((p) => p.serializer),
|
|
150
|
+
plugins,
|
|
151
|
+
});
|
|
152
|
+
expect(result.errors).toEqual([]);
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
// The page and the Op.
|
|
155
|
+
expect(result.resourceCount).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a plugin whose name differs from the declared name is refused", async () => {
|
|
159
|
+
await writeFile(join(dir, "lexicon", "index.ts"), lexiconSource("other"));
|
|
160
|
+
await loadChantConfig(dir);
|
|
161
|
+
await expect(loadPlugin("site")).rejects.toThrow(/plugin it exports is named "other"/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("run: the plugin's activities are loaded by name, with no op/activities subpath", async () => {
|
|
165
|
+
await loadChantConfig(dir);
|
|
166
|
+
const activities = await loadActivities(["site"]);
|
|
167
|
+
expect(await activities.get("siteDeploy")?.({ target: "prod" })).toEqual({ deployed: "prod" });
|
|
168
|
+
// Core's base activities are still there.
|
|
169
|
+
expect(activities.get("shellCmd")).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("activity contracts are found by name, with no op/activity-contracts subpath", async () => {
|
|
173
|
+
await loadChantConfig(dir);
|
|
174
|
+
const contracts = await loadActivityContracts(["site"]);
|
|
175
|
+
expect(contracts.get("siteDeploy")).toBeDefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("lint: the plugin's contract validates an Op step calling its activity", async () => {
|
|
179
|
+
await writeFile(join(dir, "publish.op.ts"), opSource({ target: 42 }));
|
|
180
|
+
const bad = await lintCommand({ path: dir, format: "stylish" });
|
|
181
|
+
expect(
|
|
182
|
+
bad.diagnostics.some((d) => d.ruleId === "OPS012" && d.message.includes("siteDeploy")),
|
|
183
|
+
).toBe(true);
|
|
184
|
+
|
|
185
|
+
// A fresh path: ES modules are cached by URL, so the corrected Op needs its own file.
|
|
186
|
+
await rm(join(dir, "publish.op.ts"));
|
|
187
|
+
await writeFile(join(dir, "publish2.op.ts"), opSource({ target: "prod" }));
|
|
188
|
+
const good = await lintCommand({ path: dir, format: "stylish" });
|
|
189
|
+
expect(good.diagnostics.filter((d) => d.ruleId === "OPS012")).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("the capability registry registers the module's capability plugin", async () => {
|
|
193
|
+
await loadChantConfig(dir);
|
|
194
|
+
const registry = await buildCapabilityRegistry({ lexicons: ["site"] });
|
|
195
|
+
expect(registry.has("site-publish")).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the gate policy evaluator is read from the module", async () => {
|
|
199
|
+
await loadChantConfig(dir);
|
|
200
|
+
const evaluator = await loadGatePolicyEvaluator("site");
|
|
201
|
+
expect(typeof evaluator.evaluateGatePolicy).toBe("function");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexicons declared by module path (chant #2520).
|
|
3
|
+
*
|
|
4
|
+
* A `lexicons` entry has always been a bare name, and chant resolves a name to
|
|
5
|
+
* the package `@intentius/chant-lexicon-<name>`. A project that writes its own
|
|
6
|
+
* lexicon in the same repo can instead point at the module:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* export default {
|
|
10
|
+
* lexicons: ["fly", { name: "site", module: "./lexicon/index.ts" }],
|
|
11
|
+
* } satisfies ChantConfig;
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The path is resolved against the directory holding `chant.config.ts`. That
|
|
15
|
+
* module is the whole lexicon: its `LexiconPlugin` export is the plugin, the
|
|
16
|
+
* plugin's `activities()` and `activityContracts()` members stand in for the
|
|
17
|
+
* `op/activities` and `op/activity-contracts` subpaths, a `CapabilityPlugin`
|
|
18
|
+
* export feeds the capability registry, and an `evaluateGatePolicy` export
|
|
19
|
+
* stands in for the `gate-policy` subpath.
|
|
20
|
+
*
|
|
21
|
+
* Loaders take lexicon names, so the config loader records each path-declared
|
|
22
|
+
* name here and every loader asks {@link lexiconModulePath} before falling back
|
|
23
|
+
* to the package name. A name declared as a plain string is never recorded, so
|
|
24
|
+
* a project that names lexicons by package takes exactly the path it always did.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** One `lexicons` entry: a package-backed name, or a name plus the module that implements it. */
|
|
30
|
+
export type LexiconDeclaration = string | { name: string; module: string };
|
|
31
|
+
|
|
32
|
+
/** The lexicon name an entry declares, whichever form it takes. */
|
|
33
|
+
export function lexiconDeclarationName(entry: LexiconDeclaration): string {
|
|
34
|
+
return typeof entry === "string" ? entry : entry.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every entry's name, in declaration order. `undefined` in, `undefined` out. */
|
|
38
|
+
export function lexiconNames(entries: readonly LexiconDeclaration[]): string[];
|
|
39
|
+
export function lexiconNames(entries: readonly LexiconDeclaration[] | undefined): string[] | undefined;
|
|
40
|
+
export function lexiconNames(entries: readonly LexiconDeclaration[] | undefined): string[] | undefined {
|
|
41
|
+
return entries?.map(lexiconDeclarationName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Name to absolute module path, for the lexicons declared by path in the configs loaded so far. */
|
|
45
|
+
const modulePaths = new Map<string, string>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record the path-declared entries of one loaded config. `baseDir` is the
|
|
49
|
+
* directory the config file sits in. A plain-string entry for a name removes
|
|
50
|
+
* any path recorded for it earlier in this process, so loading a second
|
|
51
|
+
* project (the example harness does) cannot leave the first one's module in
|
|
52
|
+
* place of a package.
|
|
53
|
+
*
|
|
54
|
+
* Returns the recorded name-to-path map for this config, empty when every
|
|
55
|
+
* entry is a plain name.
|
|
56
|
+
*/
|
|
57
|
+
export function registerLexiconDeclarations(
|
|
58
|
+
entries: readonly LexiconDeclaration[] | undefined,
|
|
59
|
+
baseDir: string,
|
|
60
|
+
): Record<string, string> {
|
|
61
|
+
const recorded: Record<string, string> = {};
|
|
62
|
+
for (const entry of entries ?? []) {
|
|
63
|
+
if (typeof entry === "string") {
|
|
64
|
+
modulePaths.delete(entry);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const path = isAbsolute(entry.module) ? entry.module : resolve(baseDir, entry.module);
|
|
68
|
+
modulePaths.set(entry.name, path);
|
|
69
|
+
recorded[entry.name] = path;
|
|
70
|
+
}
|
|
71
|
+
return recorded;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The absolute module path `name` was declared with, or `undefined` for a package-backed lexicon. */
|
|
75
|
+
export function lexiconModulePath(name: string): string | undefined {
|
|
76
|
+
return modulePaths.get(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Forget every recorded path. For tests. */
|
|
80
|
+
export function resetLexiconModules(): void {
|
|
81
|
+
modulePaths.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Import the module a path-declared lexicon names. Returns `undefined` when
|
|
86
|
+
* `name` is package-backed, so a caller falls through to its package import.
|
|
87
|
+
* An import failure is thrown with the declared path in the message.
|
|
88
|
+
*/
|
|
89
|
+
export async function importLexiconModule(name: string): Promise<Record<string, unknown> | undefined> {
|
|
90
|
+
const path = modulePaths.get(name);
|
|
91
|
+
if (path === undefined) return undefined;
|
|
92
|
+
try {
|
|
93
|
+
return (await import(path)) as Record<string, unknown>;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`lexicon "${name}" is declared with module ${path}, which could not be loaded: ` +
|
|
97
|
+
(err instanceof Error ? err.message : String(err)),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* chant#2578 — how a message should name a lexicon. A package-backed name is
|
|
104
|
+
* its npm package; a lexicon declared by path is that path, relative to
|
|
105
|
+
* `fromDir` when it sits under it.
|
|
106
|
+
*/
|
|
107
|
+
export function lexiconSourceLabel(name: string, fromDir: string = process.cwd()): string {
|
|
108
|
+
const path = modulePaths.get(name);
|
|
109
|
+
if (path === undefined) return `@intentius/chant-lexicon-${name}`;
|
|
110
|
+
const rel = relative(fromDir, path);
|
|
111
|
+
return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? path : `./${rel.split(sep).join("/")}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The npm packages that provide `names`, leaving out every lexicon declared by path: those have nothing to install. */
|
|
115
|
+
export function lexiconPackagesToInstall(names: readonly string[]): string[] {
|
|
116
|
+
return names.filter((name) => !modulePaths.has(name)).map((name) => `@intentius/chant-lexicon-${name}`);
|
|
117
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -486,6 +486,21 @@ export interface ComponentPipelineOptions {
|
|
|
486
486
|
* this too when the file should land under some other name than its stage.
|
|
487
487
|
*/
|
|
488
488
|
opsFileName?: string;
|
|
489
|
+
/**
|
|
490
|
+
* An environment to promote to after the deploy (#2575). The component
|
|
491
|
+
* generators add one job that needs every component job and runs
|
|
492
|
+
* `chant components promote --from <env> --to <promoteTo>`. Each component
|
|
493
|
+
* job also uploads the files its build steps wrote (see
|
|
494
|
+
* `promoteArchivePaths` in `components/promote.ts`), and the promote job
|
|
495
|
+
* downloads them, since a promote publishes from the archive on disk.
|
|
496
|
+
* Unset, the pipeline has no promote job and is unchanged.
|
|
497
|
+
*/
|
|
498
|
+
promoteTo?: string;
|
|
499
|
+
/**
|
|
500
|
+
* The promote job's command as argv, when `promoteTo` is set. Default
|
|
501
|
+
* `["chant", "components", "promote", "--from", <env>, "--to", <promoteTo>]`.
|
|
502
|
+
*/
|
|
503
|
+
promoteCommand?: string[];
|
|
489
504
|
}
|
|
490
505
|
|
|
491
506
|
/** The synthesized CI pipeline for a component graph (generate mode). */
|
|
@@ -939,6 +954,17 @@ export interface LexiconPlugin {
|
|
|
939
954
|
*/
|
|
940
955
|
activityContracts?(): ActivityContract[];
|
|
941
956
|
|
|
957
|
+
/**
|
|
958
|
+
* Op activity implementations, keyed by the name a step's `fn` calls them
|
|
959
|
+
* by (chant #2520). Read only for a lexicon declared by module path
|
|
960
|
+
* (`{ name, module }` in `chant.config.ts`), which has no
|
|
961
|
+
* `@intentius/chant-lexicon-<name>/op/activities` subpath for
|
|
962
|
+
* `loadActivities` to import. A package-backed lexicon keeps using the
|
|
963
|
+
* subpath and leaves this out.
|
|
964
|
+
*/
|
|
965
|
+
activities?(): Record<string, (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>>
|
|
966
|
+
| Promise<Record<string, (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>>>;
|
|
967
|
+
|
|
942
968
|
/**
|
|
943
969
|
* Audit catalog metadata (title/tier/fix/authority/category) for this
|
|
944
970
|
* lexicon's `postSynthChecks`, keyed by check id — the per-provider half of
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
57
|
import { sortedJsonReplacer } from "../utils";
|
|
58
|
+
import { warnOnLegacyDigests } from "./legacy-digest";
|
|
58
59
|
import { writeBlobToPath, readBlobFromPath, listFilesInDir } from "./git";
|
|
59
60
|
import type { BuildArchiveManifest } from "../components/verbs/build-archive";
|
|
60
61
|
|
|
@@ -104,11 +105,20 @@ export async function readBuildManifest(
|
|
|
104
105
|
): Promise<BuildArchiveManifest | null> {
|
|
105
106
|
const content = await readBlobFromPath(BUILDS_DIR, manifestFilename(manifestDigest), opts);
|
|
106
107
|
if (!content) return null;
|
|
108
|
+
let manifest: BuildArchiveManifest;
|
|
107
109
|
try {
|
|
108
|
-
|
|
110
|
+
manifest = JSON.parse(content) as BuildArchiveManifest;
|
|
109
111
|
} catch {
|
|
110
112
|
return null;
|
|
111
113
|
}
|
|
114
|
+
// chant #2514 — a release ahead of real SHA-256 digests, say once that
|
|
115
|
+
// the persisted manifests hold values in the old form.
|
|
116
|
+
warnOnLegacyDigests(
|
|
117
|
+
[manifest.manifestDigest, ...(Array.isArray(manifest.contents) ? manifest.contents.map((e) => e?.digest) : [])],
|
|
118
|
+
"build manifests",
|
|
119
|
+
opts?.cwd,
|
|
120
|
+
);
|
|
121
|
+
return manifest;
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
/** List the `manifestDigest` of every build manifest persisted on the orphan branch. */
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2514 — the warning a release ahead of real SHA-256 digests: a
|
|
3
|
+
* project whose release ledger or build manifests hold values from the old
|
|
4
|
+
* 32-bit `contentDigest` is told once that they will change.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
7
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { isLegacyContentDigest, resetLegacyDigestWarnings, warnOnLegacyDigests } from "./legacy-digest";
|
|
13
|
+
import { appendReleaseRecord, readReleaseLedger, type ReleaseRecordInput } from "./release-ledger";
|
|
14
|
+
import { persistBuildManifest, readAllBuildManifests } from "./build-ledger-store";
|
|
15
|
+
import { addArchiveEntry, contentDigest, createBuildArchiveManifest } from "../components/verbs/build-archive";
|
|
16
|
+
|
|
17
|
+
function git(args: string[], cwd: string): void {
|
|
18
|
+
spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function initRepo(dir: string): void {
|
|
22
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
23
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
24
|
+
git(["config", "user.name", "Test"], dir);
|
|
25
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
26
|
+
git(["add", "README.md"], dir);
|
|
27
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function release(digest: string, runId: string): ReleaseRecordInput {
|
|
31
|
+
return {
|
|
32
|
+
component: "web",
|
|
33
|
+
env: "prod",
|
|
34
|
+
digest,
|
|
35
|
+
gitSha: "deadbeef",
|
|
36
|
+
runId,
|
|
37
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
38
|
+
actor: "ci-bot",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const realSha256 = `sha256:${createHash("sha256").update("x").digest("hex")}`;
|
|
43
|
+
|
|
44
|
+
describe("legacy content digests (#2514)", () => {
|
|
45
|
+
let warn: { mock: { calls: unknown[][] }; mockRestore(): void };
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
resetLegacyDigestWarnings();
|
|
49
|
+
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
warn.mockRestore();
|
|
54
|
+
resetLegacyDigestWarnings();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const legacyWarnings = () => warn.mock.calls.filter((c: unknown[]) => String(c[0]).includes("issues/2514"));
|
|
58
|
+
|
|
59
|
+
test("today's contentDigest output is recognised as the old form", () => {
|
|
60
|
+
expect(isLegacyContentDigest(contentDigest("hello"))).toBe(true);
|
|
61
|
+
expect(isLegacyContentDigest(contentDigest(""))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a real SHA-256 value, a short value and a non-string are not", () => {
|
|
65
|
+
expect(isLegacyContentDigest(realSha256)).toBe(false);
|
|
66
|
+
expect(isLegacyContentDigest("sha256:abc123")).toBe(false);
|
|
67
|
+
expect(isLegacyContentDigest(undefined)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("warns once per project, and not at all without an old value", () => {
|
|
71
|
+
expect(warnOnLegacyDigests([realSha256], "release ledger", "/p/a")).toBe(false);
|
|
72
|
+
expect(warnOnLegacyDigests([contentDigest("x")], "release ledger", "/p/a")).toBe(true);
|
|
73
|
+
expect(warnOnLegacyDigests([contentDigest("y")], "build manifests", "/p/a")).toBe(false);
|
|
74
|
+
expect(warnOnLegacyDigests([contentDigest("y")], "build manifests", "/p/b")).toBe(true);
|
|
75
|
+
expect(legacyWarnings()).toHaveLength(2);
|
|
76
|
+
expect(String(legacyWarnings()[0][0])).toContain("next chant release computes real SHA-256");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reading a release ledger that holds an old value warns once", async () => {
|
|
80
|
+
await withTestDir(async (dir) => {
|
|
81
|
+
initRepo(dir);
|
|
82
|
+
await appendReleaseRecord(release(contentDigest("rendered"), "run-1"), { cwd: dir });
|
|
83
|
+
await appendReleaseRecord(release(contentDigest("rendered-2"), "run-2"), { cwd: dir });
|
|
84
|
+
|
|
85
|
+
await readReleaseLedger("prod", { cwd: dir });
|
|
86
|
+
await readReleaseLedger("prod", { cwd: dir });
|
|
87
|
+
expect(legacyWarnings()).toHaveLength(1);
|
|
88
|
+
expect(String(legacyWarnings()[0][0])).toContain("release ledger holds");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a release ledger of real SHA-256 values stays quiet", async () => {
|
|
93
|
+
await withTestDir(async (dir) => {
|
|
94
|
+
initRepo(dir);
|
|
95
|
+
await appendReleaseRecord(release(realSha256, "run-1"), { cwd: dir });
|
|
96
|
+
await readReleaseLedger("prod", { cwd: dir });
|
|
97
|
+
expect(legacyWarnings()).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("reading persisted build manifests that hold old values warns once", async () => {
|
|
102
|
+
await withTestDir(async (dir) => {
|
|
103
|
+
initRepo(dir);
|
|
104
|
+
let manifest = createBuildArchiveManifest("web", { now: () => new Date(0) });
|
|
105
|
+
manifest = addArchiveEntry(manifest, { kind: "template", path: "web.json", digest: contentDigest("{}") });
|
|
106
|
+
await persistBuildManifest(manifest, { cwd: dir });
|
|
107
|
+
|
|
108
|
+
await readAllBuildManifests({ cwd: dir });
|
|
109
|
+
await readAllBuildManifests({ cwd: dir });
|
|
110
|
+
expect(legacyWarnings()).toHaveLength(1);
|
|
111
|
+
expect(String(legacyWarnings()[0][0])).toContain("build manifests hold");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning a release ahead of real SHA-256 digests (chant #2514).
|
|
3
|
+
*
|
|
4
|
+
* `contentDigest` (../components/verbs/build-archive.ts) labels its output
|
|
5
|
+
* `sha256:` but computes a 32-bit string hash and repeats its 8 hex digits
|
|
6
|
+
* eight times. The next release makes it real SHA-256, so every value it
|
|
7
|
+
* produces changes once. Values already recorded in a project's release
|
|
8
|
+
* ledger or persisted build manifests keep the old form. This release only
|
|
9
|
+
* says so: readers of those records warn once per project, per process, when
|
|
10
|
+
* they meet an old value. Nothing is rewritten or flagged yet.
|
|
11
|
+
*
|
|
12
|
+
* An old value is recognisable by its shape alone. A real SHA-256 hex digest
|
|
13
|
+
* whose 64 characters are one 8-character block repeated eight times has
|
|
14
|
+
* probability 2^-224, so the check has no false positives worth considering.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
const LEGACY_DIGEST = /^sha256:([0-9a-f]{8})\1{7}$/;
|
|
20
|
+
|
|
21
|
+
/** Whether `value` is a `sha256:` string made by the old 32-bit `contentDigest`. */
|
|
22
|
+
export function isLegacyContentDigest(value: unknown): boolean {
|
|
23
|
+
return typeof value === "string" && LEGACY_DIGEST.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Project roots already warned about in this process. */
|
|
27
|
+
const warnedProjects = new Set<string>();
|
|
28
|
+
|
|
29
|
+
/** Forget which projects were warned about. For tests. */
|
|
30
|
+
export function resetLegacyDigestWarnings(): void {
|
|
31
|
+
warnedProjects.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Warn once for the project at `cwd` (default: the working directory) when
|
|
36
|
+
* any of `values` is an old-form digest. `where` names the record that held
|
|
37
|
+
* it, for the message. Returns whether a warning was printed.
|
|
38
|
+
*/
|
|
39
|
+
export function warnOnLegacyDigests(
|
|
40
|
+
values: Iterable<unknown>,
|
|
41
|
+
where: "release ledger" | "build manifests",
|
|
42
|
+
cwd?: string,
|
|
43
|
+
): boolean {
|
|
44
|
+
const project = resolve(cwd ?? process.cwd());
|
|
45
|
+
if (warnedProjects.has(project)) return false;
|
|
46
|
+
let found = false;
|
|
47
|
+
for (const value of values) {
|
|
48
|
+
if (isLegacyContentDigest(value)) {
|
|
49
|
+
found = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!found) return false;
|
|
54
|
+
warnedProjects.add(project);
|
|
55
|
+
console.warn(
|
|
56
|
+
`[chant] warning: this project's ${where} ${where === "release ledger" ? "holds" : "hold"} sha256: values made by chant's old 32-bit content hash, ` +
|
|
57
|
+
"not real SHA-256. The next chant release computes real SHA-256, so digests recorded from then on " +
|
|
58
|
+
"will not match these for the same content. Existing entries stay readable and will be marked " +
|
|
59
|
+
"legacy-digest. See https://github.com/INTENTIUS/chant/issues/2514",
|
|
60
|
+
);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { sortedJsonReplacer } from "../utils";
|
|
32
|
+
import { warnOnLegacyDigests } from "./legacy-digest";
|
|
32
33
|
import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -50,6 +51,20 @@ export interface RunOrigin {
|
|
|
50
51
|
url?: string;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Points at one earlier release record (#2530, #2531). A record has no id of its own,
|
|
56
|
+
* so it is named by the environment whose ledger holds it plus the run id and
|
|
57
|
+
* timestamp it was written with, which is enough to find the line again.
|
|
58
|
+
*/
|
|
59
|
+
export interface ReleaseRef {
|
|
60
|
+
/** The environment whose ledger holds the referenced record. */
|
|
61
|
+
env: string;
|
|
62
|
+
/** The referenced record's `runId`. */
|
|
63
|
+
runId: string;
|
|
64
|
+
/** The referenced record's `timestamp`. */
|
|
65
|
+
timestamp: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/**
|
|
54
69
|
* One immutable deploy record: `(component, env, artifact digest, git sha,
|
|
55
70
|
* run id, timestamp, actor)`, referencing the build archive by digest — the
|
|
@@ -128,6 +143,18 @@ export interface ReleaseRecord {
|
|
|
128
143
|
* bytes each cluster got. Absent when `digest` is already input-side.
|
|
129
144
|
*/
|
|
130
145
|
inputDigest?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Optional: set when this release was a promotion (`chant components
|
|
148
|
+
* promote`, #2530). Names the source environment's release whose digest
|
|
149
|
+
* was deployed here without a build. Absent on every other record.
|
|
150
|
+
*/
|
|
151
|
+
promotedFrom?: ReleaseRef;
|
|
152
|
+
/**
|
|
153
|
+
* Optional: set when this release was a rollback (`chant components
|
|
154
|
+
* rollback`, #2531). Names the earlier release of the same environment
|
|
155
|
+
* whose digest was deployed again. Absent on every other record.
|
|
156
|
+
*/
|
|
157
|
+
restores?: ReleaseRef;
|
|
131
158
|
}
|
|
132
159
|
|
|
133
160
|
/** Required, non-empty-string fields every `ReleaseRecord` must carry. */
|
|
@@ -258,6 +285,13 @@ export async function readReleaseLedger(
|
|
|
258
285
|
malformed++;
|
|
259
286
|
}
|
|
260
287
|
}
|
|
288
|
+
// chant #2514 — a release ahead of real SHA-256 digests, say once that
|
|
289
|
+
// this ledger holds values in the old form.
|
|
290
|
+
warnOnLegacyDigests(
|
|
291
|
+
records.flatMap((r) => [r.digest, r.manifestDigest, r.inputDigest]),
|
|
292
|
+
"release ledger",
|
|
293
|
+
opts?.cwd,
|
|
294
|
+
);
|
|
261
295
|
return { records, malformed };
|
|
262
296
|
}
|
|
263
297
|
|
|
@@ -24,9 +24,8 @@
|
|
|
24
24
|
* component.schema.json), a component may attach a `noRollback: "<reason>"`
|
|
25
25
|
* string property directly on the step to declare the compensation gap is
|
|
26
26
|
* intentional and understood, or supply its own explicit compensation via a
|
|
27
|
-
* component-level `rollback` field
|
|
28
|
-
* `rollback-previous
|
|
29
|
-
* in the same phase.
|
|
27
|
+
* component-level `rollback` field or a sibling
|
|
28
|
+
* `rollback-previous`/`snapshot-before` step in the same phase.
|
|
30
29
|
*
|
|
31
30
|
* Triggers on: a bare `{ kind: "run-migration", ... }` step with no
|
|
32
31
|
* `noRollback` reason, no component-level `rollback`, and no sibling
|