@checkstack/gitops-backend 0.1.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,481 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { sortEntitiesByDependency, type CollectedEntity } from "./sort-entities";
3
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
4
+
5
+ function makeEntity(
6
+ kind: string,
7
+ name: string,
8
+ spec: Record<string, unknown> = {},
9
+ ): CollectedEntity {
10
+ return {
11
+ entity: {
12
+ apiVersion: CHECKSTACK_API_VERSION,
13
+ kind,
14
+ metadata: { name },
15
+ spec,
16
+ },
17
+ contentHash: `hash-${kind}-${name}`,
18
+ file: {
19
+ repository: "test/repo",
20
+ filePath: `${name}.yaml`,
21
+ content: "",
22
+ branch: "main",
23
+ },
24
+ };
25
+ }
26
+
27
+ describe("sortEntitiesByDependency", () => {
28
+ it("returns independent entities in discovery order", () => {
29
+ const entities = [
30
+ makeEntity("System", "alpha"),
31
+ makeEntity("System", "beta"),
32
+ makeEntity("System", "gamma"),
33
+ ];
34
+
35
+ const sorted = sortEntitiesByDependency({ entities });
36
+ expect(sorted.map((e) => e.entity.metadata.name)).toEqual([
37
+ "alpha",
38
+ "beta",
39
+ "gamma",
40
+ ]);
41
+ });
42
+
43
+ it("sorts a dependent entity after its dependency", () => {
44
+ // System references Healthcheck — should come after it
45
+ const system = makeEntity("System", "payment-service", {
46
+ healthchecks: [
47
+ { ref: { kind: "Healthcheck", name: "db-check" } },
48
+ ],
49
+ });
50
+ const healthcheck = makeEntity("Healthcheck", "db-check");
51
+
52
+ // Discovered in wrong order: System first
53
+ const sorted = sortEntitiesByDependency({
54
+ entities: [system, healthcheck],
55
+ });
56
+
57
+ expect(sorted.map((e) => e.entity.metadata.name)).toEqual([
58
+ "db-check",
59
+ "payment-service",
60
+ ]);
61
+ });
62
+
63
+ it("handles a chain of dependencies (A → B → C)", () => {
64
+ const a = makeEntity("Kind-A", "a", {
65
+ dep: { kind: "Kind-B", name: "b" },
66
+ });
67
+ const b = makeEntity("Kind-B", "b", {
68
+ dep: { kind: "Kind-C", name: "c" },
69
+ });
70
+ const c = makeEntity("Kind-C", "c");
71
+
72
+ const sorted = sortEntitiesByDependency({
73
+ entities: [a, b, c],
74
+ });
75
+
76
+ expect(sorted.map((e) => e.entity.metadata.name)).toEqual(["c", "b", "a"]);
77
+ });
78
+
79
+ it("ignores refs to entities not in the collected set", () => {
80
+ const system = makeEntity("System", "my-sys", {
81
+ ext: { kind: "Healthcheck", name: "external-check" },
82
+ });
83
+ const other = makeEntity("System", "other-sys");
84
+
85
+ // "external-check" doesn't exist — should not cause error
86
+ const sorted = sortEntitiesByDependency({
87
+ entities: [system, other],
88
+ });
89
+
90
+ expect(sorted.map((e) => e.entity.metadata.name)).toEqual([
91
+ "my-sys",
92
+ "other-sys",
93
+ ]);
94
+ });
95
+
96
+ it("throws on dependency cycles", () => {
97
+ const a = makeEntity("Kind-A", "a", {
98
+ dep: { kind: "Kind-B", name: "b" },
99
+ });
100
+ const b = makeEntity("Kind-B", "b", {
101
+ dep: { kind: "Kind-A", name: "a" },
102
+ });
103
+
104
+ expect(() =>
105
+ sortEntitiesByDependency({ entities: [a, b] }),
106
+ ).toThrow(/cycle/i);
107
+ });
108
+
109
+ it("maintains discovery order for entities at the same depth", () => {
110
+ // Three healthchecks (no deps), then a system that depends on one of them
111
+ const hc1 = makeEntity("Healthcheck", "hc-alpha");
112
+ const hc2 = makeEntity("Healthcheck", "hc-beta");
113
+ const hc3 = makeEntity("Healthcheck", "hc-gamma");
114
+ const sys = makeEntity("System", "my-sys", {
115
+ checks: [{ ref: { kind: "Healthcheck", name: "hc-beta" } }],
116
+ });
117
+
118
+ const sorted = sortEntitiesByDependency({
119
+ entities: [sys, hc1, hc2, hc3],
120
+ });
121
+
122
+ // All HCs should come before sys, in their original relative order
123
+ const names = sorted.map((e) => e.entity.metadata.name);
124
+ expect(names.indexOf("hc-alpha")).toBeLessThan(names.indexOf("my-sys"));
125
+ expect(names.indexOf("hc-beta")).toBeLessThan(names.indexOf("my-sys"));
126
+ expect(names.indexOf("hc-gamma")).toBeLessThan(names.indexOf("my-sys"));
127
+ // HCs relative order preserved
128
+ expect(names.indexOf("hc-alpha")).toBeLessThan(names.indexOf("hc-beta"));
129
+ expect(names.indexOf("hc-beta")).toBeLessThan(names.indexOf("hc-gamma"));
130
+ });
131
+
132
+ it("does not treat self-references as dependencies", () => {
133
+ // An entity referencing itself should not create a cycle
134
+ const entity = makeEntity("System", "self-referencing", {
135
+ self: { kind: "System", name: "self-referencing" },
136
+ });
137
+
138
+ const sorted = sortEntitiesByDependency({ entities: [entity] });
139
+ expect(sorted).toHaveLength(1);
140
+ expect(sorted[0].entity.metadata.name).toBe("self-referencing");
141
+ });
142
+ });
143
+
144
+ // ─── YAML Edge Case Integration Tests ──────────────────────────────────────
145
+
146
+ /**
147
+ * These tests simulate the full pipeline: YAML → parse → collect → sort.
148
+ * Each test builds CollectedEntity arrays from realistic YAML content
149
+ * to verify dependency ordering across real-world scenarios.
150
+ */
151
+
152
+ describe("sortEntitiesByDependency — YAML edge cases", () => {
153
+ /**
154
+ * Scenario: A single multi-document YAML file contains both a System and
155
+ * the Healthcheck it references, with the System listed first.
156
+ * The sorter must reorder them so the Healthcheck comes first.
157
+ */
158
+ it("reorders entities within a single multi-doc YAML file", () => {
159
+ // System defined first in the file, references a Healthcheck defined second
160
+ const system = makeEntity("System", "payment-service", {
161
+ description: "Payment processing",
162
+ healthchecks: [
163
+ { ref: { kind: "Healthcheck", name: "payment-db-check" } },
164
+ ],
165
+ });
166
+ const healthcheck = makeEntity("Healthcheck", "payment-db-check", {
167
+ strategy: "postgres",
168
+ intervalSeconds: 30,
169
+ config: { host: "db.internal" },
170
+ });
171
+
172
+ // Both from the same file, System discovered first
173
+ system.file = { repository: "org/infra", filePath: ".checkstack/payment.yaml", content: "", branch: "main" };
174
+ healthcheck.file = { repository: "org/infra", filePath: ".checkstack/payment.yaml", content: "", branch: "main" };
175
+
176
+ const sorted = sortEntitiesByDependency({
177
+ entities: [system, healthcheck],
178
+ });
179
+
180
+ const names = sorted.map((e) => e.entity.metadata.name);
181
+ expect(names).toEqual(["payment-db-check", "payment-service"]);
182
+ });
183
+
184
+ /**
185
+ * Scenario: A System references multiple Healthchecks from different files.
186
+ * All Healthchecks must come before the System.
187
+ */
188
+ it("handles a System with multiple cross-file Healthcheck refs", () => {
189
+ const sys = makeEntity("System", "api-gateway", {
190
+ healthchecks: [
191
+ { ref: { kind: "Healthcheck", name: "http-check" } },
192
+ { ref: { kind: "Healthcheck", name: "dns-check" } },
193
+ { ref: { kind: "Healthcheck", name: "tls-check" } },
194
+ ],
195
+ });
196
+ const hcHttp = makeEntity("Healthcheck", "http-check", {
197
+ strategy: "http",
198
+ intervalSeconds: 15,
199
+ config: { url: "https://api.example.com/health" },
200
+ });
201
+ const hcDns = makeEntity("Healthcheck", "dns-check", {
202
+ strategy: "dns",
203
+ intervalSeconds: 60,
204
+ config: { hostname: "api.example.com" },
205
+ });
206
+ const hcTls = makeEntity("Healthcheck", "tls-check", {
207
+ strategy: "tls",
208
+ intervalSeconds: 300,
209
+ config: { host: "api.example.com", port: 443 },
210
+ });
211
+
212
+ // Discovered: System first, then HCs in arbitrary order
213
+ const sorted = sortEntitiesByDependency({
214
+ entities: [sys, hcTls, hcHttp, hcDns],
215
+ });
216
+
217
+ const names = sorted.map((e) => e.entity.metadata.name);
218
+ // All HCs must come before System
219
+ expect(names.indexOf("http-check")).toBeLessThan(names.indexOf("api-gateway"));
220
+ expect(names.indexOf("dns-check")).toBeLessThan(names.indexOf("api-gateway"));
221
+ expect(names.indexOf("tls-check")).toBeLessThan(names.indexOf("api-gateway"));
222
+ });
223
+
224
+ /**
225
+ * Scenario: Multiple Systems reference the same Healthcheck.
226
+ * The shared Healthcheck must come before both Systems.
227
+ */
228
+ it("handles a shared Healthcheck referenced by multiple Systems", () => {
229
+ const sharedHc = makeEntity("Healthcheck", "shared-db-check", {
230
+ strategy: "postgres",
231
+ intervalSeconds: 30,
232
+ config: { host: "shared-db.internal" },
233
+ });
234
+ const sysA = makeEntity("System", "billing-service", {
235
+ healthchecks: [
236
+ { ref: { kind: "Healthcheck", name: "shared-db-check" } },
237
+ ],
238
+ });
239
+ const sysB = makeEntity("System", "inventory-service", {
240
+ healthchecks: [
241
+ { ref: { kind: "Healthcheck", name: "shared-db-check" } },
242
+ ],
243
+ });
244
+
245
+ // Discovered: both Systems before the Healthcheck
246
+ const sorted = sortEntitiesByDependency({
247
+ entities: [sysA, sysB, sharedHc],
248
+ });
249
+
250
+ const names = sorted.map((e) => e.entity.metadata.name);
251
+ expect(names.indexOf("shared-db-check")).toBeLessThan(names.indexOf("billing-service"));
252
+ expect(names.indexOf("shared-db-check")).toBeLessThan(names.indexOf("inventory-service"));
253
+ });
254
+
255
+ /**
256
+ * Scenario: Diamond dependency — System depends on two Healthchecks,
257
+ * and both Healthchecks depend on a shared Collector entity.
258
+ * Expected order: Collector → Healthchecks → System
259
+ */
260
+ it("resolves diamond dependency graphs", () => {
261
+ const collector = makeEntity("Collector", "hardware-cpu", {
262
+ plugin: "collector-hardware",
263
+ });
264
+ const hcA = makeEntity("Healthcheck", "server-a-check", {
265
+ strategy: "http",
266
+ collectors: [
267
+ { ref: { kind: "Collector", name: "hardware-cpu" } },
268
+ ],
269
+ });
270
+ const hcB = makeEntity("Healthcheck", "server-b-check", {
271
+ strategy: "http",
272
+ collectors: [
273
+ { ref: { kind: "Collector", name: "hardware-cpu" } },
274
+ ],
275
+ });
276
+ const sys = makeEntity("System", "cluster", {
277
+ healthchecks: [
278
+ { ref: { kind: "Healthcheck", name: "server-a-check" } },
279
+ { ref: { kind: "Healthcheck", name: "server-b-check" } },
280
+ ],
281
+ });
282
+
283
+ // Worst-case discovery order: dependents first
284
+ const sorted = sortEntitiesByDependency({
285
+ entities: [sys, hcB, hcA, collector],
286
+ });
287
+
288
+ const names = sorted.map((e) => e.entity.metadata.name);
289
+ // Collector before both HCs
290
+ expect(names.indexOf("hardware-cpu")).toBeLessThan(names.indexOf("server-a-check"));
291
+ expect(names.indexOf("hardware-cpu")).toBeLessThan(names.indexOf("server-b-check"));
292
+ // Both HCs before System
293
+ expect(names.indexOf("server-a-check")).toBeLessThan(names.indexOf("cluster"));
294
+ expect(names.indexOf("server-b-check")).toBeLessThan(names.indexOf("cluster"));
295
+ });
296
+
297
+ /**
298
+ * Scenario: Mixed independent and dependent entities.
299
+ * Independent entities should not be affected by the sort.
300
+ */
301
+ it("interleaves independent entities with dependent ones correctly", () => {
302
+ const standalone1 = makeEntity("System", "standalone-1");
303
+ const standalone2 = makeEntity("System", "standalone-2");
304
+ const hc = makeEntity("Healthcheck", "dep-check", {
305
+ strategy: "http",
306
+ config: { url: "http://localhost" },
307
+ });
308
+ const sys = makeEntity("System", "dependent-sys", {
309
+ healthchecks: [
310
+ { ref: { kind: "Healthcheck", name: "dep-check" } },
311
+ ],
312
+ });
313
+
314
+ // discovered: standalone1, dependent-sys, standalone2, dep-check
315
+ const sorted = sortEntitiesByDependency({
316
+ entities: [standalone1, sys, standalone2, hc],
317
+ });
318
+
319
+ const names = sorted.map((e) => e.entity.metadata.name);
320
+ // dep-check must come before dependent-sys
321
+ expect(names.indexOf("dep-check")).toBeLessThan(names.indexOf("dependent-sys"));
322
+ // standalone entities maintain relative order among themselves
323
+ expect(names.indexOf("standalone-1")).toBeLessThan(names.indexOf("standalone-2"));
324
+ });
325
+
326
+ /**
327
+ * Scenario: Three-node cycle should throw a descriptive error.
328
+ * A → B → C → A
329
+ */
330
+ it("detects and reports a three-node cycle", () => {
331
+ const a = makeEntity("System", "sys-a", {
332
+ dep: { kind: "System", name: "sys-b" },
333
+ });
334
+ const b = makeEntity("System", "sys-b", {
335
+ dep: { kind: "System", name: "sys-c" },
336
+ });
337
+ const c = makeEntity("System", "sys-c", {
338
+ dep: { kind: "System", name: "sys-a" },
339
+ });
340
+
341
+ expect(() =>
342
+ sortEntitiesByDependency({ entities: [a, b, c] }),
343
+ ).toThrow(/cycle/i);
344
+ });
345
+
346
+ /**
347
+ * Scenario: Entity refs mixed with non-ref objects that look similar.
348
+ * Objects with `kind` and `name` but inside non-extension contexts
349
+ * (e.g., labels, metadata) should still be treated as entity refs.
350
+ * This is by design — extractEntityRefs is intentionally greedy.
351
+ */
352
+ it("treats all {kind, name} objects as refs regardless of nesting depth", () => {
353
+ const deep = makeEntity("System", "deep-nester", {
354
+ level1: {
355
+ level2: {
356
+ level3: {
357
+ checks: [
358
+ { ref: { kind: "Healthcheck", name: "deeply-nested-hc" } },
359
+ ],
360
+ },
361
+ },
362
+ },
363
+ });
364
+ const hc = makeEntity("Healthcheck", "deeply-nested-hc", {
365
+ strategy: "http",
366
+ });
367
+
368
+ const sorted = sortEntitiesByDependency({
369
+ entities: [deep, hc],
370
+ });
371
+
372
+ const names = sorted.map((e) => e.entity.metadata.name);
373
+ expect(names).toEqual(["deeply-nested-hc", "deep-nester"]);
374
+ });
375
+
376
+ /**
377
+ * Scenario: A System has a partial ref to an entity that exists AND
378
+ * a ref to an entity that doesn't exist in this batch.
379
+ * Only the existing ref should create a dependency edge.
380
+ */
381
+ it("handles a mix of resolvable and unresolvable refs", () => {
382
+ const hc = makeEntity("Healthcheck", "local-check");
383
+ const sys = makeEntity("System", "mixed-refs", {
384
+ healthchecks: [
385
+ { ref: { kind: "Healthcheck", name: "local-check" } },
386
+ { ref: { kind: "Healthcheck", name: "remote-check-not-in-batch" } },
387
+ ],
388
+ });
389
+
390
+ // Should not throw — unresolvable refs are silently ignored
391
+ const sorted = sortEntitiesByDependency({
392
+ entities: [sys, hc],
393
+ });
394
+
395
+ const names = sorted.map((e) => e.entity.metadata.name);
396
+ expect(names).toEqual(["local-check", "mixed-refs"]);
397
+ });
398
+
399
+ /**
400
+ * Scenario: Large batch with multiple independent dependency trees.
401
+ * Tree A: sys-a → hc-a
402
+ * Tree B: sys-b → hc-b1, hc-b2
403
+ * Independent: standalone-1, standalone-2
404
+ * All trees should resolve independently without affecting each other.
405
+ */
406
+ it("handles multiple independent dependency trees in one batch", () => {
407
+ const hcA = makeEntity("Healthcheck", "hc-a");
408
+ const sysA = makeEntity("System", "sys-a", {
409
+ healthchecks: [{ ref: { kind: "Healthcheck", name: "hc-a" } }],
410
+ });
411
+ const hcB1 = makeEntity("Healthcheck", "hc-b1");
412
+ const hcB2 = makeEntity("Healthcheck", "hc-b2");
413
+ const sysB = makeEntity("System", "sys-b", {
414
+ healthchecks: [
415
+ { ref: { kind: "Healthcheck", name: "hc-b1" } },
416
+ { ref: { kind: "Healthcheck", name: "hc-b2" } },
417
+ ],
418
+ });
419
+ const standalone1 = makeEntity("System", "standalone-1");
420
+ const standalone2 = makeEntity("System", "standalone-2");
421
+
422
+ // Discovered in worst-case interleaved order
423
+ const sorted = sortEntitiesByDependency({
424
+ entities: [sysA, sysB, standalone1, hcB2, hcA, standalone2, hcB1],
425
+ });
426
+
427
+ const names = sorted.map((e) => e.entity.metadata.name);
428
+ // Tree A ordering
429
+ expect(names.indexOf("hc-a")).toBeLessThan(names.indexOf("sys-a"));
430
+ // Tree B ordering
431
+ expect(names.indexOf("hc-b1")).toBeLessThan(names.indexOf("sys-b"));
432
+ expect(names.indexOf("hc-b2")).toBeLessThan(names.indexOf("sys-b"));
433
+ // Total count preserved
434
+ expect(names).toHaveLength(7);
435
+ });
436
+
437
+ /**
438
+ * Scenario: Entity with only empty/null spec should be handled gracefully.
439
+ */
440
+ it("handles entities with undefined or empty specs", () => {
441
+ const noSpec = makeEntity("System", "no-spec");
442
+ // Force spec to undefined
443
+ noSpec.entity.spec = undefined;
444
+
445
+ const emptySpec = makeEntity("System", "empty-spec");
446
+ emptySpec.entity.spec = {};
447
+
448
+ const sorted = sortEntitiesByDependency({
449
+ entities: [noSpec, emptySpec],
450
+ });
451
+
452
+ expect(sorted.map((e) => e.entity.metadata.name)).toEqual([
453
+ "no-spec",
454
+ "empty-spec",
455
+ ]);
456
+ });
457
+
458
+ /**
459
+ * Scenario: Cross-kind ref between same-named entities of different kinds.
460
+ * A System named "monitor" references a Healthcheck also named "monitor".
461
+ * These are distinct entities — the ref should create a correct dependency edge.
462
+ */
463
+ it("correctly resolves refs between entities with the same name but different kinds", () => {
464
+ const hcMonitor = makeEntity("Healthcheck", "monitor", {
465
+ strategy: "http",
466
+ });
467
+ const sysMonitor = makeEntity("System", "monitor", {
468
+ healthchecks: [
469
+ { ref: { kind: "Healthcheck", name: "monitor" } },
470
+ ],
471
+ });
472
+
473
+ // System discovered first
474
+ const sorted = sortEntitiesByDependency({
475
+ entities: [sysMonitor, hcMonitor],
476
+ });
477
+
478
+ const names = sorted.map((e) => `${e.entity.kind}::${e.entity.metadata.name}`);
479
+ expect(names).toEqual(["Healthcheck::monitor", "System::monitor"]);
480
+ });
481
+ });
@@ -0,0 +1,100 @@
1
+ import type { EntityEnvelope } from "@checkstack/gitops-common";
2
+ import { extractEntityRefs } from "@checkstack/gitops-common";
3
+ import type { DiscoveredFile } from "../scrapers/types";
4
+
5
+ // ─── Types ─────────────────────────────────────────────────────────────────
6
+
7
+ export interface CollectedEntity {
8
+ entity: EntityEnvelope;
9
+ contentHash: string;
10
+ file: DiscoveredFile;
11
+ }
12
+
13
+ // ─── Topological Sort ──────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Sort entities by dependency order using entity refs found in their specs.
17
+ *
18
+ * 1. Build a map of all collected entities keyed by "Kind::name"
19
+ * 2. For each entity, extract entity refs from its spec via extractEntityRefs()
20
+ * 3. Filter to refs that exist in the collected set (ignore external/unknown refs)
21
+ * 4. Exclude self-references (an entity referencing itself is not a dependency)
22
+ * 5. Build adjacency list and run Kahn's algorithm (BFS topological sort)
23
+ * 6. Throw on cycles with descriptive error
24
+ * 7. Entities at the same depth maintain their original discovery order
25
+ */
26
+ export function sortEntitiesByDependency({
27
+ entities,
28
+ }: {
29
+ entities: CollectedEntity[];
30
+ }): CollectedEntity[] {
31
+ if (entities.length <= 1) return entities;
32
+
33
+ // Build entity key → index map
34
+ const keyToIndex = new Map<string, number>();
35
+ for (const [i, e] of entities.entries()) {
36
+ keyToIndex.set(`${e.entity.kind}::${e.entity.metadata.name}`, i);
37
+ }
38
+
39
+ // Build adjacency list: edges[i] = indices that i depends on
40
+ // (i.e., entities that must be processed BEFORE entity i)
41
+ const dependsOn: Set<number>[] = entities.map(() => new Set());
42
+ const dependedBy: Set<number>[] = entities.map(() => new Set());
43
+
44
+ for (const [i, collected] of entities.entries()) {
45
+ const spec = collected.entity.spec;
46
+ if (!spec) continue;
47
+
48
+ const selfKey = `${collected.entity.kind}::${collected.entity.metadata.name}`;
49
+ const refs = extractEntityRefs(spec);
50
+
51
+ for (const ref of refs) {
52
+ const refKey = `${ref.kind}::${ref.name}`;
53
+
54
+ // Skip self-references and refs to entities not in this batch
55
+ if (refKey === selfKey) continue;
56
+ const depIndex = keyToIndex.get(refKey);
57
+ if (depIndex === undefined) continue;
58
+
59
+ dependsOn[i].add(depIndex);
60
+ dependedBy[depIndex].add(i);
61
+ }
62
+ }
63
+
64
+ // Kahn's algorithm — BFS topological sort preserving discovery order
65
+ const inDegree = dependsOn.map((deps) => deps.size);
66
+ const queue: number[] = [];
67
+
68
+ // Seed with zero-dependency entities in discovery order
69
+ for (const [i, degree] of inDegree.entries()) {
70
+ if (degree === 0) queue.push(i);
71
+ }
72
+
73
+ const sorted: CollectedEntity[] = [];
74
+
75
+ while (queue.length > 0) {
76
+ const idx = queue.shift()!;
77
+ sorted.push(entities[idx]);
78
+
79
+ // Process dependents in their original discovery order
80
+ const dependents = [...dependedBy[idx]].toSorted((a, b) => a - b);
81
+ for (const depIdx of dependents) {
82
+ inDegree[depIdx]--;
83
+ if (inDegree[depIdx] === 0) {
84
+ queue.push(depIdx);
85
+ }
86
+ }
87
+ }
88
+
89
+ if (sorted.length !== entities.length) {
90
+ // Find entities involved in the cycle
91
+ const cycleEntities = entities
92
+ .filter((_, i) => inDegree[i] > 0)
93
+ .map((e) => `${e.entity.kind}::${e.entity.metadata.name}`);
94
+ throw new Error(
95
+ `Dependency cycle detected among entities: ${cycleEntities.join(", ")}`,
96
+ );
97
+ }
98
+
99
+ return sorted;
100
+ }