@celilo/cli 0.1.5 → 0.1.6

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.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,214 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../../manifest/schema';
3
+ import {
4
+ DependencyCycleError,
5
+ buildModuleGraph,
6
+ levelsOf,
7
+ topologicalOrder,
8
+ transitiveConsumers,
9
+ } from './dep-graph';
10
+
11
+ function makeManifest(
12
+ id: string,
13
+ opts: {
14
+ provides?: string[];
15
+ requires?: string[];
16
+ optional?: string[];
17
+ } = {},
18
+ ): ModuleManifest {
19
+ return {
20
+ id,
21
+ name: id,
22
+ version: '1.0.0',
23
+ celilo_contract: '1.0',
24
+ provides: opts.provides
25
+ ? {
26
+ capabilities: opts.provides.map((name) => ({
27
+ name,
28
+ version: '1.0.0',
29
+ data: {},
30
+ functions: [],
31
+ })),
32
+ }
33
+ : { capabilities: [] },
34
+ requires: opts.requires
35
+ ? { capabilities: opts.requires.map((name) => ({ name, version: '1.0.0' })) }
36
+ : { capabilities: [] },
37
+ optional: opts.optional
38
+ ? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) }
39
+ : undefined,
40
+ } as unknown as ModuleManifest;
41
+ }
42
+
43
+ describe('buildModuleGraph', () => {
44
+ test('empty input → empty graph', () => {
45
+ const g = buildModuleGraph([]);
46
+ expect(g.nodes.size).toBe(0);
47
+ expect(g.edges.size).toBe(0);
48
+ });
49
+
50
+ test('module with no deps → node with no edges', () => {
51
+ const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
52
+ expect(g.nodes.size).toBe(1);
53
+ expect(g.edges.get('iptables')?.size).toBe(0);
54
+ });
55
+
56
+ test('builds consumer → provider edge', () => {
57
+ const g = buildModuleGraph([
58
+ makeManifest('iptables', { provides: ['firewall'] }),
59
+ makeManifest('caddy', { requires: ['firewall'] }),
60
+ ]);
61
+ expect([...(g.edges.get('caddy') ?? [])]).toEqual(['iptables']);
62
+ expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']);
63
+ });
64
+
65
+ test('optional capabilities also create edges', () => {
66
+ const g = buildModuleGraph([
67
+ makeManifest('namecheap', { provides: ['dns_registrar'] }),
68
+ makeManifest('caddy', { optional: ['dns_registrar'] }),
69
+ ]);
70
+ expect([...(g.edges.get('caddy') ?? [])]).toEqual(['namecheap']);
71
+ });
72
+
73
+ test('skips edges to missing providers (no provider for declared capability)', () => {
74
+ const g = buildModuleGraph([makeManifest('caddy', { requires: ['nonexistent'] })]);
75
+ expect(g.edges.get('caddy')?.size).toBe(0);
76
+ });
77
+
78
+ test('skips self-edges when a module consumes its own capability', () => {
79
+ const g = buildModuleGraph([makeManifest('weird', { provides: ['x'], requires: ['x'] })]);
80
+ expect(g.edges.get('weird')?.size).toBe(0);
81
+ });
82
+
83
+ test('first declared provider wins when multiple modules provide the same capability', () => {
84
+ const g = buildModuleGraph([
85
+ makeManifest('a', { provides: ['firewall'] }),
86
+ makeManifest('b', { provides: ['firewall'] }),
87
+ makeManifest('caddy', { requires: ['firewall'] }),
88
+ ]);
89
+ expect([...(g.edges.get('caddy') ?? [])]).toEqual(['a']);
90
+ });
91
+ });
92
+
93
+ describe('topologicalOrder', () => {
94
+ test('empty graph yields empty list', () => {
95
+ expect(topologicalOrder(buildModuleGraph([]))).toEqual([]);
96
+ });
97
+
98
+ test('places provider before consumer', () => {
99
+ const g = buildModuleGraph([
100
+ makeManifest('caddy', { requires: ['firewall'] }),
101
+ makeManifest('iptables', { provides: ['firewall'] }),
102
+ ]);
103
+ const order = topologicalOrder(g);
104
+ expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy'));
105
+ });
106
+
107
+ test('respects multi-level chain', () => {
108
+ const g = buildModuleGraph([
109
+ makeManifest('namecheap', { provides: ['dns_registrar'] }),
110
+ makeManifest('caddy', { requires: ['firewall', 'dns_registrar'], provides: ['public_web'] }),
111
+ makeManifest('iptables', { provides: ['firewall'] }),
112
+ makeManifest('lunacycle', { requires: ['public_web'] }),
113
+ ]);
114
+ const order = topologicalOrder(g);
115
+ expect(order.indexOf('iptables')).toBeLessThan(order.indexOf('caddy'));
116
+ expect(order.indexOf('namecheap')).toBeLessThan(order.indexOf('caddy'));
117
+ expect(order.indexOf('caddy')).toBeLessThan(order.indexOf('lunacycle'));
118
+ });
119
+
120
+ test('alphabetical tiebreak among siblings', () => {
121
+ const g = buildModuleGraph([
122
+ makeManifest('zebra'),
123
+ makeManifest('alpha'),
124
+ makeManifest('mango'),
125
+ ]);
126
+ expect(topologicalOrder(g)).toEqual(['alpha', 'mango', 'zebra']);
127
+ });
128
+
129
+ test('throws DependencyCycleError on a 2-cycle', () => {
130
+ const g = buildModuleGraph([
131
+ makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }),
132
+ makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }),
133
+ ]);
134
+ expect(() => topologicalOrder(g)).toThrow(DependencyCycleError);
135
+ });
136
+
137
+ test('cycle error contains the cycle path', () => {
138
+ const g = buildModuleGraph([
139
+ makeManifest('a', { provides: ['cap_a'], requires: ['cap_b'] }),
140
+ makeManifest('b', { provides: ['cap_b'], requires: ['cap_a'] }),
141
+ ]);
142
+ try {
143
+ topologicalOrder(g);
144
+ throw new Error('should have thrown');
145
+ } catch (err) {
146
+ expect(err).toBeInstanceOf(DependencyCycleError);
147
+ expect((err as DependencyCycleError).cycle.length).toBeGreaterThan(0);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe('transitiveConsumers', () => {
153
+ test('returns all downstream modules', () => {
154
+ const g = buildModuleGraph([
155
+ makeManifest('iptables', { provides: ['firewall'] }),
156
+ makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
157
+ makeManifest('lunacycle', { requires: ['public_web'] }),
158
+ makeManifest('celilo-website', { requires: ['public_web'] }),
159
+ ]);
160
+
161
+ expect(transitiveConsumers(g, 'iptables').sort()).toEqual([
162
+ 'caddy',
163
+ 'celilo-website',
164
+ 'lunacycle',
165
+ ]);
166
+ expect(transitiveConsumers(g, 'caddy').sort()).toEqual(['celilo-website', 'lunacycle']);
167
+ expect(transitiveConsumers(g, 'lunacycle')).toEqual([]);
168
+ });
169
+
170
+ test('returns empty for a module with no consumers', () => {
171
+ const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
172
+ expect(transitiveConsumers(g, 'iptables')).toEqual([]);
173
+ });
174
+
175
+ test('returns empty for a module not in the graph', () => {
176
+ const g = buildModuleGraph([makeManifest('iptables', { provides: ['firewall'] })]);
177
+ expect(transitiveConsumers(g, 'nonexistent')).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe('levelsOf', () => {
182
+ test('flat dependency tree → all level 0', () => {
183
+ const g = buildModuleGraph([makeManifest('a'), makeManifest('b'), makeManifest('c')]);
184
+ const levels = levelsOf(g);
185
+ expect(levels[0].sort()).toEqual(['a', 'b', 'c']);
186
+ expect(levels.length).toBe(1);
187
+ });
188
+
189
+ test('linear chain assigns level per depth', () => {
190
+ const g = buildModuleGraph([
191
+ makeManifest('iptables', { provides: ['firewall'] }),
192
+ makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
193
+ makeManifest('lunacycle', { requires: ['public_web'] }),
194
+ ]);
195
+ const levels = levelsOf(g);
196
+ expect(levels[0]).toEqual(['iptables']);
197
+ expect(levels[1]).toEqual(['caddy']);
198
+ expect(levels[2]).toEqual(['lunacycle']);
199
+ });
200
+
201
+ test('module depending on multiple providers gets max(deps)+1', () => {
202
+ const g = buildModuleGraph([
203
+ makeManifest('iptables', { provides: ['firewall'] }),
204
+ makeManifest('namecheap', { provides: ['dns_registrar'] }),
205
+ makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
206
+ makeManifest('mixed', { requires: ['public_web', 'dns_registrar'] }),
207
+ ]);
208
+ const levels = levelsOf(g);
209
+ // iptables, namecheap → 0; caddy → 1; mixed → 2 (max of caddy:1, namecheap:0)
210
+ expect(levels[0].sort()).toEqual(['iptables', 'namecheap']);
211
+ expect(levels[1]).toEqual(['caddy']);
212
+ expect(levels[2]).toEqual(['mixed']);
213
+ });
214
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Module dependency graph.
3
+ *
4
+ * Builds a directed graph from a set of module manifests where an edge
5
+ * `consumer → provider` exists iff the consumer's `requires` or
6
+ * `optional` capabilities list a name that the provider declares in
7
+ * `provides`. The graph is used by both `system audit` (to render the
8
+ * dependency tree) and `system update` (to walk in topological order
9
+ * so providers upgrade before consumers).
10
+ *
11
+ * Pure data structures and pure operations — no I/O, no logging.
12
+ * Used by tests directly.
13
+ */
14
+
15
+ import type { ModuleManifest } from '../../manifest/schema';
16
+
17
+ /**
18
+ * The id used to identify a module in the graph. Always equals
19
+ * `manifest.id` so callers don't need a separate keyspace.
20
+ */
21
+ export type ModuleId = string;
22
+
23
+ export interface ModuleNode {
24
+ id: ModuleId;
25
+ manifest: ModuleManifest;
26
+ /**
27
+ * Capability names this module provides (the keys in
28
+ * `manifest.provides.capabilities[].name`). Cached for graph
29
+ * construction.
30
+ */
31
+ provides: string[];
32
+ /**
33
+ * Required + optional capability names. Optional reqs participate
34
+ * in the graph the same way required ones do — they shape the
35
+ * upgrade order — but they don't block deploy if absent.
36
+ */
37
+ consumes: string[];
38
+ }
39
+
40
+ export interface ModuleGraph {
41
+ nodes: Map<ModuleId, ModuleNode>;
42
+ /** consumer → providers it depends on */
43
+ edges: Map<ModuleId, Set<ModuleId>>;
44
+ /** provider → consumers that depend on it (reverse index) */
45
+ reverseEdges: Map<ModuleId, Set<ModuleId>>;
46
+ }
47
+
48
+ /** Internal: build an index of capability name → providing module id. */
49
+ function indexProviders(modules: ModuleNode[]): Map<string, ModuleId> {
50
+ const index = new Map<string, ModuleId>();
51
+ for (const m of modules) {
52
+ for (const cap of m.provides) {
53
+ // First provider wins. The capability-loader's chain logic handles
54
+ // multi-provider scenarios at deploy time; for graph-walking
55
+ // purposes one edge per capability is enough.
56
+ if (!index.has(cap)) index.set(cap, m.id);
57
+ }
58
+ }
59
+ return index;
60
+ }
61
+
62
+ export function buildModuleGraph(manifests: ModuleManifest[]): ModuleGraph {
63
+ const nodes = new Map<ModuleId, ModuleNode>();
64
+ for (const m of manifests) {
65
+ const provides = (m.provides?.capabilities ?? []).map((c) => c.name);
66
+ const required = (m.requires?.capabilities ?? []).map((c) => c.name);
67
+ const optional = (m.optional?.capabilities ?? []).map((c) => c.name);
68
+ nodes.set(m.id, {
69
+ id: m.id,
70
+ manifest: m,
71
+ provides,
72
+ consumes: [...required, ...optional],
73
+ });
74
+ }
75
+
76
+ const providerIndex = indexProviders([...nodes.values()]);
77
+ const edges = new Map<ModuleId, Set<ModuleId>>();
78
+ const reverseEdges = new Map<ModuleId, Set<ModuleId>>();
79
+
80
+ for (const node of nodes.values()) {
81
+ edges.set(node.id, new Set());
82
+ reverseEdges.set(node.id, reverseEdges.get(node.id) ?? new Set());
83
+
84
+ for (const cap of node.consumes) {
85
+ const providerId = providerIndex.get(cap);
86
+ // Skip self-edges (a module that consumes its own capability —
87
+ // unusual but legal) and missing providers (deploy preflight
88
+ // handles those; graph just routes around them).
89
+ if (!providerId || providerId === node.id) continue;
90
+
91
+ edges.get(node.id)?.add(providerId);
92
+ const rev = reverseEdges.get(providerId) ?? new Set();
93
+ rev.add(node.id);
94
+ reverseEdges.set(providerId, rev);
95
+ }
96
+ }
97
+
98
+ return { nodes, edges, reverseEdges };
99
+ }
100
+
101
+ export class DependencyCycleError extends Error {
102
+ readonly cycle: readonly ModuleId[];
103
+ constructor(cycle: readonly ModuleId[]) {
104
+ super(`Dependency cycle detected: ${cycle.join(' → ')} → ${cycle[0]}`);
105
+ this.name = 'DependencyCycleError';
106
+ this.cycle = cycle;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Return module ids in dependency-safe order: every provider precedes
112
+ * every consumer. Throws `DependencyCycleError` if the graph contains
113
+ * a cycle (manifest declares mutual dependency, which is a bug in
114
+ * the manifests).
115
+ *
116
+ * Tie-breaks alphabetically so the order is deterministic across runs.
117
+ */
118
+ export function topologicalOrder(graph: ModuleGraph): ModuleId[] {
119
+ const order: ModuleId[] = [];
120
+ const visited = new Set<ModuleId>();
121
+ const visiting = new Set<ModuleId>();
122
+
123
+ function visit(id: ModuleId, path: ModuleId[]): void {
124
+ if (visited.has(id)) return;
125
+ if (visiting.has(id)) {
126
+ const cycleStart = path.indexOf(id);
127
+ throw new DependencyCycleError(path.slice(cycleStart));
128
+ }
129
+ visiting.add(id);
130
+
131
+ // Visit providers first (deterministic order).
132
+ const providers = [...(graph.edges.get(id) ?? [])].sort();
133
+ for (const dep of providers) {
134
+ visit(dep, [...path, id]);
135
+ }
136
+
137
+ visiting.delete(id);
138
+ visited.add(id);
139
+ order.push(id);
140
+ }
141
+
142
+ // Iterate in alphabetical order so callers without explicit deps
143
+ // get a stable order.
144
+ const ids = [...graph.nodes.keys()].sort();
145
+ for (const id of ids) {
146
+ visit(id, []);
147
+ }
148
+
149
+ return order;
150
+ }
151
+
152
+ /**
153
+ * Return all consumers (transitively) of `moduleId`. Used during update
154
+ * to find the subtree that should be skipped when a provider's upgrade
155
+ * fails (subject to the version-aware short-circuit in D3).
156
+ */
157
+ export function transitiveConsumers(graph: ModuleGraph, moduleId: ModuleId): ModuleId[] {
158
+ const result: ModuleId[] = [];
159
+ const seen = new Set<ModuleId>([moduleId]);
160
+ const stack = [...(graph.reverseEdges.get(moduleId) ?? [])];
161
+
162
+ while (stack.length > 0) {
163
+ const next = stack.pop();
164
+ if (next === undefined || seen.has(next)) continue;
165
+ seen.add(next);
166
+ result.push(next);
167
+ const consumers = graph.reverseEdges.get(next);
168
+ if (consumers) stack.push(...consumers);
169
+ }
170
+
171
+ return result.sort();
172
+ }
173
+
174
+ /**
175
+ * Group nodes into "levels" where level 0 has no dependencies inside
176
+ * the graph, level 1 depends only on level 0, etc. Useful for
177
+ * rendering the audit / dry-run output as an indented tree.
178
+ */
179
+ export function levelsOf(graph: ModuleGraph): ModuleId[][] {
180
+ const levelByModule = new Map<ModuleId, number>();
181
+
182
+ function levelOf(id: ModuleId, path: Set<ModuleId>): number {
183
+ const cached = levelByModule.get(id);
184
+ if (cached !== undefined) return cached;
185
+ if (path.has(id)) {
186
+ // Cycle — return 0 so the topological-order check surfaces a clearer error.
187
+ return 0;
188
+ }
189
+ const deps = graph.edges.get(id);
190
+ if (!deps || deps.size === 0) {
191
+ levelByModule.set(id, 0);
192
+ return 0;
193
+ }
194
+ const next = new Set(path);
195
+ next.add(id);
196
+ let max = 0;
197
+ for (const d of deps) {
198
+ max = Math.max(max, levelOf(d, next) + 1);
199
+ }
200
+ levelByModule.set(id, max);
201
+ return max;
202
+ }
203
+
204
+ for (const id of graph.nodes.keys()) {
205
+ levelOf(id, new Set());
206
+ }
207
+
208
+ const levels: ModuleId[][] = [];
209
+ for (const [id, lvl] of levelByModule) {
210
+ while (levels.length <= lvl) levels.push([]);
211
+ levels[lvl].push(id);
212
+ }
213
+ for (const lvl of levels) lvl.sort();
214
+ return levels;
215
+ }