@intentius/chant 0.70.1 → 0.71.1
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 +2 -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/meta/declared-imports.test.ts +141 -0
- 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,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan a change out downstream, in an order derived from the source (#2417).
|
|
3
|
+
*
|
|
4
|
+
* The order was never the missing piece. `resolveComponentGraph` (./driver.ts)
|
|
5
|
+
* already Kahn-layers a component set by `dependsOn` into parallel-safe waves,
|
|
6
|
+
* flattens to a topological order, refuses a cycle by name and refuses a
|
|
7
|
+
* `dependsOn` it has not been given. What it cannot do is order a *subset*:
|
|
8
|
+
* hand it the affected components alone and it throws `UnknownDependencyError`,
|
|
9
|
+
* because a selected component still names a dependency that is not in the set.
|
|
10
|
+
*
|
|
11
|
+
* That is what this module is. Given every component in the project and the
|
|
12
|
+
* ones whose inputs moved, it derives who else has to run, in what order, what
|
|
13
|
+
* has to be seeded because it is deliberately not running, and one digest that
|
|
14
|
+
* identifies the whole derivation so a single gate can be bound to it.
|
|
15
|
+
*
|
|
16
|
+
* ## Why derived rather than declared
|
|
17
|
+
*
|
|
18
|
+
* The competing shape is a registry where you write down that component B
|
|
19
|
+
* depends on component A. That registry is a second statement of a relationship
|
|
20
|
+
* the source already makes, and it is wrong the first time somebody adds a
|
|
21
|
+
* reference without updating it. `dependsOn` is in the component; the graph is
|
|
22
|
+
* a walk over it.
|
|
23
|
+
*
|
|
24
|
+
* ## Three outcomes, never two
|
|
25
|
+
*
|
|
26
|
+
* A component is selected, skipped as unaffected, or **indeterminate** — the
|
|
27
|
+
* same third answer `../lifecycle/affected.ts` already refuses to collapse. A
|
|
28
|
+
* component whose inputs arrive at deploy time cannot be judged from a source
|
|
29
|
+
* diff, so it is reported rather than guessed at in either direction. Fanning
|
|
30
|
+
* out to everything on any change is easy to build, worth nothing, and reads
|
|
31
|
+
* exactly like diligence in a log.
|
|
32
|
+
*
|
|
33
|
+
* This plans; it does not run. Executing the plan is ./driver.ts's job.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { resolveComponentGraph, type DriverComponent } from "./driver";
|
|
37
|
+
import { deployUnits } from "./deploy-units";
|
|
38
|
+
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
39
|
+
|
|
40
|
+
/** Thrown when `changed` or `indeterminate` names a component the project does not have. */
|
|
41
|
+
export class UnknownComponentError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
readonly field: "changed" | "indeterminate",
|
|
44
|
+
readonly component: string,
|
|
45
|
+
known: string[],
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
`${field} names "${component}", which is not a component in this project ` +
|
|
49
|
+
`(known: ${known.join(", ") || "none"})`,
|
|
50
|
+
);
|
|
51
|
+
this.name = "UnknownComponentError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why a component is in the plan but not running. */
|
|
56
|
+
export type FanOutSkipReason =
|
|
57
|
+
/** Nothing it depends on moved, and it did not move itself. */
|
|
58
|
+
| "unaffected"
|
|
59
|
+
/** Its inputs arrive at deploy time, so a source diff cannot judge it. */
|
|
60
|
+
| "indeterminate"
|
|
61
|
+
/** It already applied in an earlier attempt at this same fan-out. */
|
|
62
|
+
| "already-applied"
|
|
63
|
+
/** Something it depends on failed, so the value it would read never landed. */
|
|
64
|
+
| "blocked";
|
|
65
|
+
|
|
66
|
+
export interface FanOutSkip {
|
|
67
|
+
component: string;
|
|
68
|
+
reason: FanOutSkipReason;
|
|
69
|
+
/** For `blocked`, the failed component the walk reached this one from. */
|
|
70
|
+
blockedBy?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FanOutRequest {
|
|
74
|
+
/** Every component in the project, `dependsOn` intact. The full graph is what makes a subset orderable. */
|
|
75
|
+
components: DriverComponent[];
|
|
76
|
+
/** Components whose own inputs moved. The walk starts here. */
|
|
77
|
+
changed: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Components a source diff cannot judge (`../lifecycle/affected.ts`'s third
|
|
80
|
+
* category). One that the walk reaches anyway is selected like any other
|
|
81
|
+
* dependent — reachability is a fact about the graph, not about whether the
|
|
82
|
+
* component's own inputs could be read. One the walk does not reach is
|
|
83
|
+
* reported, never decided.
|
|
84
|
+
*/
|
|
85
|
+
indeterminate?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Per-component input identity, when the caller has it. Folded into
|
|
88
|
+
* {@link FanOutPlan.digest} so an approval is bound to *what* would be
|
|
89
|
+
* applied and not only to who would run. Absent, the digest still identifies
|
|
90
|
+
* the derivation — the selection, the order and the edges it came from.
|
|
91
|
+
*/
|
|
92
|
+
inputDigests?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FanOutPlan {
|
|
96
|
+
/** The components to run, every dependency before its dependents. */
|
|
97
|
+
order: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Parallel-safe waves over the selected set. A dependency that is not
|
|
100
|
+
* selected is already satisfied — its outputs are seeded — so it does not
|
|
101
|
+
* hold its dependents back a wave.
|
|
102
|
+
*/
|
|
103
|
+
waves: string[][];
|
|
104
|
+
/** Components deliberately not running, with why. Sorted by name. */
|
|
105
|
+
skipped: FanOutSkip[];
|
|
106
|
+
/**
|
|
107
|
+
* Dependencies of selected components that are not themselves selected.
|
|
108
|
+
* Their outputs have to be seeded for a reference to resolve, which is the
|
|
109
|
+
* price of running a subset rather than the whole graph.
|
|
110
|
+
*/
|
|
111
|
+
seeds: string[];
|
|
112
|
+
/** Components a source diff could not judge and the walk did not reach. Reported, never decided. */
|
|
113
|
+
indeterminate: string[];
|
|
114
|
+
/** Identity of this derivation, for binding one approval to the whole fan-out (#2300's pattern). */
|
|
115
|
+
digest: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Reverse the `dependsOn` edges: who has to re-run when this one moves. */
|
|
119
|
+
function consumersOf(components: DriverComponent[]): Map<string, string[]> {
|
|
120
|
+
const consumers = new Map<string, string[]>();
|
|
121
|
+
for (const c of components) {
|
|
122
|
+
for (const dep of c.dependsOn ?? []) {
|
|
123
|
+
const existing = consumers.get(dep);
|
|
124
|
+
if (existing) existing.push(c.name);
|
|
125
|
+
else consumers.set(dep, [c.name]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return consumers;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Derive the fan-out for a change.
|
|
133
|
+
*
|
|
134
|
+
* Refuses a cycle and an unknown `dependsOn` before selecting anything, by
|
|
135
|
+
* resolving the **full** graph first — a broken graph is a broken graph whether
|
|
136
|
+
* or not the change happens to touch the broken part, and finding out halfway
|
|
137
|
+
* through a fan-out is worse than finding out before it starts.
|
|
138
|
+
*/
|
|
139
|
+
export function planFanOut(request: FanOutRequest): FanOutPlan {
|
|
140
|
+
const { components, changed, indeterminate = [], inputDigests } = request;
|
|
141
|
+
|
|
142
|
+
// Refuses DependencyCycleError / UnknownDependencyError over the whole graph.
|
|
143
|
+
// Its `order` is deliberately not used: `topoSort` walks in declaration
|
|
144
|
+
// order, so two projects with the same graph and a different file layout
|
|
145
|
+
// would derive different-looking fan-outs and digest differently. The waves
|
|
146
|
+
// below are canonical, and this plan's order is their flattening.
|
|
147
|
+
resolveComponentGraph(components);
|
|
148
|
+
|
|
149
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
150
|
+
for (const name of changed) {
|
|
151
|
+
if (!byName.has(name)) throw new UnknownComponentError("changed", name, [...byName.keys()].sort());
|
|
152
|
+
}
|
|
153
|
+
for (const name of indeterminate) {
|
|
154
|
+
if (!byName.has(name)) throw new UnknownComponentError("indeterminate", name, [...byName.keys()].sort());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Everything reachable downstream of a changed component, transitively. A
|
|
158
|
+
// component reached by two paths is added once, which is the diamond case.
|
|
159
|
+
const consumers = consumersOf(components);
|
|
160
|
+
const selected = new Set<string>(changed);
|
|
161
|
+
const queue = [...changed];
|
|
162
|
+
while (queue.length > 0) {
|
|
163
|
+
const node = queue.shift()!;
|
|
164
|
+
for (const consumer of consumers.get(node) ?? []) {
|
|
165
|
+
if (selected.has(consumer)) continue;
|
|
166
|
+
selected.add(consumer);
|
|
167
|
+
queue.push(consumer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Waves over the selected subgraph only. An unselected dependency is already
|
|
172
|
+
// applied, so it is not a reason for its dependents to wait.
|
|
173
|
+
const remaining = new Set(selected);
|
|
174
|
+
const selectedDeps = new Map(
|
|
175
|
+
[...selected].map((name) => [name, new Set((byName.get(name)!.dependsOn ?? []).filter((d) => selected.has(d)))]),
|
|
176
|
+
);
|
|
177
|
+
const waves: string[][] = [];
|
|
178
|
+
while (remaining.size > 0) {
|
|
179
|
+
const wave = [...remaining]
|
|
180
|
+
.filter((n) => [...selectedDeps.get(n)!].every((d) => !remaining.has(d)))
|
|
181
|
+
.sort();
|
|
182
|
+
// resolveComponentGraph already refused every cycle in the full graph, and
|
|
183
|
+
// a subgraph of an acyclic graph is acyclic, so this cannot stall.
|
|
184
|
+
for (const n of wave) remaining.delete(n);
|
|
185
|
+
waves.push(wave);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Every dependency lands in an earlier wave than its dependents, so the
|
|
189
|
+
// flattening is a topological order, and a sorted one.
|
|
190
|
+
const order = waves.flat();
|
|
191
|
+
|
|
192
|
+
const seeds = [
|
|
193
|
+
...new Set(order.flatMap((name) => (byName.get(name)!.dependsOn ?? []).filter((d) => !selected.has(d)))),
|
|
194
|
+
].sort();
|
|
195
|
+
|
|
196
|
+
const unreachedIndeterminate = indeterminate.filter((name) => !selected.has(name)).sort();
|
|
197
|
+
const indeterminateSet = new Set(unreachedIndeterminate);
|
|
198
|
+
const skipped: FanOutSkip[] = [...byName.keys()]
|
|
199
|
+
.filter((name) => !selected.has(name))
|
|
200
|
+
.sort()
|
|
201
|
+
.map((component) => ({
|
|
202
|
+
component,
|
|
203
|
+
reason: indeterminateSet.has(component) ? ("indeterminate" as const) : ("unaffected" as const),
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// What the approver is approving: who runs, in what order, off which edges,
|
|
207
|
+
// and — when the caller knows it — what each one would apply. Never the run
|
|
208
|
+
// id or the moment, per ../lifecycle/plan-digest.ts's rules, so re-deriving
|
|
209
|
+
// an unchanged fan-out does not expire an approval.
|
|
210
|
+
const digest = computePlanDigest("component-fan-out", {
|
|
211
|
+
order,
|
|
212
|
+
waves,
|
|
213
|
+
seeds,
|
|
214
|
+
edges: order.map((name) => ({ component: name, dependsOn: [...(byName.get(name)!.dependsOn ?? [])].sort() })),
|
|
215
|
+
...(inputDigests
|
|
216
|
+
? { inputs: order.map((name) => ({ component: name, digest: inputDigests[name] ?? null })) }
|
|
217
|
+
: {}),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return { order, waves, skipped, seeds, indeterminate: unreachedIndeterminate, digest };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Joining the change signal to components ──────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `../lifecycle/affected.ts`'s answer, which is about **stacks**.
|
|
227
|
+
*
|
|
228
|
+
* `AffectedResult` itself is not imported: this takes the two fields the join
|
|
229
|
+
* needs, so a caller can also hand in a signal that did not come from a git
|
|
230
|
+
* diff (a CI system's own changed-paths answer, an operator naming a stack by
|
|
231
|
+
* hand) without manufacturing the rest of that shape.
|
|
232
|
+
*/
|
|
233
|
+
export interface ChangedUnits {
|
|
234
|
+
/** Stacks whose built artifact moved between base and head. */
|
|
235
|
+
changed: string[];
|
|
236
|
+
/** Stacks a source diff could not judge, because their inputs arrive at deploy time. */
|
|
237
|
+
indeterminate?: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** What {@link componentsForUnits} resolved, ready to hand to {@link planFanOut}. */
|
|
241
|
+
export interface ComponentChangeSignal {
|
|
242
|
+
/** Components deploying at least one changed unit. */
|
|
243
|
+
changed: string[];
|
|
244
|
+
/** Components deploying no changed unit but at least one indeterminate one. */
|
|
245
|
+
indeterminate: string[];
|
|
246
|
+
/**
|
|
247
|
+
* Changed or indeterminate units no component claims. Reported rather than
|
|
248
|
+
* dropped: a stack that moved and belongs to nothing this project deploys is
|
|
249
|
+
* a hole in the fan-out's coverage, and a silent one is the failure mode
|
|
250
|
+
* this whole feature exists to avoid.
|
|
251
|
+
*/
|
|
252
|
+
unclaimed: string[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Join a stack-level change signal to the components that deploy those stacks.
|
|
257
|
+
*
|
|
258
|
+
* The key is `deployUnits` (./deploy-units.ts), which already answers "what
|
|
259
|
+
* live units does this component's composition target" for `chant components
|
|
260
|
+
* status --live`. Reusing it means a component's claim on a stack is stated
|
|
261
|
+
* once, by its deploy steps, rather than restated in a fan-out config — the
|
|
262
|
+
* same argument this module's doc makes about `dependsOn`.
|
|
263
|
+
*
|
|
264
|
+
* Changed beats indeterminate. A component deploying one stack that certainly
|
|
265
|
+
* moved and another that could not be judged is changed: the certainty already
|
|
266
|
+
* decides it, and reporting it as indeterminate would lose that.
|
|
267
|
+
*/
|
|
268
|
+
export function componentsForUnits(
|
|
269
|
+
components: DriverComponent[],
|
|
270
|
+
units: ChangedUnits,
|
|
271
|
+
): ComponentChangeSignal {
|
|
272
|
+
const changedUnits = new Set(units.changed);
|
|
273
|
+
const indeterminateUnits = new Set(units.indeterminate ?? []);
|
|
274
|
+
|
|
275
|
+
const changed: string[] = [];
|
|
276
|
+
const indeterminate: string[] = [];
|
|
277
|
+
const claimed = new Set<string>();
|
|
278
|
+
|
|
279
|
+
for (const component of components) {
|
|
280
|
+
const names = deployUnits(component.deploy).map((u) => u.unit);
|
|
281
|
+
let touchesChanged = false;
|
|
282
|
+
let touchesIndeterminate = false;
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
if (changedUnits.has(name)) {
|
|
285
|
+
touchesChanged = true;
|
|
286
|
+
claimed.add(name);
|
|
287
|
+
}
|
|
288
|
+
if (indeterminateUnits.has(name)) {
|
|
289
|
+
touchesIndeterminate = true;
|
|
290
|
+
claimed.add(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (touchesChanged) changed.push(component.name);
|
|
294
|
+
else if (touchesIndeterminate) indeterminate.push(component.name);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const unclaimed = [...changedUnits, ...indeterminateUnits].filter((u) => !claimed.has(u));
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
changed: changed.sort(),
|
|
301
|
+
indeterminate: indeterminate.sort(),
|
|
302
|
+
unclaimed: [...new Set(unclaimed)].sort(),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Components inside `within` that are transitively downstream of any of
|
|
308
|
+
* `roots`, each mapped to the **root** it was reached from rather than to its
|
|
309
|
+
* immediate parent — so a report names the one thing to fix instead of the
|
|
310
|
+
* nearest consequence of it.
|
|
311
|
+
*
|
|
312
|
+
* `roots` is sorted before the walk, so a component downstream of two separate
|
|
313
|
+
* roots always names the same one. Unsorted, the answer would depend on the
|
|
314
|
+
* order the caller listed them in, which is not a fact about anything.
|
|
315
|
+
*
|
|
316
|
+
* Shared by {@link remainingFanOut}, which needs it over a whole plan, and by
|
|
317
|
+
* the runner in ./fan-out-run.ts, which needs it as failures accumulate.
|
|
318
|
+
*/
|
|
319
|
+
export function downstreamWithin(
|
|
320
|
+
components: DriverComponent[],
|
|
321
|
+
within: Iterable<string>,
|
|
322
|
+
roots: readonly string[],
|
|
323
|
+
): Map<string, string> {
|
|
324
|
+
const inside = new Set(within);
|
|
325
|
+
const consumers = consumersOf(components);
|
|
326
|
+
const rootSet = new Set(roots);
|
|
327
|
+
const reached = new Map<string, string>();
|
|
328
|
+
const queue = [...roots].sort();
|
|
329
|
+
while (queue.length > 0) {
|
|
330
|
+
const node = queue.shift()!;
|
|
331
|
+
const blamed = rootSet.has(node) ? node : reached.get(node)!;
|
|
332
|
+
for (const consumer of consumers.get(node) ?? []) {
|
|
333
|
+
if (!inside.has(consumer) || rootSet.has(consumer) || reached.has(consumer)) continue;
|
|
334
|
+
reached.set(consumer, blamed);
|
|
335
|
+
queue.push(consumer);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return reached;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Finishing a fan-out that stopped ─────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
export interface FanOutProgress {
|
|
344
|
+
/** Components that reached `ok` in an earlier attempt at this same plan. */
|
|
345
|
+
completed?: string[];
|
|
346
|
+
/** Components that failed. Everything downstream of one is blocked, not failed. */
|
|
347
|
+
failed?: string[];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Narrow a plan to what still has to run.
|
|
352
|
+
*
|
|
353
|
+
* **The digest does not change.** That is the whole point: an operator approved
|
|
354
|
+
* a fan-out, a component in the middle of it failed, and finishing the work
|
|
355
|
+
* they already approved must not ask them to approve it again. `remainingFanOut`
|
|
356
|
+
* returns the same `digest` the original derivation produced, so the standing
|
|
357
|
+
* resolution still satisfies the gate on the next attempt. Re-deriving with
|
|
358
|
+
* {@link planFanOut} would mint a new identity and invalidate the approval,
|
|
359
|
+
* which is why resume narrows a plan rather than recomputing one.
|
|
360
|
+
*
|
|
361
|
+
* **A component beneath a failure is `blocked`, never `failed`.** Nothing about
|
|
362
|
+
* it failed. It did not run because the value it would have read never landed,
|
|
363
|
+
* and the distinction is what makes the next attempt legible: an operator
|
|
364
|
+
* reading `blocked by "cluster-a"` knows to fix one thing, not fourteen.
|
|
365
|
+
*
|
|
366
|
+
* **Independent branches keep going.** The order was derived from the source,
|
|
367
|
+
* so a branch that shares no edge with the failure is *known* to be independent
|
|
368
|
+
* rather than assumed to be. Stopping it is the conservative-looking choice
|
|
369
|
+
* that throws away the reason for deriving the graph in the first place.
|
|
370
|
+
*/
|
|
371
|
+
export function remainingFanOut(
|
|
372
|
+
plan: FanOutPlan,
|
|
373
|
+
components: DriverComponent[],
|
|
374
|
+
progress: FanOutProgress,
|
|
375
|
+
): FanOutPlan {
|
|
376
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
377
|
+
const planned = new Set(plan.order);
|
|
378
|
+
const completed = new Set((progress.completed ?? []).filter((n) => planned.has(n)));
|
|
379
|
+
const failed = new Set((progress.failed ?? []).filter((n) => planned.has(n)));
|
|
380
|
+
|
|
381
|
+
const blockedBy = downstreamWithin(components, planned, [...failed]);
|
|
382
|
+
|
|
383
|
+
const runnable = new Set(
|
|
384
|
+
plan.order.filter((n) => !completed.has(n) && !failed.has(n) && !blockedBy.has(n)),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Re-layer what is left. A dependency that already applied is satisfied, so
|
|
388
|
+
// it does not hold its dependents back — the same rule the original
|
|
389
|
+
// derivation applies to a dependency outside the selection.
|
|
390
|
+
const remaining = new Set(runnable);
|
|
391
|
+
const deps = new Map(
|
|
392
|
+
[...runnable].map((n) => [n, new Set((byName.get(n)?.dependsOn ?? []).filter((d) => runnable.has(d)))]),
|
|
393
|
+
);
|
|
394
|
+
const waves: string[][] = [];
|
|
395
|
+
while (remaining.size > 0) {
|
|
396
|
+
const wave = [...remaining].filter((n) => [...deps.get(n)!].every((d) => !remaining.has(d))).sort();
|
|
397
|
+
for (const n of wave) remaining.delete(n);
|
|
398
|
+
waves.push(wave);
|
|
399
|
+
}
|
|
400
|
+
const order = waves.flat();
|
|
401
|
+
|
|
402
|
+
// A completed component's outputs have to be seeded for a reference to
|
|
403
|
+
// resolve, exactly like a component that was never selected.
|
|
404
|
+
const seeds = [
|
|
405
|
+
...new Set([
|
|
406
|
+
...plan.seeds,
|
|
407
|
+
...order.flatMap((n) => (byName.get(n)?.dependsOn ?? []).filter((d) => !runnable.has(d))),
|
|
408
|
+
]),
|
|
409
|
+
].sort();
|
|
410
|
+
|
|
411
|
+
const carried = plan.skipped.filter((s) => !runnable.has(s.component));
|
|
412
|
+
const skipped: FanOutSkip[] = [
|
|
413
|
+
...carried,
|
|
414
|
+
...[...completed].sort().map((component) => ({ component, reason: "already-applied" as const })),
|
|
415
|
+
...[...blockedBy.entries()]
|
|
416
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
417
|
+
.map(([component, by]) => ({ component, reason: "blocked" as const, blockedBy: by })),
|
|
418
|
+
].sort((a, b) => a.component.localeCompare(b.component));
|
|
419
|
+
|
|
420
|
+
return { order, waves, skipped, seeds, indeterminate: plan.indeterminate, digest: plan.digest };
|
|
421
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -100,6 +100,39 @@ export {
|
|
|
100
100
|
UnknownDependencyError,
|
|
101
101
|
DriverRunFailure,
|
|
102
102
|
} from "./driver";
|
|
103
|
+
export {
|
|
104
|
+
type FanOutRequest,
|
|
105
|
+
type FanOutPlan,
|
|
106
|
+
type FanOutSkip,
|
|
107
|
+
type FanOutSkipReason,
|
|
108
|
+
type ChangedUnits,
|
|
109
|
+
type ComponentChangeSignal,
|
|
110
|
+
type FanOutProgress,
|
|
111
|
+
planFanOut,
|
|
112
|
+
componentsForUnits,
|
|
113
|
+
remainingFanOut,
|
|
114
|
+
downstreamWithin,
|
|
115
|
+
UnknownComponentError,
|
|
116
|
+
} from "./fan-out";
|
|
117
|
+
export {
|
|
118
|
+
type FanOutGate,
|
|
119
|
+
type FanOutRunOptions,
|
|
120
|
+
type FanOutRunResult,
|
|
121
|
+
runFanOut,
|
|
122
|
+
} from "./fan-out-run";
|
|
123
|
+
export {
|
|
124
|
+
type FanOutGateRef,
|
|
125
|
+
type FanOutRenderOptions,
|
|
126
|
+
renderFanOutPlan,
|
|
127
|
+
renderFanOutHuman,
|
|
128
|
+
renderFanOutJson,
|
|
129
|
+
} from "./fan-out-output";
|
|
130
|
+
export {
|
|
131
|
+
type DeriveFanOutOptions,
|
|
132
|
+
type DerivedFanOut,
|
|
133
|
+
deriveFanOut,
|
|
134
|
+
fanOutRegistry,
|
|
135
|
+
} from "./fan-out-support";
|
|
103
136
|
export {
|
|
104
137
|
EcsFargateComponent,
|
|
105
138
|
type EcsFargateComponentConfig,
|
|
@@ -1247,6 +1247,21 @@ interface ResolveCtx {
|
|
|
1247
1247
|
* behavior doesn't depend on it.
|
|
1248
1248
|
*/
|
|
1249
1249
|
crossFileFailures: Map<string, string>;
|
|
1250
|
+
/**
|
|
1251
|
+
* chant #2422/#2423 — for a same-file `const x = new T(...)` whose pre-build
|
|
1252
|
+
* ({@link preresolveResourceConsts}) failed, WHY, located.
|
|
1253
|
+
*
|
|
1254
|
+
* The pre-build swallows a failed construction by design, so the name stays
|
|
1255
|
+
* absent from {@link externals} and the first reference to it rejects with
|
|
1256
|
+
* "same-file resource `x` used as a value", at the reference. That rejection
|
|
1257
|
+
* is right, and it points at the consequence: the located cause is on the
|
|
1258
|
+
* `new` line, which the reader never sees. `F-Reason` asks a reason to carry
|
|
1259
|
+
* the innermost located cause, so {@link describeFoldFailure} appends this.
|
|
1260
|
+
*
|
|
1261
|
+
* Present only on the one context the pre-build runs in. Purely cosmetic;
|
|
1262
|
+
* nothing about whether a file folds depends on it.
|
|
1263
|
+
*/
|
|
1264
|
+
prebuildFailures?: Map<string, string>;
|
|
1250
1265
|
/**
|
|
1251
1266
|
* chant #1020 hang fix — session-wide {@link importModule} memo (see
|
|
1252
1267
|
* {@link FoldSession.importCache}'s doc). Every constructor/composite-
|
|
@@ -3235,9 +3250,16 @@ async function preresolveResourceConsts(ctx: ResolveCtx): Promise<Map<ts.Express
|
|
|
3235
3250
|
stampParamDependencies(instance, initializer, ctx);
|
|
3236
3251
|
built.set(initializer, instance);
|
|
3237
3252
|
ctx.externals.set(name, instance);
|
|
3238
|
-
} catch {
|
|
3253
|
+
} catch (err) {
|
|
3239
3254
|
// Not constructible here (an unresolvable constructor import, a prop
|
|
3240
|
-
// outside the fold subset, a --sandbox refusal). Leave the name alone
|
|
3255
|
+
// outside the fold subset, a --sandbox refusal). Leave the name alone:
|
|
3256
|
+
// the failure is deliberately not fatal, and the first reference to the
|
|
3257
|
+
// name rejects on its own terms.
|
|
3258
|
+
//
|
|
3259
|
+
// chant#2423 — but keep the located reason. Without it the file's only
|
|
3260
|
+
// reported cause is the reference site, which is where the consequence
|
|
3261
|
+
// is, not where the problem is.
|
|
3262
|
+
ctx.prebuildFailures?.set(name, describeFoldFailure(err, ctx));
|
|
3241
3263
|
}
|
|
3242
3264
|
}
|
|
3243
3265
|
return built;
|
|
@@ -3292,6 +3314,9 @@ async function resolveResourceEntity(
|
|
|
3292
3314
|
|
|
3293
3315
|
const UNRESOLVED_IDENTIFIER_RE = /unresolved identifier: (\S+)$/;
|
|
3294
3316
|
|
|
3317
|
+
/** The rejection a reference to a const whose pre-build failed produces (chant#2423). */
|
|
3318
|
+
const SAME_FILE_RESOURCE_RE = /same-file resource `([^`]+)` used as a value is not foldable/;
|
|
3319
|
+
|
|
3295
3320
|
/**
|
|
3296
3321
|
* Enrich an otherwise-generic "unresolved identifier: X" failure when X is a
|
|
3297
3322
|
* name whose OWN cross-file resolution was attempted and failed for a known
|
|
@@ -3306,6 +3331,13 @@ function describeFoldFailure(err: unknown, ctx: ResolveCtx): string {
|
|
|
3306
3331
|
const reason = ctx.crossFileFailures.get(match[1]);
|
|
3307
3332
|
if (reason) return `${err.message} (${reason})`;
|
|
3308
3333
|
}
|
|
3334
|
+
// chant#2423 — the reference rejected because the pre-build never produced
|
|
3335
|
+
// the instance. Say what stopped the pre-build, at the line it stopped on.
|
|
3336
|
+
const sameFile = SAME_FILE_RESOURCE_RE.exec(err.message);
|
|
3337
|
+
if (sameFile) {
|
|
3338
|
+
const cause = ctx.prebuildFailures?.get(sameFile[1]);
|
|
3339
|
+
if (cause) return `${err.message} (${cause})`;
|
|
3340
|
+
}
|
|
3309
3341
|
return err.message;
|
|
3310
3342
|
}
|
|
3311
3343
|
|
|
@@ -3635,6 +3667,10 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
|
|
|
3635
3667
|
sandbox: session.sandbox,
|
|
3636
3668
|
session,
|
|
3637
3669
|
interpretDepth: 0,
|
|
3670
|
+
// chant#2423 — filled by the pre-build below, read by
|
|
3671
|
+
// `describeFoldFailure` when a reference rejects for a const it could
|
|
3672
|
+
// not build.
|
|
3673
|
+
prebuildFailures: new Map<string, string>(),
|
|
3638
3674
|
};
|
|
3639
3675
|
|
|
3640
3676
|
// chant #1169 — every same-file `const x = new Type(...)`, built once, in
|
|
@@ -4034,6 +4070,37 @@ export async function planFoldTaintWithEdges(
|
|
|
4034
4070
|
return { tainted, reachedBy };
|
|
4035
4071
|
}
|
|
4036
4072
|
|
|
4073
|
+
/**
|
|
4074
|
+
* The rest of the session a whole-build fold needs, mirroring the same fields
|
|
4075
|
+
* on `DiscoveryOptions` (chant#2422).
|
|
4076
|
+
*
|
|
4077
|
+
* Without them {@link foldProject} answered a strictly harsher question than a
|
|
4078
|
+
* real `chant build --fold` does. `lexiconPackages` was always empty, and per
|
|
4079
|
+
* its own contract an empty set "disables lexicon-package resolution entirely
|
|
4080
|
+
* rather than falling back to something more permissive", so no file that reads
|
|
4081
|
+
* a lexicon data export as a value could fold through this entry. `buildParams`
|
|
4082
|
+
* was always unset, so no file reading `params` could either. Both fold under a
|
|
4083
|
+
* real build, and 21 corpus files flipped from `run` to `fold` at
|
|
4084
|
+
* `chant-v0.70.1` once the list was supplied.
|
|
4085
|
+
*
|
|
4086
|
+
* `../build.ts` already threads all three into `../discovery/index.ts`; this is
|
|
4087
|
+
* the same three reaching the same `createFoldSession` by the other route.
|
|
4088
|
+
*/
|
|
4089
|
+
export interface FoldProjectOptions {
|
|
4090
|
+
/**
|
|
4091
|
+
* Lexicon NAMES active for this build (`["aws", "k8s"]`), as
|
|
4092
|
+
* `resolveProjectLexicons()` returns them. A caller building through core has
|
|
4093
|
+
* to supply these itself, the same way `examples/differential-corpus.ts`
|
|
4094
|
+
* reproduces the CLI's `options.plugins.map((p) => p.name)` step, or the fold
|
|
4095
|
+
* is measured without the bare-specifier allowlist a real build gives it.
|
|
4096
|
+
*/
|
|
4097
|
+
readonly lexicons?: readonly string[];
|
|
4098
|
+
/** Resolved build-time parameter values, so a file reading `params.<name>` folds. */
|
|
4099
|
+
readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
|
|
4100
|
+
/** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
|
|
4101
|
+
readonly sandbox?: boolean;
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4037
4104
|
/** One file's place in a whole-build fold, as {@link foldProject} reports it. */
|
|
4038
4105
|
export interface FoldProjectVerdict {
|
|
4039
4106
|
/** What the build does with this file. */
|
|
@@ -4063,12 +4130,23 @@ export interface FoldProjectVerdict {
|
|
|
4063
4130
|
* Same session, same memo, same taint walk as a real build — this is not a
|
|
4064
4131
|
* second implementation of the rules, it is the existing one with its
|
|
4065
4132
|
* intermediate results kept instead of consumed.
|
|
4133
|
+
*
|
|
4134
|
+
* `options` carries the rest of what a build's session holds (chant#2422).
|
|
4135
|
+
* Omitting it answers a harsher question than a real build asks: with no
|
|
4136
|
+
* lexicon list nothing reading a lexicon data export folds, and with no build
|
|
4137
|
+
* parameters nothing reading `params` does. See {@link FoldProjectOptions}.
|
|
4066
4138
|
*/
|
|
4067
4139
|
export async function foldProject(
|
|
4068
4140
|
files: readonly string[],
|
|
4069
4141
|
intrinsics: readonly IntrinsicDef[] = [],
|
|
4142
|
+
options: FoldProjectOptions = {},
|
|
4070
4143
|
): Promise<Map<string, FoldProjectVerdict>> {
|
|
4071
|
-
const session = createFoldSession(
|
|
4144
|
+
const session = createFoldSession(
|
|
4145
|
+
intrinsics,
|
|
4146
|
+
options.buildParams,
|
|
4147
|
+
options.lexicons ?? [],
|
|
4148
|
+
options.sandbox ?? false,
|
|
4149
|
+
);
|
|
4072
4150
|
const attempts = new Map<string, FoldFileResult>();
|
|
4073
4151
|
for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));
|
|
4074
4152
|
|
|
@@ -25,3 +25,34 @@ describe("findSubsetViolation is exported from the package entry", () => {
|
|
|
25
25
|
expect(chant.findSubsetViolation(initializerOf("export const x = cfg[key];"))?.ruleId).toBe("EVL003");
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* chant#2424 — the specification's conformance adapter reads `SPEC_VERSION`
|
|
31
|
+
* off exactly this namespace:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* specVersion: (chant as { SPEC_VERSION?: string }).SPEC_VERSION ?? "undeclared",
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* so a suite that finds nothing there reports chant as `undeclared` rather
|
|
38
|
+
* than as implementing anything. The barrel is the thing that can silently
|
|
39
|
+
* drop it, which is what this pins.
|
|
40
|
+
*/
|
|
41
|
+
describe("SPEC_VERSION is declared on the package entry (chant#2424)", () => {
|
|
42
|
+
test("the public namespace carries it", () => {
|
|
43
|
+
expect(typeof chant.SPEC_VERSION).toBe("string");
|
|
44
|
+
expect(chant.SPEC_VERSION).not.toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("it is a specification version, not a chant release", () => {
|
|
48
|
+
// `spec/VERSION` carries a two-part version that moves separately from
|
|
49
|
+
// chant's own releases (INTENTIUS/typescript-as-data#18), so a value that
|
|
50
|
+
// looks like a package version is the mistake worth catching.
|
|
51
|
+
expect(chant.SPEC_VERSION).toMatch(/^\d+\.\d+$/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("and it is the same string the subset module defines", async () => {
|
|
55
|
+
const { SPEC_VERSION } = await import("./subset");
|
|
56
|
+
expect(chant.SPEC_VERSION).toBe(SPEC_VERSION);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -147,7 +147,30 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
147
147
|
* folder would actually accept) — never the reverse. Making EVL
|
|
148
148
|
* flow-sensitive would mean re-implementing an evaluator inside a lint
|
|
149
149
|
* rule; out of scope here. See #1024.
|
|
150
|
+
*
|
|
151
|
+
* ## Which version of the specification this is
|
|
152
|
+
*
|
|
153
|
+
* A specification version names a set of rules and moves on its own schedule,
|
|
154
|
+
* separately from chant's releases (INTENTIUS/typescript-as-data#18). The
|
|
155
|
+
* version chant implements is {@link SPEC_VERSION}, and it is exported from
|
|
156
|
+
* `@intentius/chant`'s public entry so the specification's conformance suite
|
|
157
|
+
* can read it. A suite that finds no declaration reports the implementation as
|
|
158
|
+
* `undeclared` rather than assuming it is current, which is the right default
|
|
159
|
+
* and a useless answer to get from an implementation that does know.
|
|
160
|
+
*
|
|
161
|
+
* Raising it is part of adopting a new version of the rules, alongside the
|
|
162
|
+
* spec-first change process above: land the rule there, implement it here
|
|
163
|
+
* citing the identifier, then move this constant.
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The version of the TypeScript-as-Data specification chant implements
|
|
168
|
+
* (chant#2424).
|
|
169
|
+
*
|
|
170
|
+
* `spec/VERSION` in the specification repository carries the same string, and
|
|
171
|
+
* the conformance adapter reads this one to fill its `specVersion` field.
|
|
150
172
|
*/
|
|
173
|
+
export const SPEC_VERSION = "1.0";
|
|
151
174
|
|
|
152
175
|
/** The two EVL rule ids a shape violation can be attributed to. */
|
|
153
176
|
export type SubsetRuleId = "EVL001" | "EVL003";
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,11 @@ export * from "./fold/fold";
|
|
|
44
44
|
// half of the fold subset a conformance adapter needs that `fold()` alone
|
|
45
45
|
// does not expose. INTENTIUS/typescript-as-data#11.
|
|
46
46
|
export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
|
|
47
|
+
// The version of the TypeScript-as-Data specification chant implements
|
|
48
|
+
// (INTENTIUS/typescript-as-data#18, chant#2424). The specification's
|
|
49
|
+
// conformance adapter reads this off the public entry; without it a suite
|
|
50
|
+
// reports chant as `undeclared` rather than as implementing anything.
|
|
51
|
+
export { SPEC_VERSION } from "./fold/subset";
|
|
47
52
|
// The whole-build fold. `fold()` and `foldModule()` answer one expression and
|
|
48
53
|
// one file; neither cross-file rule is observable at that granularity — the
|
|
49
54
|
// forward rule needs an importer, the reverse rule needs a capturing sibling,
|
|
@@ -52,6 +57,7 @@ export { findSubsetViolation, checkObjectMember, type SubsetViolation, type Subs
|
|
|
52
57
|
export {
|
|
53
58
|
foldProject,
|
|
54
59
|
planFoldTaintWithEdges,
|
|
60
|
+
type FoldProjectOptions,
|
|
55
61
|
type FoldProjectVerdict,
|
|
56
62
|
type TaintPlan,
|
|
57
63
|
type TaintEdgeKind,
|