@intentius/chant 0.8.1 → 0.9.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 +2 -1
- 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 +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-detail.d.ts +21 -0
- package/dist/graph-detail.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +78 -0
- package/dist/graph-ir.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/provenance.d.ts +8 -1
- package/dist/provenance.d.ts.map +1 -1
- package/dist/reconcile.d.ts +147 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +43 -1
- package/src/cli/main.ts +5 -1
- package/src/cli/registry.ts +2 -0
- package/src/discovery/collect.ts +2 -2
- package/src/graph-detail.test.ts +79 -0
- package/src/graph-detail.ts +149 -0
- package/src/graph-ir.test.ts +122 -0
- package/src/graph-ir.ts +285 -0
- package/src/index.ts +2 -0
- package/src/provenance.ts +9 -1
- package/src/reconcile.test.ts +224 -0
- package/src/reconcile.ts +346 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the provider-agnostic reconcile primitive.
|
|
3
|
+
*
|
|
4
|
+
* Pure unit tests over the generic primitives — no provider types, no I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
deepEqual,
|
|
10
|
+
diffFields,
|
|
11
|
+
diffCollection,
|
|
12
|
+
summarizeChangeSet,
|
|
13
|
+
renderChangeSet,
|
|
14
|
+
resolveRenames,
|
|
15
|
+
removalDeltaCap,
|
|
16
|
+
runGuardrailChecks,
|
|
17
|
+
} from "./reconcile";
|
|
18
|
+
import type { ChangeSet, ChangeSetEntry, DiffOptions, GuardrailCheck } from "./reconcile";
|
|
19
|
+
|
|
20
|
+
const noOpts: DiffOptions = {};
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// deepEqual / diffFields
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("deepEqual", () => {
|
|
27
|
+
test("compares primitives and nested structures", () => {
|
|
28
|
+
expect(deepEqual(1, 1)).toBe(true);
|
|
29
|
+
expect(deepEqual("a", "b")).toBe(false);
|
|
30
|
+
expect(deepEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
|
|
31
|
+
expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false);
|
|
32
|
+
expect(deepEqual(null, undefined)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("diffFields", () => {
|
|
37
|
+
test("compares every key of desired when no key list is given", () => {
|
|
38
|
+
expect(diffFields({ a: 1, b: 2 }, { a: 1, b: 9 })).toEqual([{ field: "b", before: 9, after: 2 }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("compares only listed keys present in desired", () => {
|
|
42
|
+
expect(diffFields({ a: 1, b: 2 }, { a: 9, b: 9 }, ["a"])).toEqual([{ field: "a", before: 9, after: 1 }]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("ignores listed keys absent from desired (selective-by-omission)", () => {
|
|
46
|
+
expect(diffFields({ a: 1 }, { a: 1, b: 2 }, ["a", "b"])).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// diffCollection
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
interface D {
|
|
55
|
+
name: string;
|
|
56
|
+
v?: number;
|
|
57
|
+
}
|
|
58
|
+
interface L {
|
|
59
|
+
name: string;
|
|
60
|
+
v?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runCollection(desired: D[], live: L[], opts: DiffOptions = noOpts): ChangeSetEntry[] {
|
|
64
|
+
const out: ChangeSetEntry[] = [];
|
|
65
|
+
diffCollection<D, L>({
|
|
66
|
+
resourceType: "thing",
|
|
67
|
+
keyPrefix: "p/",
|
|
68
|
+
desired: new Map(desired.map((d) => [d.name, d])),
|
|
69
|
+
live: new Map(live.map((l) => [l.name, l])),
|
|
70
|
+
compareFields: (d, l) => (d.v !== l.v ? [{ field: "v", before: l.v, after: d.v }] : []),
|
|
71
|
+
opts,
|
|
72
|
+
out,
|
|
73
|
+
});
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("diffCollection", () => {
|
|
78
|
+
test("creates entries for desired-not-live (with key prefix)", () => {
|
|
79
|
+
const out = runCollection([{ name: "a", v: 1 }], []);
|
|
80
|
+
expect(out).toEqual([{ kind: "create", resourceType: "thing", key: "p/a", after: { name: "a", v: 1 } }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("updates when compareFields reports differences", () => {
|
|
84
|
+
const out = runCollection([{ name: "a", v: 2 }], [{ name: "a", v: 1 }]);
|
|
85
|
+
expect(out[0]!.kind).toBe("update");
|
|
86
|
+
expect(out[0]!.fields).toEqual([{ field: "v", before: 1, after: 2 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("emits no entry when live matches desired", () => {
|
|
90
|
+
expect(runCollection([{ name: "a", v: 1 }], [{ name: "a", v: 1 }])).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("only deletes live-not-desired when ownership-gated", () => {
|
|
94
|
+
const live = [
|
|
95
|
+
{ name: "a", v: 1 },
|
|
96
|
+
{ name: "stray", v: 9 },
|
|
97
|
+
];
|
|
98
|
+
expect(runCollection([{ name: "a", v: 1 }], live)).toEqual([]); // no predicate
|
|
99
|
+
const owned = runCollection([{ name: "a", v: 1 }], live, { isOwned: (_t, k) => k === "p/stray" });
|
|
100
|
+
expect(owned).toEqual([
|
|
101
|
+
{ kind: "delete", resourceType: "thing", key: "p/stray", before: { name: "stray", v: 9 } },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("honours createAfter / updateAfter mappers", () => {
|
|
106
|
+
const out: ChangeSetEntry[] = [];
|
|
107
|
+
diffCollection<D, L>({
|
|
108
|
+
resourceType: "thing",
|
|
109
|
+
desired: new Map([["a", { name: "a", v: 5 }]]),
|
|
110
|
+
live: new Map(),
|
|
111
|
+
compareFields: () => [],
|
|
112
|
+
createAfter: (key, d) => ({ normalized: key, v: d.v }),
|
|
113
|
+
opts: noOpts,
|
|
114
|
+
out,
|
|
115
|
+
});
|
|
116
|
+
expect(out[0]!.after).toEqual({ normalized: "a", v: 5 });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// summarize / render
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("summarizeChangeSet / renderChangeSet", () => {
|
|
125
|
+
const cs: ChangeSet = {
|
|
126
|
+
org: "acme",
|
|
127
|
+
entries: [
|
|
128
|
+
{ kind: "create", resourceType: "thing", key: "a" },
|
|
129
|
+
{ kind: "update", resourceType: "thing", key: "b", fields: [{ field: "v", before: 1, after: 2 }] },
|
|
130
|
+
{ kind: "delete", resourceType: "thing", key: "c" },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
test("counts entries by kind", () => {
|
|
135
|
+
expect(summarizeChangeSet(cs)).toEqual({ create: 1, update: 1, delete: 1 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("renders a readable plan with the scope id and field changes", () => {
|
|
139
|
+
const out = renderChangeSet(cs);
|
|
140
|
+
expect(out).toContain("Plan for acme: 1 to create, 1 to update, 1 to delete");
|
|
141
|
+
expect(out).toContain("[thing] b");
|
|
142
|
+
expect(out).toContain("v: 1 → 2");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("renders 'No changes.' for an empty set", () => {
|
|
146
|
+
expect(renderChangeSet({ org: "acme", entries: [] })).toContain("No changes.");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Guardrail framework
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("resolveRenames", () => {
|
|
155
|
+
test("collapses delete(previously)+create(key) into one update", () => {
|
|
156
|
+
const cs: ChangeSet = {
|
|
157
|
+
org: "acme",
|
|
158
|
+
entries: [
|
|
159
|
+
{ kind: "delete", resourceType: "team", key: "old", before: { slug: "old" } },
|
|
160
|
+
{ kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
const resolved = resolveRenames(cs);
|
|
164
|
+
expect(resolved.entries.some((e) => e.kind === "delete")).toBe(false);
|
|
165
|
+
const update = resolved.entries.find((e) => e.kind === "update")!;
|
|
166
|
+
expect(update.key).toBe("new");
|
|
167
|
+
expect(update.before).toEqual({ slug: "old" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("is a no-op without a matching previously alias", () => {
|
|
171
|
+
const cs: ChangeSet = { org: "acme", entries: [{ kind: "delete", resourceType: "team", key: "old" }] };
|
|
172
|
+
expect(resolveRenames(cs)).toBe(cs);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("removalDeltaCap", () => {
|
|
177
|
+
test("trips when deletes exceed the fraction of pre-existing entries", () => {
|
|
178
|
+
const cs: ChangeSet = {
|
|
179
|
+
org: "acme",
|
|
180
|
+
entries: Array.from({ length: 4 }, (_, i) => ({
|
|
181
|
+
kind: "delete" as const,
|
|
182
|
+
resourceType: "x",
|
|
183
|
+
key: `k${i}`,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
expect(removalDeltaCap(cs)!.guardrail).toBe("removalDeltaCap");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("excludes creates from the denominator and passes under the cap", () => {
|
|
190
|
+
const cs: ChangeSet = {
|
|
191
|
+
org: "acme",
|
|
192
|
+
entries: [
|
|
193
|
+
{ kind: "delete", resourceType: "x", key: "d" },
|
|
194
|
+
{ kind: "update", resourceType: "x", key: "u1" },
|
|
195
|
+
{ kind: "update", resourceType: "x", key: "u2" },
|
|
196
|
+
{ kind: "update", resourceType: "x", key: "u3" },
|
|
197
|
+
{ kind: "create", resourceType: "x", key: "c" },
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
expect(removalDeltaCap(cs)).toBeNull(); // 1/4 = 25%, not > 25%
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("runGuardrailChecks", () => {
|
|
205
|
+
test("resolves renames once and aggregates failing checks", () => {
|
|
206
|
+
const cs: ChangeSet = {
|
|
207
|
+
org: "acme",
|
|
208
|
+
entries: [
|
|
209
|
+
{ kind: "delete", resourceType: "x", key: "a" },
|
|
210
|
+
{ kind: "delete", resourceType: "x", key: "b" },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
const failing: GuardrailCheck = (resolved) => removalDeltaCap(resolved);
|
|
214
|
+
const passing: GuardrailCheck = () => null;
|
|
215
|
+
const result = runGuardrailChecks(cs, [failing, passing]);
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
if (!result.ok) expect(result.diagnostics).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns ok when every check passes", () => {
|
|
221
|
+
const cs: ChangeSet = { org: "acme", entries: [{ kind: "create", resourceType: "x", key: "a" }] };
|
|
222
|
+
expect(runGuardrailChecks(cs, [() => null])).toEqual({ ok: true });
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/reconcile.ts
ADDED
|
@@ -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
|
+
}
|