bun-workspaces 1.0.0-alpha.37 → 1.0.0-alpha.38

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/README.md CHANGED
@@ -67,6 +67,13 @@ bw run lint my-alias-a my-alias-b # Run by alias (set by optional config)
67
67
  bw run lint "my-workspace-*" # Run for matching workspace names
68
68
  bw run lint "alias:my-alias-pattern-*" "path:my-glob/**/*" # Use matching specifiers
69
69
 
70
+ # A workspace's script will wait until any workspaces it depends on have completed
71
+ # Similar to Bun's --filter behavior
72
+ bw run lint --dep-order
73
+
74
+ # Continue running scripts even if a dependency fails
75
+ bw run lint --dep-order --ignore-dep-failure
76
+
70
77
  bw run lint --args="--my-appended-args" # Add args to each script call
71
78
  bw run lint --args="--my-arg=<workspaceName>" # Use the workspace name in args
72
79
 
@@ -154,6 +161,14 @@ const runManyScripts = async () => {
154
161
 
155
162
  // Optional. Whether to run the scripts in parallel
156
163
  parallel: true,
164
+
165
+ // Optional. When true, a workspace's script will wait
166
+ // until any workspaces it depends on have completed
167
+ dependencyOrder: true,
168
+
169
+ // Optional. When true and dependencyOrder is true,
170
+ // continue running scripts even if a dependency fails
171
+ ignoreDependencyFailure: true,
157
172
  });
158
173
 
159
174
  // Get a stream of script output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-workspaces",
3
- "version": "1.0.0-alpha.37",
3
+ "version": "1.0.0-alpha.38",
4
4
  "description": "A monorepo management tool for Bun, with a CLI and API to enhance Bun's native workspaces.",
5
5
  "license": "MIT",
6
6
  "main": "src/index.mjs",
@@ -161,6 +161,14 @@ export declare const CLI_COMMANDS_CONFIG: {
161
161
  readonly values: ["bun", "system", "default"];
162
162
  readonly description: "When using --inline, the shell to use to run the script";
163
163
  };
164
+ readonly depOrder: {
165
+ readonly flags: ["-d", "--dep-order"];
166
+ readonly description: "Scripts for dependent workspaces run only after their dependencies";
167
+ };
168
+ readonly ignoreDepFailure: {
169
+ readonly flags: ["-f", "--ignore-dep-failure"];
170
+ readonly description: "In dependency order, continue running scripts even if a dependency fails";
171
+ };
164
172
  readonly jsonOutfile: {
165
173
  readonly flags: ["-j", "--json-outfile <file>"];
166
174
  readonly description: "Output results in a JSON file";
@@ -304,6 +312,14 @@ export declare const getCliCommandConfig: (commandName: CliCommandName) =>
304
312
  readonly values: ["bun", "system", "default"];
305
313
  readonly description: "When using --inline, the shell to use to run the script";
306
314
  };
