Skip to content

Commit 9f49904

Browse files
committed
replace topological sort with @fuzdev/fuz_util/sort.js
1 parent af1541f commit 9f49904

18 files changed

Lines changed: 98 additions & 302 deletions

.changeset/every-tires-wear.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@fuzdev/fuz_gitops': patch
3+
---
4+
5+
- replace topological sort with `@fuzdev/fuz_util/sort.js`
6+
- remove unused `detect_cycles()` method
7+
- deduplicate DFS cycle detection into `#find_cycles` helper

docs/publishing.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@ The system uses topological sort with dev dependency exclusion:
130130
cycles
131131
- **Publishing order**: Computed via topological sort on prod/peer deps only
132132
- Ensures dependencies publish before dependents
133-
- Deterministic and reproducible (alphabetically sorted within dependency
134-
tiers)
133+
- Deterministic and reproducible
135134
- Dev dependencies updated in separate phase after all publishing completes
136135
- **Dependency priority**: When a package appears in multiple dependency types,
137136
production/peer takes priority over dev

src/lib/dependency_graph.ts

Lines changed: 42 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
/**
22
* Dependency graph data structure and algorithms for multi-repo publishing.
33
*
4-
* Provides `DependencyGraph` class with topological sort and cycle detection.
4+
* Provides `DependencyGraph` class with topological sort (via `@fuzdev/fuz_util/sort.js`)
5+
* and cycle detection by dependency type.
56
* For validation workflow and publishing order computation, see `graph_validation.ts`.
67
*
78
* @module
89
*/
910

10-
import type {LocalRepo} from './local_repo.js';
1111
import {EMPTY_OBJECT} from '@fuzdev/fuz_util/object.js';
12+
import {topological_sort as topological_sort_generic} from '@fuzdev/fuz_util/sort.js';
13+
14+
import type {LocalRepo} from './local_repo.js';
1215

