@crossplatformai/dependency-graph 0.9.2

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.
@@ -0,0 +1,147 @@
1
+ import type { DependencyGraph, GraphStats } from './types.js';
2
+
3
+ export function analyzeGraph(graph: DependencyGraph): GraphStats {
4
+ const leafNodes: string[] = [];
5
+ const rootNodes: string[] = [];
6
+ let totalEdges = 0;
7
+
8
+ for (const [pkgName, dependents] of graph.dependedBy.entries()) {
9
+ if (dependents.size === 0) {
10
+ leafNodes.push(pkgName);
11
+ }
12
+ totalEdges += dependents.size;
13
+ }
14
+
15
+ for (const [pkgName, dependencies] of graph.dependsOn.entries()) {
16
+ if (dependencies.size === 0) {
17
+ rootNodes.push(pkgName);
18
+ }
19
+ }
20
+
21
+ const maxDepth = calculateMaxDepth(graph);
22
+ const cycles = detectCycles(graph);
23
+
24
+ return {
25
+ totalPackages: graph.packages.size,
26
+ totalEdges,
27
+ maxDepth,
28
+ leafNodes,
29
+ rootNodes,
30
+ cycles,
31
+ };
32
+ }
33
+
34
+ function calculateMaxDepth(graph: DependencyGraph): number {
35
+ let maxDepth = 0;
36
+
37
+ for (const pkgName of graph.packages.keys()) {
38
+ const depth = getPackageDepth(pkgName, graph);
39
+ if (depth > maxDepth) {
40
+ maxDepth = depth;
41
+ }
42
+ }
43
+
44
+ return maxDepth;
45
+ }
46
+
47
+ function getPackageDepth(
48
+ pkgName: string,
49
+ graph: DependencyGraph,
50
+ visited = new Set<string>(),
51
+ ): number {
52
+ if (visited.has(pkgName)) return 0;
53
+ visited.add(pkgName);
54
+
55
+ const dependencies = graph.dependsOn.get(pkgName) || new Set();
56
+ if (dependencies.size === 0) return 0;
57
+
58
+ let maxDepth = 0;
59
+ for (const dep of dependencies) {
60
+ const depth = getPackageDepth(dep, graph, new Set(visited));
61
+ if (depth > maxDepth) {
62
+ maxDepth = depth;
63
+ }
64
+ }
65
+
66
+ return maxDepth + 1;
67
+ }
68
+
69
+ export function detectCycles(graph: DependencyGraph): string[][] {
70
+ const cycles: string[][] = [];
71
+ const visited = new Set<string>();
72
+ const recursionStack = new Set<string>();
73
+
74
+ function dfs(pkgName: string, path: string[]): void {
75
+ visited.add(pkgName);
76
+ recursionStack.add(pkgName);
77
+ path.push(pkgName);
78
+
79
+ const dependencies = graph.dependsOn.get(pkgName) || new Set();
80
+ for (const dep of dependencies) {
81
+ if (!visited.has(dep)) {
82
+ dfs(dep, [...path]);
83
+ } else if (recursionStack.has(dep)) {
84
+ const cycleStart = path.indexOf(dep);
85
+ if (cycleStart !== -1) {
86
+ const cycle = path.slice(cycleStart);
87
+ cycle.push(dep);
88
+ cycles.push(cycle);
89
+ }
90
+ }
91
+ }
92
+
93
+ recursionStack.delete(pkgName);
94
+ }
95
+
96
+ for (const pkgName of graph.packages.keys()) {
97
+ if (!visited.has(pkgName)) {
98
+ dfs(pkgName, []);
99
+ }
100
+ }
101
+
102
+ return cycles;
103
+ }
104
+
105
+ export function getTransitiveDependencies(
106
+ pkgName: string,
107
+ graph: DependencyGraph,
108
+ ): Set<string> {
109
+ const transitive = new Set<string>();
110
+ const visited = new Set<string>();
111
+
112
+ function collect(current: string): void {
113
+ if (visited.has(current)) return;
114
+ visited.add(current);
115
+
116
+ const deps = graph.dependsOn.get(current) || new Set();
117
+ for (const dep of deps) {
118
+ transitive.add(dep);
119
+ collect(dep);
120
+ }
121
+ }
122
+
123
+ collect(pkgName);
124
+ return transitive;
125
+ }
126
+
127
+ export function getTransitiveDependents(
128
+ pkgName: string,
129
+ graph: DependencyGraph,
130
+ ): Set<string> {
131
+ const transitive = new Set<string>();
132
+ const visited = new Set<string>();
133
+
134
+ function collect(current: string): void {
135
+ if (visited.has(current)) return;
136
+ visited.add(current);
137
+
138
+ const dependents = graph.dependedBy.get(current) || new Set();
139
+ for (const dependent of dependents) {
140
+ transitive.add(dependent);
141
+ collect(dependent);
142
+ }
143
+ }
144
+
145
+ collect(pkgName);
146
+ return transitive;
147
+ }
@@ -0,0 +1,52 @@
1
+ import type { WorkspacePackage, DependencyGraph } from './types.js';
2
+
3
+ export function buildDependencyGraph(
4
+ packages: WorkspacePackage[],
5
+ ): DependencyGraph {
6
+ const graph: DependencyGraph = {
7
+ packages: new Map(),
8
+ dependsOn: new Map(),
9
+ dependedBy: new Map(),
10
+ };
11
+
12
+ for (const pkg of packages) {
13
+ graph.packages.set(pkg.name, pkg);
14
+ graph.dependsOn.set(pkg.name, new Set());
15
+ graph.dependedBy.set(pkg.name, new Set());
16
+ }
17
+
18
+ for (const pkg of packages) {
19
+ const allDeps = {
20
+ ...pkg.dependencies,
21
+ ...pkg.devDependencies,
22
+ };
23
+
24
+ for (const depName of Object.keys(allDeps)) {
25
+ const matchedPkg = findMatchingPackage(depName, packages);
26
+
27
+ if (matchedPkg) {
28
+ graph.dependsOn.get(pkg.name)!.add(matchedPkg.name);
29
+ graph.dependedBy.get(matchedPkg.name)!.add(pkg.name);
30
+ }
31
+ }
32
+ }
33
+
34
+ return graph;
35
+ }
36
+
37
+ function findMatchingPackage(
38
+ depName: string,
39
+ packages: WorkspacePackage[],
40
+ ): WorkspacePackage | undefined {
41
+ let match = packages.find((p) => p.name === depName);
42
+ if (match) return match;
43
+
44
+ match = packages.find((p) => p.name === `@repo/${depName}`);
45
+ if (match) return match;
46
+
47
+ const nameWithoutRepo = depName.replace(/^@repo\//, '');
48
+ match = packages.find((p) => p.name === nameWithoutRepo);
49
+ if (match) return match;
50
+
51
+ return undefined;
52
+ }
@@ -0,0 +1,132 @@
1
+ import type { DependencyGraph, TraversalOptions } from './types.js';
2
+
3
+ export function findAffectedPackages(
4
+ startingPackages: Set<string>,
5
+ graph: DependencyGraph,
6
+ options: TraversalOptions = {},
7
+ ): Set<string> {
8
+ const {
9
+ direction = 'upstream',
10
+ maxDepth = Infinity,
11
+ filter,
12
+ respectAffectsUpstream = false,
13
+ } = options;
14
+
15
+ const affected = new Set<string>(startingPackages);
16
+ const queue: Array<{ name: string; depth: number }> = Array.from(
17
+ startingPackages,
18
+ ).map((name) => ({ name, depth: 0 }));
19
+ const visited = new Set<string>();
20
+
21
+ while (queue.length > 0) {
22
+ const current = queue.shift()!;
23
+
24
+ if (visited.has(current.name)) continue;
25
+ visited.add(current.name);
26
+
27
+ if (current.depth >= maxDepth) {
28
+ continue;
29
+ }
30
+
31
+ const pkg = graph.packages.get(current.name);
32
+ if (!pkg) continue;
33
+
34
+ if (respectAffectsUpstream && direction === 'upstream') {
35
+ const release = pkg.packageJson.release;
36
+ if (release && release.affectsUpstream === false) {
37
+ continue;
38
+ }
39
+ }
40
+
41
+ const nextPackages = new Set<string>();
42
+
43
+ if (direction === 'upstream' || direction === 'both') {
44
+ const upstream = graph.dependedBy.get(current.name) || new Set();
45
+ upstream.forEach((p) => nextPackages.add(p));
46
+ }
47
+
48
+ if (direction === 'downstream' || direction === 'both') {
49
+ const downstream = graph.dependsOn.get(current.name) || new Set();
50
+ downstream.forEach((p) => nextPackages.add(p));
51
+ }
52
+
53
+ for (const pkgName of nextPackages) {
54
+ const nextPkg = graph.packages.get(pkgName);
55
+
56
+ if (filter && nextPkg && !filter(nextPkg)) {
57
+ continue;
58
+ }
59
+
60
+ if (!affected.has(pkgName)) {
61
+ affected.add(pkgName);
62
+ queue.push({ name: pkgName, depth: current.depth + 1 });
63
+ }
64
+ }
65
+ }
66
+
67
+ return affected;
68
+ }
69
+
70
+ export function findDependencyPath(
71
+ from: string,
72
+ to: string,
73
+ graph: DependencyGraph,
74
+ ): string[] | null {
75
+ const queue: string[][] = [[from]];
76
+ const visited = new Set<string>([from]);
77
+
78
+ while (queue.length > 0) {
79
+ const path = queue.shift()!;
80
+ const current = path[path.length - 1];
81
+
82
+ if (!current) {
83
+ continue;
84
+ }
85
+
86
+ if (current === to) {
87
+ return path;
88
+ }
89
+
90
+ const dependents = graph.dependedBy.get(current) || new Set();
91
+ for (const dependent of dependents) {
92
+ if (!visited.has(dependent)) {
93
+ visited.add(dependent);
94
+ queue.push([...path, dependent]);
95
+ }
96
+ }
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ export function findAllPaths(
103
+ from: string,
104
+ to: string,
105
+ graph: DependencyGraph,
106
+ maxPaths = 10,
107
+ ): string[][] {
108
+ const paths: string[][] = [];
109
+ const visited = new Set<string>();
110
+
111
+ function dfs(current: string, path: string[]): void {
112
+ if (paths.length >= maxPaths) return;
113
+
114
+ if (current === to) {
115
+ paths.push([...path]);
116
+ return;
117
+ }
118
+
119
+ if (visited.has(current)) return;
120
+ visited.add(current);
121
+
122
+ const dependents = graph.dependedBy.get(current) || new Set();
123
+ for (const dependent of dependents) {
124
+ dfs(dependent, [...path, dependent]);
125
+ }
126
+
127
+ visited.delete(current);
128
+ }
129
+
130
+ dfs(from, [from]);
131
+ return paths;
132
+ }
@@ -0,0 +1,50 @@
1
+ export interface PackageJson {
2
+ name: string;
3
+ version?: string;
4
+ dependencies?: Record<string, string>;
5
+ devDependencies?: Record<string, string>;
6
+ release?: {
7
+ type?: string;
8
+ enabled?: boolean;
9
+ affectsUpstream?: boolean;
10
+ platform?: string;
11
+ service?: string;
12
+ workflow?: string;
13
+ versioned?: {
14
+ enabled?: boolean;
15
+ strategy?: string;
16
+ };
17
+ };
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ export interface WorkspacePackage {
22
+ name: string;
23
+ version: string;
24
+ path: string;
25
+ packageJson: PackageJson;
26
+ dependencies: Record<string, string>;
27
+ devDependencies: Record<string, string>;
28
+ }
29
+
30
+ export interface DependencyGraph {
31
+ packages: Map<string, WorkspacePackage>;
32
+ dependsOn: Map<string, Set<string>>;
33
+ dependedBy: Map<string, Set<string>>;
34
+ }
35
+
36
+ export interface TraversalOptions {
37
+ direction?: 'upstream' | 'downstream' | 'both';
38
+ maxDepth?: number;
39
+ filter?: (pkg: WorkspacePackage) => boolean;
40
+ respectAffectsUpstream?: boolean;
41
+ }
42
+
43
+ export interface GraphStats {
44
+ totalPackages: number;
45
+ totalEdges: number;
46
+ maxDepth: number;
47
+ leafNodes: string[];
48
+ rootNodes: string[];
49
+ cycles: string[][];
50
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildDependencyGraph } from './graph/builder.js';
3
+ import {
4
+ findPackageForFile,
5
+ mapFilesToPackages,
6
+ } from './workspace/file-mapping.js';
7
+ import type { WorkspacePackage } from './graph/types.js';
8
+
9
+ describe('@repo/dependency-graph', () => {
10
+ describe('buildDependencyGraph', () => {
11
+ it('should build a graph from workspace packages', () => {
12
+ const packages: WorkspacePackage[] = [
13
+ {
14
+ name: 'pkg-a',
15
+ version: '1.0.0',
16
+ path: '/test/pkg-a',
17
+ packageJson: { name: 'pkg-a', dependencies: { 'pkg-b': '^1.0.0' } },
18
+ dependencies: { 'pkg-b': '^1.0.0' },
19
+ devDependencies: {},
20
+ },
21
+ {
22
+ name: 'pkg-b',
23
+ version: '1.0.0',
24
+ path: '/test/pkg-b',
25
+ packageJson: { name: 'pkg-b' },
26
+ dependencies: {},
27
+ devDependencies: {},
28
+ },
29
+ ];
30
+
31
+ const graph = buildDependencyGraph(packages);
32
+
33
+ expect(graph.packages.size).toBe(2);
34
+ expect(graph.dependsOn.get('pkg-a')).toContain('pkg-b');
35
+ expect(graph.dependedBy.get('pkg-b')).toContain('pkg-a');
36
+ });
37
+ });
38
+
39
+ describe('findPackageForFile', () => {
40
+ it('should find package for file path', () => {
41
+ const packages: WorkspacePackage[] = [
42
+ {
43
+ name: 'pkg-a',
44
+ version: '1.0.0',
45
+ path: '/test/pkg-a',
46
+ packageJson: { name: 'pkg-a' },
47
+ dependencies: {},
48
+ devDependencies: {},
49
+ },
50
+ ];
51
+
52
+ const pkg = findPackageForFile('/test/pkg-a/src/index.ts', packages);
53
+
54
+ expect(pkg?.name).toBe('pkg-a');
55
+ });
56
+
57
+ it('should return undefined for unknown file', () => {
58
+ const packages: WorkspacePackage[] = [];
59
+
60
+ const pkg = findPackageForFile('/unknown/file.ts', packages);
61
+
62
+ expect(pkg).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('mapFilesToPackages', () => {
67
+ it('should map files to packages', () => {
68
+ const packages: WorkspacePackage[] = [
69
+ {
70
+ name: 'pkg-a',
71
+ version: '1.0.0',
72
+ path: '/test/pkg-a',
73
+ packageJson: { name: 'pkg-a' },
74
+ dependencies: {},
75
+ devDependencies: {},
76
+ },
77
+ {
78
+ name: 'pkg-b',
79
+ version: '1.0.0',
80
+ path: '/test/pkg-b',
81
+ packageJson: { name: 'pkg-b' },
82
+ dependencies: {},
83
+ devDependencies: {},
84
+ },
85
+ ];
86
+
87
+ const files = ['/test/pkg-a/src/index.ts', '/test/pkg-b/src/other.ts'];
88
+ const fileMap = mapFilesToPackages(files, packages);
89
+
90
+ expect(fileMap.get('pkg-a')).toEqual(['/test/pkg-a/src/index.ts']);
91
+ expect(fileMap.get('pkg-b')).toEqual(['/test/pkg-b/src/other.ts']);
92
+ });
93
+ });
94
+ });
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type {
2
+ WorkspacePackage,
3
+ DependencyGraph,
4
+ TraversalOptions,
5
+ GraphStats,
6
+ PackageJson,
7
+ } from './graph/types.js';
8
+
9
+ export type {
10
+ WorkspaceConfig,
11
+ WorkspaceDiscoveryConfig,
12
+ } from './workspace/discovery.js';
13
+
14
+ export type {
15
+ FileSystemClient,
16
+ GlobClient,
17
+ YamlClient,
18
+ } from './types/clients.js';
19
+
20
+ export { buildDependencyGraph } from './graph/builder.js';
21
+
22
+ export {
23
+ findAffectedPackages,
24
+ findDependencyPath,
25
+ findAllPaths,
26
+ } from './graph/traversal.js';
27
+
28
+ export {
29
+ analyzeGraph,
30
+ detectCycles,
31
+ getTransitiveDependencies,
32
+ getTransitiveDependents,
33
+ } from './graph/analysis.js';
34
+
35
+ export { discoverWorkspaces } from './workspace/discovery.js';
36
+
37
+ export {
38
+ findPackageForFile,
39
+ mapFilesToPackages,
40
+ } from './workspace/file-mapping.js';
@@ -0,0 +1,19 @@
1
+ export interface FileSystemClient {
2
+ readFile(path: string, encoding: 'utf-8'): Promise<string>;
3
+ exists(path: string): Promise<boolean>;
4
+ }
5
+
6
+ export interface GlobClient {
7
+ glob(
8
+ pattern: string,
9
+ options: {
10
+ cwd: string;
11
+ absolute: boolean;
12
+ ignore?: string[];
13
+ },
14
+ ): Promise<string[]>;
15
+ }
16
+
17
+ export interface YamlClient {
18
+ parse(content: string): unknown;
19
+ }
@@ -0,0 +1,94 @@
1
+ import { join, resolve } from 'node:path';
2
+ import type { WorkspacePackage } from '../graph/types.js';
3
+ import type {
4
+ FileSystemClient,
5
+ GlobClient,
6
+ YamlClient,
7
+ } from '../types/clients.js';
8
+
9
+ export interface WorkspaceConfig {
10
+ packages: string[];
11
+ }
12
+
13
+ export interface WorkspaceDiscoveryConfig {
14
+ fs: FileSystemClient;
15
+ glob: GlobClient;
16
+ yaml: YamlClient;
17
+ }
18
+
19
+ export async function discoverWorkspaces(
20
+ rootDir: string,
21
+ config: WorkspaceDiscoveryConfig,
22
+ ): Promise<WorkspacePackage[]> {
23
+ const workspaceConfig = await loadWorkspaceConfig(rootDir, config);
24
+ const packages: WorkspacePackage[] = [];
25
+
26
+ for (const pattern of workspaceConfig.packages) {
27
+ if (pattern.startsWith('!')) continue;
28
+
29
+ const pkgDirs = await findPackageDirectories(rootDir, pattern, config);
30
+
31
+ for (const pkgDir of pkgDirs) {
32
+ const pkgJsonPath = join(pkgDir, 'package.json');
33
+
34
+ try {
35
+ const pkgJsonContent: string = await config.fs.readFile(
36
+ pkgJsonPath,
37
+ 'utf-8',
38
+ );
39
+ const pkgJson = JSON.parse(pkgJsonContent) as {
40
+ name: string;
41
+ version?: string;
42
+ dependencies?: Record<string, string>;
43
+ devDependencies?: Record<string, string>;
44
+ [key: string]: unknown;
45
+ };
46
+
47
+ packages.push({
48
+ name: pkgJson.name,
49
+ version: pkgJson.version || '0.0.0',
50
+ path: pkgDir,
51
+ packageJson: pkgJson,
52
+ dependencies: pkgJson.dependencies || {},
53
+ devDependencies: pkgJson.devDependencies || {},
54
+ });
55
+ } catch {
56
+ continue;
57
+ }
58
+ }
59
+ }
60
+
61
+ return packages;
62
+ }
63
+
64
+ async function loadWorkspaceConfig(
65
+ rootDir: string,
66
+ config: WorkspaceDiscoveryConfig,
67
+ ): Promise<WorkspaceConfig> {
68
+ const workspaceFilePath = join(rootDir, 'pnpm-workspace.yaml');
69
+
70
+ try {
71
+ const content: string = await config.fs.readFile(
72
+ workspaceFilePath,
73
+ 'utf-8',
74
+ );
75
+ const parsed = config.yaml.parse(content) as WorkspaceConfig;
76
+ return parsed;
77
+ } catch {
78
+ return { packages: [] };
79
+ }
80
+ }
81
+
82
+ async function findPackageDirectories(
83
+ rootDir: string,
84
+ pattern: string,
85
+ config: WorkspaceDiscoveryConfig,
86
+ ): Promise<string[]> {
87
+ const matches: string[] = await config.glob.glob(pattern, {
88
+ cwd: rootDir,
89
+ absolute: false,
90
+ ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
91
+ });
92
+
93
+ return matches.map((match: string) => resolve(rootDir, match));
94
+ }
@@ -0,0 +1,35 @@
1
+ import type { WorkspacePackage } from '../graph/types.js';
2
+
3
+ export function findPackageForFile(
4
+ filePath: string,
5
+ packages: WorkspacePackage[],
6
+ ): WorkspacePackage | undefined {
7
+ const sorted = packages.sort((a, b) => b.path.length - a.path.length);
8
+
9
+ for (const pkg of sorted) {
10
+ if (filePath.startsWith(pkg.path + '/') || filePath === pkg.path) {
11
+ return pkg;
12
+ }
13
+ }
14
+
15
+ return undefined;
16
+ }
17
+
18
+ export function mapFilesToPackages(
19
+ files: string[],
20
+ packages: WorkspacePackage[],
21
+ ): Map<string, string[]> {
22
+ const fileMap = new Map<string, string[]>();
23
+
24
+ for (const file of files) {
25
+ const pkg = findPackageForFile(file, packages);
26
+ if (pkg) {
27
+ if (!fileMap.has(pkg.name)) {
28
+ fileMap.set(pkg.name, []);
29
+ }
30
+ fileMap.get(pkg.name)!.push(file);
31
+ }
32
+ }
33
+
34
+ return fileMap;
35
+ }