315
+ readonly depOrder: {
316
+ readonly flags: ["-d", "--dep-order"];
317
+ readonly description: "Scripts for dependent workspaces run only after their dependencies";
318
+ };
319
+ readonly ignoreDepFailure: {
320
+ readonly flags: ["-f", "--ignore-dep-failure"];
321
+ readonly description: "In dependency order, continue running scripts even if a dependency fails";
322
+ };
307
323
  readonly jsonOutfile: {
308
324
  readonly flags: ["-j", "--json-outfile <file>"];
309
325
  readonly description: "Output results in a JSON file";
@@ -141,6 +141,16 @@ const CLI_COMMANDS_CONFIG = {
141
141
  values: [...SCRIPT_SHELL_OPTIONS, "default"],
142
142
  description: `When using --inline, the shell to use to run the script`,
143
143
  },
144
+ depOrder: {
145
+ flags: ["-d", "--dep-order"],
146
+ description:
147
+ "Scripts for dependent workspaces run only after their dependencies",
148
+ },
149
+ ignoreDepFailure: {
150
+ flags: ["-f", "--ignore-dep-failure"],
151
+ description:
152
+ "In dependency order, continue running scripts even if a dependency fails",
153
+ },
144
154
  jsonOutfile: {
145
155
  flags: ["-j", "--json-outfile <file>"],
146
156
  description: "Output results in a JSON file",
@@ -4,12 +4,13 @@ import type {
4
4
  } from "../../../project";
5
5
  import type { OutputStreamName } from "../../../runScript";
6
6
  export type FormatRunScriptOutputOptions = {
7
+ stripDisruptiveControls?: boolean;
7
8
  prefix?: boolean;
8
9
  scriptName: string;
9
10
  };
10
11
  export declare function formatRunScriptOutput(
11
12
  output: RunScriptAcrossWorkspacesProcessOutput,
12
- { scriptName, prefix }: FormatRunScriptOutputOptions,
13
+ { scriptName, stripDisruptiveControls, prefix }: FormatRunScriptOutputOptions,
13
14
  ): AsyncGenerator<{
14
15
  line: string;
15
16
  metadata: RunWorkspaceScriptMetadata & {
@@ -1,5 +1,8 @@
1
1
  // CONCATENATED MODULE: ./src/cli/commands/runScript/formatRunScriptOutput.ts
2
- const sanitizeChunk = (input) => {
2
+ const sanitizeChunk = (input, stripDisruptiveControls = false) => {
3
+ if (!stripDisruptiveControls) {
4
+ return input.replace(/\r\n/g, "\n");
5
+ }
3
6
  // 1) Normalize newline-ish controls
4
7
  let s = input
5
8
  .replace(/\r\n/g, "\n")
@@ -90,7 +93,10 @@ const sanitizeChunk = (input) => {
90
93
  }
91
94
  return out;
92
95
  };
93
- async function* formatRunScriptOutput(output, { scriptName, prefix = false }) {
96
+ async function* formatRunScriptOutput(
97
+ output,
98
+ { scriptName, stripDisruptiveControls = true, prefix = false },
99
+ ) {
94
100
  const workspaceLineBuffers = {};
95
101
  const formatLine = (line, workspaceName, scriptName) => {
96
102
  const prefixedLine = prefix
@@ -100,7 +106,7 @@ async function* formatRunScriptOutput(output, { scriptName, prefix = false }) {
100
106
  };
101
107
  for await (const { metadata, chunk } of output.text()) {
102
108
  const workspaceName = metadata.workspace.name;
103
- const sanitizedChunk = sanitizeChunk(chunk);
109
+ const sanitizedChunk = sanitizeChunk(chunk, stripDisruptiveControls);
104
110
  const prior = workspaceLineBuffers[workspaceName] ?? "";
105
111
  const content = prior + sanitizedChunk;
106
112
  const lines = content.split("\n");
@@ -53,6 +53,9 @@ const runScript = handleProjectCommand(
53
53
  logger.debug(
54
54
  `Command: Run script ${JSON.stringify(script)} for ${workspacePatterns.length ? "workspaces " + workspacePatterns.join(", ") : "all workspaces"} (parallel: ${!!options.parallel}, args: ${JSON.stringify(scriptArgs)})`,
55
55
  );
56
+ const workspaceCount = project.findWorkspacesByPattern(
57
+ ...workspacePatterns,
58
+ ).length;
56
59
  const { output, summary } = project.runScriptAcrossWorkspaces({
57
60
  workspacePatterns: workspacePatterns.length
58
61
  ? workspacePatterns
@@ -67,6 +70,8 @@ const runScript = handleProjectCommand(
67
70
  : true
68
71
  : undefined,
69
72
  args: scriptArgs,
73
+ dependencyOrder: options.depOrder,
74
+ ignoreDependencyFailure: options.ignoreDepFailure,
70
75
  parallel:
71
76
  typeof options.parallel === "boolean" ||
72
77
  typeof options.parallel === "undefined"
@@ -87,6 +92,7 @@ const runScript = handleProjectCommand(
87
92
  for await (const { line, metadata } of formatRunScriptOutput(output, {
88
93
  prefix: options.prefix,
89
94
  scriptName,
95
+ stripDisruptiveControls: workspaceCount > 1 || !!options.parallel,
90
96
  })) {
91
97
  process[metadata.streamName].write(line);
92
98
  }
@@ -95,18 +101,29 @@ const runScript = handleProjectCommand(
95
101
  const exitResults = await summary;
96
102
  exitResults.scriptResults.forEach(
97
103
  ({ success, metadata: { workspace }, exitCode }) => {
98
- logger.info(
99
- `${success ? "✅" : "❌"} ${workspace.name}: ${scriptName}${exitCode ? ` (exited with code ${exitCode})` : ""}`,
100
- );
104
+ const isSkipped = exitCode === -1;
105
+ if (isSkipped) {
106
+ logger.info(
107
+ `➖ ${workspace.name}: ${scriptName} (skipped due to dependency failure)`,
108
+ );
109
+ } else {
110
+ logger.info(
111
+ `${success ? "✅" : "❌"} ${workspace.name}: ${scriptName}${exitCode ? ` (exited with code ${exitCode})` : ""}`,
112
+ );
113
+ }
101
114
  },
102
115
  );
103
116
  const s = exitResults.scriptResults.length === 1 ? "" : "s";
117
+ const skippedCount = exitResults.scriptResults.filter(
118
+ ({ exitCode }) => exitCode === -1,
119
+ ).length;
120
+ const skippedMessage = skippedCount ? ` (${skippedCount} skipped)` : "";
104
121
  if (exitResults.failureCount) {
105
- const message = `${exitResults.failureCount} of ${exitResults.scriptResults.length} script${s} failed`;
122
+ const message = `${exitResults.failureCount} of ${exitResults.scriptResults.length} script${s} failed${skippedMessage}`;
106
123
  logger.info(message);
107
124
  } else {
108
125
  logger.info(
109
- `${exitResults.scriptResults.length} script${s} ran successfully`,
126
+ `${exitResults.scriptResults.length} script${s} ran successfully${skippedMessage}`,
110
127
  );
111
128
  }
112
129
  if (options.jsonOutfile) {
@@ -79,6 +79,10 @@ export type RunScriptAcrossWorkspacesOptions = {
79
79
  args?: string;
80
80
  /** Whether to run the scripts in parallel (series by default) */
81
81
  parallel?: ParallelOption;
82
+ /** When `true`, run scripts so that dependent workspaces run only after their dependencies */
83
+ dependencyOrder?: boolean;
84
+ /** When `true`, continue running scripts even if a dependency fails (Only relevant when `dependencyOrder` is `true`) */
85
+ ignoreDependencyFailure?: boolean;
82
86
  };
83
87
  export type RunScriptAcrossWorkspacesOutput = Simplify<
84
88
  RunScriptsOutput<RunWorkspaceScriptMetadata>
@@ -13,6 +13,7 @@ import {
13
13
  import { checkIsRecursiveScript } from "../../runScript/recursion.mjs";
14
14
  import { resolveScriptShell } from "../../runScript/scriptShellOption.mjs";
15
15
  import { findWorkspaces, sortWorkspaces } from "../../workspaces/index.mjs";
16
+ import { preventDependencyCycles } from "../../workspaces/dependencyGraph/index.mjs";
16
17
  import { PROJECT_ERRORS } from "../errors.mjs";
17
18
  import {
18
19
  ProjectBase,
@@ -29,6 +30,7 @@ import {
29
30
  // CONCATENATED MODULE: external "../../runScript/recursion.mjs"
30
31
  // CONCATENATED MODULE: external "../../runScript/scriptShellOption.mjs"
31
32
  // CONCATENATED MODULE: external "../../workspaces/index.mjs"
33
+ // CONCATENATED MODULE: external "../../workspaces/dependencyGraph/index.mjs"
32
34
  // CONCATENATED MODULE: external "../errors.mjs"
33
35
  // CONCATENATED MODULE: external "./projectBase.mjs"
34
36
  // CONCATENATED MODULE: ./src/project/implementations/fileSystemProject.ts
@@ -166,7 +168,7 @@ class _FileSystemProject extends ProjectBase {
166
168
  this.workspaces.map((workspace) => workspace.name)
167
169
  ).flatMap((pattern) => this.findWorkspacesByPattern(pattern)),
168
170
  );
169
- const workspaces = matchedWorkspaces
171
+ let workspaces = matchedWorkspaces
170
172
  .filter(
171
173
  (workspace) =>
172
174
  options.inline || workspace.scripts.includes(options.script),
@@ -195,6 +197,15 @@ class _FileSystemProject extends ProjectBase {
195
197
  : `No matching workspaces found with script ${JSON.stringify(options.script)}`,
196
198
  );
197
199
  }
200
+ if (options.dependencyOrder) {
201
+ const cycleDetection = preventDependencyCycles(workspaces);
202
+ workspaces = cycleDetection.workspaces;
203
+ for (const cycle of cycleDetection.cycles) {
204
+ logger.warn(
205
+ `Dependency cycle detected: ${cycle.dependency} -> ${cycle.dependent} (ignoring)`,
206
+ );
207
+ }
208
+ }
198
209
  const recursiveWorkspace = workspaces.find((workspace) =>
199
210
  checkIsRecursiveScript(workspace.name, options.script),
200
211
  );
@@ -257,8 +268,16 @@ class _FileSystemProject extends ProjectBase {
257
268
  scriptCommand,
258
269
  env: createScriptRuntimeEnvVars(scriptRuntimeMetadata),
259
270
  shell,
271
+ dependsOn: options.dependencyOrder
272
+ ? workspace.dependencies
273
+ .map((dependency) =>
274
+ workspaces.findIndex((w) => w.name === dependency),
275
+ )
276
+ .filter((index) => index !== -1)
277
+ : undefined,
260
278
  };
261
279
  }),
280
+ ignoreDependencyFailure: options.ignoreDependencyFailure,
262
281
  parallel:
263
282
  options.parallel === true
264
283
  ? {
@@ -7,6 +7,8 @@ export type RunScriptExit<ScriptMetadata extends object = object> = {
7
7
  exitCode: number;
8
8
  signal: NodeJS.Signals | null;
9
9
  success: boolean;
10
+ /** Whether the script was skipped due to a failed dependency */
11
+ skipped?: boolean;
10
12
  startTimeISO: string;
11
13
  endTimeISO: string;
12
14
  durationMs: number;
@@ -4,10 +4,15 @@ import { type OutputChunk, type OutputStreamName } from "./outputChunk";
4
4
  import { type ParallelMaxValue } from "./parallel";
5
5
  import { type RunScriptExit, type RunScriptResult } from "./runScript";
6
6
  import { type ScriptCommand } from "./scriptCommand";
7
+ import { type ScriptShellOption } from "./scriptShellOption";
7
8
  export type RunScriptsScript<ScriptMetadata extends object = object> = {
8
9
  scriptCommand: ScriptCommand;
9
10
  metadata: ScriptMetadata;
10
11
  env: Record<string, string>;
12
+ /** The shell to use to run the script */
13
+ shell?: ScriptShellOption;
14
+ /** Indices of other scripts in the array that must complete before this one starts */
15
+ dependsOn?: number[];
11
16
  };
12
17
  export type RunScriptsScriptResult<ScriptMetadata extends object = object> = {
13
18
  /** The result of running the script */
@@ -49,9 +54,12 @@ export type RunScriptsParallelOptions = {
49
54
  export type RunScriptsOptions<ScriptMetadata extends object = object> = {
50
55
  scripts: RunScriptsScript<ScriptMetadata>[];
51
56
  parallel: boolean | RunScriptsParallelOptions;
57
+ /** When true, run scripts even if a dependency failed. Default: false (skip them). */
58
+ ignoreDependencyFailure?: boolean;
52
59
  };
53
60
  /** Run a list of scripts */
54
61
  export declare const runScripts: <ScriptMetadata extends object = object>({
55
62
  scripts,
56
63
  parallel,
64
+ ignoreDependencyFailure,
57
65
  }: RunScriptsOptions<ScriptMetadata>) => RunScriptsResult<ScriptMetadata>;
@@ -13,7 +13,60 @@ import { runScript } from "./runScript.mjs"; // CONCATENATED MODULE: external ".
13
13
  // CONCATENATED MODULE: external "./runScript.mjs"
14
14
  // CONCATENATED MODULE: ./src/runScript/runScripts.ts
15
15
 
16
- /** Run a list of scripts */ const runScripts = ({ scripts, parallel }) => {
16
+ /** Validate dependency indices and detect cycles via DFS */ const validateScriptDependencies =
17
+ (scripts) => {
18
+ const scriptCount = scripts.length;
19
+ for (let i = 0; i < scriptCount; i++) {
20
+ const deps = scripts[i].dependsOn;
21
+ if (!deps) continue;
22
+ for (const dep of deps) {
23
+ if (dep === i) {
24
+ throw new Error(
25
+ `Script at index ${i} has a self-referencing dependency`,
26
+ );
27
+ }
28
+ if (dep < 0 || dep >= scriptCount) {
29
+ throw new Error(
30
+ `Script at index ${i} depends on invalid index ${dep} (valid range: 0-${scriptCount - 1})`,
31
+ );
32
+ }
33
+ }
34
+ }
35
+ const WHITE = 0;
36
+ const GRAY = 1;
37
+ const BLACK = 2;
38
+ const colors = new Array(scriptCount).fill(WHITE);
39
+ const visit = (node, path) => {
40
+ colors[node] = GRAY;
41
+ const deps = scripts[node].dependsOn;
42
+ if (deps) {
43
+ for (const dep of deps) {
44
+ if (colors[dep] === GRAY) {
45
+ const cycleStart = path.indexOf(dep);
46
+ const cycle = path.slice(cycleStart);
47
+ throw new Error(
48
+ `Dependency cycle detected: ${[...cycle, dep].join(" -> ")}`,
49
+ );
50
+ }
51
+ if (colors[dep] === WHITE) {
52
+ visit(dep, [...path, dep]);
53
+ }
54
+ }
55
+ }
56
+ colors[node] = BLACK;
57
+ };
58
+ for (let i = 0; i < scriptCount; i++) {
59
+ if (colors[i] === WHITE) {
60
+ visit(i, [i]);
61
+ }
62
+ }
63
+ };
64
+ /** Run a list of scripts */ const runScripts = ({
65
+ scripts,
66
+ parallel,
67
+ ignoreDependencyFailure = false,
68
+ }) => {
69
+ validateScriptDependencies(scripts);
17
70
  const startTime = new Date();
18
71
  const scriptTriggers = scripts.map((_, index) => {
19
72
  let trigger = () => {
@@ -48,33 +101,88 @@ import { runScript } from "./runScript.mjs"; // CONCATENATED MODULE: external ".
48
101
  `Number of scripts to run in parallel (${parallelBatchSize}) is greater than the available CPUs (${recommendedParallelMax})`,
49
102
  );
50
103
  }
51
- let runningScriptCount = 0;
52
- let nextScriptIndex = 0;
53
- const queueScript = (index) => {
54
- if (runningScriptCount >= parallelMax) {
55
- return;
56
- }
57
- const scriptResult = {
58
- ...scripts[index],
59
- result: runScript({
60
- ...scripts[index],
61
- env: {
62
- ...scripts[index].env,
63
- _BW_PARALLEL_MAX: parallelMax.toString(),
64
- },
65
- }),
104
+ const pendingScripts = new Set(scripts.map((_, i) => i));
105
+ const runningScripts = new Set();
106
+ const completedScripts = new Set();
107
+ const exitResults = scripts.map(() => null);
108
+ const scriptProcessBytes = scripts.map(() => null);
109
+ const createSkippedExit = (index) => {
110
+ const now = new Date().toISOString();
111
+ return {
112
+ exitCode: -1,
113
+ signal: null,
114
+ success: false,
115
+ skipped: true,
116
+ startTimeISO: now,
117
+ endTimeISO: now,
118
+ durationMs: 0,
119
+ metadata: scripts[index].metadata,
66
120
  };
67
- scriptResults[index] = scriptResult;
68
- scriptTriggers[index].trigger();
69
- runningScriptCount++;
70
- nextScriptIndex++;
71
- scriptResults[index].result.exit.then(() => {
72
- runningScriptCount--;
73
- if (nextScriptIndex < scripts.length) {
74
- queueScript(nextScriptIndex);
121
+ };
122
+ const createSkippedResult = (index) => {
123
+ const skippedExit = createSkippedExit(index);
124
+ exitResults[index] = skippedExit;
125
+ return {
126
+ result: {
127
+ output: (async function* () {})(),
128
+ processOutput: createMultiProcessOutput([]),
129
+ exit: Promise.resolve(skippedExit),
130
+ metadata: scripts[index].metadata,
131
+ kill: () => {},
132
+ },
133
+ };
134
+ };
135
+ const hasDependencyFailure = (index) => {
136
+ const deps = scripts[index].dependsOn;
137
+ if (!deps) return false;
138
+ return deps.some(
139
+ (dep) => exitResults[dep] !== null && !exitResults[dep].success,
140
+ );
141
+ };
142
+ const areDependenciesMet = (index) => {
143
+ const deps = scripts[index].dependsOn;
144
+ if (!deps) return true;
145
+ return deps.every((dep) => completedScripts.has(dep));
146
+ };
147
+ const scheduleReadyScripts = () => {
148
+ let changed = true;
149
+ while (changed) {
150
+ changed = false;
151
+ for (const index of [...pendingScripts]) {
152
+ if (runningScripts.size >= parallelMax) return;
153
+ if (!areDependenciesMet(index)) continue;
154
+ if (!ignoreDependencyFailure && hasDependencyFailure(index)) {
155
+ pendingScripts.delete(index);
156
+ completedScripts.add(index);
157
+ scriptResults[index] = createSkippedResult(index);
158
+ scriptProcessBytes[index] = (async function* () {})();
159
+ scriptTriggers[index].trigger();
160
+ changed = true;
161
+ continue;
162
+ }
163
+ pendingScripts.delete(index);
164
+ runningScripts.add(index);
165
+ const scriptResult = {
166
+ ...scripts[index],
167
+ result: runScript({
168
+ ...scripts[index],
169
+ env: {
170
+ ...scripts[index].env,
171
+ _BW_PARALLEL_MAX: parallelMax.toString(),
172
+ },
173
+ }),
174
+ };
175
+ scriptResults[index] = scriptResult;
176
+ scriptProcessBytes[index] = scriptResult.result.processOutput.bytes();
177
+ scriptTriggers[index].trigger();
178
+ scriptResult.result.exit.then((exit) => {
179
+ runningScripts.delete(index);
180
+ completedScripts.add(index);
181
+ exitResults[index] = exit;
182
+ scheduleReadyScripts();
183
+ });
75
184
  }
76
- });
77
- return scriptResult;
185
+ }
78
186
  };
79
187
  const scriptOutputQueues = scripts.map(() =>
80
188
  ["stdout", "stderr"].map(() => createAsyncIterableQueue()),
@@ -105,9 +213,7 @@ import { runScript } from "./runScript.mjs"; // CONCATENATED MODULE: external ".
105
213
  });
106
214
  outputReaders.push(
107
215
  (async () => {
108
- for await (const chunk of scriptResults[
109
- index
110
- ].result.processOutput.bytes()) {
216
+ for await (const chunk of scriptProcessBytes[index]) {
111
217
  outputQueue.push({
112
218
  outputChunk: createOutputChunk(
113
219
  chunk.metadata.streamName,
@@ -134,7 +240,7 @@ import { runScript } from "./runScript.mjs"; // CONCATENATED MODULE: external ".
134
240
  });
135
241
  };
136
242
  const awaitSummary = async () => {
137
- scripts.forEach((_, index) => queueScript(index));
243
+ scheduleReadyScripts();
138
244
  await handleScriptProcesses();
139
245
  const scriptExitResults = await Promise.all(
140
246
  scripts.map((_, index) => scriptResults[index].result.exit),
@@ -8,6 +8,8 @@ const preventDependencyCycles = (workspaces) => {
8
8
  const inStack = new Set();
9
9
  // dedupe cycle edges
10
10
  const cyclesKeyed = new Map();
11
+ // all nodes that participate in at least one cycle
12
+ const cycleNodeSet = new Set();
11
13
  const recordCycleEdge = (dependency, dependent) => {
12
14
  const key = `${dependency}\u0000${dependent}`;
13
15
  if (!cyclesKeyed.has(key))
@@ -34,6 +36,11 @@ const preventDependencyCycles = (workspaces) => {
34
36
  // Cycle edge: current `name` depends on `dep`, and `dep` is already in the active stack
35
37
  if (inStack.has(dep)) {
36
38
  recordCycleEdge(dep, name);
39
+ // Mark every node between `dep` and `name` (inclusive) as a cycle participant.
40
+ // `name` is already at stack[stack.length - 1] since it was pushed above.
41
+ const depIndex = stack.indexOf(dep);
42
+ for (let i = depIndex; i < stack.length; i++)
43
+ cycleNodeSet.add(stack[i]);
37
44
  continue;
38
45
  }
39
46
  // Missing dependency name: treat as a leaf chain [dep, name]
@@ -62,17 +69,16 @@ const preventDependencyCycles = (workspaces) => {
62
69
  ...workspace,
63
70
  }));
64
71
  const cycles = [...cyclesKeyed.values()];
65
- for (const cycle of cycles) {
66
- const { dependency, dependent } = cycle;
67
- const dependencyWorkspace = workspaces.find((w) => w.name === dependency);
68
- const dependentWorkspace = workspaces.find((w) => w.name === dependent);
69
- if (dependencyWorkspace) {
70
- dependencyWorkspace.dependencies =
71
- dependencyWorkspace.dependencies.filter((d) => d !== dependent);
72
- }
73
- if (dependentWorkspace) {
74
- dependentWorkspace.dependents = dependentWorkspace.dependents.filter(
75
- (d) => d !== dependency,
72
+ // Remove all dependency/dependent edges between workspaces that share a cycle.
73
+ // This leaves no opinionated "winner": if two workspaces are in the same cycle,
74
+ // all edges between them are stripped.
75
+ for (const workspace of workspaces) {
76
+ if (cycleNodeSet.has(workspace.name)) {
77
+ workspace.dependencies = workspace.dependencies.filter(
78
+ (d) => !cycleNodeSet.has(d),
79
+ );
80
+ workspace.dependents = workspace.dependents.filter(
81
+ (d) => !cycleNodeSet.has(d),
76
82
  );
77
83
  }
78
84
  }