@fuzdev/fuz_gitops 0.72.0 → 0.74.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.
Files changed (122) hide show
  1. package/README.md +15 -8
  2. package/dist/PageHeader.svelte +3 -3
  3. package/dist/PageHeader.svelte.d.ts +3 -3
  4. package/dist/PageHeader.svelte.d.ts.map +1 -1
  5. package/dist/changeset_generator.d.ts.map +1 -1
  6. package/dist/changeset_generator.js +2 -16
  7. package/dist/changeset_reader.d.ts +1 -1
  8. package/dist/changeset_reader.d.ts.map +1 -1
  9. package/dist/changeset_reader.js +1 -1
  10. package/dist/dependency_graph.d.ts.map +1 -1
  11. package/dist/dependency_graph.js +6 -6
  12. package/dist/fetch_repo_data.d.ts.map +1 -1
  13. package/dist/fetch_repo_data.js +3 -2
  14. package/dist/git_operations.d.ts +5 -0
  15. package/dist/git_operations.d.ts.map +1 -1
  16. package/dist/git_operations.js +5 -0
  17. package/dist/gitops_analyze.task.d.ts +1 -0
  18. package/dist/gitops_analyze.task.d.ts.map +1 -1
  19. package/dist/gitops_analyze.task.js +12 -17
  20. package/dist/gitops_plan.task.d.ts +1 -0
  21. package/dist/gitops_plan.task.d.ts.map +1 -1
  22. package/dist/gitops_plan.task.js +9 -2
  23. package/dist/gitops_publish.task.d.ts +2 -2
  24. package/dist/gitops_publish.task.d.ts.map +1 -1
  25. package/dist/gitops_publish.task.js +83 -28
  26. package/dist/gitops_run.task.d.ts +2 -1
  27. package/dist/gitops_run.task.d.ts.map +1 -1
  28. package/dist/gitops_run.task.js +40 -7
  29. package/dist/gitops_sync.task.d.ts +1 -0
  30. package/dist/gitops_sync.task.d.ts.map +1 -1
  31. package/dist/gitops_sync.task.js +16 -2
  32. package/dist/gitops_task_helpers.d.ts +16 -2
  33. package/dist/gitops_task_helpers.d.ts.map +1 -1
  34. package/dist/gitops_task_helpers.js +11 -3
  35. package/dist/gitops_validate.task.d.ts +1 -0
  36. package/dist/gitops_validate.task.d.ts.map +1 -1
  37. package/dist/gitops_validate.task.js +20 -20
  38. package/dist/graph_validation.d.ts +16 -1
  39. package/dist/graph_validation.d.ts.map +1 -1
  40. package/dist/graph_validation.js +15 -0
  41. package/dist/local_repo.d.ts +25 -12
  42. package/dist/local_repo.d.ts.map +1 -1
  43. package/dist/local_repo.js +99 -79
  44. package/dist/log_helpers.d.ts +0 -5
  45. package/dist/log_helpers.d.ts.map +1 -1
  46. package/dist/log_helpers.js +8 -19
  47. package/dist/multi_repo_publisher.d.ts +21 -2
  48. package/dist/multi_repo_publisher.d.ts.map +1 -1
  49. package/dist/multi_repo_publisher.js +279 -287
  50. package/dist/operations.d.ts +6 -26
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations_defaults.d.ts.map +1 -1
  53. package/dist/operations_defaults.js +4 -20
  54. package/dist/output_helpers.d.ts.map +1 -1
  55. package/dist/output_helpers.js +7 -6
  56. package/dist/paths.d.ts +0 -4
  57. package/dist/paths.d.ts.map +1 -1
  58. package/dist/paths.js +0 -4
  59. package/dist/publish_gate.d.ts +44 -0
  60. package/dist/publish_gate.d.ts.map +1 -0
  61. package/dist/publish_gate.js +32 -0
  62. package/dist/publish_steps.d.ts +60 -0
  63. package/dist/publish_steps.d.ts.map +1 -0
  64. package/dist/publish_steps.js +113 -0
  65. package/dist/publishing_event.d.ts +12 -19
  66. package/dist/publishing_event.d.ts.map +1 -1
  67. package/dist/publishing_event.js +16 -25
  68. package/dist/publishing_plan.d.ts +1 -2
  69. package/dist/publishing_plan.d.ts.map +1 -1
  70. package/dist/publishing_plan.js +21 -4
  71. package/dist/publishing_plan_helpers.d.ts +1 -1
  72. package/dist/publishing_plan_helpers.d.ts.map +1 -1
  73. package/dist/publishing_plan_helpers.js +2 -15
  74. package/dist/publishing_plan_logging.d.ts.map +1 -1
  75. package/dist/publishing_plan_logging.js +15 -23
  76. package/dist/repo.svelte.d.ts +10 -6
  77. package/dist/repo.svelte.d.ts.map +1 -1
  78. package/dist/repo.svelte.js +6 -14
  79. package/dist/version_utils.d.ts +17 -3
  80. package/dist/version_utils.d.ts.map +1 -1
  81. package/dist/version_utils.js +22 -0
  82. package/package.json +14 -15
  83. package/src/lib/changeset_generator.ts +10 -19
  84. package/src/lib/changeset_reader.ts +2 -3
  85. package/src/lib/dependency_graph.ts +6 -8
  86. package/src/lib/fetch_repo_data.ts +3 -2
  87. package/src/lib/git_operations.ts +5 -0
  88. package/src/lib/gitops_analyze.task.ts +18 -23
  89. package/src/lib/gitops_plan.task.ts +10 -2
  90. package/src/lib/gitops_publish.task.ts +92 -27
  91. package/src/lib/gitops_run.task.ts +39 -7
  92. package/src/lib/gitops_sync.task.ts +18 -3
  93. package/src/lib/gitops_task_helpers.ts +20 -3
  94. package/src/lib/gitops_validate.task.ts +30 -22
  95. package/src/lib/graph_validation.ts +26 -0
  96. package/src/lib/local_repo.ts +124 -88
  97. package/src/lib/log_helpers.ts +8 -26
  98. package/src/lib/multi_repo_publisher.ts +336 -353
  99. package/src/lib/operations.ts +6 -22
  100. package/src/lib/operations_defaults.ts +4 -20
  101. package/src/lib/output_helpers.ts +7 -6
  102. package/src/lib/paths.ts +0 -5
  103. package/src/lib/publish_gate.ts +53 -0
  104. package/src/lib/publish_steps.ts +149 -0
  105. package/src/lib/publishing_event.ts +16 -25
  106. package/src/lib/publishing_plan.ts +26 -6
  107. package/src/lib/publishing_plan_helpers.ts +5 -18
  108. package/src/lib/publishing_plan_logging.ts +19 -39
  109. package/src/lib/repo.svelte.ts +12 -15
  110. package/src/lib/version_utils.ts +30 -9
  111. package/dist/npm_install_helpers.d.ts +0 -23
  112. package/dist/npm_install_helpers.d.ts.map +0 -1
  113. package/dist/npm_install_helpers.js +0 -60
  114. package/dist/semver.d.ts +0 -26
  115. package/dist/semver.d.ts.map +0 -1
  116. package/dist/semver.js +0 -137
  117. package/dist/serialization_types.d.ts +0 -59
  118. package/dist/serialization_types.d.ts.map +0 -1
  119. package/dist/serialization_types.js +0 -42
  120. package/src/lib/npm_install_helpers.ts +0 -85
  121. package/src/lib/semver.ts +0 -170
  122. package/src/lib/serialization_types.ts +0 -92
