@intentius/chant 0.9.0 → 0.11.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.
@@ -0,0 +1,85 @@
1
+ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
2
+
3
+ /**
4
+ * Render the graph IR as a Mermaid `flowchart`. Mermaid is the zero-install
5
+ * default — it renders in GitHub, docs, and browsers with no native dependency,
6
+ * so `chant graph --format mermaid` gives a diagram out of the box without the
7
+ * standalone painter. Lower fidelity than a custom painter, but portable.
8
+ *
9
+ * Consumes whatever IR it is given, so it honours `--detail` and `--lens` for
10
+ * free (those are IR → IR transforms). See issue #496 / epic #492.
11
+ *
12
+ * Known limits: Mermaid owns layout, so there is little control over node
13
+ * placement, and very large graphs get hard to read — that is the trade-off for
14
+ * zero-install portability. Reach for the graphviz/custom-painter path (#497,
15
+ * pinhole) when fidelity matters.
16
+ */
17
+ export function toMermaid(ir: GraphIR): string {
18
+ const ids = new Map<string, string>(); // logical name -> mermaid-safe id
19
+ for (const n of ir.nodes) safeId(n.id, ids);
20
+
21
+ const lines: string[] = ["flowchart TD"];
22
+
23
+ // Cluster by lexicon when grouping is available; nodes outside any group fall
24
+ // through to the top level. byLexicon is sorted, so output is deterministic.
25
+ const byLexicon = ir.groups.byLexicon;
26
+ const grouped = new Set<string>();
27
+ if (byLexicon) {
28
+ for (const [lexicon, members] of Object.entries(byLexicon)) {
29
+ lines.push(` subgraph ${safeId(`lex_${lexicon}`, ids)}[${quote(lexicon)}]`);
30
+ for (const id of members) {
31
+ const node = ir.nodes.find((n) => n.id === id);
32
+ if (!node) continue;
33
+ grouped.add(id);
34
+ lines.push(` ${nodeLine(node, ids)}`);
35
+ }
36
+ lines.push(" end");
37
+ }
38
+ }
39
+ for (const node of ir.nodes) {
40
+ if (grouped.has(node.id)) continue;
41
+ lines.push(` ${nodeLine(node, ids)}`);
42
+ }
43
+
44
+ for (const e of ir.edges) {
45
+ lines.push(` ${edgeLine(e, ids)}`);
46
+ }
47
+
48
+ return lines.join("\n") + "\n";
49
+ }
50
+
51
+ function nodeLine(node: IRNode, ids: Map<string, string>): string {
52
+ const id = safeId(node.id, ids);
53
+ const parts = [node.id];
54
+ if (node.kind && node.kind !== node.id) parts.push(node.kind);
55
+ return `${id}[${quote(parts.join("\n"))}]`;
56
+ }
57
+
58
+ function edgeLine(e: IREdge, ids: Map<string, string>): string {
59
+ const from = safeId(e.from, ids);
60
+ const to = safeId(e.to, ids);
61
+ const label = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
62
+ return label ? `${from} -->|${quote(label)}| ${to}` : `${from} --> ${to}`;
63
+ }
64
+
65
+ /** Map an arbitrary logical name to a stable, unique Mermaid-safe node id. */
66
+ function safeId(raw: string, ids: Map<string, string>): string {
67
+ const existing = ids.get(raw);
68
+ if (existing) return existing;
69
+ let base = raw.replace(/[^A-Za-z0-9_]/g, "_");
70
+ if (base === "" || /^[0-9]/.test(base)) base = `n_${base}`;
71
+ const taken = new Set(ids.values());
72
+ let candidate = base;
73
+ let i = 1;
74
+ while (taken.has(candidate)) candidate = `${base}_${i++}`;
75
+ ids.set(raw, candidate);
76
+ return candidate;
77
+ }
78
+
79
+ /** Quote a Mermaid label, escaping markup and turning newlines into <br/>. */
80
+ function quote(text: string): string {
81
+ const esc = (s: string): string =>
82
+ s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
+ const body = text.split("\n").map(esc).join("<br/>");
84
+ return `"${body}"`;
85
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,10 @@ export * from "./discovery/cache";
27
27
  export * from "./build";
28
28
  export * from "./graph-ir";
29
29
  export * from "./graph-detail";
30
+ export * from "./graph-mermaid";
31
+ export * from "./graph-dot";
32
+ export * from "./graph-layout";
33
+ export * from "./graph-lens";
30
34
  export * from "./detectLexicon";
31
35
  export * from "./lint/parser";
32
36
  export * from "./lint/rule";
@@ -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. It complements
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`. Pure and deterministic: no I/O,
17
- * no clock.
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
+ }