@intentius/chant 0.10.0 → 0.12.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/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-layout.d.ts +61 -13
- package/dist/graph-layout.d.ts.map +1 -1
- package/dist/reconcile.d.ts +111 -3
- package/dist/reconcile.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/handlers/graph.test.ts +4 -6
- package/src/cli/handlers/graph.ts +44 -5
- package/src/cli/main.ts +8 -2
- package/src/cli/registry.ts +5 -0
- package/src/graph-layout.test.ts +131 -0
- package/src/graph-layout.ts +0 -0
- package/src/reconcile.test.ts +148 -0
- package/src/reconcile.ts +249 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAOnE"}
|
package/dist/cli/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAKA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAe9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAKA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAe9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAiJpD"}
|
package/dist/cli/registry.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface ParsedArgs {
|
|
|
64
64
|
up?: boolean;
|
|
65
65
|
/** `chant graph --lens blast:<node> --down` — include downstream dependents */
|
|
66
66
|
down?: boolean;
|
|
67
|
+
/** `chant graph --format layout --node-sizes <json|-|@file>` — painter-measured
|
|
68
|
+
* node footprints `{id:{w,h}}` so the layout spaces for real card sizes (#509). */
|
|
69
|
+
nodeSizes?: string;
|
|
70
|
+
/** `chant graph --format layout --layout-engine dagre|graphviz` (default dagre). */
|
|
71
|
+
layoutEngine?: string;
|
|
67
72
|
/** `chant lifecycle affected --base <ref>` — base git ref to diff against */
|
|
68
73
|
base?: string;
|
|
69
74
|
/** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/cli/registry.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/cli/registry.ts"],"names":[],"mappings":"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"}
|
package/dist/graph-layout.d.ts
CHANGED
|
@@ -1,39 +1,87 @@
|
|
|
1
|
+
import type { GraphIR } from "./graph-ir.js";
|
|
1
2
|
/**
|
|
2
3
|
* Layout-position export — node coordinates a custom painter consumes (the
|
|
3
|
-
* rackattack pattern:
|
|
4
|
-
* for LAYOUT ONLY;
|
|
4
|
+
* rackattack pattern: an engine lays out, the painter draws). The engine is used
|
|
5
|
+
* for LAYOUT ONLY; any rendering it can do is discarded. See issue #497 / epic
|
|
6
|
+
* #492, and #509 for the size-aware redesign.
|
|
5
7
|
*
|
|
6
|
-
* The {@link
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* The engine takes an **engine-neutral {@link LayoutInput}** — sized nodes plus
|
|
9
|
+
* edges — not a DOT string, so the painter's real node sizes drive spacing
|
|
10
|
+
* (without sizes a layout engine packs for tiny default boxes and big cards
|
|
11
|
+
* collide). Two engines implement it:
|
|
12
|
+
*
|
|
13
|
+
* - {@link DagreLayout} (default) — pure JS, size-aware, **no native dependency**.
|
|
14
|
+
* - {@link GraphvizLayout} — opt-in; shells `dot`. Honours `groups` as clusters.
|
|
15
|
+
*
|
|
16
|
+
* Coordinate convention (both engines): **y grows up, origin bottom-left**, as
|
|
17
|
+
* `dot -Tjson` reports. A painter flips y to read top-to-bottom.
|
|
9
18
|
*/
|
|
10
|
-
/** A laid-out position in the engine's coordinate space. */
|
|
19
|
+
/** A laid-out position in the engine's coordinate space (y-up). */
|
|
11
20
|
export interface Point {
|
|
12
21
|
x: number;
|
|
13
22
|
y: number;
|
|
14
23
|
}
|
|
15
|
-
/** Node positions plus the overall canvas size a painter needs.
|
|
24
|
+
/** Node positions plus the overall canvas size a painter needs. Centres are
|
|
25
|
+
* y-up (origin bottom-left). `w`/`h` echo the footprint the engine laid out
|
|
26
|
+
* with, so a painter can route edges to card borders rather than centres. */
|
|
16
27
|
export interface Layout {
|
|
17
28
|
/** Canvas width in the engine's coordinate space. */
|
|
18
29
|
width: number;
|
|
19
30
|
/** Canvas height in the engine's coordinate space. */
|
|
20
31
|
height: number;
|
|
21
|
-
/** Each node's centre position, ordered by id for
|
|
32
|
+
/** Each node's centre position (+ footprint), ordered by id for determinism. */
|
|
22
33
|
nodes: Array<{
|
|
23
34
|
id: string;
|
|
35
|
+
w?: number;
|
|
36
|
+
h?: number;
|
|
24
37
|
} & Point>;
|
|
25
38
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
39
|
+
/** A node's painted footprint, in layout units (≈ px / points). */
|
|
40
|
+
export interface NodeSize {
|
|
41
|
+
w: number;
|
|
42
|
+
h: number;
|
|
43
|
+
}
|
|
44
|
+
/** Engine-neutral layout input: sized nodes + edges (+ optional groups). */
|
|
45
|
+
export interface LayoutInput {
|
|
46
|
+
nodes: Array<{
|
|
47
|
+
id: string;
|
|
48
|
+
} & NodeSize>;
|
|
49
|
+
edges: Array<{
|
|
50
|
+
from: string;
|
|
51
|
+
to: string;
|
|
52
|
+
}>;
|
|
53
|
+
/** Group name → member ids, for cluster-aware engines (graphviz subgraphs). */
|
|
54
|
+
groups?: Record<string, string[]>;
|
|
55
|
+
}
|
|
56
|
+
/** Turns sized nodes + edges into node positions. The painter consumes the
|
|
57
|
+
* result; it never asks the engine to paint. */
|
|
28
58
|
export interface LayoutEngine {
|
|
29
59
|
readonly name: string;
|
|
30
|
-
layout(
|
|
60
|
+
layout(input: LayoutInput): Promise<Layout>;
|
|
61
|
+
}
|
|
62
|
+
/** Graphviz's default node box, in layout units — the fallback for a node the
|
|
63
|
+
* painter didn't measure, so a size-less call still lays out as it always did. */
|
|
64
|
+
export declare const DEFAULT_NODE_SIZE: NodeSize;
|
|
65
|
+
/** Build engine input from an IR and a painter-measured size map (id → {w,h}).
|
|
66
|
+
* Nodes absent from the map fall back to {@link DEFAULT_NODE_SIZE}. Pure. */
|
|
67
|
+
export declare function toLayoutInput(ir: GraphIR, sizes?: Record<string, NodeSize>): LayoutInput;
|
|
68
|
+
/**
|
|
69
|
+
* Layout via dagre — pure JS, size-aware, no native dependency. The default.
|
|
70
|
+
* Fed nodes/edges in id order so the result is deterministic. dagre's space is
|
|
71
|
+
* y-down; we flip to y-up so the {@link Layout} contract matches Graphviz.
|
|
72
|
+
*/
|
|
73
|
+
export declare class DagreLayout implements LayoutEngine {
|
|
74
|
+
readonly name = "dagre";
|
|
75
|
+
layout(input: LayoutInput): Promise<Layout>;
|
|
31
76
|
}
|
|
32
|
-
/** Layout via `dot -Tjson`.
|
|
77
|
+
/** Layout via `dot -Tjson`. Opt-in; requires Graphviz (`brew install graphviz`).
|
|
78
|
+
* Lays out with real node sizes (`fixedsize`) and honours `groups` as clusters. */
|
|
33
79
|
export declare class GraphvizLayout implements LayoutEngine {
|
|
34
80
|
readonly name = "graphviz";
|
|
35
|
-
layout(
|
|
81
|
+
layout(input: LayoutInput): Promise<Layout>;
|
|
36
82
|
}
|
|
83
|
+
/** Resolve a layout engine by name. Defaults to dagre (no native dependency). */
|
|
84
|
+
export declare function getLayoutEngine(name?: string): LayoutEngine;
|
|
37
85
|
/** Parse `dot -Tjson` output into a {@link Layout}. Pure; exported for testing. */
|
|
38
86
|
export declare function parseDotJson(json: string): Layout;
|
|
39
87
|
//# sourceMappingURL=graph-layout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph-layout.d.ts","sourceRoot":"","sources":["../src/graph-layout.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"graph-layout.d.ts","sourceRoot":"","sources":["../src/graph-layout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C;;;;;;;;;;;;;;;;GAgBG;AAEH,mEAAmE;AACnE,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;6EAE6E;AAC7E,MAAM,WAAW,MAAM;IACrB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAC,CAAC;CAC9D;AAED,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,QAAQ,CAAC,CAAC;IACxC,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3C,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CACnC;AAED;gDACgD;AAChD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC7C;AAED;kFACkF;AAClF,eAAO,MAAM,iBAAiB,EAAE,QAA2B,CAAC;AAM5D;6EAC6E;AAC7E,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAM,GAAG,WAAW,CAM5F;AAiBD;;;;GAIG;AACH,qBAAa,WAAY,YAAW,YAAY;IAC9C,QAAQ,CAAC,IAAI,WAAW;IAElB,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;CA0BlD;AAED;mFACmF;AACnF,qBAAa,cAAe,YAAW,YAAY;IACjD,QAAQ,CAAC,IAAI,cAAc;IAErB,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;CAGlD;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,YAAY,CAM3D;AA0ED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAqBjD"}
|
package/dist/reconcile.d.ts
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* cap + a pluggable check runner).
|
|
9
9
|
*
|
|
10
10
|
* A "warden" (e.g. github-warden) builds its provider-specific resource diffing,
|
|
11
|
-
* live-state types, and domain guardrails on top of this
|
|
11
|
+
* live-state types, and domain guardrails on top of this, and drives them with
|
|
12
|
+
* the generic `runReconcile` loop + `Cycle` interface (below). It complements
|
|
12
13
|
* chant's `ownership.ts` marker contract: ownership markers make a `delete`
|
|
13
14
|
* precise; this module decides *which* entries are creates / updates / deletes
|
|
14
15
|
* in the first place.
|
|
15
16
|
*
|
|
16
|
-
* Consumed as `@intentius/chant/reconcile`.
|
|
17
|
-
*
|
|
17
|
+
* Consumed as `@intentius/chant/reconcile`. The diff and guardrail primitives
|
|
18
|
+
* are pure and clock-free; `runReconcile` is the orchestration loop and is the
|
|
19
|
+
* only part that drives I/O (through the provider's `Cycle` implementations).
|
|
18
20
|
*/
|
|
19
21
|
/** A single field-level change: what the old value was and what it will become. */
|
|
20
22
|
export interface FieldChange {
|
|
@@ -144,4 +146,110 @@ export declare function removalDeltaCap(changeSet: ChangeSet, opts?: RemovalDelt
|
|
|
144
146
|
* caller composes provider-specific checks (e.g. an admin floor) as closures.
|
|
145
147
|
*/
|
|
146
148
|
export declare function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[]): GuardrailResult;
|
|
149
|
+
/** Controls how a cycle tracks its API usage against a shared request budget. */
|
|
150
|
+
export interface RateBudget {
|
|
151
|
+
/** Remaining request capacity for this run. */
|
|
152
|
+
readonly remaining: number;
|
|
153
|
+
/** True once `remaining` has reached zero. */
|
|
154
|
+
readonly exhausted: boolean;
|
|
155
|
+
/** Decrement by `n` (default 1). Throws `BudgetExhaustedError` if exhausted. */
|
|
156
|
+
use(n?: number): void;
|
|
157
|
+
}
|
|
158
|
+
/** Thrown when a cycle or apply step attempts to use an exhausted budget. */
|
|
159
|
+
export declare class BudgetExhaustedError extends Error {
|
|
160
|
+
constructor(message?: string);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A reconcile cycle: fetch live state for one resource domain, build desired
|
|
164
|
+
* state from config, and apply a single `ChangeSetEntry` back to the provider.
|
|
165
|
+
* Generic over the provider client (`TClient`), the per-scope config slice
|
|
166
|
+
* (`TConfig`), the live snapshot (`TLive`), and caller-supplied scope (`TScope`).
|
|
167
|
+
*
|
|
168
|
+
* `scopeId` is the current scope being iterated (e.g. an org login or group
|
|
169
|
+
* path); cycles use it — not `TScope` — for provider API paths, so a multi-scope
|
|
170
|
+
* config targets the right scope. Every network call must charge `budget`.
|
|
171
|
+
*/
|
|
172
|
+
export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
173
|
+
/** Human-readable name, e.g. "branch-protection". */
|
|
174
|
+
name: string;
|
|
175
|
+
fetchLive(client: TClient, scopeId: string, scope: TScope, budget: RateBudget): Promise<TLive>;
|
|
176
|
+
buildDesired(config: TConfig, scopeId: string, scope: TScope): TConfig;
|
|
177
|
+
apply(client: TClient, entry: ChangeSetEntry, scopeId: string, scope: TScope, budget: RateBudget): Promise<void>;
|
|
178
|
+
}
|
|
179
|
+
/** Per-cycle outcome recorded in the run result. */
|
|
180
|
+
export interface CycleResult {
|
|
181
|
+
name: string;
|
|
182
|
+
/** Scope id this result is for (e.g. an org login). */
|
|
183
|
+
org: string;
|
|
184
|
+
counts: {
|
|
185
|
+
create: number;
|
|
186
|
+
update: number;
|
|
187
|
+
delete: number;
|
|
188
|
+
};
|
|
189
|
+
guardrails: GuardrailResult;
|
|
190
|
+
applied: ChangeSetEntry[];
|
|
191
|
+
failed: Array<{
|
|
192
|
+
entry: ChangeSetEntry;
|
|
193
|
+
error: string;
|
|
194
|
+
}>;
|
|
195
|
+
plan: string;
|
|
196
|
+
guardrailBlocked: boolean;
|
|
197
|
+
}
|
|
198
|
+
/** A cycle that errored during `fetchLive`/`buildDesired` (non-budget error). */
|
|
199
|
+
export interface CycleError {
|
|
200
|
+
name: string;
|
|
201
|
+
org: string;
|
|
202
|
+
stage: "fetchLive" | "buildDesired";
|
|
203
|
+
error: string;
|
|
204
|
+
}
|
|
205
|
+
/** Work that could not complete due to budget exhaustion. */
|
|
206
|
+
export interface DeferredWork {
|
|
207
|
+
skippedCycles: string[];
|
|
208
|
+
skippedEntries: Array<{
|
|
209
|
+
cycleName: string;
|
|
210
|
+
entry: ChangeSetEntry;
|
|
211
|
+
}>;
|
|
212
|
+
}
|
|
213
|
+
/** Structured result from a single `runReconcile` call. */
|
|
214
|
+
export interface ReconcileResult {
|
|
215
|
+
mode: "dry-run" | "apply";
|
|
216
|
+
completed: boolean;
|
|
217
|
+
cycles: CycleResult[];
|
|
218
|
+
errored: CycleError[];
|
|
219
|
+
deferred: DeferredWork;
|
|
220
|
+
budgetRemaining: number;
|
|
221
|
+
}
|
|
222
|
+
/** Options for `runReconcile`. */
|
|
223
|
+
export interface RunReconcileOptions<TClient, TConfig, TLive, TScope = unknown> {
|
|
224
|
+
/** Per-scope configs to reconcile, keyed by scope id (e.g. org login). */
|
|
225
|
+
scopes: Record<string, TConfig>;
|
|
226
|
+
/** Authed provider client, passed to every cycle. */
|
|
227
|
+
client: TClient;
|
|
228
|
+
/** Cycles to run; each runs against every scope in `scopes`. */
|
|
229
|
+
cycles: Array<Cycle<TClient, TConfig, TLive, TScope>>;
|
|
230
|
+
/** Scope forwarded to each cycle (filter/cursor); does not vary by scopeId. */
|
|
231
|
+
scope?: TScope;
|
|
232
|
+
/** "dry-run" (default) computes + reports; "apply" mutates after guardrails. */
|
|
233
|
+
mode?: "dry-run" | "apply";
|
|
234
|
+
/** Provider diff: turn (desired, live) into a ChangeSet for one scope. */
|
|
235
|
+
diff: (scopeId: string, desired: TConfig, live: TLive, opts: DiffOptions) => ChangeSet;
|
|
236
|
+
/** Guardrail check over the change set + live. Defaults to always-ok. */
|
|
237
|
+
guardrails?: (changeSet: ChangeSet, live: TLive) => GuardrailResult;
|
|
238
|
+
/** Diff options forwarded to `diff`. */
|
|
239
|
+
diffOptions?: DiffOptions;
|
|
240
|
+
/** Apply even when guardrails trip. Default false. */
|
|
241
|
+
allowGuardrailOverride?: boolean;
|
|
242
|
+
/** Max requests for the run (across all cycles). Default 1000. */
|
|
243
|
+
requestBudget?: number;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Run the reconcile loop. For each scope in `scopes` and each cycle:
|
|
247
|
+
* 1. fetchLive 2. buildDesired 3. diff 4. guardrails
|
|
248
|
+
* 5a. dry-run: record the plan 5b. apply: apply each entry (if guardrails pass)
|
|
249
|
+
*
|
|
250
|
+
* Budget-aware (stops cleanly + records deferred work on exhaustion) and
|
|
251
|
+
* fault-tolerant (a cycle that errors is recorded and the run continues).
|
|
252
|
+
* Returns a structured `ReconcileResult`.
|
|
253
|
+
*/
|
|
254
|
+
export declare function runReconcile<TClient, TConfig, TLive, TScope = unknown>(opts: RunReconcileOptions<TClient, TConfig, TLive, TScope>): Promise<ReconcileResult>;
|
|
147
255
|
//# sourceMappingURL=reconcile.d.ts.map
|
package/dist/reconcile.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../src/reconcile.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../src/reconcile.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"prepack": "npm run build"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
+
"@dagrejs/dagre": "^3.0.0",
|
|
68
69
|
"fflate": "^0.8.2",
|
|
69
70
|
"picomatch": "^4.0.3",
|
|
70
71
|
"tsx": "^4.0.0",
|
|
@@ -18,14 +18,12 @@ vi.mock("../commands/lint", () => ({
|
|
|
18
18
|
lintCommand: () => lintMock(),
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
// Avoid
|
|
21
|
+
// Avoid running a real layout engine in tests; the format dispatch + size/engine
|
|
22
|
+
// plumbing is what matters here (engines have their own unit tests).
|
|
22
23
|
const layoutMock = vi.fn();
|
|
23
24
|
vi.mock("../../graph-layout", () => ({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return layoutMock();
|
|
27
|
-
}
|
|
28
|
-
},
|
|
25
|
+
toLayoutInput: (ir: { nodes: { id: string }[] }, sizes: unknown) => ({ ir, sizes }),
|
|
26
|
+
getLayoutEngine: (name?: string) => ({ name: name ?? "dagre", layout: (input: unknown) => layoutMock(input) }),
|
|
29
27
|
}));
|
|
30
28
|
|
|
31
29
|
const { runGraph } = await import("./graph");
|
|
@@ -7,8 +7,9 @@ import { applyDetail, type DetailLevel } from "../../graph-detail";
|
|
|
7
7
|
import { applyLens, parseLens } from "../../graph-lens";
|
|
8
8
|
import { toMermaid } from "../../graph-mermaid";
|
|
9
9
|
import { toDot } from "../../graph-dot";
|
|
10
|
-
import {
|
|
10
|
+
import { getLayoutEngine, toLayoutInput, type NodeSize } from "../../graph-layout";
|
|
11
11
|
import { lintCommand } from "../commands/lint";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { formatError, formatWarning, formatBold } from "../format";
|
|
13
14
|
import type { CommandContext } from "../registry";
|
|
14
15
|
|
|
@@ -30,9 +31,11 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
|
30
31
|
/**
|
|
31
32
|
* `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
|
|
32
33
|
* `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
|
|
33
|
-
* positions from a layout engine.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* positions from a layout engine. `layout` takes optional painter-measured
|
|
35
|
+
* `--node-sizes` so spacing fits real node footprints, and defaults to the dagre
|
|
36
|
+
* engine (no native dependency); `--layout-engine graphviz` opts into `dot`.
|
|
37
|
+
* Lint-gated: the IR represents valid infra, so we refuse to emit for source that
|
|
38
|
+
* does not pass lint. Non-zero on discovery errors or a layout-engine failure.
|
|
36
39
|
*/
|
|
37
40
|
async function runGraphView(
|
|
38
41
|
ctx: CommandContext,
|
|
@@ -86,7 +89,9 @@ async function runGraphView(
|
|
|
86
89
|
return 0;
|
|
87
90
|
case "layout":
|
|
88
91
|
try {
|
|
89
|
-
const
|
|
92
|
+
const sizes = readNodeSizes(ctx.args.nodeSizes);
|
|
93
|
+
const engine = getLayoutEngine(ctx.args.layoutEngine);
|
|
94
|
+
const layout = await engine.layout(toLayoutInput(ir, sizes));
|
|
90
95
|
console.log(JSON.stringify(layout, null, 2));
|
|
91
96
|
return 0;
|
|
92
97
|
} catch (err) {
|
|
@@ -100,6 +105,40 @@ async function runGraphView(
|
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the `--node-sizes` value into a `{ id: {w, h} }` map. The spec is one
|
|
110
|
+
* of: inline JSON, `-` (read JSON from stdin, to dodge arg-length limits), or
|
|
111
|
+
* `@path` (read from a file). Empty/absent → no sizes (engine uses defaults).
|
|
112
|
+
* Throws on malformed JSON so a typo fails loudly rather than mis-laying out.
|
|
113
|
+
*/
|
|
114
|
+
function readNodeSizes(spec?: string): Record<string, NodeSize> {
|
|
115
|
+
if (!spec) return {};
|
|
116
|
+
let raw: string;
|
|
117
|
+
if (spec === "-") raw = readFileSync(0, "utf8");
|
|
118
|
+
else if (spec.startsWith("@")) raw = readFileSync(spec.slice(1), "utf8");
|
|
119
|
+
else raw = spec;
|
|
120
|
+
raw = raw.trim();
|
|
121
|
+
if (!raw) return {};
|
|
122
|
+
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(raw);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(`--node-sizes is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
+
}
|
|
129
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
130
|
+
throw new Error("--node-sizes must be a JSON object mapping node id → {w, h}");
|
|
131
|
+
}
|
|
132
|
+
const out: Record<string, NodeSize> = {};
|
|
133
|
+
for (const [id, val] of Object.entries(parsed as Record<string, unknown>)) {
|
|
134
|
+
const v = val as { w?: unknown; h?: unknown };
|
|
135
|
+
if (typeof v?.w === "number" && typeof v?.h === "number" && v.w > 0 && v.h > 0) {
|
|
136
|
+
out[id] = { w: v.w, h: v.h };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
async function runOpGraph(): Promise<number> {
|
|
104
143
|
const { ops, errors } = await discoverOps();
|
|
105
144
|
for (const err of errors) console.error(formatError({ message: err }));
|
package/src/cli/main.ts
CHANGED
|
@@ -134,6 +134,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
134
134
|
result.up = true;
|
|
135
135
|
} else if (arg === "--down") {
|
|
136
136
|
result.down = true;
|
|
137
|
+
} else if (arg === "--node-sizes") {
|
|
138
|
+
result.nodeSizes = args[++i];
|
|
139
|
+
} else if (arg === "--layout-engine") {
|
|
140
|
+
result.layoutEngine = args[++i];
|
|
137
141
|
} else if (arg === "--base") {
|
|
138
142
|
result.base = args[++i];
|
|
139
143
|
} else if (arg === "--head") {
|
|
@@ -200,8 +204,10 @@ Ops:
|
|
|
200
204
|
|
|
201
205
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
202
206
|
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
|
203
|
-
a Mermaid flowchart, Graphviz DOT, or node positions
|
|
204
|
-
|
|
207
|
+
a Mermaid flowchart, Graphviz DOT, or node positions;
|
|
208
|
+
layout uses dagre by default (no native dep) — pass
|
|
209
|
+
--node-sizes <json|-|@file> for size-aware spacing,
|
|
210
|
+
--layout-engine graphviz to use dot instead;
|
|
205
211
|
--detail 0..3: stacks|composites|declarables|attributes;
|
|
206
212
|
--lens lexicon:<n>|stack:<n>|blast:<node> (--up/--down))
|
|
207
213
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -65,6 +65,11 @@ export interface ParsedArgs {
|
|
|
65
65
|
up?: boolean;
|
|
66
66
|
/** `chant graph --lens blast:<node> --down` — include downstream dependents */
|
|
67
67
|
down?: boolean;
|
|
68
|
+
/** `chant graph --format layout --node-sizes <json|-|@file>` — painter-measured
|
|
69
|
+
* node footprints `{id:{w,h}}` so the layout spaces for real card sizes (#509). */
|
|
70
|
+
nodeSizes?: string;
|
|
71
|
+
/** `chant graph --format layout --layout-engine dagre|graphviz` (default dagre). */
|
|
72
|
+
layoutEngine?: string;
|
|
68
73
|
/** `chant lifecycle affected --base <ref>` — base git ref to diff against */
|
|
69
74
|
base?: string;
|
|
70
75
|
/** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
toLayoutInput,
|
|
4
|
+
DagreLayout,
|
|
5
|
+
GraphvizLayout,
|
|
6
|
+
getLayoutEngine,
|
|
7
|
+
parseDotJson,
|
|
8
|
+
DEFAULT_NODE_SIZE,
|
|
9
|
+
type LayoutInput,
|
|
10
|
+
} from "./graph-layout";
|
|
11
|
+
import type { GraphIR } from "./graph-ir";
|
|
12
|
+
|
|
13
|
+
const ir: GraphIR = {
|
|
14
|
+
nodes: [
|
|
15
|
+
{ id: "vpc", kind: "Vpc", lexicon: "aws", attrs: {} },
|
|
16
|
+
{ id: "subnet", kind: "Subnet", lexicon: "aws", attrs: {} },
|
|
17
|
+
],
|
|
18
|
+
edges: [{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "VpcId" }],
|
|
19
|
+
groups: { byLexicon: { aws: ["vpc", "subnet"] } },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("toLayoutInput", () => {
|
|
23
|
+
it("maps IR nodes/edges and carries groups", () => {
|
|
24
|
+
const input = toLayoutInput(ir, { vpc: { w: 180, h: 60 }, subnet: { w: 180, h: 76 } });
|
|
25
|
+
expect(input.nodes).toEqual([
|
|
26
|
+
{ id: "vpc", w: 180, h: 60 },
|
|
27
|
+
{ id: "subnet", w: 180, h: 76 },
|
|
28
|
+
]);
|
|
29
|
+
expect(input.edges).toEqual([{ from: "subnet", to: "vpc" }]);
|
|
30
|
+
expect(input.groups).toEqual({ aws: ["vpc", "subnet"] });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to the default box for unmeasured nodes", () => {
|
|
34
|
+
const input = toLayoutInput(ir);
|
|
35
|
+
expect(input.nodes.every((n) => n.w === DEFAULT_NODE_SIZE.w && n.h === DEFAULT_NODE_SIZE.h)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("getLayoutEngine", () => {
|
|
40
|
+
it("defaults to dagre (no native dependency)", () => {
|
|
41
|
+
expect(getLayoutEngine().name).toBe("dagre");
|
|
42
|
+
expect(getLayoutEngine("dagre").name).toBe("dagre");
|
|
43
|
+
});
|
|
44
|
+
it("returns graphviz on request", () => {
|
|
45
|
+
expect(getLayoutEngine("graphviz").name).toBe("graphviz");
|
|
46
|
+
});
|
|
47
|
+
it("throws on an unknown engine", () => {
|
|
48
|
+
expect(() => getLayoutEngine("elk")).toThrow(/unknown layout engine/i);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("DagreLayout", () => {
|
|
53
|
+
const input: LayoutInput = {
|
|
54
|
+
nodes: [
|
|
55
|
+
{ id: "vpc", w: 180, h: 60 },
|
|
56
|
+
{ id: "subnetA", w: 180, h: 76 },
|
|
57
|
+
{ id: "subnetB", w: 180, h: 76 },
|
|
58
|
+
],
|
|
59
|
+
edges: [
|
|
60
|
+
{ from: "subnetA", to: "vpc" },
|
|
61
|
+
{ from: "subnetB", to: "vpc" },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
it("lays out with no native dependency and echoes sizes", async () => {
|
|
66
|
+
const layout = await new DagreLayout().layout(input);
|
|
67
|
+
expect(layout.nodes.map((n) => n.id)).toEqual(["subnetA", "subnetB", "vpc"]);
|
|
68
|
+
expect(layout.width).toBeGreaterThan(0);
|
|
69
|
+
expect(layout.nodes.find((n) => n.id === "vpc")).toMatchObject({ w: 180, h: 60 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("spaces sized cards so none overlap", async () => {
|
|
73
|
+
const layout = await new DagreLayout().layout(input);
|
|
74
|
+
const r = layout.nodes.map((n) => ({
|
|
75
|
+
id: n.id,
|
|
76
|
+
x0: n.x - (n.w ?? 0) / 2,
|
|
77
|
+
x1: n.x + (n.w ?? 0) / 2,
|
|
78
|
+
y0: n.y - (n.h ?? 0) / 2,
|
|
79
|
+
y1: n.y + (n.h ?? 0) / 2,
|
|
80
|
+
}));
|
|
81
|
+
for (let i = 0; i < r.length; i++) {
|
|
82
|
+
for (let j = i + 1; j < r.length; j++) {
|
|
83
|
+
const xOverlap = Math.min(r[i].x1, r[j].x1) - Math.max(r[i].x0, r[j].x0);
|
|
84
|
+
const yOverlap = Math.min(r[i].y1, r[j].y1) - Math.max(r[i].y0, r[j].y0);
|
|
85
|
+
expect(xOverlap > 0 && yOverlap > 0).toBe(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("emits y-up coordinates matching graphviz (edge from drawn above to)", async () => {
|
|
91
|
+
// Edges are subnet → vpc (consumer → producer). rankdir=TB draws the tail
|
|
92
|
+
// above the head, so subnets sit above vpc — same as `dot`. In y-up space
|
|
93
|
+
// that means subnets have the larger y. (Verified against GraphvizLayout.)
|
|
94
|
+
const layout = await new DagreLayout().layout(input);
|
|
95
|
+
const vpc = layout.nodes.find((n) => n.id === "vpc")!;
|
|
96
|
+
const subnetA = layout.nodes.find((n) => n.id === "subnetA")!;
|
|
97
|
+
expect(subnetA.y).toBeGreaterThan(vpc.y);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("is deterministic", async () => {
|
|
101
|
+
const a = await new DagreLayout().layout(input);
|
|
102
|
+
const b = await new DagreLayout().layout(input);
|
|
103
|
+
expect(a).toEqual(b);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("parseDotJson", () => {
|
|
108
|
+
it("reads bounds, positions, and echoes node size in points", () => {
|
|
109
|
+
const json = JSON.stringify({
|
|
110
|
+
bb: "0,0,200,100",
|
|
111
|
+
objects: [
|
|
112
|
+
{ name: "b", pos: "50,80", width: "2.5", height: "0.5" },
|
|
113
|
+
{ name: "a", pos: "50,20" },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
const layout = parseDotJson(json);
|
|
117
|
+
expect(layout).toMatchObject({ width: 200, height: 100 });
|
|
118
|
+
expect(layout.nodes.map((n) => n.id)).toEqual(["a", "b"]); // sorted
|
|
119
|
+
expect(layout.nodes.find((n) => n.id === "b")).toMatchObject({ x: 50, y: 80, w: 180, h: 36 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects zero bounds", () => {
|
|
123
|
+
expect(() => parseDotJson(JSON.stringify({ bb: "0,0,0,0", objects: [] }))).toThrow(/zero graph bounds/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("GraphvizLayout", () => {
|
|
128
|
+
it("exposes the graphviz engine name", () => {
|
|
129
|
+
expect(new GraphvizLayout().name).toBe("graphviz");
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/graph-layout.ts
CHANGED
|
Binary file
|
package/src/reconcile.test.ts
CHANGED
|
@@ -222,3 +222,151 @@ describe("runGuardrailChecks", () => {
|
|
|
222
222
|
expect(runGuardrailChecks(cs, [() => null])).toEqual({ ok: true });
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Reconcile runner (fake provider — proves provider-agnosticism)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
import { runReconcile, BudgetExhaustedError } from "./reconcile";
|
|
231
|
+
import type { Cycle } from "./reconcile";
|
|
232
|
+
|
|
233
|
+
interface FakeClient {
|
|
234
|
+
calls: string[];
|
|
235
|
+
}
|
|
236
|
+
interface FakeConfig {
|
|
237
|
+
create?: number;
|
|
238
|
+
}
|
|
239
|
+
type FakeLive = Record<string, never>;
|
|
240
|
+
|
|
241
|
+
function fakeCycle(
|
|
242
|
+
name: string,
|
|
243
|
+
over: Partial<Cycle<FakeClient, FakeConfig, FakeLive>> = {},
|
|
244
|
+
): Cycle<FakeClient, FakeConfig, FakeLive> {
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
async fetchLive(client, scopeId, _scope, budget) {
|
|
248
|
+
budget.use(1);
|
|
249
|
+
client.calls.push(`fetch:${name}@${scopeId}`);
|
|
250
|
+
return {};
|
|
251
|
+
},
|
|
252
|
+
buildDesired(config) {
|
|
253
|
+
return config;
|
|
254
|
+
},
|
|
255
|
+
async apply(client, entry, _scopeId, _scope, budget) {
|
|
256
|
+
budget.use(1);
|
|
257
|
+
client.calls.push(`apply:${entry.key}`);
|
|
258
|
+
},
|
|
259
|
+
...over,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Injected diff: emit N create entries from config.create.
|
|
264
|
+
const fakeDiff = (scopeId: string, desired: FakeConfig): ChangeSet => ({
|
|
265
|
+
org: scopeId,
|
|
266
|
+
entries: Array.from({ length: desired.create ?? 0 }, (_, i) => ({
|
|
267
|
+
kind: "create" as const,
|
|
268
|
+
resourceType: "thing",
|
|
269
|
+
key: `k${i}`,
|
|
270
|
+
})),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("runReconcile (generic)", () => {
|
|
274
|
+
test("dry-run reports the plan and mutates nothing", async () => {
|
|
275
|
+
const client: FakeClient = { calls: [] };
|
|
276
|
+
const result = await runReconcile<FakeClient, FakeConfig, FakeLive>({
|
|
277
|
+
client,
|
|
278
|
+
scopes: { acme: { create: 3 } },
|
|
279
|
+
cycles: [fakeCycle("c1")],
|
|
280
|
+
diff: fakeDiff,
|
|
281
|
+
mode: "dry-run",
|
|
282
|
+
});
|
|
283
|
+
expect(result.mode).toBe("dry-run");
|
|
284
|
+
expect(result.completed).toBe(true);
|
|
285
|
+
expect(result.cycles[0]!.counts.create).toBe(3);
|
|
286
|
+
expect(result.cycles[0]!.applied).toHaveLength(0);
|
|
287
|
+
expect(client.calls.filter((c) => c.startsWith("apply:"))).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("apply applies each entry across multiple scopes", async () => {
|
|
291
|
+
const client: FakeClient = { calls: [] };
|
|
292
|
+
const result = await runReconcile<FakeClient, FakeConfig, FakeLive>({
|
|
293
|
+
client,
|
|
294
|
+
scopes: { acme: { create: 2 }, beta: { create: 1 } },
|
|
295
|
+
cycles: [fakeCycle("c1")],
|
|
296
|
+
diff: fakeDiff,
|
|
297
|
+
mode: "apply",
|
|
298
|
+
});
|
|
299
|
+
expect(result.completed).toBe(true);
|
|
300
|
+
expect(result.cycles.flatMap((c) => c.applied)).toHaveLength(3);
|
|
301
|
+
expect(client.calls.filter((c) => c.startsWith("apply:"))).toHaveLength(3);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("guardrails block the apply unless overridden", async () => {
|
|
305
|
+
const client: FakeClient = { calls: [] };
|
|
306
|
+
const opts = {
|
|
307
|
+
client,
|
|
308
|
+
scopes: { acme: { create: 1 } },
|
|
309
|
+
cycles: [fakeCycle("c1")],
|
|
310
|
+
diff: fakeDiff,
|
|
311
|
+
mode: "apply" as const,
|
|
312
|
+
guardrails: () => ({ ok: false as const, diagnostics: [{ guardrail: "x", message: "no" }] }),
|
|
313
|
+
};
|
|
314
|
+
const blocked = await runReconcile<FakeClient, FakeConfig, FakeLive>(opts);
|
|
315
|
+
expect(blocked.cycles[0]!.guardrailBlocked).toBe(true);
|
|
316
|
+
expect(blocked.cycles[0]!.applied).toHaveLength(0);
|
|
317
|
+
|
|
318
|
+
const overridden = await runReconcile<FakeClient, FakeConfig, FakeLive>({ ...opts, allowGuardrailOverride: true });
|
|
319
|
+
expect(overridden.cycles[0]!.guardrailBlocked).toBe(false);
|
|
320
|
+
expect(overridden.cycles[0]!.applied).toHaveLength(1);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("records deferred work when the budget is exhausted", async () => {
|
|
324
|
+
const client: FakeClient = { calls: [] };
|
|
325
|
+
const result = await runReconcile<FakeClient, FakeConfig, FakeLive>({
|
|
326
|
+
client,
|
|
327
|
+
scopes: { acme: { create: 0 }, beta: { create: 0 } },
|
|
328
|
+
cycles: [fakeCycle("c1"), fakeCycle("c2")],
|
|
329
|
+
diff: fakeDiff,
|
|
330
|
+
requestBudget: 1, // only the first fetchLive fits
|
|
331
|
+
});
|
|
332
|
+
expect(result.completed).toBe(false);
|
|
333
|
+
expect(result.deferred.skippedCycles.length).toBeGreaterThan(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("an errored fetchLive is recorded and the run continues", async () => {
|
|
337
|
+
const client: FakeClient = { calls: [] };
|
|
338
|
+
const boom = fakeCycle("boom", {
|
|
339
|
+
async fetchLive() {
|
|
340
|
+
throw new Error("kaboom");
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const result = await runReconcile<FakeClient, FakeConfig, FakeLive>({
|
|
344
|
+
client,
|
|
345
|
+
scopes: { acme: { create: 1 } },
|
|
346
|
+
cycles: [boom, fakeCycle("ok")],
|
|
347
|
+
diff: fakeDiff,
|
|
348
|
+
});
|
|
349
|
+
expect(result.errored).toHaveLength(1);
|
|
350
|
+
expect(result.errored[0]!.name).toBe("boom");
|
|
351
|
+
expect(result.cycles.some((c) => c.name === "ok")).toBe(true); // ran past the error
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("a budget-exhausted throw mid-fetch is deferred, not errored", async () => {
|
|
355
|
+
const client: FakeClient = { calls: [] };
|
|
356
|
+
const greedy = fakeCycle("greedy", {
|
|
357
|
+
async fetchLive(_c, _s, _scope, budget) {
|
|
358
|
+
budget.use(1);
|
|
359
|
+
throw new BudgetExhaustedError();
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
const result = await runReconcile<FakeClient, FakeConfig, FakeLive>({
|
|
363
|
+
client,
|
|
364
|
+
scopes: { acme: { create: 0 } },
|
|
365
|
+
cycles: [greedy],
|
|
366
|
+
diff: fakeDiff,
|
|
367
|
+
requestBudget: 5,
|
|
368
|
+
});
|
|
369
|
+
expect(result.errored).toHaveLength(0);
|
|
370
|
+
expect(result.deferred.skippedCycles).toContain("greedy@acme");
|
|
371
|
+
});
|
|
372
|
+
});
|
package/src/reconcile.ts
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* cap + a pluggable check runner).
|
|
9
9
|
*
|
|
10
10
|
* A "warden" (e.g. github-warden) builds its provider-specific resource diffing,
|
|
11
|
-
* live-state types, and domain guardrails on top of this
|
|
11
|
+
* live-state types, and domain guardrails on top of this, and drives them with
|
|
12
|
+
* the generic `runReconcile` loop + `Cycle` interface (below). It complements
|
|
12
13
|
* chant's `ownership.ts` marker contract: ownership markers make a `delete`
|
|
13
14
|
* precise; this module decides *which* entries are creates / updates / deletes
|
|
14
15
|
* in the first place.
|
|
15
16
|
*
|
|
16
|
-
* Consumed as `@intentius/chant/reconcile`.
|
|
17
|
-
*
|
|
17
|
+
* Consumed as `@intentius/chant/reconcile`. The diff and guardrail primitives
|
|
18
|
+
* are pure and clock-free; `runReconcile` is the orchestration loop and is the
|
|
19
|
+
* only part that drives I/O (through the provider's `Cycle` implementations).
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
@@ -344,3 +346,247 @@ export function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[
|
|
|
344
346
|
}
|
|
345
347
|
return diagnostics.length > 0 ? { ok: false, diagnostics } : { ok: true };
|
|
346
348
|
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Reconcile runner (generic over provider client / config / live / scope)
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/** Controls how a cycle tracks its API usage against a shared request budget. */
|
|
355
|
+
export interface RateBudget {
|
|
356
|
+
/** Remaining request capacity for this run. */
|
|
357
|
+
readonly remaining: number;
|
|
358
|
+
/** True once `remaining` has reached zero. */
|
|
359
|
+
readonly exhausted: boolean;
|
|
360
|
+
/** Decrement by `n` (default 1). Throws `BudgetExhaustedError` if exhausted. */
|
|
361
|
+
use(n?: number): void;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Thrown when a cycle or apply step attempts to use an exhausted budget. */
|
|
365
|
+
export class BudgetExhaustedError extends Error {
|
|
366
|
+
constructor(message = "rate budget exhausted") {
|
|
367
|
+
super(message);
|
|
368
|
+
this.name = "BudgetExhaustedError";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
class MutableRateBudget implements RateBudget {
|
|
373
|
+
private _remaining: number;
|
|
374
|
+
constructor(initial: number) {
|
|
375
|
+
this._remaining = initial;
|
|
376
|
+
}
|
|
377
|
+
get remaining(): number {
|
|
378
|
+
return this._remaining;
|
|
379
|
+
}
|
|
380
|
+
get exhausted(): boolean {
|
|
381
|
+
return this._remaining <= 0;
|
|
382
|
+
}
|
|
383
|
+
use(n = 1): void {
|
|
384
|
+
if (this.exhausted) throw new BudgetExhaustedError();
|
|
385
|
+
this._remaining = Math.max(0, this._remaining - n);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A reconcile cycle: fetch live state for one resource domain, build desired
|
|
391
|
+
* state from config, and apply a single `ChangeSetEntry` back to the provider.
|
|
392
|
+
* Generic over the provider client (`TClient`), the per-scope config slice
|
|
393
|
+
* (`TConfig`), the live snapshot (`TLive`), and caller-supplied scope (`TScope`).
|
|
394
|
+
*
|
|
395
|
+
* `scopeId` is the current scope being iterated (e.g. an org login or group
|
|
396
|
+
* path); cycles use it — not `TScope` — for provider API paths, so a multi-scope
|
|
397
|
+
* config targets the right scope. Every network call must charge `budget`.
|
|
398
|
+
*/
|
|
399
|
+
export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
400
|
+
/** Human-readable name, e.g. "branch-protection". */
|
|
401
|
+
name: string;
|
|
402
|
+
fetchLive(client: TClient, scopeId: string, scope: TScope, budget: RateBudget): Promise<TLive>;
|
|
403
|
+
buildDesired(config: TConfig, scopeId: string, scope: TScope): TConfig;
|
|
404
|
+
apply(
|
|
405
|
+
client: TClient,
|
|
406
|
+
entry: ChangeSetEntry,
|
|
407
|
+
scopeId: string,
|
|
408
|
+
scope: TScope,
|
|
409
|
+
budget: RateBudget,
|
|
410
|
+
): Promise<void>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Per-cycle outcome recorded in the run result. */
|
|
414
|
+
export interface CycleResult {
|
|
415
|
+
name: string;
|
|
416
|
+
/** Scope id this result is for (e.g. an org login). */
|
|
417
|
+
org: string;
|
|
418
|
+
counts: { create: number; update: number; delete: number };
|
|
419
|
+
guardrails: GuardrailResult;
|
|
420
|
+
applied: ChangeSetEntry[];
|
|
421
|
+
failed: Array<{ entry: ChangeSetEntry; error: string }>;
|
|
422
|
+
plan: string;
|
|
423
|
+
guardrailBlocked: boolean;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A cycle that errored during `fetchLive`/`buildDesired` (non-budget error). */
|
|
427
|
+
export interface CycleError {
|
|
428
|
+
name: string;
|
|
429
|
+
org: string;
|
|
430
|
+
stage: "fetchLive" | "buildDesired";
|
|
431
|
+
error: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Work that could not complete due to budget exhaustion. */
|
|
435
|
+
export interface DeferredWork {
|
|
436
|
+
skippedCycles: string[];
|
|
437
|
+
skippedEntries: Array<{ cycleName: string; entry: ChangeSetEntry }>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Structured result from a single `runReconcile` call. */
|
|
441
|
+
export interface ReconcileResult {
|
|
442
|
+
mode: "dry-run" | "apply";
|
|
443
|
+
completed: boolean;
|
|
444
|
+
cycles: CycleResult[];
|
|
445
|
+
errored: CycleError[];
|
|
446
|
+
deferred: DeferredWork;
|
|
447
|
+
budgetRemaining: number;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Options for `runReconcile`. */
|
|
451
|
+
export interface RunReconcileOptions<TClient, TConfig, TLive, TScope = unknown> {
|
|
452
|
+
/** Per-scope configs to reconcile, keyed by scope id (e.g. org login). */
|
|
453
|
+
scopes: Record<string, TConfig>;
|
|
454
|
+
/** Authed provider client, passed to every cycle. */
|
|
455
|
+
client: TClient;
|
|
456
|
+
/** Cycles to run; each runs against every scope in `scopes`. */
|
|
457
|
+
cycles: Array<Cycle<TClient, TConfig, TLive, TScope>>;
|
|
458
|
+
/** Scope forwarded to each cycle (filter/cursor); does not vary by scopeId. */
|
|
459
|
+
scope?: TScope;
|
|
460
|
+
/** "dry-run" (default) computes + reports; "apply" mutates after guardrails. */
|
|
461
|
+
mode?: "dry-run" | "apply";
|
|
462
|
+
/** Provider diff: turn (desired, live) into a ChangeSet for one scope. */
|
|
463
|
+
diff: (scopeId: string, desired: TConfig, live: TLive, opts: DiffOptions) => ChangeSet;
|
|
464
|
+
/** Guardrail check over the change set + live. Defaults to always-ok. */
|
|
465
|
+
guardrails?: (changeSet: ChangeSet, live: TLive) => GuardrailResult;
|
|
466
|
+
/** Diff options forwarded to `diff`. */
|
|
467
|
+
diffOptions?: DiffOptions;
|
|
468
|
+
/** Apply even when guardrails trip. Default false. */
|
|
469
|
+
allowGuardrailOverride?: boolean;
|
|
470
|
+
/** Max requests for the run (across all cycles). Default 1000. */
|
|
471
|
+
requestBudget?: number;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function errMsg(err: unknown): string {
|
|
475
|
+
return err instanceof Error ? err.message : String(err);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Run the reconcile loop. For each scope in `scopes` and each cycle:
|
|
480
|
+
* 1. fetchLive 2. buildDesired 3. diff 4. guardrails
|
|
481
|
+
* 5a. dry-run: record the plan 5b. apply: apply each entry (if guardrails pass)
|
|
482
|
+
*
|
|
483
|
+
* Budget-aware (stops cleanly + records deferred work on exhaustion) and
|
|
484
|
+
* fault-tolerant (a cycle that errors is recorded and the run continues).
|
|
485
|
+
* Returns a structured `ReconcileResult`.
|
|
486
|
+
*/
|
|
487
|
+
export async function runReconcile<TClient, TConfig, TLive, TScope = unknown>(
|
|
488
|
+
opts: RunReconcileOptions<TClient, TConfig, TLive, TScope>,
|
|
489
|
+
): Promise<ReconcileResult> {
|
|
490
|
+
const {
|
|
491
|
+
scopes,
|
|
492
|
+
client,
|
|
493
|
+
cycles,
|
|
494
|
+
scope,
|
|
495
|
+
mode = "dry-run",
|
|
496
|
+
diff: diffFn,
|
|
497
|
+
guardrails = (): GuardrailResult => ({ ok: true }),
|
|
498
|
+
diffOptions = {},
|
|
499
|
+
allowGuardrailOverride = false,
|
|
500
|
+
requestBudget = 1000,
|
|
501
|
+
} = opts;
|
|
502
|
+
|
|
503
|
+
const budget = new MutableRateBudget(requestBudget);
|
|
504
|
+
const cycleResults: CycleResult[] = [];
|
|
505
|
+
const erroredCycles: CycleError[] = [];
|
|
506
|
+
const deferred: DeferredWork = { skippedCycles: [], skippedEntries: [] };
|
|
507
|
+
|
|
508
|
+
const scopeEntries = Object.entries(scopes);
|
|
509
|
+
|
|
510
|
+
for (const cycle of cycles) {
|
|
511
|
+
for (const [scopeId, scopeConfig] of scopeEntries) {
|
|
512
|
+
if (budget.exhausted) {
|
|
513
|
+
deferred.skippedCycles.push(`${cycle.name}@${scopeId}`);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let live: TLive;
|
|
518
|
+
try {
|
|
519
|
+
live = await cycle.fetchLive(client, scopeId, scope as TScope, budget);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (err instanceof BudgetExhaustedError) {
|
|
522
|
+
deferred.skippedCycles.push(`${cycle.name}@${scopeId}`);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
erroredCycles.push({ name: cycle.name, org: scopeId, stage: "fetchLive", error: errMsg(err) });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let desired: TConfig;
|
|
530
|
+
try {
|
|
531
|
+
desired = cycle.buildDesired(scopeConfig, scopeId, scope as TScope);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
erroredCycles.push({ name: cycle.name, org: scopeId, stage: "buildDesired", error: errMsg(err) });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const changeSet = diffFn(scopeId, desired, live, diffOptions);
|
|
538
|
+
const guardrailResult = guardrails(changeSet, live);
|
|
539
|
+
|
|
540
|
+
const counts = { create: 0, update: 0, delete: 0 };
|
|
541
|
+
for (const e of changeSet.entries) counts[e.kind]++;
|
|
542
|
+
|
|
543
|
+
const cycleResult: CycleResult = {
|
|
544
|
+
name: cycle.name,
|
|
545
|
+
org: scopeId,
|
|
546
|
+
counts,
|
|
547
|
+
guardrails: guardrailResult,
|
|
548
|
+
applied: [],
|
|
549
|
+
failed: [],
|
|
550
|
+
plan: renderChangeSet(changeSet),
|
|
551
|
+
guardrailBlocked: false,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
if (mode === "dry-run") {
|
|
555
|
+
cycleResults.push(cycleResult);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!guardrailResult.ok && !allowGuardrailOverride) {
|
|
560
|
+
cycleResult.guardrailBlocked = true;
|
|
561
|
+
cycleResults.push(cycleResult);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const entry of changeSet.entries) {
|
|
566
|
+
if (budget.exhausted) {
|
|
567
|
+
deferred.skippedEntries.push({ cycleName: cycle.name, entry });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
await cycle.apply(client, entry, scopeId, scope as TScope, budget);
|
|
572
|
+
cycleResult.applied.push(entry);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
if (err instanceof BudgetExhaustedError) {
|
|
575
|
+
deferred.skippedEntries.push({ cycleName: cycle.name, entry });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
cycleResult.failed.push({ entry, error: errMsg(err) });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
cycleResults.push(cycleResult);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const completed =
|
|
587
|
+
deferred.skippedCycles.length === 0 &&
|
|
588
|
+
deferred.skippedEntries.length === 0 &&
|
|
589
|
+
erroredCycles.length === 0;
|
|
590
|
+
|
|
591
|
+
return { mode, completed, cycles: cycleResults, errored: erroredCycles, deferred, budgetRemaining: budget.remaining };
|
|
592
|
+
}
|