@intentius/chant 0.8.2 → 0.10.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.
Files changed (45) hide show
  1. package/dist/cli/handlers/graph.d.ts +2 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +8 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/graph-detail.d.ts +21 -0
  7. package/dist/graph-detail.d.ts.map +1 -0
  8. package/dist/graph-dot.d.ts +12 -0
  9. package/dist/graph-dot.d.ts.map +1 -0
  10. package/dist/graph-ir.d.ts +78 -0
  11. package/dist/graph-ir.d.ts.map +1 -0
  12. package/dist/graph-layout.d.ts +39 -0
  13. package/dist/graph-layout.d.ts.map +1 -0
  14. package/dist/graph-lens.d.ts +30 -0
  15. package/dist/graph-lens.d.ts.map +1 -0
  16. package/dist/graph-mermaid.d.ts +17 -0
  17. package/dist/graph-mermaid.d.ts.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/provenance.d.ts +8 -1
  21. package/dist/provenance.d.ts.map +1 -1
  22. package/dist/reconcile.d.ts +147 -0
  23. package/dist/reconcile.d.ts.map +1 -0
  24. package/package.json +1 -1
  25. package/src/cli/handlers/graph.test.ts +166 -48
  26. package/src/cli/handlers/graph.ts +86 -1
  27. package/src/cli/main.test.ts +16 -0
  28. package/src/cli/main.ts +14 -1
  29. package/src/cli/registry.ts +8 -0
  30. package/src/discovery/collect.ts +2 -2
  31. package/src/graph-detail.test.ts +79 -0
  32. package/src/graph-detail.ts +149 -0
  33. package/src/graph-dot.test.ts +68 -0
  34. package/src/graph-dot.ts +58 -0
  35. package/src/graph-ir.test.ts +122 -0
  36. package/src/graph-ir.ts +285 -0
  37. package/src/graph-layout.ts +104 -0
  38. package/src/graph-lens.test.ts +80 -0
  39. package/src/graph-lens.ts +127 -0
  40. package/src/graph-mermaid.test.ts +61 -0
  41. package/src/graph-mermaid.ts +85 -0
  42. package/src/index.ts +6 -0
  43. package/src/provenance.ts +9 -1
  44. package/src/reconcile.test.ts +224 -0
  45. package/src/reconcile.ts +346 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Provider-agnostic reconcile primitive.
