@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.
Files changed (84) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +36 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/fold/subset.d.ts +22 -0
  26. package/dist/fold/subset.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/lifecycle/affected.d.ts +26 -0
  30. package/dist/lifecycle/affected.d.ts.map +1 -1
  31. package/dist/op/activities/activity-contracts.d.ts +16 -1
  32. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +1 -1
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/shell.d.ts +31 -2
  36. package/dist/op/activities/shell.d.ts.map +1 -1
  37. package/dist/op/activity-contract.d.ts +1 -1
  38. package/dist/op/activity-contract.d.ts.map +1 -1
  39. package/dist/op/activity-profiles.d.ts +19 -0
  40. package/dist/op/activity-profiles.d.ts.map +1 -1
  41. package/dist/op/builders.d.ts +21 -3
  42. package/dist/op/builders.d.ts.map +1 -1
  43. package/dist/op/gate-name.d.ts +10 -0
  44. package/dist/op/gate-name.d.ts.map +1 -1
  45. package/dist/op/step-output-ref.d.ts +25 -8
  46. package/dist/op/step-output-ref.d.ts.map +1 -1
  47. package/package.json +2 -1
  48. package/src/cli/handlers/fan-out.test.ts +394 -0
  49. package/src/cli/handlers/fan-out.ts +336 -0
  50. package/src/cli/handlers/lifecycle.test.ts +74 -1
  51. package/src/cli/handlers/lifecycle.ts +29 -1
  52. package/src/cli/handlers/operator.test.ts +22 -0
  53. package/src/cli/handlers/operator.ts +11 -3
  54. package/src/cli/handlers/run.ts +7 -1
  55. package/src/cli/main.ts +25 -3
  56. package/src/cli/registry.ts +23 -0
  57. package/src/components/deploy-units.ts +14 -4
  58. package/src/components/fan-out-output.test.ts +216 -0
  59. package/src/components/fan-out-output.ts +162 -0
  60. package/src/components/fan-out-run.test.ts +194 -0
  61. package/src/components/fan-out-run.ts +221 -0
  62. package/src/components/fan-out-support.test.ts +125 -0
  63. package/src/components/fan-out-support.ts +95 -0
  64. package/src/components/fan-out.test.ts +284 -0
  65. package/src/components/fan-out.ts +421 -0
  66. package/src/components/index.ts +33 -0
  67. package/src/discovery/fold-import.ts +81 -3
  68. package/src/fold/subset-public-export.test.ts +31 -0
  69. package/src/fold/subset.ts +23 -0
  70. package/src/index.ts +6 -0
  71. package/src/lifecycle/affected.test.ts +118 -0
  72. package/src/lifecycle/affected.ts +118 -14
  73. package/src/meta/declared-imports.test.ts +141 -0
  74. package/src/op/activities/activity-contracts.ts +17 -1
  75. package/src/op/activities/index.ts +1 -1
  76. package/src/op/activities/shell.test.ts +156 -0
  77. package/src/op/activities/shell.ts +84 -9
  78. package/src/op/activity-profiles.test.ts +16 -2
  79. package/src/op/activity-profiles.ts +18 -0
  80. package/src/op/builders.ts +22 -4
  81. package/src/op/gate-name.ts +11 -0
  82. package/src/op/op-ir.test.ts +4 -1
  83. package/src/op/op.test.ts +7 -2
  84. 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
+ }
@@ -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(intrinsics);
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
+ });
@@ -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,