@@ -109,3 +109,29 @@ export const validate_dependency_graph = (
109
109
  sort_error,
110
110
  };
111
111
  };
112
+
113
+ /** Cycles, wildcard deps, and missing peers produced by `DependencyGraphBuilder.analyze`. */
114
+ export type DependencyAnalysis = ReturnType<DependencyGraphBuilder['analyze']>;
115
+
116
+ export interface RepoAnalysis {
117
+ graph: DependencyGraph;
118
+ analysis: DependencyAnalysis;
119
+ /** Topological publishing order, or `null` when prod/peer cycles prevent ordering. */
120
+ publishing_order: Array<string> | null;
121
+ }
122
+
123
+ /**
124
+ * Builds the dependency graph and runs cycle/wildcard analysis, tolerating cycles
125
+ * (reports rather than throws). The shared core of `gitops_analyze` and
126
+ * `gitops_validate`, which format the result themselves — so this stays silent
127
+ * (no `log`) to avoid emitting a redundant "Analyzing dependencies" line.
128
+ */
129
+ export const analyze_repos = (repos: Array<LocalRepo>): RepoAnalysis => {
130
+ const {graph, publishing_order: order} = validate_dependency_graph(repos, {
131
+ throw_on_prod_cycles: false, // report, don't throw
132
+ log_cycles: false, // callers format their own cycle output
133
+ log_order: false,
134
+ });
135
+ const analysis = new DependencyGraphBuilder().analyze(graph);
136
+ return {graph, analysis, publishing_order: order.length > 0 ? order : null};
137
+ };
@@ -1,5 +1,6 @@
1
1
  import {strip_end} from '@fuzdev/fuz_util/string.js';