1316
export const DEPENDENCY_TYPE = {
1417
PROD: 'prod',
@@ -121,120 +124,29 @@ export class DependencyGraph {
121124
/**
122125
* Computes topological sort order for dependency graph.
123126
*
124-
* Uses Kahn's algorithm with alphabetical ordering within tiers for
125-
* deterministic results. Throws if cycles detected.
127+
* Delegates to `@fuzdev/fuz_util/sort.js` for the sorting algorithm.
128+
* Throws if cycles detected.
126129
*
127130
* @param exclude_dev if true, excludes dev dependencies to break cycles.
128131
* Publishing uses exclude_dev=true to handle circular dev deps.
129132
* @returns array of package names in dependency order (dependencies before dependents)
130133
* @throws {Error} if circular dependencies detected in included dependency types
131134
*/
132135
topological_sort(exclude_dev = false): Array<string> {
133-
const visited: Set<string> = new Set();
134-
const result: Array<string> = [];
135-
136-
// Count incoming edges for each node
137-
const in_degree: Map<string, number> = new Map();
138-
for (const name of this.nodes.keys()) {
139-
in_degree.set(name, 0);
140-
}
141-
for (const node of this.nodes.values()) {
142-
for (const [dep_name, spec] of node.dependencies) {
143-
// Skip dev dependencies if requested
144-
if (exclude_dev && spec.type === DEPENDENCY_TYPE.DEV) continue;
145-
146-
if (this.nodes.has(dep_name)) {
147-
in_degree.set(node.name, in_degree.get(node.name)! + 1);
148-
}
149-
}
150-
}
151-
152-
// Start with nodes that have no dependencies
153-
const queue: Array<string> = [];
154-
for (const [name, degree] of in_degree) {
155-
if (degree === 0) {
156-
queue.push(name);
157-
}
158-
}
159-
160-
// Sort initial queue alphabetically for deterministic ordering within tier
161-
queue.sort();
162-
163-
// Process nodes
164-
while (queue.length > 0) {
165-
const name = queue.shift()!;
166-
result.push(name);
167-
visited.add(name);
168-
169-
// Reduce in-degree for dependents
170-
const node = this.nodes.get(name);
171-
if (node) {
172-
// Find packages that depend on this one
173-
// Sort nodes to ensure deterministic iteration order
174-
const sorted_nodes = Array.from(this.nodes.values()).sort((a, b) =>
175-
a.name.localeCompare(b.name),
176-
);
177-
for (const other_node of sorted_nodes) {
178-
for (const [dep_name, spec] of other_node.dependencies) {
179-
// Skip dev dependencies if requested
180-
if (exclude_dev && spec.type === DEPENDENCY_TYPE.DEV) continue;
181-
182-
if (dep_name === name) {
183-
const new_degree = in_degree.get(other_node.name)! - 1;
184-
in_degree.set(other_node.name, new_degree);
185-
if (new_degree === 0) {
186-
queue.push(other_node.name);
187-
}
188-
}
189-
}
190-
}
191-
}
192-
}
193-
194-
// Check for cycles
195-
if (result.length !== this.nodes.size) {
196-
const unvisited = Array.from(this.nodes.keys()).filter((n) => !visited.has(n));
197-
throw new Error(`Circular dependency detected involving: ${unvisited.join(', ')}`);
198-
}
199-
200-
return result;
201-
}
202-
203-
detect_cycles(): Array<Array<string>> {
204-
const cycles: Array<Array<string>> = [];
205-
const visited: Set<string> = new Set();
206-
const rec_stack: Set<string> = new Set();
207-
208-
const dfs = (name: string, path: Array<string>): void => {
209-
visited.add(name);
210-
rec_stack.add(name);
211-
path.push(name);
212-
213-
const node = this.nodes.get(name);
214-
if (node) {
215-
for (const [dep_name] of node.dependencies) {
216-
if (this.nodes.has(dep_name)) {
217-
if (!visited.has(dep_name)) {
218-
dfs(dep_name, [...path]);
219-
} else if (rec_stack.has(dep_name)) {
220-
// Found a cycle
221-
const cycle_start = path.indexOf(dep_name);
222-
cycles.push(path.slice(cycle_start).concat(dep_name));
223-
}
224-
}
225-
}
226-
}
227-
228-
rec_stack.delete(name);
229-
};
230-
231-
for (const name of this.nodes.keys()) {
232-
if (!visited.has(name)) {
233-
dfs(name, []);
234-
}
136+
const items = Array.from(this.nodes.values()).map((node) => ({
137+
id: node.name,
138+
depends_on: Array.from(node.dependencies.entries())
139+
.filter(([dep_name, spec]) => {
140+
if (exclude_dev && spec.type === DEPENDENCY_TYPE.DEV) return false;
141+
return this.nodes.has(dep_name);
142+
})
143+
.map(([dep_name]) => dep_name),
144+
}));
145+
const result = topological_sort_generic(items, 'package');
146+
if (!result.ok) {
147+
throw new Error(result.error);
235148
}
236-
237-
return cycles;
149+
return result.sorted.map((item) => item.id);
238150
}
239151

240152
/**
@@ -252,94 +164,53 @@ export class DependencyGraph {
252164
production_cycles: Array<Array<string>>;
253165
dev_cycles: Array<Array<string>>;
254166
} {
255-
const production_cycles: Array<Array<string>> = [];
256-
const dev_cycles: Array<Array<string>> = [];
257-
const visited_prod: Set<string> = new Set();
258-
const visited_dev: Set<string> = new Set();
259-
const rec_stack_prod: Set<string> = new Set();
260-
const rec_stack_dev: Set<string> = new Set();
261-
262-
// DFS for production/peer dependencies only
263-
const dfs_prod = (name: string, path: Array<string>): void => {
264-
visited_prod.add(name);
265-
rec_stack_prod.add(name);
266-
path.push(name);
267-
268-
const node = this.nodes.get(name);
269-
if (node) {
270-
for (const [dep_name, spec] of node.dependencies) {
271-
// Skip dev dependencies
272-
if (spec.type === DEPENDENCY_TYPE.DEV) continue;
273-
274-
if (this.nodes.has(dep_name)) {
275-
if (!visited_prod.has(dep_name)) {
276-
dfs_prod(dep_name, [...path]);
277-
} else if (rec_stack_prod.has(dep_name)) {
278-
// Found a production cycle
279-
const cycle_start = path.indexOf(dep_name);
280-
const cycle = path.slice(cycle_start).concat(dep_name);
281-
// Check if this cycle is unique
282-
const cycle_key = [...cycle].sort().join(',');
283-
const exists = production_cycles.some((c) => [...c].sort().join(',') === cycle_key);
284-
if (!exists) {
285-
production_cycles.push(cycle);
286-
}
287-
}
288-
}
289-
}
290-
}
167+
const production_cycles = this.#find_cycles((spec) => spec.type !== DEPENDENCY_TYPE.DEV);
168+
const dev_cycles = this.#find_cycles((spec) => spec.type === DEPENDENCY_TYPE.DEV);
169+
return {production_cycles, dev_cycles};
170+
}
291171

292-
rec_stack_prod.delete(name);
293-
};
172+
/** DFS cycle detection following only edges that match the filter. */
173+
#find_cycles(include: (spec: DependencySpec) => boolean): Array<Array<string>> {
174+
const cycles: Array<Array<string>> = [];
175+
const visited: Set<string> = new Set();
176+
const rec_stack: Set<string> = new Set();
294177

295-
// DFS for dev dependencies only
296-
const dfs_dev = (name: string, path: Array<string>): void => {
297-
visited_dev.add(name);
298-
rec_stack_dev.add(name);
178+
const dfs = (name: string, path: Array<string>): void => {
179+
visited.add(name);
180+
rec_stack.add(name);
299181
path.push(name);
300182

301183
const node = this.nodes.get(name);
302184
if (node) {
303185
for (const [dep_name, spec] of node.dependencies) {
304-
// Only check dev dependencies
305-
if (spec.type !== DEPENDENCY_TYPE.DEV) continue;
186+
if (!include(spec)) continue;
306187

307188
if (this.nodes.has(dep_name)) {
308-
if (!visited_dev.has(dep_name)) {
309-
dfs_dev(dep_name, [...path]);
310-
} else if (rec_stack_dev.has(dep_name)) {
311-
// Found a dev cycle
189+
if (!visited.has(dep_name)) {
190+
dfs(dep_name, [...path]);
191+
} else if (rec_stack.has(dep_name)) {
312192
const cycle_start = path.indexOf(dep_name);
313193
const cycle = path.slice(cycle_start).concat(dep_name);
314-
// Check if this cycle is unique
315194
const cycle_key = [...cycle].sort().join(',');
316-
const exists = dev_cycles.some((c) => [...c].sort().join(',') === cycle_key);
195+
const exists = cycles.some((c) => [...c].sort().join(',') === cycle_key);
317196
if (!exists) {
318-
dev_cycles.push(cycle);
197+
cycles.push(cycle);
319198
}
320199
}
321200
}
322201
}
323202
}
324203

325-
rec_stack_dev.delete(name);
204+
rec_stack.delete(name);
326205
};
327206

328-
// Check for production/peer cycles
329-
for (const name of this.nodes.keys()) {
330-
if (!visited_prod.has(name)) {
331-
dfs_prod(name, []);
332-
}
333-
}
334-
335-
// Check for dev cycles
336207
for (const name of this.nodes.keys()) {
337-
if (!visited_dev.has(name)) {
338-
dfs_dev(name, []);
208+
if (!visited.has(name)) {
209+
dfs(name, []);
339210
}
340211
}
341212

342-
return {production_cycles, dev_cycles};
213+
return cycles;
343214
}
344215

345216
toJSON(): DependencyGraphJson {

src/lib/semver.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// TODO: candidate for extraction to `@fuzdev/fuz_util`
2+
13
/**
24
* Semantic Versioning 2.0.0 utilities
35
* @see https://semver.org/

src/lib/version_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// TODO: candidate for extraction to `@fuzdev/fuz_util`
2+
13
import type {BumpType} from './semver.js';
24

35
export const is_wildcard = (version: string): boolean => {

0 commit comments

Comments
 (0)