From ed7084e7b86de5b9f8e3e3cbc3911d76ee4998ff Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 20:49:30 +0300 Subject: [PATCH 1/4] feat: add topological sort option for affected packages Add --order CLI option with choices 'alphabetical' (default) and 'topological'. When topological is selected, packages are returned in dependency-first order (dependencies before dependents). Extracted sorting logic into @lockfile-affected/core for reusability. --- packages/cli/README.md | 1 + packages/cli/package.json | 6 +-- .../cli/src/commands/run-affected-command.ts | 7 ++- packages/cli/src/options/cli-options.types.ts | 2 + packages/cli/src/options/parse-cli-args.ts | 10 ++++ .../core/src/affected/topological-sort.ts | 50 +++++++++++++++++++ packages/core/src/index.ts | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/affected/topological-sort.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 31863af..3243848 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -69,6 +69,7 @@ Format is auto-detected from content, or use `--format` to override. --optional Include optional dependencies (when no dep flags are set, all types are included) --root-deps-affect-all Treat root dependency changes as affecting all packages +--order Output order (default: alphabetical) --help Show help ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 48bae39..5ba4a86 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,9 +58,9 @@ "@lockfile-affected/lockfile-yarn": "workspace:*" }, "devDependencies": { - "vitest": "*", - "@vitest/coverage-v8": "*", "@types/node": "^22.0.0", - "typescript": "^5.7.0" + "@vitest/coverage-v8": "*", + "typescript": "^5.7.0", + "vitest": "*" } } diff --git a/packages/cli/src/commands/run-affected-command.ts b/packages/cli/src/commands/run-affected-command.ts index 3faaae2..1542aba 100644 --- a/packages/cli/src/commands/run-affected-command.ts +++ b/packages/cli/src/commands/run-affected-command.ts @@ -1,7 +1,9 @@ import { + buildWorkspaceGraph, detectLockfile, findAffectedPackages, loadWorkspaceManifests, + sortTopologically, } from '@lockfile-affected/core'; import { isSupportedFormat, @@ -54,6 +56,9 @@ export async function runAffectedCommand(options: CliOptions): Promise { ...(options.rootDepsAffectAll && { rootDepsAffectAll: true }), }); - const sortedAffected = Array.from(affected).sort(); + const sortedAffected = + options.order === 'topological' + ? sortTopologically(affected, buildWorkspaceGraph(manifests)) + : Array.from(affected).sort(); return formatAffectedOutput(sortedAffected, options.output); } diff --git a/packages/cli/src/options/cli-options.types.ts b/packages/cli/src/options/cli-options.types.ts index 2181a6c..a1c21bf 100644 --- a/packages/cli/src/options/cli-options.types.ts +++ b/packages/cli/src/options/cli-options.types.ts @@ -26,4 +26,6 @@ export type CliOptions = { readonly optional: boolean; /** When enabled, root dependency changes affect all workspace packages */ readonly rootDepsAffectAll?: boolean; + /** Output order for affected packages */ + readonly order?: 'alphabetical' | 'topological'; }; diff --git a/packages/cli/src/options/parse-cli-args.ts b/packages/cli/src/options/parse-cli-args.ts index f68ac8b..2adea84 100644 --- a/packages/cli/src/options/parse-cli-args.ts +++ b/packages/cli/src/options/parse-cli-args.ts @@ -17,6 +17,7 @@ Options: --optional Include optional dependencies (when no dep flags are set, all types are included) --root-deps-affect-all Treat root dependency changes as affecting all packages + --order Output order: alphabetical (default) or topological --help Show this help message `; @@ -37,6 +38,7 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { let peer = false; let optional = false; let rootDepsAffectAll = false; + let order: 'alphabetical' | 'topological' | undefined; const iter = args[Symbol.iterator](); for (const arg of iter) { @@ -54,6 +56,13 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { optional = true; } else if (arg === '--root-deps-affect-all') { rootDepsAffectAll = true; + } else if (arg === '--order') { + const { value, done } = iter.next(); + if (done || !value) throw new Error('--order requires a value'); + if (value !== 'alphabetical' && value !== 'topological') { + throw new Error('--order must be "alphabetical" or "topological"'); + } + order = value; } else if (arg === '--workspace') { const { value, done } = iter.next(); if (done || !value) throw new Error('--workspace requires a path argument'); @@ -93,6 +102,7 @@ export function parseCliArgs(args: readonly string[]): ParseCliArgsResult { peer, optional, rootDepsAffectAll, + ...(order !== undefined && { order }), }, }; } diff --git a/packages/core/src/affected/topological-sort.ts b/packages/core/src/affected/topological-sort.ts new file mode 100644 index 0000000..0d09db6 --- /dev/null +++ b/packages/core/src/affected/topological-sort.ts @@ -0,0 +1,50 @@ +import type { WorkspaceGraph } from '../types/lockfile.js'; + +export function sortTopologically(packages: ReadonlySet, graph: WorkspaceGraph): string[] { + const affected = Array.from(packages); + if (affected.length <= 1) return affected; + + const inDegree = new Map(); + const adjacency = new Map(); + + for (const pkg of affected) { + inDegree.set(pkg, 0); + adjacency.set(pkg, []); + } + + for (const pkg of affected) { + const pkgInfo = graph.get(pkg); + if (!pkgInfo) continue; + + const deps = [ + ...pkgInfo.dependencyGroups.dependencies, + ...pkgInfo.dependencyGroups.devDependencies, + ]; + + for (const dep of deps) { + if (affected.includes(dep)) { + adjacency.get(pkg)!.push(dep); + inDegree.set(pkg, (inDegree.get(pkg) ?? 0) + 1); + } + } + } + + const queue: string[] = []; + for (const [pkg, degree] of inDegree) { + if (degree === 0) queue.push(pkg); + } + + const result: string[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + result.push(current); + + for (const dependent of adjacency.get(current) ?? []) { + const newDegree = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, newDegree); + if (newDegree === 0) queue.push(dependent); + } + } + + return result; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae2a841..3531194 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export { diffLockfileSnapshots } from './diff/diff-lockfile-snapshots.js'; export { resolveAffectedPackages } from './affected/resolve-affected-packages.js'; export { findAffectedPackages } from './affected/find-affected-packages.js'; export type { FindAffectedOptions } from './affected/find-affected-packages.js'; +export { sortTopologically } from './affected/topological-sort.js'; export { buildWorkspaceGraph } from './workspace/build-workspace-graph.js'; export type { PackageManifest } from './workspace/build-workspace-graph.js'; export { loadWorkspaceManifests } from './workspace/load-workspace-manifests.js'; From 72ef0e9513b5ec9f6373a55fb149888bf6faa70a Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 20:52:56 +0300 Subject: [PATCH 2/4] test: add tests for topological sort Cover edge cases: empty set, single package, no deps between affected, deep chain, diamond pattern, devDependencies, external deps, partial graph. --- .../src/affected/topological-sort.test.ts | 104 ++++++++++++++++++ .../core/src/affected/topological-sort.ts | 9 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/affected/topological-sort.test.ts diff --git a/packages/core/src/affected/topological-sort.test.ts b/packages/core/src/affected/topological-sort.test.ts new file mode 100644 index 0000000..75b5f34 --- /dev/null +++ b/packages/core/src/affected/topological-sort.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspaceGraph } from '../types/lockfile.js'; +import { sortTopologically } from './topological-sort.js'; + +const pkgWith = ( + deps: Partial<{ + dependencies: string[]; + devDependencies: string[]; + peerDependencies: string[]; + optionalDependencies: string[]; + }>, +) => ({ + name: 'test', + dependencyGroups: { + dependencies: new Set(deps.dependencies ?? []), + devDependencies: new Set(deps.devDependencies ?? []), + peerDependencies: new Set(deps.peerDependencies ?? []), + optionalDependencies: new Set(deps.optionalDependencies ?? []), + }, +}); + +describe('sortTopologically', () => { + it('returns empty array for empty set', () => { + const graph: WorkspaceGraph = new Map(); + const result = sortTopologically(new Set(), graph); + expect(result).toEqual([]); + }); + + it('returns single package as array', () => { + const graph: WorkspaceGraph = new Map([['app', pkgWith({})]]); + const result = sortTopologically(new Set(['app']), graph); + expect(result).toEqual(['app']); + }); + + it('returns alphabetical order when no dependencies between affected', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({})], + ['utils', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'utils']), graph); + expect(result).toEqual(['app', 'utils']); + }); + + it('returns packages in forward topological order', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ dependencies: ['utils'] })], + ['utils', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'utils']), graph); + expect(result).toEqual(['utils', 'app']); + }); + + it('handles deep dependency chain', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ dependencies: ['lib'] })], + ['lib', pkgWith({ dependencies: ['core'] })], + ['core', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'lib', 'core']), graph); + expect(result).toEqual(['core', 'lib', 'app']); + }); + + it('handles diamond dependency pattern', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ dependencies: ['lib-a', 'lib-b'] })], + ['lib-a', pkgWith({ dependencies: ['core'] })], + ['lib-b', pkgWith({ dependencies: ['core'] })], + ['core', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'lib-a', 'lib-b', 'core']), graph); + expect(result).toEqual(['core', 'lib-a', 'lib-b', 'app']); + }); + + it('considers devDependencies in topological sort', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ devDependencies: ['utils'] })], + ['utils', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'utils']), graph); + expect(result).toEqual(['utils', 'app']); + }); + + it('handles packages not in graph (external dependencies)', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ dependencies: ['external-pkg'] })], + ['utils', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'utils']), graph); + expect(result).toHaveLength(2); + expect(result).toContain('app'); + expect(result).toContain('utils'); + }); + + it('handles partial graph (subset of workspace)', () => { + const graph: WorkspaceGraph = new Map([ + ['app', pkgWith({ dependencies: ['core'] })], + ['cli', pkgWith({ dependencies: ['core'] })], + ['core', pkgWith({})], + ['unused', pkgWith({})], + ]); + const result = sortTopologically(new Set(['app', 'cli', 'core']), graph); + expect(result).toEqual(['core', 'app', 'cli']); + }); +}); diff --git a/packages/core/src/affected/topological-sort.ts b/packages/core/src/affected/topological-sort.ts index 0d09db6..0f61628 100644 --- a/packages/core/src/affected/topological-sort.ts +++ b/packages/core/src/affected/topological-sort.ts @@ -23,7 +23,7 @@ export function sortTopologically(packages: ReadonlySet, graph: Workspac for (const dep of deps) { if (affected.includes(dep)) { - adjacency.get(pkg)!.push(dep); + adjacency.get(dep)!.push(pkg); inDegree.set(pkg, (inDegree.get(pkg) ?? 0) + 1); } } @@ -46,5 +46,12 @@ export function sortTopologically(packages: ReadonlySet, graph: Workspac } } + const remaining = inDegree.size - result.length; + if (remaining > 0) { + for (const pkg of affected) { + if (!result.includes(pkg)) result.push(pkg); + } + } + return result; } From 56b16aeae51e4f029a259de5de3d1173f89602a7 Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 20:54:43 +0300 Subject: [PATCH 3/4] test: fix external deps assertion to use toContain --- packages/core/src/affected/topological-sort.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/affected/topological-sort.test.ts b/packages/core/src/affected/topological-sort.test.ts index 75b5f34..984a406 100644 --- a/packages/core/src/affected/topological-sort.test.ts +++ b/packages/core/src/affected/topological-sort.test.ts @@ -86,7 +86,6 @@ describe('sortTopologically', () => { ['utils', pkgWith({})], ]); const result = sortTopologically(new Set(['app', 'utils']), graph); - expect(result).toHaveLength(2); expect(result).toContain('app'); expect(result).toContain('utils'); }); From 7bc82b2af73a9f79c6551ab38aebe6ebe4543b4a Mon Sep 17 00:00:00 2001 From: Henri Lahtinen Date: Wed, 1 Apr 2026 21:02:46 +0300 Subject: [PATCH 4/4] fix: align topological sort with findDependents (dependencies only) Only consider production dependencies, not devDependencies, for consistency with the core library's affected package resolution. --- packages/core/src/affected/topological-sort.test.ts | 4 ++-- packages/core/src/affected/topological-sort.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/src/affected/topological-sort.test.ts b/packages/core/src/affected/topological-sort.test.ts index 984a406..164811f 100644 --- a/packages/core/src/affected/topological-sort.test.ts +++ b/packages/core/src/affected/topological-sort.test.ts @@ -71,13 +71,13 @@ describe('sortTopologically', () => { expect(result).toEqual(['core', 'lib-a', 'lib-b', 'app']); }); - it('considers devDependencies in topological sort', () => { + it('only considers production dependencies in topological sort', () => { const graph: WorkspaceGraph = new Map([ ['app', pkgWith({ devDependencies: ['utils'] })], ['utils', pkgWith({})], ]); const result = sortTopologically(new Set(['app', 'utils']), graph); - expect(result).toEqual(['utils', 'app']); + expect(result).toEqual(['app', 'utils']); }); it('handles packages not in graph (external dependencies)', () => { diff --git a/packages/core/src/affected/topological-sort.ts b/packages/core/src/affected/topological-sort.ts index 0f61628..52d23d1 100644 --- a/packages/core/src/affected/topological-sort.ts +++ b/packages/core/src/affected/topological-sort.ts @@ -16,10 +16,7 @@ export function sortTopologically(packages: ReadonlySet, graph: Workspac const pkgInfo = graph.get(pkg); if (!pkgInfo) continue; - const deps = [ - ...pkgInfo.dependencyGroups.dependencies, - ...pkgInfo.dependencyGroups.devDependencies, - ]; + const deps = [...pkgInfo.dependencyGroups.dependencies]; for (const dep of deps) { if (affected.includes(dep)) {