2
2
  import type {LibraryJson} from '@fuzdev/fuz_util/library_json.js';
3
+ import type {PackageJson} from '@fuzdev/fuz_util/package_json.js';
3
4
  import {Library} from '@fuzdev/fuz_ui/library.svelte.js';
4
5
  import {existsSync} from 'node:fs';
5
6
  import {join} from 'node:path';
@@ -21,7 +22,8 @@ import {GITOPS_CONCURRENCY_DEFAULT} from './gitops_constants.js';
21
22
  */
22
23
  export interface LocalRepo {
23
24
  library: Library;
24
- library_json: LibraryJson;
25
+ /** The repo's full `package.json` (with `dependencies`/`devDependencies`). */
26
+ package_json: PackageJson;
25
27
  repo_dir: string;
26
28
  repo_git_ssh_url: string;
27
29
  repo_config: GitopsRepoConfig;
@@ -55,135 +57,164 @@ export interface LocalRepoMissing {
55
57
  }
56
58
 
57
59
  /**
58
- * Loads repo data with automatic syncing and dependency management.
60
+ * Loads repo data, optionally syncing the working tree first.
59
61
  *
60
- * Workflow:
62
+ * When `sync` is `false` (the default for read-only diagnostics like
63
+ * `gitops_analyze`/`gitops_plan`), the repo is loaded exactly as it sits on
64
+ * disk — no branch switch, pull, install, or clean-workspace check. This makes
65
+ * those commands safe to run on an active workspace with uncommitted changes or
66
+ * feature branches checked out.
67
+ *
68
+ * When `sync` is `true` (used by `gitops_sync`), the working tree is brought in
69
+ * line with the configured branch first:
61
70
  * 1. Records current commit hash (for detecting changes)
62
- * 2. Switches to target branch if needed (requires clean workspace)
71
+ * 2. Switches to target branch if needed (requires clean workspace unless `allow_dirty`)
63
72
  * 3. Pulls latest changes from remote (skipped for local-only repos)
64
- * 4. Validates workspace is clean after pull
73
+ * 4. Validates workspace is clean after pull (skipped if `allow_dirty`)
65
74
  * 5. Auto-installs dependencies if `package.json` changed
75
+ *
76
+ * Either way it then:
66
77
  * 6. Loads `library_json` via `library_load_from_repo` (svelte-docinfo analysis)
67
78
  * 7. Creates `Library` and extracts dependency maps
68
79
  *
69
- * This ensures repos are always in sync with their configured branch
70
- * before being used by gitops commands.
71
- *
72
- * @throws {TaskError} if workspace dirty, branch switch fails, install fails, or analysis fails
80
+ * @param sync - sync the working tree to the configured branch before loading (default `true`)
81
+ * @param allow_dirty - when syncing, tolerate uncommitted changes instead of throwing (default `false`)
82
+ * @throws {TaskError} if syncing fails (dirty workspace, branch switch, install) or analysis fails
73
83
  */
74
84
  export const local_repo_load = async ({
75
85
  local_repo_path,
76
86
  log: _log,
77
87
  git_ops = default_git_operations,
78
88
  npm_ops = default_npm_operations,
89
+ sync = true,
90
+ allow_dirty = false,
79
91
  }: {
80
92
  local_repo_path: LocalRepoPath;
81
93
  log?: Logger;
82
94
  git_ops?: GitOperations;
83
95
  npm_ops?: NpmOperations;
96
+ sync?: boolean;
97
+ allow_dirty?: boolean;
84
98
  }): Promise<LocalRepo> => {
85
99
  const {repo_config, repo_dir, repo_name, repo_git_ssh_url} = local_repo_path;
86
100
 
87
- // Record commit hash before any changes
88
- const commit_before_result = await git_ops.current_commit_hash({cwd: repo_dir});
89
- if (!commit_before_result.ok) {
90
- throw new TaskError(
91
- `Failed to get commit hash in ${repo_dir}: ${commit_before_result.message}`,
92
- );
93
- }
94
- const commit_before = commit_before_result.value;
95
-
96
- // Switch to target branch if needed
97
- const branch_result = await git_ops.current_branch_name({cwd: repo_dir});
98
- if (!branch_result.ok) {
99
- throw new TaskError(`Failed to get current branch in ${repo_dir}: ${branch_result.message}`);
100
- }
101
-
102
- const switched_branches = branch_result.value !== repo_config.branch;
103
- if (switched_branches) {
104
- const clean_result = await git_ops.check_clean_workspace({cwd: repo_dir});
105
- if (!clean_result.ok) {
106
- throw new TaskError(`Failed to check workspace in ${repo_dir}: ${clean_result.message}`);
107
- }
108
-
109
- if (!clean_result.value) {
101
+ if (sync) {
102
+ // Record commit hash before any changes
103
+ const commit_before_result = await git_ops.current_commit_hash({cwd: repo_dir});
104
+ if (!commit_before_result.ok) {
110
105
  throw new TaskError(
111
- `Repo ${repo_dir} is not on branch "${repo_config.branch}" and the workspace is unclean, blocking switch`,
106
+ `Failed to get commit hash in ${repo_dir}: ${commit_before_result.message}`,
112
107
  );
113
108
  }
109
+ const commit_before = commit_before_result.value;
114
110
 
115
- const checkout_result = await git_ops.checkout({branch: repo_config.branch, cwd: repo_dir});
116
- if (!checkout_result.ok) {
117
- throw new TaskError(
118
- `Failed to checkout branch "${repo_config.branch}" in ${repo_dir}: ${checkout_result.message}`,
119
- );
111
+ // Switch to target branch if needed
112
+ const branch_result = await git_ops.current_branch_name({cwd: repo_dir});
113
+ if (!branch_result.ok) {
114
+ throw new TaskError(`Failed to get current branch in ${repo_dir}: ${branch_result.message}`);
120
115
  }
121
- }
122
116
 
123
- // Only pull if remote exists (skip for local-only repos, test fixtures)
124
- const origin_result = await git_ops.has_remote({remote: 'origin', cwd: repo_dir});
125
- if (!origin_result.ok) {
126
- throw new TaskError(`Failed to check for remote in ${repo_dir}: ${origin_result.message}`);
127
- }
117
+ const switched_branches = branch_result.value !== repo_config.branch;
118
+ if (switched_branches) {
119
+ // Guard the switch on a clean workspace unless the caller opts into `allow_dirty`,
120
+ // in which case we let `git checkout` itself fail loudly if it can't proceed.
121
+ if (!allow_dirty) {
122
+ const clean_result = await git_ops.check_clean_workspace({cwd: repo_dir});
123
+ if (!clean_result.ok) {
124
+ throw new TaskError(`Failed to check workspace in ${repo_dir}: ${clean_result.message}`);
125
+ }
126
+
127
+ if (!clean_result.value) {
128
+ throw new TaskError(
129
+ `Repo ${repo_dir} is not on branch "${repo_config.branch}" and the workspace is unclean, blocking switch`,
130
+ );
131
+ }
132
+ }
128
133
 
129
- if (origin_result.value) {
130
- const pull_result = await git_ops.pull({cwd: repo_dir});
131
- if (!pull_result.ok) {
132
- throw new TaskError(`Failed to pull in ${repo_dir}: ${pull_result.message}`);
134
+ const checkout_result = await git_ops.checkout({branch: repo_config.branch, cwd: repo_dir});
135
+ if (!checkout_result.ok) {
136
+ throw new TaskError(
137
+ `Failed to checkout branch "${repo_config.branch}" in ${repo_dir}: ${checkout_result.message}`,
138
+ );
139
+ }
133
140
  }
134
- }
135
141
 
136
- // Check clean workspace after pull to ensure we're in a good state
137
- const clean_after_result = await git_ops.check_clean_workspace({cwd: repo_dir});
138
- if (!clean_after_result.ok) {
139
- throw new TaskError(`Failed to check workspace in ${repo_dir}: ${clean_after_result.message}`);
140
- }
141
-
142
- if (!clean_after_result.value) {
143
- throw new TaskError(
144
- `Workspace ${repo_dir} is unclean after pulling branch "${repo_config.branch}"`,
145
- );
146
- }
142
+ // Only pull if remote exists (skip for local-only repos, test fixtures)
143
+ const origin_result = await git_ops.has_remote({remote: 'origin', cwd: repo_dir});
144
+ if (!origin_result.ok) {
145
+ throw new TaskError(`Failed to check for remote in ${repo_dir}: ${origin_result.message}`);
146
+ }
147
147
 
148
- // Record commit hash after pull
149
- const commit_after_result = await git_ops.current_commit_hash({cwd: repo_dir});
150
- if (!commit_after_result.ok) {
151
- throw new TaskError(`Failed to get commit hash in ${repo_dir}: ${commit_after_result.message}`);
152
- }
153
- const commit_after = commit_after_result.value;
148
+ if (origin_result.value) {
149
+ // Pull the configured branch explicitly. Without a branch, `git pull origin`
150
+ // targets the remote's default branch (origin/HEAD), which for a repo checked
151
+ // out on a non-default branch rebases the wrong branch onto it.
152
+ const pull_result = await git_ops.pull({branch: repo_config.branch, cwd: repo_dir});
153
+ if (!pull_result.ok) {
154
+ throw new TaskError(`Failed to pull in ${repo_dir}: ${pull_result.message}`);
155
+ }
156
+ }
154
157
 
155
- // Track if we got new commits
156
- const got_new_commits = commit_before !== commit_after;
158
+ // Check clean workspace after pull to ensure we're in a good state
159
+ // (skipped when `allow_dirty`, since uncommitted changes are expected then)
160
+ if (!allow_dirty) {
161
+ const clean_after_result = await git_ops.check_clean_workspace({cwd: repo_dir});
162
+ if (!clean_after_result.ok) {
163
+ throw new TaskError(
164
+ `Failed to check workspace in ${repo_dir}: ${clean_after_result.message}`,
165
+ );
166
+ }
157
167
 
158
- // Only install if package.json changed
159
- if (got_new_commits) {
160
- const changed_result = await git_ops.has_file_changed({
161
- from_commit: commit_before,
162
- to_commit: commit_after,
163
- file_path: 'package.json',
164
- cwd: repo_dir,
165
- });
168
+ if (!clean_after_result.value) {
169
+ throw new TaskError(
170
+ `Workspace ${repo_dir} is unclean after pulling branch "${repo_config.branch}"`,
171
+ );
172
+ }
173
+ }
166
174
 
167
- if (!changed_result.ok) {
175
+ // Record commit hash after pull
176
+ const commit_after_result = await git_ops.current_commit_hash({cwd: repo_dir});
177
+ if (!commit_after_result.ok) {
168
178
  throw new TaskError(
169
- `Failed to check if package.json changed in ${repo_dir}: ${changed_result.message}`,
179
+ `Failed to get commit hash in ${repo_dir}: ${commit_after_result.message}`,
170
180
  );
171
181
  }
182
+ const commit_after = commit_after_result.value;
183
+
184
+ // Track if we got new commits
185
+ const got_new_commits = commit_before !== commit_after;
186
+
187
+ // Only install if package.json changed
188
+ if (got_new_commits) {
189
+ const changed_result = await git_ops.has_file_changed({
190
+ from_commit: commit_before,
191
+ to_commit: commit_after,
192
+ file_path: 'package.json',
193
+ cwd: repo_dir,
194
+ });
172
195
 
173
- if (changed_result.value) {
174
- const install_result = await npm_ops.install({cwd: repo_dir});
175
- if (!install_result.ok) {
196
+ if (!changed_result.ok) {
176
197
  throw new TaskError(
177
- `Failed to install dependencies in ${repo_dir}: ${install_result.message}${install_result.stderr ? `\n${install_result.stderr}` : ''}`,
198
+ `Failed to check if package.json changed in ${repo_dir}: ${changed_result.message}`,
178
199
  );
179
200
  }
201
+
202
+ if (changed_result.value) {
203
+ const install_result = await npm_ops.install({cwd: repo_dir});
204
+ if (!install_result.ok) {
205
+ throw new TaskError(
206
+ `Failed to install dependencies in ${repo_dir}: ${install_result.message}${install_result.stderr ? `\n${install_result.stderr}` : ''}`,
207
+ );
208
+ }
209
+ }
180
210
  }
181
211
  }
182
212
 
183
213
  // Load library metadata via svelte-docinfo analysis (cached under `.gro/library.json`).
184
214
  let library_json: LibraryJson;
215
+ let package_json: PackageJson;
185
216
  try {
186
- library_json = await library_load_from_repo(repo_dir, {log: _log});
217
+ ({library_json, package_json} = await library_load_from_repo(repo_dir, {log: _log}));
187
218
  } catch (err) {
188
219
  const message = err instanceof Error ? err.message : String(err);
189
220
  _log?.warn(
@@ -197,14 +228,13 @@ export const local_repo_load = async ({
197
228
 
198
229
  const local_repo: LocalRepo = {
199
230
  library,
200
- library_json,
231
+ package_json,
201
232
  repo_dir,
202
233
  repo_git_ssh_url,
203
234
  repo_config,
204
235
  };
205
236
 
206
- // Extract dependencies from package_json
207
- const {package_json} = library;
237
+ // Extract dependencies from the full package_json
208
238
  if (package_json.dependencies) {
209
239
  local_repo.dependencies = new Map(Object.entries(package_json.dependencies));
210
240
  }
@@ -275,6 +305,8 @@ export const local_repos_load = async ({
275
305
  npm_ops = default_npm_operations,
276
306
  parallel = true,
277
307
  concurrency = GITOPS_CONCURRENCY_DEFAULT,
308
+ sync = true,
309
+ allow_dirty = false,
278
310
  }: {
279
311
  local_repo_paths: Array<LocalRepoPath>;
280
312
  log?: Logger;
@@ -282,12 +314,16 @@ export const local_repos_load = async ({
282
314
  npm_ops?: NpmOperations;
283
315
  parallel?: boolean;
284
316
  concurrency?: number;
317
+ sync?: boolean;
318
+ allow_dirty?: boolean;
285
319
  }): Promise<Array<LocalRepo>> => {
286
320
  if (!parallel) {
287
321
  // Sequential loading (original behavior)
288
322
  const loaded: Array<LocalRepo> = [];
289
323
  for (const local_repo_path of local_repo_paths) {
290
- loaded.push(await local_repo_load({local_repo_path, log, git_ops, npm_ops}));
324
+ loaded.push(
325
+ await local_repo_load({local_repo_path, log, git_ops, npm_ops, sync, allow_dirty}),
326
+ );
291
327
  }
292
328
  return loaded;
293
329
  }
@@ -297,7 +333,7 @@ export const local_repos_load = async ({
297
333
  local_repo_paths,
298
334
  concurrency,
299
335
  async (local_repo_path) => {
300
- return local_repo_load({local_repo_path, log, git_ops, npm_ops});
336
+ return local_repo_load({local_repo_path, log, git_ops, npm_ops, sync, allow_dirty});
301
337
  },
302
338
  );
303
339
 
@@ -13,7 +13,10 @@ export const format_wildcard_dependencies = (
13
13
  if (analysis.wildcard_deps.length === 0) return [];
14
14
 
15
15
  const lines: Array<string> = [];
16
- lines.push(st('yellow', `\n⚠️ Found ${analysis.wildcard_deps.length} wildcard dependencies:`));
16
+ // Blank separator as its own line so the logger prefixes it correctly; embedding
17
+ // a leading `\n` in the header would leave the header line unprefixed.
18
+ lines.push('');
19
+ lines.push(st('yellow', `⚠️ Found ${analysis.wildcard_deps.length} wildcard dependencies:`));
17
20
  for (const {pkg, dep, version} of analysis.wildcard_deps) {
18
21
  lines.push(` ${pkg} → ${dep} ${st('red', version)}`);
19
22
  }
@@ -30,10 +33,11 @@ export const format_dev_cycles = (
30
33
  if (analysis.dev_cycles.length === 0) return [];
31
34
 
32
35
  const lines: Array<string> = [];
36
+ lines.push('');
33
37
  lines.push(
34
38
  st(
35
39
  'dim',
36
- `\nℹ️ Found ${analysis.dev_cycles.length} dev circular dependencies (normal, non-blocking):`,
40
+ `ℹ️ Found ${analysis.dev_cycles.length} dev circular dependencies (normal, non-blocking):`,
37
41
  ),
38
42
  );
39
43
  for (const cycle of analysis.dev_cycles) {
@@ -52,10 +56,11 @@ export const format_production_cycles = (
52
56
  if (analysis.production_cycles.length === 0) return [];
53
57
 
54
58
  const lines: Array<string> = [];
59
+ lines.push('');
55
60
  lines.push(
56
61
  st(
57
62
  'red',
58
- `\n❌ Found ${analysis.production_cycles.length} production/peer circular dependencies (blocks publishing):`,
63
+ `❌ Found ${analysis.production_cycles.length} production/peer circular dependencies (blocks publishing):`,
59
64
  ),
60
65
  );
61
66
  for (const cycle of analysis.production_cycles) {
@@ -122,26 +127,3 @@ export const log_dependency_analysis = (
122
127
  log_production_cycles(analysis, log, indent);
123
128
  log_dev_cycles(analysis, log, indent);
124
129
  };
125
-
126
- /**
127
- * Logs a simple bulleted list with a header.
128
- * Common pattern for warnings, info messages, and other lists.
129
- */
130
- export const log_list = (
131
- items: Array<string>,
132
- header: string,
133
- color: 'cyan' | 'yellow' | 'red' | 'dim',
134
- log: Logger,
135
- log_method: 'info' | 'warn' | 'error' = 'info',
136
- ): void => {
137
- if (items.length === 0) return;
138
-
139
- // Only add newline prefix if header is non-empty
140
- const formatted_header = header ? `\n${header}` : '';
141
- if (formatted_header) {
142
- log[log_method](st(color, formatted_header));
143
- }
144
- for (const item of items) {
145
- log[log_method](st(color, ` • ${item}`));
146
- }
147
- };