@intentius/chant 0.70.1 → 0.71.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/cli/handlers/fan-out.d.ts +45 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +35 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +23 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +12 -2
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +70 -0
- package/dist/components/fan-out-output.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +80 -0
- package/dist/components/fan-out-run.d.ts.map +1 -0
- package/dist/components/fan-out-support.d.ts +65 -0
- package/dist/components/fan-out-support.d.ts.map +1 -0
- package/dist/components/fan-out.d.ts +194 -0
- package/dist/components/fan-out.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +36 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +22 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts +26 -0
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +16 -1
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +31 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +19 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +21 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +10 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +25 -8
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/fan-out.test.ts +394 -0
- package/src/cli/handlers/fan-out.ts +336 -0
- package/src/cli/handlers/lifecycle.test.ts +74 -1
- package/src/cli/handlers/lifecycle.ts +29 -1
- package/src/cli/handlers/operator.test.ts +22 -0
- package/src/cli/handlers/operator.ts +11 -3
- package/src/cli/handlers/run.ts +7 -1
- package/src/cli/main.ts +25 -3
- package/src/cli/registry.ts +23 -0
- package/src/components/deploy-units.ts +14 -4
- package/src/components/fan-out-output.test.ts +216 -0
- package/src/components/fan-out-output.ts +162 -0
- package/src/components/fan-out-run.test.ts +194 -0
- package/src/components/fan-out-run.ts +221 -0
- package/src/components/fan-out-support.test.ts +125 -0
- package/src/components/fan-out-support.ts +95 -0
- package/src/components/fan-out.test.ts +284 -0
- package/src/components/fan-out.ts +421 -0
- package/src/components/index.ts +33 -0
- package/src/discovery/fold-import.ts +81 -3
- package/src/fold/subset-public-export.test.ts +31 -0
- package/src/fold/subset.ts +23 -0
- package/src/index.ts +6 -0
- package/src/lifecycle/affected.test.ts +118 -0
- package/src/lifecycle/affected.ts +118 -14
- package/src/op/activities/activity-contracts.ts +17 -1
- package/src/op/activities/index.ts +1 -1
- package/src/op/activities/shell.test.ts +156 -0
- package/src/op/activities/shell.ts +84 -9
- package/src/op/activity-profiles.test.ts +16 -2
- package/src/op/activity-profiles.ts +18 -0
- package/src/op/builders.ts +22 -4
- package/src/op/gate-name.ts +11 -0
- package/src/op/op-ir.test.ts +4 -1
- package/src/op/op.test.ts +7 -2
- package/src/op/step-output-ref.ts +29 -8
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a derived fan-out (#2417).
|
|
3
|
+
*
|
|
4
|
+
* `./fan-out.ts` decides who runs and in what order; this dispatches it. The
|
|
5
|
+
* two are separate because the deciding is worth testing without a cloud, and
|
|
6
|
+
* because the same plan is what one approval is bound to.
|
|
7
|
+
*
|
|
8
|
+
* ## Why not `runInterpretDriver`
|
|
9
|
+
*
|
|
10
|
+
* That runner stops the whole run at the first failed component, which is the
|
|
11
|
+
* right default for `chant run --components all`: there, the user asked for
|
|
12
|
+
* everything, and a failure means the estate is in a state nobody described.
|
|
13
|
+
*
|
|
14
|
+
* A fan-out is the case where that default throws away the point. The order was
|
|
15
|
+
* derived from the source, so a branch sharing no edge with the failure is
|
|
16
|
+
* *known* to be independent rather than assumed to be. Stopping it buys nothing
|
|
17
|
+
* and costs a second approval to finish. So this runner skips the failed
|
|
18
|
+
* component's subtree and lets the rest of the wave plan proceed.
|
|
19
|
+
*
|
|
20
|
+
* Neither runner's behaviour changes the other's: `runInterpretDriver` is
|
|
21
|
+
* untouched.
|
|
22
|
+
*
|
|
23
|
+
* ## One approval, not one per component
|
|
24
|
+
*
|
|
25
|
+
* The gate is evaluated once, before the first wave, bound to the plan's digest
|
|
26
|
+
* through the same `planDigest` mechanism #2300 gave gates. Twenty components
|
|
27
|
+
* behind one fan-out is one approval, and re-deriving the same fan-out does not
|
|
28
|
+
* expire it. A component's *own* authored gates still decide per component —
|
|
29
|
+
* this adds a gate over the set, it does not remove the ones inside it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
runComponentDeploy,
|
|
34
|
+
type DriverComponent,
|
|
35
|
+
type DriverComponentResult,
|
|
36
|
+
type GateContext,
|
|
37
|
+
} from "./driver";
|
|
38
|
+
import { downstreamWithin, remainingFanOut, type FanOutPlan, type FanOutProgress, type FanOutSkip } from "./fan-out";
|
|
39
|
+
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
40
|
+
import type { RunProgressEvent } from "./run-progress";
|
|
41
|
+
import type { CapabilityRegistry } from "./capability";
|
|
42
|
+
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
43
|
+
|
|
44
|
+
/** The single gate over a whole fan-out. Omit it to run without one. */
|
|
45
|
+
export interface FanOutGate {
|
|
46
|
+
/** The name `chant approve <op> <gate>` takes — the fan-out's name, not a component's. */
|
|
47
|
+
op: string;
|
|
48
|
+
gate: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
timeout?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FanOutRunOptions {
|
|
54
|
+
env: string;
|
|
55
|
+
vars?: Record<string, unknown>;
|
|
56
|
+
/** Outputs of components this run is not running: the ones the plan lists in `seeds`. */
|
|
57
|
+
componentOutputs?: Record<string, Record<string, unknown>>;
|
|
58
|
+
onProgress?: (event: RunProgressEvent) => void;
|
|
59
|
+
gates?: GateLedgerPort;
|
|
60
|
+
now?: string;
|
|
61
|
+
/** One approval over the ordered set, bound to `plan.digest`. */
|
|
62
|
+
gate?: FanOutGate;
|
|
63
|
+
/** What an earlier attempt at this same plan already did. */
|
|
64
|
+
progress?: FanOutProgress;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FanOutRunResult {
|
|
68
|
+
/** The plan actually dispatched — `plan` narrowed by `options.progress`. */
|
|
69
|
+
plan: FanOutPlan;
|
|
70
|
+
status: "ok" | "fail" | "gated";
|
|
71
|
+
results: DriverComponentResult[];
|
|
72
|
+
/** Reached `ok` this attempt. Feed back as `progress.completed` to resume. */
|
|
73
|
+
completed: string[];
|
|
74
|
+
/** Failed this attempt. Feed back as `progress.failed`. */
|
|
75
|
+
failed: string[];
|
|
76
|
+
/** Never dispatched because something upstream failed, each naming the failure. */
|
|
77
|
+
blocked: FanOutSkip[];
|
|
78
|
+
componentOutputs: Record<string, Record<string, unknown>>;
|
|
79
|
+
/** Present when `status === "gated"`: the pending fact to approve. */
|
|
80
|
+
gate?: PendingGateRecord;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Dispatch a fan-out plan.
|
|
85
|
+
*
|
|
86
|
+
* Resolves rather than throws on a failed component: a fan-out's partial
|
|
87
|
+
* outcome is the interesting one, and the caller needs `completed` to resume.
|
|
88
|
+
*/
|
|
89
|
+
export async function runFanOut(
|
|
90
|
+
plan: FanOutPlan,
|
|
91
|
+
components: DriverComponent[],
|
|
92
|
+
registry: CapabilityRegistry,
|
|
93
|
+
options: FanOutRunOptions,
|
|
94
|
+
): Promise<FanOutRunResult> {
|
|
95
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
96
|
+
const gates: GateContext = {
|
|
97
|
+
port: options.gates ?? gitGateLedgerPort(),
|
|
98
|
+
...(options.now ? { now: options.now } : {}),
|
|
99
|
+
};
|
|
100
|
+
const componentOutputs: Record<string, Record<string, unknown>> = { ...(options.componentOutputs ?? {}) };
|
|
101
|
+
|
|
102
|
+
// Resume first, so the gate is decided against the work that is actually left
|
|
103
|
+
// and a re-run does not re-approve. The digest is carried, not recomputed.
|
|
104
|
+
const active = options.progress ? remainingFanOut(plan, components, options.progress) : plan;
|
|
105
|
+
|
|
106
|
+
if (options.gate) {
|
|
107
|
+
const check = await evaluateGate(gates.port, {
|
|
108
|
+
op: options.gate.op,
|
|
109
|
+
gate: options.gate.gate,
|
|
110
|
+
planDigest: active.digest,
|
|
111
|
+
...(options.gate.description ? { description: options.gate.description } : {}),
|
|
112
|
+
...(options.gate.timeout ? { timeout: options.gate.timeout } : {}),
|
|
113
|
+
...(options.now ? { now: options.now } : {}),
|
|
114
|
+
});
|
|
115
|
+
if (!check.satisfied) {
|
|
116
|
+
// Nothing ran, so nothing is half-applied and there is nothing to
|
|
117
|
+
// compensate. A gate is a fact, not a failure.
|
|
118
|
+
return {
|
|
119
|
+
plan: active,
|
|
120
|
+
status: "gated",
|
|
121
|
+
results: [],
|
|
122
|
+
completed: [],
|
|
123
|
+
failed: [],
|
|
124
|
+
blocked: [],
|
|
125
|
+
componentOutputs,
|
|
126
|
+
gate: check.pending,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const results: DriverComponentResult[] = [];
|
|
132
|
+
const completed: string[] = [];
|
|
133
|
+
const failed: string[] = [];
|
|
134
|
+
const blockedBy = new Map<string, string>();
|
|
135
|
+
let gatedFact: PendingGateRecord | undefined;
|
|
136
|
+
|
|
137
|
+
const inPlan = new Set(active.order);
|
|
138
|
+
/** Skip everything downstream of `root`, blaming `root` rather than the nearest edge. */
|
|
139
|
+
const markBlocked = (root: string): void => {
|
|
140
|
+
for (const [component, by] of downstreamWithin(components, inPlan, [root])) {
|
|
141
|
+
if (blockedBy.has(component) || failed.includes(component)) continue;
|
|
142
|
+
blockedBy.set(component, by);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
options.onProgress?.({ type: "run-start", waves: active.waves });
|
|
147
|
+
|
|
148
|
+
for (const [waveIndex, wave] of active.waves.entries()) {
|
|
149
|
+
const waveNum = waveIndex + 1;
|
|
150
|
+
// A component whose upstream failed in an earlier wave is not dispatched.
|
|
151
|
+
const runnable = wave.filter((name) => inPlan.has(name) && !blockedBy.has(name));
|
|
152
|
+
if (runnable.length === 0) continue;
|
|
153
|
+
|
|
154
|
+
options.onProgress?.({ type: "wave-start", wave: waveNum, components: runnable });
|
|
155
|
+
const waveResults = await Promise.all(
|
|
156
|
+
runnable.map(async (name) => {
|
|
157
|
+
options.onProgress?.({ type: "component-start", wave: waveNum, component: name });
|
|
158
|
+
const result = await runComponentDeploy(
|
|
159
|
+
byName.get(name)!,
|
|
160
|
+
{ env: options.env, component: name, vars: options.vars },
|
|
161
|
+
registry,
|
|
162
|
+
componentOutputs,
|
|
163
|
+
options.onProgress,
|
|
164
|
+
gates,
|
|
165
|
+
);
|
|
166
|
+
options.onProgress?.({
|
|
167
|
+
type: "component-done",
|
|
168
|
+
wave: waveNum,
|
|
169
|
+
component: name,
|
|
170
|
+
status: result.status === "fail" ? "failed" : result.status,
|
|
171
|
+
});
|
|
172
|
+
return result;
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
results.push(...waveResults);
|
|
176
|
+
|
|
177
|
+
for (const result of waveResults) {
|
|
178
|
+
if (result.status === "ok") {
|
|
179
|
+
completed.push(result.component);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (result.status === "fail") {
|
|
183
|
+
failed.push(result.component);
|
|
184
|
+
markBlocked(result.component);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// A component of its own stopped at one of its authored gates. Its
|
|
188
|
+
// dependents cannot run either, for the same reason a failure's cannot:
|
|
189
|
+
// the value they would read has not landed.
|
|
190
|
+
gatedFact ??= result.gate;
|
|
191
|
+
markBlocked(result.component);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const waveFailed = waveResults.some((r) => r.status === "fail");
|
|
195
|
+
options.onProgress?.({
|
|
196
|
+
type: "wave-done",
|
|
197
|
+
wave: waveNum,
|
|
198
|
+
status: waveFailed ? "failed" : waveResults.some((r) => r.status === "gated") ? "gated" : "ok",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const blocked: FanOutSkip[] = [...blockedBy.entries()]
|
|
203
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
204
|
+
.map(([component, by]) => ({ component, reason: "blocked" as const, blockedBy: by }));
|
|
205
|
+
|
|
206
|
+
// A failure outranks a gate: something is actually broken, and reporting the
|
|
207
|
+
// run as "waiting on a human" would hide it. Same precedence the driver uses.
|
|
208
|
+
const status: FanOutRunResult["status"] = failed.length > 0 ? "fail" : gatedFact ? "gated" : "ok";
|
|
209
|
+
options.onProgress?.({ type: "run-done", status: status === "fail" ? "failed" : status });
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
plan: active,
|
|
213
|
+
status,
|
|
214
|
+
results,
|
|
215
|
+
completed: [...completed].sort(),
|
|
216
|
+
failed: [...failed].sort(),
|
|
217
|
+
blocked,
|
|
218
|
+
componentOutputs,
|
|
219
|
+
...(gatedFact ? { gate: gatedFact } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery to plan (#2420).
|
|
3
|
+
*
|
|
4
|
+
* The join is the part that had no caller: `componentsForUnits` turns a
|
|
5
|
+
* stack-level change signal into components, and `planFanOut` needs the whole
|
|
6
|
+
* component set to order a subset of it. These tests hold that seam to the
|
|
7
|
+
* selection semantics `chant run --components all` already has, so the two
|
|
8
|
+
* commands cannot disagree about which components exist.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
12
|
+
import type { Component } from "./component";
|
|
13
|
+
|
|
14
|
+
const discoverComponentsMock = vi.fn();
|
|
15
|
+
const loadChantConfigMock = vi.fn();
|
|
16
|
+
|
|
17
|
+
vi.mock("./discover", () => ({
|
|
18
|
+
discoverComponents: (...args: unknown[]) => discoverComponentsMock(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../config", async () => {
|
|
22
|
+
const actual = await vi.importActual<typeof import("../config")>("../config");
|
|
23
|
+
return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const { deriveFanOut } = await import("./fan-out-support");
|
|
27
|
+
|
|
28
|
+
function component(name: string, dependsOn: string[] = [], over: Partial<Component> = {}): Component {
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
dependsOn,
|
|
32
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: `${name}-stack` }] }],
|
|
33
|
+
...over,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ESTATE = [
|
|
38
|
+
component("network"),
|
|
39
|
+
component("cluster-a", ["network"]),
|
|
40
|
+
component("cluster-b", ["network"]),
|
|
41
|
+
component("app-one", ["cluster-a"]),
|
|
42
|
+
component("app-two", ["cluster-b"]),
|
|
43
|
+
component("billing"),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function discovers(components: Component[]): void {
|
|
47
|
+
discoverComponentsMock.mockResolvedValue({
|
|
48
|
+
components: new Map(components.map((c) => [c.name, { component: c, exportName: c.name, filePath: `${c.name}.component.ts` }])),
|
|
49
|
+
sourceFiles: [],
|
|
50
|
+
errors: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["aws"] } });
|
|
57
|
+
discovers(ESTATE);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("joining a stack-level signal to components", () => {
|
|
61
|
+
test("a changed stack selects the component that deploys it, and everything downstream", async () => {
|
|
62
|
+
const derived = await deriveFanOut({ path: "/project", units: { changed: ["network-stack"] } });
|
|
63
|
+
|
|
64
|
+
expect(derived.success).toBe(true);
|
|
65
|
+
expect(derived.signal.changed).toEqual(["network"]);
|
|
66
|
+
expect(derived.plan.order).toEqual(["network", "cluster-a", "cluster-b", "app-one", "app-two"]);
|
|
67
|
+
expect(derived.plan.waves).toEqual([["network"], ["cluster-a", "cluster-b"], ["app-one", "app-two"]]);
|
|
68
|
+
expect(derived.plan.skipped).toEqual([{ component: "billing", reason: "unaffected" }]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("ordering a subset works, and the dependency outside it is seeded rather than run", async () => {
|
|
72
|
+
const derived = await deriveFanOut({ path: "/project", units: { changed: ["cluster-a-stack"] } });
|
|
73
|
+
|
|
74
|
+
expect(derived.plan.order).toEqual(["cluster-a", "app-one"]);
|
|
75
|
+
expect(derived.plan.seeds).toEqual(["network"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a changed stack no component deploys is reported, never dropped", async () => {
|
|
79
|
+
const derived = await deriveFanOut({
|
|
80
|
+
path: "/project",
|
|
81
|
+
units: { changed: ["network-stack", "legacy-stack"] },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(derived.signal.unclaimed).toEqual(["legacy-stack"]);
|
|
85
|
+
expect(derived.signal.changed).toEqual(["network"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("an indeterminate stack the walk does not reach stays the third outcome", async () => {
|
|
89
|
+
const derived = await deriveFanOut({
|
|
90
|
+
path: "/project",
|
|
91
|
+
units: { changed: ["app-one-stack"], indeterminate: ["billing-stack"] },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(derived.plan.order).toEqual(["app-one"]);
|
|
95
|
+
expect(derived.plan.indeterminate).toEqual(["billing"]);
|
|
96
|
+
expect(derived.plan.skipped).toContainEqual({ component: "billing", reason: "indeterminate" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a component a build parameter turned off sits out, exactly as it does under `run --components all`", async () => {
|
|
100
|
+
discovers([...ESTATE.filter((c) => c.name !== "app-two"), component("app-two", ["cluster-b"], { enabled: false })]);
|
|
101
|
+
|
|
102
|
+
const derived = await deriveFanOut({ path: "/project", units: { changed: ["network-stack"] } });
|
|
103
|
+
|
|
104
|
+
expect(derived.components.map((c) => c.name)).not.toContain("app-two");
|
|
105
|
+
expect(derived.plan.order).not.toContain("app-two");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the whole graph is refused by name before anything is selected", async () => {
|
|
109
|
+
discovers([component("a", ["b"]), component("b", ["a"]), component("network")]);
|
|
110
|
+
|
|
111
|
+
await expect(deriveFanOut({ path: "/project", units: { changed: ["network-stack"] } })).rejects.toThrow(/cycle/i);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a discovery failure comes back as an error rather than an empty fan-out", async () => {
|
|
115
|
+
discoverComponentsMock.mockResolvedValue({
|
|
116
|
+
components: new Map(),
|
|
117
|
+
sourceFiles: [],
|
|
118
|
+
errors: [{ message: "app-one.component.ts failed to import" }],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const derived = await deriveFanOut({ path: "/project", units: { changed: ["network-stack"] } });
|
|
122
|
+
expect(derived.success).toBe(false);
|
|
123
|
+
expect(derived.error).toContain("failed to import");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery-to-plan wiring for `chant components fan-out` (#2420).
|
|
3
|
+
*
|
|
4
|
+
* `./cli-support.ts` does this for `chant run --components`: the CLI handler
|
|
5
|
+
* stays a thin dispatcher and the component-specific work lives beside the
|
|
6
|
+
* rest of the component subsystem. This is the same seam for the fan-out, and
|
|
7
|
+
* it exists mostly so the join can be tested against a real discovery result
|
|
8
|
+
* without a CLI, a git repo or a cloud.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here decides anything. `componentsForUnits` joins the stack-level
|
|
11
|
+
* change signal to components, `planFanOut` derives the order, and both live
|
|
12
|
+
* in ./fan-out.ts. This finds the components those two need and hands them
|
|
13
|
+
* over.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolveComponentTargets } from "./cli-support";
|
|
17
|
+
import { applyConfigDefaults } from "./config-defaults";
|
|
18
|
+
import { buildCapabilityRegistry } from "./capability-plugin-loader";
|
|
19
|
+
import { componentsForUnits, planFanOut, type ChangedUnits, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
|
|
20
|
+
import type { CapabilityRegistry } from "./capability";
|
|
21
|
+
import type { DriverComponent } from "./driver";
|
|
22
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
23
|
+
import { loadChantConfig, type ChantConfig } from "../config";
|
|
24
|
+
|
|
25
|
+
export interface DeriveFanOutOptions {
|
|
26
|
+
/** Project root to discover `*.component.ts` under. */
|
|
27
|
+
path: string;
|
|
28
|
+
/** The stack-level change signal, from `lifecycle affected` or from a caller who already had one. */
|
|
29
|
+
units: ChangedUnits;
|
|
30
|
+
sandbox?: boolean;
|
|
31
|
+
/** chant #1108 — this invocation's resolved build-time parameters, so a `params.*` reference in a component file resolves. */
|
|
32
|
+
buildParams?: BuildParamProvenance[];
|
|
33
|
+
/** Already-loaded project config, when the caller read it for its own reasons. */
|
|
34
|
+
config?: ChantConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DerivedFanOut {
|
|
38
|
+
success: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Every component in the project, config defaults applied. The full set, not
|
|
41
|
+
* the selection: ordering a subset is only possible against the whole graph
|
|
42
|
+
* (./fan-out.ts), and the runner needs the unselected ones to work out what
|
|
43
|
+
* a failure blocks.
|
|
44
|
+
*/
|
|
45
|
+
components: DriverComponent[];
|
|
46
|
+
/** What the stack-level signal meant in component terms, `unclaimed` included. */
|
|
47
|
+
signal: ComponentChangeSignal;
|
|
48
|
+
plan: FanOutPlan;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMPTY_PLAN: FanOutPlan = { order: [], waves: [], skipped: [], seeds: [], indeterminate: [], digest: "" };
|
|
53
|
+
const EMPTY_SIGNAL: ComponentChangeSignal = { changed: [], indeterminate: [], unclaimed: [] };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Discover the project's components and derive the fan-out for `units`.
|
|
57
|
+
*
|
|
58
|
+
* Selection is `"all"`'s, borrowed wholesale from `resolveComponentTargets`, so
|
|
59
|
+
* a component a build parameter turned off (`enabled: false`, chant #1522) sits
|
|
60
|
+
* out of a fan-out exactly as it sits out of `chant run --components all`. Two
|
|
61
|
+
* commands disagreeing about which components exist would be worse than either
|
|
62
|
+
* answer on its own.
|
|
63
|
+
*
|
|
64
|
+
* Resolves with `success: false` and an `error` rather than throwing on a
|
|
65
|
+
* discovery failure; a cycle or an unknown `dependsOn` still throws out of
|
|
66
|
+
* `planFanOut`, which is #2417's "refuse by name, before anything is selected".
|
|
67
|
+
*/
|
|
68
|
+
export async function deriveFanOut(options: DeriveFanOutOptions): Promise<DerivedFanOut> {
|
|
69
|
+
const resolved = await resolveComponentTargets(options.path, "all", options.sandbox, options.buildParams);
|
|
70
|
+
if (!resolved.success) {
|
|
71
|
+
return { success: false, components: [], signal: EMPTY_SIGNAL, plan: EMPTY_PLAN, error: resolved.error };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { config } = options.config ? { config: options.config } : await loadChantConfig(options.path);
|
|
75
|
+
const components = resolved.targets.map((component) => applyConfigDefaults(component, config));
|
|
76
|
+
|
|
77
|
+
const signal = componentsForUnits(components, options.units);
|
|
78
|
+
const plan = planFanOut({
|
|
79
|
+
components,
|
|
80
|
+
changed: signal.changed,
|
|
81
|
+
...(signal.indeterminate.length > 0 ? { indeterminate: signal.indeterminate } : {}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { success: true, components, signal, plan };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The capability registry a fan-out dispatches through — the project's own,
|
|
89
|
+
* built exactly the way `runComponents` builds it, so a fan-out and a
|
|
90
|
+
* `chant run --components` of the same component reach the same leaves.
|
|
91
|
+
*/
|
|
92
|
+
export async function fanOutRegistry(path: string, config?: ChantConfig): Promise<CapabilityRegistry> {
|
|
93
|
+
const resolved = config ?? (await loadChantConfig(path)).config;
|
|
94
|
+
return buildCapabilityRegistry({ plugins: resolved.capabilities, lexicons: resolved.lexicons });
|
|
95
|
+
}
|