3
+ *
4
+ * The reusable machinery behind a declarative reconcile loop, with NO knowledge
5
+ * of any specific provider (GitHub, GitLab, a cloud, …): the change-set model,
6
+ * the generic collection diff (selective-by-omission + ownership-gated deletes),
7
+ * the plan renderer, and the guardrail framework (rename resolution + a removal
8
+ * cap + a pluggable check runner).
9
+ *
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
12
+ * chant's `ownership.ts` marker contract: ownership markers make a `delete`
13
+ * precise; this module decides *which* entries are creates / updates / deletes
14
+ * in the first place.
15
+ *
16
+ * Consumed as `@intentius/chant/reconcile`. Pure and deterministic: no I/O,
17
+ * no clock.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Change-set model
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** A single field-level change: what the old value was and what it will become. */
25
+ export interface FieldChange {
26
+ field: string;
27
+ before: unknown;
28
+ after: unknown;
29
+ }
30
+
31
+ /** The kind of operation this change represents. */
32
+ export type ChangeKind = "create" | "update" | "delete";
33
+
34
+ /** A single entry in the change set. */
35
+ export interface ChangeSetEntry {
36
+ kind: ChangeKind;
37
+ /** High-level resource category (e.g. "team", "member", "branch-protection"). */
38
+ resourceType: string;
39
+ /**
40
+ * Unique key identifying this resource within its type.
41
+ * - For top-level resources: a single name (team slug, member login, …).
42
+ * - For nested resources: "<parent>/<child>" (e.g. "backend/alice").
43
+ */
44
+ key: string;
45
+ /** The live value before the change (absent for creates). */
46
+ before?: unknown;
47
+ /** The desired value after the change (absent for deletes). */
48
+ after?: unknown;
49
+ /** Field-level diff, populated for `update` entries. */
50
+ fields?: FieldChange[];
51
+ }
52
+
53
+ /** The full set of changes to reconcile for one scope (e.g. one org). */
54
+ export interface ChangeSet {
55
+ /** Scope identifier this change set applies to (e.g. a GitHub org login). */
56
+ org: string;
57
+ /** All proposed changes, in stable order. */
58
+ entries: ChangeSetEntry[];
59
+ }
60
+
61
+ /** Options controlling diff behaviour. */
62
+ export interface DiffOptions {
63
+ /**
64
+ * Ownership predicate for collection entries. The diff only emits a `delete`
65
+ * for a live entry absent from desired when this returns `true`. Omitted →
66
+ * deletes are never emitted ("assume nothing is owned").
67
+ */
68
+ isOwned?: (resourceType: string, key: string) => boolean;
69
+
70
+ /**
71
+ * Reference "now" in epoch milliseconds, used by time-based diffs. Callers
72
+ * inject `Date.now()` when unset; tests pass an explicit value.
73
+ */
74
+ nowMs?: number;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Generic field/value diffing
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Deep value equality via JSON for plain data (config/live snapshots). */
82
+ export function deepEqual(a: unknown, b: unknown): boolean {
83
+ if (a === b) return true;
84
+ if (a === null || b === null) return false;
85
+ if (typeof a !== "object" || typeof b !== "object") return false;
86
+ return JSON.stringify(a) === JSON.stringify(b);
87
+ }
88
+
89
+ /**
90
+ * Diff fields of `desired` against `live`, returning one `FieldChange` per
91
+ * differing field. When `keys` is given, only those keys are compared (and only
92
+ * when present in `desired`); otherwise every key in `desired` is compared.
93
+ * Selective-by-omission: keys absent from `desired` are never compared.
94
+ */
95
+ export function diffFields(
96
+ desired: Record<string, unknown>,
97
+ live: Record<string, unknown>,
98
+ keys?: string[],
99
+ ): FieldChange[] {
100
+ const fields: FieldChange[] = [];
101
+ const compareKeys = keys ?? Object.keys(desired);
102
+ for (const key of compareKeys) {
103
+ if (keys && !Object.prototype.hasOwnProperty.call(desired, key)) continue;
104
+ const dv = desired[key];
105
+ const lv = live[key];
106
+ if (!deepEqual(dv, lv)) fields.push({ field: key, before: lv, after: dv });
107
+ }
108
+ return fields;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Generic collection diff
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Parameters for {@link diffCollection}. */
116
+ export interface DiffCollectionParams<D, L> {
117
+ /** Resource type stamped on emitted entries. */
118
+ resourceType: string;
119
+ /** Prefix prepended to each entry key (e.g. "<parent>/"). Default "". */
120
+ keyPrefix?: string;
121
+ /** Desired entries, keyed by logical key. */
122
+ desired: Map<string, D>;
123
+ /** Live entries, keyed by logical key. */
124
+ live: Map<string, L>;
125
+ /** Fields that differ → an update. Return `[]` for "no change". */
126
+ compareFields: (desired: D, live: L) => FieldChange[];
127
+ /** `after` value for a create entry. Defaults to the desired value. */
128
+ createAfter?: (key: string, desired: D) => unknown;
129
+ /** `after` value for an update entry. Defaults to the desired value. */
130
+ updateAfter?: (key: string, desired: D, live: L) => unknown;
131
+ opts: DiffOptions;
132
+ out: ChangeSetEntry[];
133
+ }
134
+
135
+ /**
136
+ * The generic managed-collection diff: creates for desired-not-live, updates
137
+ * when `compareFields` reports differences, and ownership-gated deletes for
138
+ * live-not-desired. This is the selective-by-omission + ownership-gated-delete
139
+ * pattern shared by every keyed-collection diff.
140
+ */
141
+ export function diffCollection<D, L>(params: DiffCollectionParams<D, L>): void {
142
+ const {
143
+ resourceType,
144
+ keyPrefix = "",
145
+ desired,
146
+ live,
147
+ compareFields,
148
+ createAfter,
149
+ updateAfter,
150
+ opts,
151
+ out,
152
+ } = params;
153
+
154
+ for (const [key, d] of desired) {
155
+ const entryKey = `${keyPrefix}${key}`;
156
+ const l = live.get(key);
157
+ if (l === undefined) {
158
+ out.push({
159
+ kind: "create",
160
+ resourceType,
161
+ key: entryKey,
162
+ after: createAfter ? createAfter(key, d) : d,
163
+ });
164
+ continue;
165
+ }
166
+ const fields = compareFields(d, l);
167
+ if (fields.length > 0) {
168
+ out.push({
169
+ kind: "update",
170
+ resourceType,
171
+ key: entryKey,
172
+ before: l,
173
+ after: updateAfter ? updateAfter(key, d, l) : d,
174
+ fields,
175
+ });
176
+ }
177
+ }
178
+
179
+ for (const [key, l] of live) {
180
+ if (desired.has(key)) continue;
181
+ const entryKey = `${keyPrefix}${key}`;
182
+ if (opts.isOwned?.(resourceType, entryKey)) {
183
+ out.push({ kind: "delete", resourceType, key: entryKey, before: l });
184
+ }
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Summary / rendering
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** Count entries per change kind. */
193
+ export function summarizeChangeSet(cs: ChangeSet): Record<ChangeKind, number> {
194
+ const counts: Record<ChangeKind, number> = { create: 0, update: 0, delete: 0 };
195
+ for (const e of cs.entries) counts[e.kind]++;
196
+ return counts;
197
+ }
198
+
199
+ /** Human-readable plan summary for dry-run output. Pure. */
200
+ export function renderChangeSet(cs: ChangeSet): string {
201
+ const counts = summarizeChangeSet(cs);
202
+ const header = `Plan for ${cs.org}: ${counts.create} to create, ${counts.update} to update, ${counts.delete} to delete`;
203
+
204
+ if (cs.entries.length === 0) return `${header}\nNo changes.`;
205
+
206
+ const lines: string[] = [header];
207
+ const byKind: Record<ChangeKind, ChangeSetEntry[]> = { create: [], update: [], delete: [] };
208
+ for (const e of cs.entries) byKind[e.kind].push(e);
209
+
210
+ const ORDER: ChangeKind[] = ["create", "update", "delete"];
211
+ for (const kind of ORDER) {
212
+ const group = byKind[kind];
213
+ if (group.length === 0) continue;
214
+ lines.push(`\n${kind.toUpperCase()}:`);
215
+ for (const e of group) {
216
+ lines.push(` [${e.resourceType}] ${e.key}`);
217
+ for (const f of e.fields ?? []) {
218
+ lines.push(` ${f.field}: ${fmt(f.before)} → ${fmt(f.after)}`);
219
+ }
220
+ }
221
+ }
222
+ return lines.join("\n");
223
+ }
224
+
225
+ function fmt(v: unknown): string {
226
+ if (v === undefined) return "<unset>";
227
+ if (typeof v === "string") return v.length > 60 ? `${v.slice(0, 57)}...` : v;
228
+ const json = JSON.stringify(v);
229
+ return json.length > 60 ? `${json.slice(0, 57)}...` : json;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Guardrail framework
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /** A single tripped guardrail with a human-readable message. */
237
+ export interface GuardrailDiagnostic {
238
+ /** Short identifier, e.g. "removalDeltaCap". */
239
+ guardrail: string;
240
+ /** Clear, actionable description of why the apply was refused. */
241
+ message: string;
242
+ }
243
+
244
+ /** Aggregated guardrail result. */
245
+ export type GuardrailResult = { ok: true } | { ok: false; diagnostics: GuardrailDiagnostic[] };
246
+
247
+ /** A guardrail check over a (rename-resolved) change set. Returns null when it passes. */
248
+ export type GuardrailCheck = (resolved: ChangeSet) => GuardrailDiagnostic | null;
249
+
250
+ /** Config for `removalDeltaCap`. */
251
+ export interface RemovalDeltaCapOptions {
252
+ /** Max fraction of pre-existing entries that may be deleted. Must be in (0,1]. Default 0.25. */
253
+ maxFraction?: number;
254
+ }
255
+
256
+ /**
257
+ * Resolve rename aliases. A create entry carrying a `previously` key matching a
258
+ * delete entry's key is collapsed into an update, removing the delete. Returns a
259
+ * new ChangeSet with renames resolved. Provider-agnostic — works on any entry
260
+ * whose `after.previously` is a string.
261
+ */
262
+ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
263
+ const deleteEntries = new Map<string, ChangeSetEntry>();
264
+ for (const e of changeSet.entries) {
265
+ if (e.kind === "delete") deleteEntries.set(e.key, e);
266
+ }
267
+
268
+ const resolvedDeletes = new Set<string>();
269
+ const resolvedCreates = new Set<string>();
270
+ const syntheticUpdates: ChangeSetEntry[] = [];
271
+
272
+ for (const e of changeSet.entries) {
273
+ if (e.kind !== "create") continue;
274
+ const after = e.after as Record<string, unknown> | undefined;
275
+ if (!after) continue;
276
+ const previously = after["previously"];
277
+ if (typeof previously !== "string") continue;
278
+
279
+ const deleted = deleteEntries.get(previously);
280
+ if (!deleted) continue;
281
+
282
+ resolvedDeletes.add(previously);
283
+ resolvedCreates.add(e.key);
284
+ syntheticUpdates.push({
285
+ kind: "update",
286
+ resourceType: e.resourceType,
287
+ key: e.key,
288
+ before: deleted.before,
289
+ after: e.after,
290
+ fields: [{ field: "key", before: previously, after: e.key }],
291
+ });
292
+ }
293
+
294
+ if (resolvedDeletes.size === 0) return changeSet;
295
+
296
+ const filteredEntries = changeSet.entries.filter(
297
+ (e) =>
298
+ !(e.kind === "delete" && resolvedDeletes.has(e.key)) &&
299
+ !(e.kind === "create" && resolvedCreates.has(e.key)),
300
+ );
301
+
302
+ return { org: changeSet.org, entries: [...filteredEntries, ...syntheticUpdates] };
303
+ }
304
+
305
+ /**
306
+ * Refuse if deletes exceed `maxFraction` of the pre-existing managed entries
307
+ * (deletes + updates; creates excluded so a flood of new entries can't dilute
308
+ * the delete fraction). Guards against a typo wiping the config in one apply.
309
+ *
310
+ * CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}).
311
+ */
312
+ export function removalDeltaCap(
313
+ changeSet: ChangeSet,
314
+ opts: RemovalDeltaCapOptions = {},
315
+ ): GuardrailDiagnostic | null {
316
+ const maxFraction = opts.maxFraction ?? 0.25;
317
+ const total = changeSet.entries.filter((e) => e.kind !== "create").length;
318
+ if (total === 0) return null;
319
+ const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
320
+ const fraction = deletes / total;
321
+ if (fraction > maxFraction) {
322
+ return {
323
+ guardrail: "removalDeltaCap",
324
+ message:
325
+ `${deletes} of ${total} managed entries (${Math.round(fraction * 100)}%) would be deleted, ` +
326
+ `exceeding the ${Math.round(maxFraction * 100)}% threshold. ` +
327
+ `Check for typos in config or raise maxFraction to proceed.`,
328
+ };
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Run a set of guardrail checks against a change set. Resolves renames ONCE,
335
+ * then runs every check on the resolved set, aggregating any diagnostics. The
336
+ * caller composes provider-specific checks (e.g. an admin floor) as closures.
337
+ */
338
+ export function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[]): GuardrailResult {
339
+ const resolved = resolveRenames(changeSet);
340
+ const diagnostics: GuardrailDiagnostic[] = [];
341
+ for (const check of checks) {
342
+ const d = check(resolved);
343
+ if (d) diagnostics.push(d);
344
+ }
345
+ return diagnostics.length > 0 ? { ok: false, diagnostics } : { ok: true };
346
+ }