@intentius/chant 0.54.3 → 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.
- package/dist/cli/commands/migrate.d.ts +11 -0
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/reconcile.d.ts +92 -8
- package/dist/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/migrate.ts +3 -2
- package/src/reconcile.test.ts +270 -0
- package/src/reconcile.ts +206 -17
|
@@ -46,6 +46,17 @@ interface ValidatorResult {
|
|
|
46
46
|
* Exported for testability.
|
|
47
47
|
*/
|
|
48
48
|
export declare function tryValidateExternal(yamlText: string): ValidatorResult;
|
|
49
|
+
/**
|
|
50
|
+
* Format the migration report as Markdown, mirroring the output format
|
|
51
|
+
* prescribed by the upstream gitlab-org/ci-cd/github-actions-to-gitlab-ci
|
|
52
|
+
* skill: overview, classification, diagnostic table, aggregated manual
|
|
53
|
+
* setup steps, suggested GitLab improvements, caveats.
|
|
54
|
+
*
|
|
55
|
+
* The same data backs the SARIF report (via formatSarif); this is the
|
|
56
|
+
* human-readable surface. Exported so downstream migrate consumers can render
|
|
57
|
+
* the same report without reimplementing it (#2066).
|
|
58
|
+
*/
|
|
59
|
+
export declare function formatMarkdownSummary(provenance: Array<Record<string, unknown>>, diagnostics: Array<Record<string, unknown>>, sourceContent?: string): string;
|
|
49
60
|
export declare function printMigrateResult(result: MigrateCliResult): void;
|
|
50
61
|
export {};
|
|
51
62
|
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAGnD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC5C,4DAA4D;IAC5D,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3C,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAsJpF;AAED,UAAU,eAAe;IACvB,GAAG,EAAE,OAAO,CAAC;IACb,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAUrE;
|
|
1
|
+
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAGnD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC5C,4DAA4D;IAC5D,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3C,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAsJpF;AAED,UAAU,eAAe;IACvB,GAAG,EAAE,OAAO,CAAC;IACb,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAUrE;AAqBD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC1C,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC3C,aAAa,CAAC,EAAE,MAAM,GACrB,MAAM,CAkER;AAyID,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAajE"}
|
package/dist/reconcile.d.ts
CHANGED
|
@@ -57,6 +57,28 @@ export interface ChangeSet {
|
|
|
57
57
|
org: string;
|
|
58
58
|
/** All proposed changes, in stable order. */
|
|
59
59
|
entries: ChangeSetEntry[];
|
|
60
|
+
/**
|
|
61
|
+
* Live entries under management across the diffed collections (#2067).
|
|
62
|
+
* `diffCollection` returns each collection's live count so the diff layer
|
|
63
|
+
* can sum it here. Optional — when absent, guardrails that want a live
|
|
64
|
+
* denominator (see {@link removalDeltaCap}) fall back to the plan-relative
|
|
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.
|
|
72
|
+
*/
|
|
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>>;
|
|
60
82
|
}
|
|
61
83
|
/** Options controlling diff behaviour. */
|
|
62
84
|
export interface DiffOptions {
|
|
@@ -105,8 +127,11 @@ export interface DiffCollectionParams<D, L> {
|
|
|
105
127
|
* when `compareFields` reports differences, and ownership-gated deletes for
|
|
106
128
|
* live-not-desired. This is the selective-by-omission + ownership-gated-delete
|
|
107
129
|
* pattern shared by every keyed-collection diff.
|
|
130
|
+
*
|
|
131
|
+
* Returns the live entry count for this collection, so the diff layer can sum
|
|
132
|
+
* `ChangeSet.managedCount` across the collections it diffs (#2067).
|
|
108
133
|
*/
|
|
109
|
-
export declare function diffCollection<D, L>(params: DiffCollectionParams<D, L>):
|
|
134
|
+
export declare function diffCollection<D, L>(params: DiffCollectionParams<D, L>): number;
|
|
110
135
|
/** Count entries per change kind. */
|
|
111
136
|
export declare function summarizeChangeSet(cs: ChangeSet): Record<ChangeKind, number>;
|
|
112
137
|
/** Human-readable plan summary for dry-run output. Pure. */
|
|
@@ -125,18 +150,57 @@ export type GuardrailResult = {
|
|
|
125
150
|
ok: false;
|
|
126
151
|
diagnostics: GuardrailDiagnostic[];
|
|
127
152
|
};
|
|
128
|
-
/**
|
|
129
|
-
export
|
|
153
|
+
/** Live context threaded to guardrail checks by `runGuardrailChecks` (#2067). */
|
|
154
|
+
export interface GuardrailContext {
|
|
155
|
+
/** Live entries under management this cycle (see `ChangeSet.managedCount`). */
|
|
156
|
+
managedCount?: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A guardrail check over a (rename-resolved) change set. Returns null when it
|
|
160
|
+
* passes. `ctx` carries live context when the caller has it (#2067); a check
|
|
161
|
+
* written against the single-argument shape keeps working unchanged.
|
|
162
|
+
*/
|
|
163
|
+
export type GuardrailCheck = (resolved: ChangeSet, ctx?: GuardrailContext) => GuardrailDiagnostic | null;
|
|
130
164
|
/** Config for `removalDeltaCap`. */
|
|
131
165
|
export interface RemovalDeltaCapOptions {
|
|
132
|
-
/**
|
|
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
|
+
*/
|
|
133
172
|
maxFraction?: number;
|
|
173
|
+
/**
|
|
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.
|
|
183
|
+
*/
|
|
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>>;
|
|
134
195
|
}
|
|
135
196
|
/**
|
|
136
197
|
* Resolve rename aliases. A create entry carrying a `previously` key matching a
|
|
137
|
-
* delete entry's key is collapsed into an update,
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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.
|
|
140
204
|
*/
|
|
141
205
|
export declare function resolveRenames(changeSet: ChangeSet): ChangeSet;
|
|
142
206
|
/**
|
|
@@ -144,6 +208,24 @@ export declare function resolveRenames(changeSet: ChangeSet): ChangeSet;
|
|
|
144
208
|
* (deletes + updates; creates excluded so a flood of new entries can't dilute
|
|
145
209
|
* the delete fraction). Guards against a typo wiping the config in one apply.
|
|
146
210
|
*
|
|
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.
|
|
228
|
+
*
|
|
147
229
|
* CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}).
|
|
148
230
|
*/
|
|
149
231
|
export declare function removalDeltaCap(changeSet: ChangeSet, opts?: RemovalDeltaCapOptions): GuardrailDiagnostic | null;
|
|
@@ -151,8 +233,10 @@ export declare function removalDeltaCap(changeSet: ChangeSet, opts?: RemovalDelt
|
|
|
151
233
|
* Run a set of guardrail checks against a change set. Resolves renames ONCE,
|
|
152
234
|
* then runs every check on the resolved set, aggregating any diagnostics. The
|
|
153
235
|
* caller composes provider-specific checks (e.g. an admin floor) as closures.
|
|
236
|
+
* `ctx` (optional, #2067) threads live context — e.g. the managed-live count —
|
|
237
|
+
* to every check; single-argument checks ignore it.
|
|
154
238
|
*/
|
|
155
|
-
export declare function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[]): GuardrailResult;
|
|
239
|
+
export declare function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[], ctx?: GuardrailContext): GuardrailResult;
|
|
156
240
|
/** Controls how a cycle tracks its API usage against a shared request budget. */
|
|
157
241
|
export interface RateBudget {
|
|
158
242
|
/** Remaining request capacity for this run. */
|
package/dist/reconcile.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../src/reconcile.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -251,9 +251,10 @@ async function loadMigrationRules(targetLexicon: string): Promise<LintRule[]> {
|
|
|
251
251
|
* setup steps, suggested GitLab improvements, caveats.
|
|
252
252
|
*
|
|
253
253
|
* The same data backs the SARIF report (via formatSarif); this is the
|
|
254
|
-
* human-readable surface.
|
|
254
|
+
* human-readable surface. Exported so downstream migrate consumers can render
|
|
255
|
+
* the same report without reimplementing it (#2066).
|
|
255
256
|
*/
|
|
256
|
-
function formatMarkdownSummary(
|
|
257
|
+
export function formatMarkdownSummary(
|
|
257
258
|
provenance: Array<Record<string, unknown>>,
|
|
258
259
|
diagnostics: Array<Record<string, unknown>>,
|
|
259
260
|
sourceContent?: string,
|
package/src/reconcile.test.ts
CHANGED
|
@@ -102,6 +102,23 @@ describe("diffCollection", () => {
|
|
|
102
102
|
]);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("returns the live entry count for managedCount accumulation (#2067)", () => {
|
|
106
|
+
const out: ChangeSetEntry[] = [];
|
|
107
|
+
const count = diffCollection<D, L>({
|
|
108
|
+
resourceType: "thing",
|
|
109
|
+
desired: new Map([["a", { name: "a", v: 1 }]]),
|
|
110
|
+
live: new Map([
|
|
111
|
+
["a", { name: "a", v: 1 }],
|
|
112
|
+
["b", { name: "b", v: 2 }],
|
|
113
|
+
["c", { name: "c", v: 3 }],
|
|
114
|
+
]),
|
|
115
|
+
compareFields: () => [],
|
|
116
|
+
opts: noOpts,
|
|
117
|
+
out,
|
|
118
|
+
});
|
|
119
|
+
expect(count).toBe(3);
|
|
120
|
+
});
|
|
121
|
+
|
|
105
122
|
test("honours createAfter / updateAfter mappers", () => {
|
|
106
123
|
const out: ChangeSetEntry[] = [];
|
|
107
124
|
diffCollection<D, L>({
|
|
@@ -171,6 +188,53 @@ describe("resolveRenames", () => {
|
|
|
171
188
|
const cs: ChangeSet = { org: "acme", entries: [{ kind: "delete", resourceType: "team", key: "old" }] };
|
|
172
189
|
expect(resolveRenames(cs)).toBe(cs);
|
|
173
190
|
});
|
|
191
|
+
|
|
192
|
+
test("preserves managedCount and managedCounts on the resolved set", () => {
|
|
193
|
+
const cs: ChangeSet = {
|
|
194
|
+
org: "acme",
|
|
195
|
+
managedCount: 7,
|
|
196
|
+
managedCounts: { team: 4, member: 3 },
|
|
197
|
+
entries: [
|
|
198
|
+
{ kind: "delete", resourceType: "team", key: "old", before: { slug: "old" } },
|
|
199
|
+
{ kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
|
|
200
|
+
],
|
|
201
|
+
};
|
|
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" });
|
|
237
|
+
});
|
|
174
238
|
});
|
|
175
239
|
|
|
176
240
|
describe("removalDeltaCap", () => {
|
|
@@ -199,6 +263,199 @@ describe("removalDeltaCap", () => {
|
|
|
199
263
|
};
|
|
200
264
|
expect(removalDeltaCap(cs)).toBeNull(); // 1/4 = 25%, not > 25%
|
|
201
265
|
});
|
|
266
|
+
|
|
267
|
+
// Live-denominator mode (#2067) — the warden reference cases.
|
|
268
|
+
|
|
269
|
+
test("one stale delete of 10 live managed entries passes at the default cap", () => {
|
|
270
|
+
const cs: ChangeSet = {
|
|
271
|
+
org: "acme",
|
|
272
|
+
entries: [{ kind: "delete", resourceType: "x", key: "stale" }],
|
|
273
|
+
};
|
|
274
|
+
// Plan-relative this is 1/1 = 100% and trips; against the live count it
|
|
275
|
+
// is 1/10 = 10% and passes — the routine converged-cycle cleanup.
|
|
276
|
+
expect(removalDeltaCap(cs)).not.toBeNull();
|
|
277
|
+
expect(removalDeltaCap(cs, { managedTotal: 10 })).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("4 deletes of 10 live managed entries blocks, with the live-mode message", () => {
|
|
281
|
+
const cs: ChangeSet = {
|
|
282
|
+
org: "acme",
|
|
283
|
+
entries: Array.from({ length: 4 }, (_, i) => ({
|
|
284
|
+
kind: "delete" as const,
|
|
285
|
+
resourceType: "x",
|
|
286
|
+
key: `k${i}`,
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
const d = removalDeltaCap(cs, { managedTotal: 10 })!;
|
|
290
|
+
expect(d.guardrail).toBe("removalDeltaCap");
|
|
291
|
+
expect(d.message).toContain("4 of 10 live managed entries (40%)");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("reads changeSet.managedCount when opts.managedTotal is absent", () => {
|
|
295
|
+
const cs: ChangeSet = {
|
|
296
|
+
org: "acme",
|
|
297
|
+
managedCount: 10,
|
|
298
|
+
entries: [{ kind: "delete", resourceType: "x", key: "stale" }],
|
|
299
|
+
};
|
|
300
|
+
expect(removalDeltaCap(cs)).toBeNull(); // 1/10 via the change set's own count
|
|
301
|
+
// An explicit managedTotal wins over the change set's count.
|
|
302
|
+
expect(removalDeltaCap({ ...cs, managedCount: 2 }, { managedTotal: 10 })).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("without managed info the diagnostic deep-equals the plan-relative one", () => {
|
|
306
|
+
const cs: ChangeSet = {
|
|
307
|
+
org: "acme",
|
|
308
|
+
entries: [
|
|
309
|
+
{ kind: "delete", resourceType: "x", key: "a" },
|
|
310
|
+
{ kind: "delete", resourceType: "x", key: "b" },
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
const expected = {
|
|
314
|
+
guardrail: "removalDeltaCap",
|
|
315
|
+
message:
|
|
316
|
+
"2 of 2 managed entries (100%) would be deleted, exceeding the 25% threshold. " +
|
|
317
|
+
"Check for typos in config or raise maxFraction to proceed.",
|
|
318
|
+
};
|
|
319
|
+
expect(removalDeltaCap(cs)).toEqual(expected);
|
|
320
|
+
// A zero live count means "no live info" — it never divides by zero and
|
|
321
|
+
// never loosens the cap; behavior stays exactly plan-relative.
|
|
322
|
+
expect(removalDeltaCap(cs, { managedTotal: 0 })).toEqual(expected);
|
|
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
|
+
});
|
|
202
459
|
});
|
|
203
460
|
|
|
204
461
|
describe("runGuardrailChecks", () => {
|
|
@@ -221,6 +478,19 @@ describe("runGuardrailChecks", () => {
|
|
|
221
478
|
const cs: ChangeSet = { org: "acme", entries: [{ kind: "create", resourceType: "x", key: "a" }] };
|
|
222
479
|
expect(runGuardrailChecks(cs, [() => null])).toEqual({ ok: true });
|
|
223
480
|
});
|
|
481
|
+
|
|
482
|
+
test("threads live context to checks that take it (#2067)", () => {
|
|
483
|
+
const cs: ChangeSet = {
|
|
484
|
+
org: "acme",
|
|
485
|
+
entries: [{ kind: "delete", resourceType: "x", key: "stale" }],
|
|
486
|
+
};
|
|
487
|
+
const check: GuardrailCheck = (resolved, ctx) =>
|
|
488
|
+
removalDeltaCap(resolved, { managedTotal: ctx?.managedCount });
|
|
489
|
+
// Without ctx the check falls back to plan-relative and trips (1/1).
|
|
490
|
+
expect(runGuardrailChecks(cs, [check]).ok).toBe(false);
|
|
491
|
+
// With the live count threaded through, 1/10 passes.
|
|
492
|
+
expect(runGuardrailChecks(cs, [check], { managedCount: 10 })).toEqual({ ok: true });
|
|
493
|
+
});
|
|
224
494
|
});
|
|
225
495
|
|
|
226
496
|
// ---------------------------------------------------------------------------
|
package/src/reconcile.ts
CHANGED
|
@@ -66,6 +66,28 @@ export interface ChangeSet {
|
|
|
66
66
|
org: string;
|
|
67
67
|
/** All proposed changes, in stable order. */
|
|
68
68
|
entries: ChangeSetEntry[];
|
|
69
|
+
/**
|
|
70
|
+
* Live entries under management across the diffed collections (#2067).
|
|
71
|
+
* `diffCollection` returns each collection's live count so the diff layer
|
|
72
|
+
* can sum it here. Optional — when absent, guardrails that want a live
|
|
73
|
+
* denominator (see {@link removalDeltaCap}) fall back to the plan-relative
|
|
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.
|
|
81
|
+
*/
|
|
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>>;
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
/** Options controlling diff behaviour. */
|
|
@@ -147,8 +169,11 @@ export interface DiffCollectionParams<D, L> {
|
|
|
147
169
|
* when `compareFields` reports differences, and ownership-gated deletes for
|
|
148
170
|
* live-not-desired. This is the selective-by-omission + ownership-gated-delete
|
|
149
171
|
* pattern shared by every keyed-collection diff.
|
|
172
|
+
*
|
|
173
|
+
* Returns the live entry count for this collection, so the diff layer can sum
|
|
174
|
+
* `ChangeSet.managedCount` across the collections it diffs (#2067).
|
|
150
175
|
*/
|
|
151
|
-
export function diffCollection<D, L>(params: DiffCollectionParams<D, L>):
|
|
176
|
+
export function diffCollection<D, L>(params: DiffCollectionParams<D, L>): number {
|
|
152
177
|
const {
|
|
153
178
|
resourceType,
|
|
154
179
|
keyPrefix = "",
|
|
@@ -193,6 +218,8 @@ export function diffCollection<D, L>(params: DiffCollectionParams<D, L>): void {
|
|
|
193
218
|
out.push({ kind: "delete", resourceType, key: entryKey, before: l });
|
|
194
219
|
}
|
|
195
220
|
}
|
|
221
|
+
|
|
222
|
+
return live.size;
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
// ---------------------------------------------------------------------------
|
|
@@ -263,25 +290,69 @@ export interface GuardrailDiagnostic {
|
|
|
263
290
|
/** Aggregated guardrail result. */
|
|
264
291
|
export type GuardrailResult = { ok: true } | { ok: false; diagnostics: GuardrailDiagnostic[] };
|
|
265
292
|
|
|
266
|
-
/**
|
|
267
|
-
export
|
|
293
|
+
/** Live context threaded to guardrail checks by `runGuardrailChecks` (#2067). */
|
|
294
|
+
export interface GuardrailContext {
|
|
295
|
+
/** Live entries under management this cycle (see `ChangeSet.managedCount`). */
|
|
296
|
+
managedCount?: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A guardrail check over a (rename-resolved) change set. Returns null when it
|
|
301
|
+
* passes. `ctx` carries live context when the caller has it (#2067); a check
|
|
302
|
+
* written against the single-argument shape keeps working unchanged.
|
|
303
|
+
*/
|
|
304
|
+
export type GuardrailCheck = (resolved: ChangeSet, ctx?: GuardrailContext) => GuardrailDiagnostic | null;
|
|
268
305
|
|
|
269
306
|
/** Config for `removalDeltaCap`. */
|
|
270
307
|
export interface RemovalDeltaCapOptions {
|
|
271
|
-
/**
|
|
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
|
+
*/
|
|
272
314
|
maxFraction?: number;
|
|
315
|
+
/**
|
|
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.
|
|
325
|
+
*/
|
|
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>>;
|
|
273
337
|
}
|
|
274
338
|
|
|
275
339
|
/**
|
|
276
340
|
* Resolve rename aliases. A create entry carrying a `previously` key matching a
|
|
277
|
-
* delete entry's key is collapsed into an update,
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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.
|
|
280
347
|
*/
|
|
281
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
|
+
|
|
282
353
|
const deleteEntries = new Map<string, ChangeSetEntry>();
|
|
283
354
|
for (const e of changeSet.entries) {
|
|
284
|
-
if (e.kind === "delete") deleteEntries.set(e.key, e);
|
|
355
|
+
if (e.kind === "delete") deleteEntries.set(typedKey(e.resourceType, e.key), e);
|
|
285
356
|
}
|
|
286
357
|
|
|
287
358
|
const resolvedDeletes = new Set<string>();
|
|
@@ -295,11 +366,11 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
|
|
|
295
366
|
const previously = after["previously"];
|
|
296
367
|
if (typeof previously !== "string") continue;
|
|
297
368
|
|
|
298
|
-
const deleted = deleteEntries.get(previously);
|
|
369
|
+
const deleted = deleteEntries.get(typedKey(e.resourceType, previously));
|
|
299
370
|
if (!deleted) continue;
|
|
300
371
|
|
|
301
|
-
resolvedDeletes.add(previously);
|
|
302
|
-
resolvedCreates.add(e.key);
|
|
372
|
+
resolvedDeletes.add(typedKey(e.resourceType, previously));
|
|
373
|
+
resolvedCreates.add(typedKey(e.resourceType, e.key));
|
|
303
374
|
syntheticUpdates.push({
|
|
304
375
|
kind: "update",
|
|
305
376
|
resourceType: e.resourceType,
|
|
@@ -314,11 +385,11 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
|
|
|
314
385
|
|
|
315
386
|
const filteredEntries = changeSet.entries.filter(
|
|
316
387
|
(e) =>
|
|
317
|
-
!(e.kind === "delete" && resolvedDeletes.has(e.key)) &&
|
|
318
|
-
!(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))),
|
|
319
390
|
);
|
|
320
391
|
|
|
321
|
-
return {
|
|
392
|
+
return { ...changeSet, entries: [...filteredEntries, ...syntheticUpdates] };
|
|
322
393
|
}
|
|
323
394
|
|
|
324
395
|
/**
|
|
@@ -326,6 +397,24 @@ export function resolveRenames(changeSet: ChangeSet): ChangeSet {
|
|
|
326
397
|
* (deletes + updates; creates excluded so a flood of new entries can't dilute
|
|
327
398
|
* the delete fraction). Guards against a typo wiping the config in one apply.
|
|
328
399
|
*
|
|
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.
|
|
417
|
+
*
|
|
329
418
|
* CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}).
|
|
330
419
|
*/
|
|
331
420
|
export function removalDeltaCap(
|
|
@@ -333,9 +422,36 @@ export function removalDeltaCap(
|
|
|
333
422
|
opts: RemovalDeltaCapOptions = {},
|
|
334
423
|
): GuardrailDiagnostic | null {
|
|
335
424
|
const maxFraction = opts.maxFraction ?? 0.25;
|
|
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
|
+
}
|
|
436
|
+
|
|
437
|
+
const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
|
|
438
|
+
const managedTotal = opts.managedTotal ?? changeSet.managedCount;
|
|
439
|
+
if (managedTotal !== undefined && managedTotal > 0) {
|
|
440
|
+
const fraction = deletes / managedTotal;
|
|
441
|
+
if (fraction > maxFraction) {
|
|
442
|
+
return {
|
|
443
|
+
guardrail: "removalDeltaCap",
|
|
444
|
+
message:
|
|
445
|
+
`${deletes} of ${managedTotal} live managed entries (${Math.round(fraction * 100)}%) would be deleted, ` +
|
|
446
|
+
`exceeding the ${Math.round(maxFraction * 100)}% threshold. ` +
|
|
447
|
+
`Check for typos in config or raise maxFraction to proceed.`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
336
453
|
const total = changeSet.entries.filter((e) => e.kind !== "create").length;
|
|
337
454
|
if (total === 0) return null;
|
|
338
|
-
const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
|
|
339
455
|
const fraction = deletes / total;
|
|
340
456
|
if (fraction > maxFraction) {
|
|
341
457
|
return {
|
|
@@ -349,16 +465,89 @@ export function removalDeltaCap(
|
|
|
349
465
|
return null;
|
|
350
466
|
}
|
|
351
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
|
+
|
|
352
535
|
/**
|
|
353
536
|
* Run a set of guardrail checks against a change set. Resolves renames ONCE,
|
|
354
537
|
* then runs every check on the resolved set, aggregating any diagnostics. The
|
|
355
538
|
* caller composes provider-specific checks (e.g. an admin floor) as closures.
|
|
539
|
+
* `ctx` (optional, #2067) threads live context — e.g. the managed-live count —
|
|
540
|
+
* to every check; single-argument checks ignore it.
|
|
356
541
|
*/
|
|
357
|
-
export function runGuardrailChecks(
|
|
542
|
+
export function runGuardrailChecks(
|
|
543
|
+
changeSet: ChangeSet,
|
|
544
|
+
checks: GuardrailCheck[],
|
|
545
|
+
ctx?: GuardrailContext,
|
|
546
|
+
): GuardrailResult {
|
|
358
547
|
const resolved = resolveRenames(changeSet);
|
|
359
548
|
const diagnostics: GuardrailDiagnostic[] = [];
|
|
360
549
|
for (const check of checks) {
|
|
361
|
-
const d = check(resolved);
|
|
550
|
+
const d = check(resolved, ctx);
|
|
362
551
|
if (d) diagnostics.push(d);
|
|
363
552
|
}
|
|
364
553
|
return diagnostics.length > 0 ? { ok: false, diagnostics } : { ok: true };
|