@intentius/chant 0.8.2 → 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.
@@ -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
+ });
@@ -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
+ }