@intentius/chant 0.55.0 → 0.56.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.
@@ -63,8 +63,22 @@ export interface ChangeSet {
63
63
  * can sum it here. Optional — when absent, guardrails that want a live
64
64
  * denominator (see {@link removalDeltaCap}) fall back to the plan-relative
65
65
  * one.
66
+ *
67
+ * Superseded by {@link ChangeSet.managedCounts}: a single pooled total lets
68
+ * non-deletable and cross-collection live entries dilute the delete
69
+ * fraction (wiping 3 of 4 teams reads as 12.5% when 20 team members pad
70
+ * the pool). Kept for compatibility — a change set carrying only this
71
+ * total keeps the 0.55.0 pooled behavior.
66
72
  */
67
73
  managedCount?: number;
74
+ /**
75
+ * Live managed-entry counts per resource type, keyed by
76
+ * `ChangeSetEntry.resourceType`. When present, {@link removalDeltaCap}
77
+ * evaluates the delete fraction per resource type instead of against the
78
+ * pooled `managedCount`, so live entries of one type cannot dilute a wipe
79
+ * of another. Supersedes `managedCount`.
80
+ */
81
+ managedCounts?: Readonly<Record<string, number>>;
68
82
  }
69
83
  /** Options controlling diff behaviour. */
70
84
  export interface DiffOptions {
@@ -149,23 +163,44 @@ export interface GuardrailContext {
149
163
  export type GuardrailCheck = (resolved: ChangeSet, ctx?: GuardrailContext) => GuardrailDiagnostic | null;
150
164
  /** Config for `removalDeltaCap`. */
151
165
  export interface RemovalDeltaCapOptions {
152
- /** Max fraction of pre-existing entries that may be deleted. Must be in (0,1]. Default 0.25. */
166
+ /**
167
+ * Max fraction of pre-existing entries that may be deleted. Must be in
168
+ * (0,1]: `removalDeltaCap` throws an `Error` on any value outside that
169
+ * range (0, negatives, above 1, NaN) rather than silently disabling the
170
+ * cap. Default 0.25.
171
+ */
153
172
  maxFraction?: number;
154
173
  /**
155
- * Live managed-entry denominator (#2067). When positive, deletes are divided
156
- * by this count of live entries under management instead of the plan's
157
- * non-create entries — a converged cycle deleting one stale entry out of
158
- * many no longer reads as 1/1 = 100%. Defaults to `changeSet.managedCount`;
159
- * when neither is present (or not positive) the cap keeps its plan-relative
160
- * behavior.
174
+ * Pooled live managed-entry denominator (#2067). When positive, deletes are
175
+ * divided by this count of live entries under management instead of the
176
+ * plan's non-create entries — a converged cycle deleting one stale entry
177
+ * out of many no longer reads as 1/1 = 100%. Defaults to
178
+ * `changeSet.managedCount`; when neither is present (or not positive) the
179
+ * cap keeps its plan-relative behavior.
180
+ *
181
+ * Superseded by {@link RemovalDeltaCapOptions.managedTotals} — the pooled
182
+ * total lets unrelated live entries dilute the delete fraction.
161
183
  */
162
184
  managedTotal?: number;
185
+ /**
186
+ * Live managed-entry counts per resource type (keys =
187
+ * `ChangeSetEntry.resourceType`), mirroring {@link ChangeSet.managedCounts}
188
+ * and winning over it when both are present. When per-type counts are
189
+ * available from either source, the cap is evaluated per resource type:
190
+ * each type's deletes divide by that type's live count when present and
191
+ * positive, else by that type's own plan non-creates. Supersedes
192
+ * `managedTotal`.
193
+ */
194
+ managedTotals?: Readonly<Record<string, number>>;
163
195
  }
164
196
  /**
165
197
  * Resolve rename aliases. A create entry carrying a `previously` key matching a
166
- * delete entry's key is collapsed into an update, removing the delete. Returns a
167
- * new ChangeSet with renames resolved. Provider-agnostic — works on any entry
168
- * whose `after.previously` is a string.
198
+ * delete entry's key OF THE SAME RESOURCE TYPE is collapsed into an update,
199
+ * removing the delete. Matching across resource types would let e.g. a team
200
+ * create with `previously: "old"` swallow an unrelated member delete keyed
201
+ * "old", hiding a real delete from guardrail counting. Returns a new ChangeSet
202
+ * with renames resolved. Provider-agnostic — works on any entry whose
203
+ * `after.previously` is a string.
169
204
  */
170
205
  export declare function resolveRenames(changeSet: ChangeSet): ChangeSet;
171
206
  /**
@@ -173,10 +208,23 @@ export declare function resolveRenames(changeSet: ChangeSet): ChangeSet;
173
208
  * (deletes + updates; creates excluded so a flood of new entries can't dilute
174
209
  * the delete fraction). Guards against a typo wiping the config in one apply.
175
210
  *
176
- * With a live denominator (`opts.managedTotal`, else `changeSet.managedCount`;
177
- * #2067) deletes are measured against the live entries under management
178
- * instead — so a converged cycle's plan of exactly one stale delete does not
179
- * trip at 1/1 = 100%. Without one, behavior is plan-relative as before.
211
+ * With per-type live counts (`opts.managedTotals`, else
212
+ * `changeSet.managedCounts`) the cap is evaluated PER RESOURCE TYPE: for each
213
+ * type with deletes, that type's deletes divide by that type's live count when
214
+ * present and positive, else by that type's own plan non-creates. Any type
215
+ * over `maxFraction` blocks, and the diagnostic names the worst offender. The
216
+ * pooled denominator this replaces was unsafe — live entries of one type
217
+ * (team members, repos) diluted a wipe of another (teams, org secrets) below
218
+ * the cap.
219
+ *
220
+ * With only a pooled live denominator (`opts.managedTotal`, else
221
+ * `changeSet.managedCount`; #2067) deletes are measured against the pooled
222
+ * live entries under management — so a converged cycle's plan of exactly one
223
+ * stale delete does not trip at 1/1 = 100%. Without either, behavior is
224
+ * plan-relative as before.
225
+ *
226
+ * Throws on a `maxFraction` outside (0,1] (NaN included) — fail closed, never
227
+ * silently disable the cap.
180
228
  *
181
229
  * CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}).
182
230
  */
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../src/reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAoB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAMxE,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,oDAAoD;AACpD,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAExD,wCAAwC;AACxC,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,UAAU,CAAC;IACjB,iFAAiF;IACjF,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,6DAA6D;IAC7D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,wDAAwD;IACxD,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;CACxB;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAC7C,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,0CAA0C;AAC1C,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAEzD;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAKzD;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,CAAC,EAAE,MAAM,EAAE,GACd,WAAW,EAAE,CAUf;AAMD,6CAA6C;AAC7C,MAAM,WAAW,oBAAoB,CAAC,CAAC,EAAE,CAAC;IACxC,gDAAgD;IAChD,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACxB,0CAA0C;IAC1C,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACrB,mEAAmE;IACnE,aAAa,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,KAAK,WAAW,EAAE,CAAC;IACtD,uEAAuE;IACvE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,OAAO,CAAC;IACnD,wEAAwE;IACxE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC;IAC5D,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,EAAE,cAAc,EAAE,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,MAAM,CA+C/E;AAMD,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAI5E;AAED,4DAA4D;AAC5D,wBAAgB,eAAe,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,CAgCrD;AAaD,gEAAgE;AAChE,MAAM,WAAW,mBAAmB;IAClC,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,mCAAmC;AACnC,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,WAAW,EAAE,mBAAmB,EAAE,CAAA;CAAE,CAAC;AAE/F,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAC/B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,gBAAgB,KAAK,mBAAmB,GAAG,IAAI,CAAC;AAEzG,oCAAoC;AACpC,MAAM,WAAW,sBAAsB;IACrC,gGAAgG;IAChG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAyC9D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,EACpB,IAAI,GAAE,sBAA2B,GAChC,mBAAmB,GAAG,IAAI,CAgC5B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,cAAc,EAAE,EACxB,GAAG,CAAC,EAAE,gBAAgB,GACrB,eAAe,CAQjB;AAMD,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACzB,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,gFAAgF;IAChF,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,6EAA6E;AAC7E,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,OAAO,SAA0B;CAI9C;AAmBD;;;;;;;;;GASG;AACH,MAAM,WAAW,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAC9D,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,SAAS,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC/F,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACvE,KAAK,CACH,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,cAAc,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAED,oDAAoD;AACpD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,cAAc,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,GAAG,cAAc,CAAC;IACpC,KAAK,EAAE,MAAM,CAAC;CACf;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,cAAc,CAAA;KAAE,CAAC,CAAC;CACrE;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,QAAQ,EAAE,YAAY,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,kCAAkC;AAClC,MAAM,WAAW,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAC5E,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,qDAAqD;IACrD,MAAM,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC;IAC3B,0EAA0E;IAC1E,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,KAAK,SAAS,CAAC;IACvF,yEAAyE;IACzE,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,KAAK,eAAe,CAAC;IACpE,wCAAwC;IACxC,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,sDAAsD;IACtD,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,kEAAkE;IAClE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAMD;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,EAC1E,IAAI,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,GACzD,OAAO,CAAC,eAAe,CAAC,CA6G1B"}
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.55.0",
3
+ "version": "0.56.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",
@@ -189,16 +189,51 @@ describe("resolveRenames", () => {
189
189
  expect(resolveRenames(cs)).toBe(cs);
190
190
  });
191
191
 
192
- test("preserves managedCount on the resolved set (#2067)", () => {
192
+ test("preserves managedCount and managedCounts on the resolved set", () => {
193
193
  const cs: ChangeSet = {
194
194
  org: "acme",
195
195
  managedCount: 7,
196
+ managedCounts: { team: 4, member: 3 },
196
197
  entries: [
197
198
  { kind: "delete", resourceType: "team", key: "old", before: { slug: "old" } },
198
199
  { kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
199
200
  ],
200
201
  };
201
- expect(resolveRenames(cs).managedCount).toBe(7);
202
+ const resolved = resolveRenames(cs);
203
+ expect(resolved.managedCount).toBe(7);
204
+ expect(resolved.managedCounts).toEqual({ team: 4, member: 3 });
205
+ });
206
+
207
+ test("only matches previously within the same resourceType", () => {
208
+ // A team create claiming previously "old" must NOT swallow a member
209
+ // delete that happens to share the key — that would hide a real delete
210
+ // from guardrail counting.
211
+ const cs: ChangeSet = {
212
+ org: "acme",
213
+ entries: [
214
+ { kind: "delete", resourceType: "member", key: "old", before: { login: "old" } },
215
+ { kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
216
+ ],
217
+ };
218
+ const resolved = resolveRenames(cs);
219
+ expect(resolved).toBe(cs); // untouched: no same-type match exists
220
+ expect(resolved.entries.filter((e) => e.kind === "delete")).toHaveLength(1);
221
+
222
+ // With a same-type delete alongside the cross-type collision, exactly
223
+ // the team delete collapses; the member delete survives.
224
+ const both: ChangeSet = {
225
+ org: "acme",
226
+ entries: [
227
+ { kind: "delete", resourceType: "member", key: "old", before: { login: "old" } },
228
+ { kind: "delete", resourceType: "team", key: "old", before: { slug: "old" } },
229
+ { kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
230
+ ],
231
+ };
232
+ const bothResolved = resolveRenames(both);
233
+ expect(bothResolved.entries.filter((e) => e.kind === "delete")).toEqual([
234
+ { kind: "delete", resourceType: "member", key: "old", before: { login: "old" } },
235
+ ]);
236
+ expect(bothResolved.entries.find((e) => e.kind === "update")!.before).toEqual({ slug: "old" });
202
237
  });
203
238
  });
204
239
 
@@ -286,6 +321,141 @@ describe("removalDeltaCap", () => {
286
321
  // never loosens the cap; behavior stays exactly plan-relative.
287
322
  expect(removalDeltaCap(cs, { managedTotal: 0 })).toEqual(expected);
288
323
  });
324
+
325
+ test("a changeSet with only the legacy managedCount deep-equals the 0.55.0 behavior", () => {
326
+ const entries = Array.from({ length: 4 }, (_, i) => ({
327
+ kind: "delete" as const,
328
+ resourceType: "x",
329
+ key: `k${i}`,
330
+ }));
331
+ expect(removalDeltaCap({ org: "acme", managedCount: 10, entries })).toEqual({
332
+ guardrail: "removalDeltaCap",
333
+ message:
334
+ "4 of 10 live managed entries (40%) would be deleted, exceeding the 25% threshold. " +
335
+ "Check for typos in config or raise maxFraction to proceed.",
336
+ });
337
+ expect(removalDeltaCap({ org: "acme", managedCount: 20, entries })).toBeNull(); // 4/20
338
+ });
339
+
340
+ // Per-resource-type mode — the three-repo review cases. The pooled
341
+ // denominator let live entries of one type dilute a wipe of another.
342
+
343
+ test("3 of 4 team deletes blocks at 75% even with 20 live members in another type", () => {
344
+ const cs: ChangeSet = {
345
+ org: "acme",
346
+ managedCounts: { team: 4, member: 20 },
347
+ entries: Array.from({ length: 3 }, (_, i) => ({
348
+ kind: "delete" as const,
349
+ resourceType: "team",
350
+ key: `t${i}`,
351
+ })),
352
+ };
353
+ // Pooled (0.55.0) this read 3/24 = 12.5% and sailed under the default cap.
354
+ expect(removalDeltaCap({ ...cs, managedCounts: undefined, managedCount: 24 })).toBeNull();
355
+ const d = removalDeltaCap(cs)!;
356
+ expect(d.guardrail).toBe("removalDeltaCap");
357
+ expect(d.message).toContain("3 of 4 live team entries (75%) would be deleted");
358
+ });
359
+
360
+ test("a full org-secret wipe blocks at 100% regardless of live repo count", () => {
361
+ const cs: ChangeSet = {
362
+ org: "acme",
363
+ managedCounts: { "org-secret": 2, repo: 75 },
364
+ entries: [
365
+ { kind: "delete", resourceType: "org-secret", key: "s1" },
366
+ { kind: "delete", resourceType: "org-secret", key: "s2" },
367
+ ],
368
+ };
369
+ // Pooled this read 2/77 = 2.6% and passed.
370
+ expect(removalDeltaCap({ ...cs, managedCounts: undefined, managedCount: 77 })).toBeNull();
371
+ const d = removalDeltaCap(cs)!;
372
+ expect(d.message).toContain("2 of 2 live org-secret entries (100%) would be deleted");
373
+ });
374
+
375
+ test("a type with no live count falls back to its own plan non-creates", () => {
376
+ const cs: ChangeSet = {
377
+ org: "acme",
378
+ managedCounts: { team: 10 },
379
+ entries: [
380
+ { kind: "delete", resourceType: "team", key: "t0" }, // 1/10 live: fine
381
+ { kind: "delete", resourceType: "webhook", key: "w0" },
382
+ { kind: "delete", resourceType: "webhook", key: "w1" },
383
+ { kind: "update", resourceType: "webhook", key: "w2" },
384
+ ],
385
+ };
386
+ // webhook has no live count → plan-relative within the type: 2/3 = 67%.
387
+ const d = removalDeltaCap(cs)!;
388
+ expect(d.message).toContain("2 of 3 planned webhook entries (67%) would be deleted");
389
+ // team creates never pad the webhook denominator, and vice versa.
390
+ expect(d.message).not.toContain("team");
391
+ });
392
+
393
+ test("passes when every type stays under the cap, and zero live counts fall back", () => {
394
+ const cs: ChangeSet = {
395
+ org: "acme",
396
+ managedCounts: { team: 10, member: 20 },
397
+ entries: [
398
+ { kind: "delete", resourceType: "team", key: "t0" },
399
+ { kind: "delete", resourceType: "member", key: "m0" },
400
+ { kind: "delete", resourceType: "member", key: "m1" },
401
+ ],
402
+ };
403
+ expect(removalDeltaCap(cs)).toBeNull(); // 1/10 and 2/20
404
+
405
+ // A zero live count for a type means "no live info for it" — that type
406
+ // is measured plan-relative (1/1 here), never divided by zero.
407
+ const zero: ChangeSet = {
408
+ org: "acme",
409
+ managedCounts: { team: 0 },
410
+ entries: [{ kind: "delete", resourceType: "team", key: "t0" }],
411
+ };
412
+ expect(removalDeltaCap(zero)!.message).toContain("1 of 1 planned team entries (100%)");
413
+ });
414
+
415
+ test("names the worst offender first and lists other tripping types", () => {
416
+ const cs: ChangeSet = {
417
+ org: "acme",
418
+ managedCounts: { "org-secret": 2, team: 4 },
419
+ entries: [
420
+ { kind: "delete", resourceType: "team", key: "t0" },
421
+ { kind: "delete", resourceType: "team", key: "t1" },
422
+ { kind: "delete", resourceType: "team", key: "t2" },
423
+ { kind: "delete", resourceType: "org-secret", key: "s0" },
424
+ { kind: "delete", resourceType: "org-secret", key: "s1" },
425
+ ],
426
+ };
427
+ const d = removalDeltaCap(cs)!;
428
+ // 100% secrets beats 75% teams for the headline; teams are still listed.
429
+ expect(d.message).toMatch(/^2 of 2 live org-secret entries \(100%\) would be deleted/);
430
+ expect(d.message).toContain("Also over the cap: 3 of 4 live team entries (75%)");
431
+ });
432
+
433
+ test("opts.managedTotals wins over changeSet counts; per-type wins over pooled", () => {
434
+ const cs: ChangeSet = {
435
+ org: "acme",
436
+ managedCounts: { team: 2 },
437
+ managedCount: 100,
438
+ entries: [{ kind: "delete", resourceType: "team", key: "t0" }],
439
+ };
440
+ // Per-type (2) beats the diluting pooled 100: 1/2 = 50% trips.
441
+ expect(removalDeltaCap(cs)).not.toBeNull();
442
+ // Explicit opts.managedTotals overrides the change set's counts.
443
+ expect(removalDeltaCap(cs, { managedTotals: { team: 10 } })).toBeNull();
444
+ });
445
+
446
+ test("maxFraction outside (0,1] throws instead of silently disabling the cap", () => {
447
+ const cs: ChangeSet = {
448
+ org: "acme",
449
+ entries: [{ kind: "delete", resourceType: "x", key: "a" }],
450
+ };
451
+ for (const bad of [Number.NaN, 0, 1.5, -1]) {
452
+ expect(() => removalDeltaCap(cs, { maxFraction: bad })).toThrow(/maxFraction must be in \(0, 1\]/);
453
+ }
454
+ // The bounds themselves: 1 is allowed (nothing short of a full wipe trips)…
455
+ expect(removalDeltaCap(cs, { maxFraction: 1, managedTotal: 2 })).toBeNull();
456
+ // …and the default 0.25 stays valid explicitly.
457
+ expect(removalDeltaCap(cs, { maxFraction: 0.25, managedTotal: 10 })).toBeNull();
458
+ });
289
459
  });
290
460
 
291
461
  describe("runGuardrailChecks", () => {
package/src/reconcile.ts CHANGED
@@ -72,8 +72,22 @@ export interface ChangeSet {
72
72
  * can sum it here. Optional — when absent, guardrails that want a live
73
73
  * denominator (see {@link removalDeltaCap}) fall back to the plan-relative
74
74
  * one.
75
+ *
76
+ * Superseded by {@link ChangeSet.managedCounts}: a single pooled total lets
77
+ * non-deletable and cross-collection live entries dilute the delete
78
+ * fraction (wiping 3 of 4 teams reads as 12.5% when 20 team members pad
79
+ * the pool). Kept for compatibility — a change set carrying only this
80
+ * total keeps the 0.55.0 pooled behavior.
75
81
  */
76
82
  managedCount?: number;
83
+ /**
84
+ * Live managed-entry counts per resource type, keyed by
85
+ * `ChangeSetEntry.resourceType`. When present, {@link removalDeltaCap}
86
+ * evaluates the delete fraction per resource type instead of against the
87
+ * pooled `managedCount`, so live entries of one type cannot dilute a wipe
88
+ * of another. Supersedes `managedCount`.
89
+ */
90
+ managedCounts?: Readonly<Record<string, number>>;
77
91
  }
78
92
 
79
93
  /** Options controlling diff behaviour. */
@@ -291,29 +305,54 @@ export type GuardrailCheck = (resolved: ChangeSet, ctx?: GuardrailContext) => Gu
291
305
 
292
306
  /** Config for `removalDeltaCap`. */
293
307
  export interface RemovalDeltaCapOptions {
294
- /** Max fraction of pre-existing entries that may be deleted. Must be in (0,1]. Default 0.25. */
308
+ /**
309
+ * Max fraction of pre-existing entries that may be deleted. Must be in
310
+ * (0,1]: `removalDeltaCap` throws an `Error` on any value outside that
311
+ * range (0, negatives, above 1, NaN) rather than silently disabling the
312
+ * cap. Default 0.25.
313
+ */
295
314
  maxFraction?: number;
296
315
  /**
297
- * Live managed-entry denominator (#2067). When positive, deletes are divided
298
- * by this count of live entries under management instead of the plan's
299
- * non-create entries — a converged cycle deleting one stale entry out of
300
- * many no longer reads as 1/1 = 100%. Defaults to `changeSet.managedCount`;
301
- * when neither is present (or not positive) the cap keeps its plan-relative
302
- * behavior.
316
+ * Pooled live managed-entry denominator (#2067). When positive, deletes are
317
+ * divided by this count of live entries under management instead of the
318
+ * plan's non-create entries — a converged cycle deleting one stale entry
319
+ * out of many no longer reads as 1/1 = 100%. Defaults to
320
+ * `changeSet.managedCount`; when neither is present (or not positive) the
321
+ * cap keeps its plan-relative behavior.
322
+ *
323
+ * Superseded by {@link RemovalDeltaCapOptions.managedTotals} — the pooled
324
+ * total lets unrelated live entries dilute the delete fraction.
303
325
  */
304
326
  managedTotal?: number;
327
+ /**
328
+ * Live managed-entry counts per resource type (keys =
329
+ * `ChangeSetEntry.resourceType`), mirroring {@link ChangeSet.managedCounts}
330
+ * and winning over it when both are present. When per-type counts are
331
+ * available from either source, the cap is evaluated per resource type:
332
+ * each type's deletes divide by that type's live count when present and
333
+ * positive, else by that type's own plan non-creates. Supersedes
334
+ * `managedTotal`.
335
+ */
336
+ managedTotals?: Readonly<Record<string, number>>;
305
337
  }
306
338
 
307
339
  /**
308
340
  * Resolve rename aliases. A create entry carrying a `previously` key matching a
309
- * delete entry's key is collapsed into an update, removing the delete. Returns a
310
- * new ChangeSet with renames resolved. Provider-agnostic — works on any entry
311
- * whose `after.previously` is a string.
341
+ * delete entry's key OF THE SAME RESOURCE TYPE is collapsed into an update,
342
+ * removing the delete. Matching across resource types would let e.g. a team
343
+ * create with `previously: "old"` swallow an unrelated member delete keyed
344
+ * "old", hiding a real delete from guardrail counting. Returns a new ChangeSet
345
+ * with renames resolved. Provider-agnostic — works on any entry whose
346
+ * `after.previously` is a string.
312
347
  */
313
348
  export function resolveRenames(changeSet: ChangeSet): ChangeSet {
349
+ // Keys and resource types are both free-form strings, so scope the lookup
350
+ // with an unambiguous composite key ("\u0000" cannot appear in either).
351
+ const typedKey = (resourceType: string, key: string): string => `${resourceType}\u0000${key}`;
352
+
314
353
  const deleteEntries = new Map<string, ChangeSetEntry>();
315
354
  for (const e of changeSet.entries) {
316
- if (e.kind === "delete") deleteEntries.set(e.key, e);
355
+ if (e.kind === "delete") deleteEntries.set(typedKey(e.resourceType, e.key), e);
317
356
  }
318
357
 
319
358
  const resolvedDeletes = new Set<string>();
@@ -327,11 +366,11 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
327
366
  const previously = after["previously"];
328
367
  if (typeof previously !== "string") continue;
329
368
 
330
- const deleted = deleteEntries.get(previously);
369
+ const deleted = deleteEntries.get(typedKey(e.resourceType, previously));
331
370
  if (!deleted) continue;
332
371
 
333
- resolvedDeletes.add(previously);
334
- resolvedCreates.add(e.key);
372
+ resolvedDeletes.add(typedKey(e.resourceType, previously));
373
+ resolvedCreates.add(typedKey(e.resourceType, e.key));
335
374
  syntheticUpdates.push({
336
375
  kind: "update",
337
376
  resourceType: e.resourceType,
@@ -346,8 +385,8 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
346
385
 
347
386
  const filteredEntries = changeSet.entries.filter(
348
387
  (e) =>
349
- !(e.kind === "delete" && resolvedDeletes.has(e.key)) &&
350
- !(e.kind === "create" && resolvedCreates.has(e.key)),
388
+ !(e.kind === "delete" && resolvedDeletes.has(typedKey(e.resourceType, e.key))) &&
389
+ !(e.kind === "create" && resolvedCreates.has(typedKey(e.resourceType, e.key))),
351
390
  );
352
391
 
353
392
  return { ...changeSet, entries: [...filteredEntries, ...syntheticUpdates] };
@@ -358,10 +397,23 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
358
397
  * (deletes + updates; creates excluded so a flood of new entries can't dilute
359
398
  * the delete fraction). Guards against a typo wiping the config in one apply.
360
399
  *
361
- * With a live denominator (`opts.managedTotal`, else `changeSet.managedCount`;
362
- * #2067) deletes are measured against the live entries under management
363
- * instead — so a converged cycle's plan of exactly one stale delete does not
364
- * trip at 1/1 = 100%. Without one, behavior is plan-relative as before.
400
+ * With per-type live counts (`opts.managedTotals`, else
401
+ * `changeSet.managedCounts`) the cap is evaluated PER RESOURCE TYPE: for each
402
+ * type with deletes, that type's deletes divide by that type's live count when
403
+ * present and positive, else by that type's own plan non-creates. Any type
404
+ * over `maxFraction` blocks, and the diagnostic names the worst offender. The
405
+ * pooled denominator this replaces was unsafe — live entries of one type
406
+ * (team members, repos) diluted a wipe of another (teams, org secrets) below
407
+ * the cap.
408
+ *
409
+ * With only a pooled live denominator (`opts.managedTotal`, else
410
+ * `changeSet.managedCount`; #2067) deletes are measured against the pooled
411
+ * live entries under management — so a converged cycle's plan of exactly one
412
+ * stale delete does not trip at 1/1 = 100%. Without either, behavior is
413
+ * plan-relative as before.
414
+ *
415
+ * Throws on a `maxFraction` outside (0,1] (NaN included) — fail closed, never
416
+ * silently disable the cap.
365
417
  *
366
418
  * CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}).
367
419
  */
@@ -370,8 +422,19 @@ export function removalDeltaCap(
370
422
  opts: RemovalDeltaCapOptions = {},
371
423
  ): GuardrailDiagnostic | null {
372
424
  const maxFraction = opts.maxFraction ?? 0.25;
373
- const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
425
+ if (!(maxFraction > 0 && maxFraction <= 1)) {
426
+ // The negated form catches NaN too. A bad cap must never pass silently:
427
+ // maxFraction: 5 would otherwise wave every wipe through.
428
+ throw new Error(
429
+ `removalDeltaCap: maxFraction must be in (0, 1], got ${String(maxFraction)}`,
430
+ );
431
+ }
432
+ const managedTotals = opts.managedTotals ?? changeSet.managedCounts;
433
+ if (managedTotals !== undefined) {
434
+ return removalDeltaCapPerType(changeSet, managedTotals, maxFraction);
435
+ }
374
436
 
437
+ const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
375
438
  const managedTotal = opts.managedTotal ?? changeSet.managedCount;
376
439
  if (managedTotal !== undefined && managedTotal > 0) {
377
440
  const fraction = deletes / managedTotal;
@@ -402,6 +465,73 @@ export function removalDeltaCap(
402
465
  return null;
403
466
  }
404
467
 
468
+ /** One resource type's delete fraction, for the per-type cap evaluation. */
469
+ interface TypeFraction {
470
+ resourceType: string;
471
+ deletes: number;
472
+ total: number;
473
+ fraction: number;
474
+ /** True when `total` is a live count; false for the plan-relative fallback. */
475
+ live: boolean;
476
+ }
477
+
478
+ /**
479
+ * Per-resource-type evaluation for {@link removalDeltaCap}. Each type with
480
+ * deletes gets its own denominator: the type's live count from `managedTotals`
481
+ * when present and positive, else that type's plan non-creates (the same
482
+ * plan-relative rule as the typeless cap, scoped to the type). Blocks when ANY
483
+ * type exceeds `maxFraction`; the message leads with the worst offender and
484
+ * lists the rest.
485
+ */
486
+ function removalDeltaCapPerType(
487
+ changeSet: ChangeSet,
488
+ managedTotals: Readonly<Record<string, number>>,
489
+ maxFraction: number,
490
+ ): GuardrailDiagnostic | null {
491
+ const deletesByType = new Map<string, number>();
492
+ const nonCreatesByType = new Map<string, number>();
493
+ for (const e of changeSet.entries) {
494
+ if (e.kind === "create") continue;
495
+ nonCreatesByType.set(e.resourceType, (nonCreatesByType.get(e.resourceType) ?? 0) + 1);
496
+ if (e.kind === "delete") {
497
+ deletesByType.set(e.resourceType, (deletesByType.get(e.resourceType) ?? 0) + 1);
498
+ }
499
+ }
500
+
501
+ const tripping: TypeFraction[] = [];
502
+ for (const [resourceType, deletes] of deletesByType) {
503
+ const liveCount = managedTotals[resourceType];
504
+ // A zero (or negative) live count means "no live info for this type",
505
+ // never a looser or divide-by-zero cap — same rule as the pooled total.
506
+ const live = liveCount !== undefined && liveCount > 0;
507
+ const total = live ? (liveCount as number) : nonCreatesByType.get(resourceType)!;
508
+ const fraction = deletes / total;
509
+ if (fraction > maxFraction) tripping.push({ resourceType, deletes, total, fraction, live });
510
+ }
511
+
512
+ if (tripping.length === 0) return null;
513
+
514
+ // Worst offender first; deterministic tie-break by delete count then name.
515
+ tripping.sort(
516
+ (a, b) =>
517
+ b.fraction - a.fraction ||
518
+ b.deletes - a.deletes ||
519
+ a.resourceType.localeCompare(b.resourceType),
520
+ );
521
+ const [worst, ...rest] = tripping as [TypeFraction, ...TypeFraction[]];
522
+ const pct = (f: number): number => Math.round(f * 100);
523
+ const describe = (t: TypeFraction): string =>
524
+ `${t.deletes} of ${t.total} ${t.live ? "live" : "planned"} ${t.resourceType} entries (${pct(t.fraction)}%)`;
525
+
526
+ return {
527
+ guardrail: "removalDeltaCap",
528
+ message:
529
+ `${describe(worst)} would be deleted, exceeding the ${pct(maxFraction)}% threshold. ` +
530
+ (rest.length > 0 ? `Also over the cap: ${rest.map(describe).join(", ")}. ` : "") +
531
+ `Check for typos in config or raise maxFraction to proceed.`,
532
+ };
533
+ }
534
+
405
535
  /**
406
536
  * Run a set of guardrail checks against a change set. Resolves renames ONCE,
407
537
  * then runs every check on the resolved set, aggregating any diagnostics. The