@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.
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2024-2026 CrossPlatform.ai. All Rights Reserved.
2
+
3
+ This software is proprietary and confidential. Unauthorized copying, distribution,
4
+ modification, or use of this software, via any medium, is strictly prohibited
5
+ without the express written permission of CrossPlatform.ai.
6
+
7
+ For licensing inquiries, contact: legal@crossplatform.ai
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # @repo/dependency-graph
2
+
3
+ Shared utility plugin for workspace dependency graph analysis. Provides workspace discovery, graph building, traversal, and analysis utilities.
4
+
5
+ ## Zero Dependencies
6
+
7
+ This plugin follows the **zero dependencies** pattern. It defines interfaces for external services (`FileSystemClient`, `GlobClient`, `YamlClient`) and accepts implementations via dependency injection.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @repo/dependency-graph
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Discovering Workspaces
18
+
19
+ ```typescript
20
+ import { discoverWorkspaces } from '@repo/dependency-graph';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { glob } from 'glob';
23
+ import { parse } from 'yaml';
24
+
25
+ const packages = await discoverWorkspaces(process.cwd(), {
26
+ fs: {
27
+ readFile: async (path, encoding) => readFile(path, encoding),
28
+ exists: async (path) => {
29
+ try {
30
+ await readFile(path);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ },
36
+ },
37
+ glob: {
38
+ glob: async (pattern, options) => glob(pattern, options),
39
+ },
40
+ yaml: {
41
+ parse: (content) => parse(content),
42
+ },
43
+ });
44
+ ```
45
+
46
+ ### Building Dependency Graph
47
+
48
+ ```typescript
49
+ import { buildDependencyGraph } from '@repo/dependency-graph';
50
+
51
+ const graph = buildDependencyGraph(packages);
52
+ ```
53
+
54
+ ### Finding Affected Packages
55
+
56
+ ```typescript
57
+ import { findAffectedPackages } from '@repo/dependency-graph';
58
+
59
+ const affected = findAffectedPackages(
60
+ graph,
61
+ 'my-package',
62
+ { includeSelf: true }
63
+ );
64
+ ```
65
+
66
+ ### Detecting Cycles
67
+
68
+ ```typescript
69
+ import { detectCycles } from '@repo/dependency-graph';
70
+
71
+ const cycles = detectCycles(graph);
72
+ if (cycles.length > 0) {
73
+ console.error('Circular dependencies detected:', cycles);
74
+ }
75
+ ```
76
+
77
+ ### Mapping Files to Packages
78
+
79
+ ```typescript
80
+ import { mapFilesToPackages } from '@repo/dependency-graph';
81
+
82
+ const changedFiles = ['apps/web/src/index.ts', 'packages/ui/src/button.tsx'];
83
+ const fileMap = mapFilesToPackages(changedFiles, packages);
84
+
85
+ console.log(fileMap);
86
+ // Map { 'web' => ['apps/web/src/index.ts'], '@repo/shared' => ['packages/ui/src/button.tsx'] }
87
+ ```
88
+
89
+ ## API
90
+
91
+ ### Types
92
+
93
+ - `WorkspacePackage` - Package metadata from package.json
94
+ - `DependencyGraph` - Graph representation of package dependencies
95
+ - `DependencyNode` - Node in the dependency graph
96
+ - `GraphStats` - Statistics about the dependency graph
97
+
98
+ ### Client Interfaces (Dependency Injection)
99
+
100
+ - `FileSystemClient` - File system operations interface
101
+ - `GlobClient` - Glob pattern matching interface
102
+ - `YamlClient` - YAML parsing interface
103
+
104
+ ### Functions
105
+
106
+ #### Workspace Discovery
107
+
108
+ - `discoverWorkspaces(rootDir, config)` - Discover all workspace packages
109
+
110
+ #### Graph Building
111
+
112
+ - `buildDependencyGraph(packages)` - Build dependency graph from packages
113
+
114
+ #### Graph Traversal
115
+
116
+ - `findAffectedPackages(graph, packageName, options)` - Find all packages affected by changes
117
+ - `findDependencyPath(graph, from, to)` - Find shortest path between packages
118
+ - `findAllPaths(graph, from, to)` - Find all paths between packages
119
+
120
+ #### Graph Analysis
121
+
122
+ - `analyzeGraph(graph)` - Get comprehensive graph statistics
123
+ - `detectCycles(graph)` - Detect circular dependencies
124
+ - `getTransitiveDependencies(graph, packageName)` - Get all transitive dependencies
125
+ - `getTransitiveDependents(graph, packageName)` - Get all transitive dependents
126
+
127
+ #### File Mapping
128
+
129
+ - `findPackageForFile(filePath, packages)` - Find which package owns a file
130
+ - `mapFilesToPackages(files, packages)` - Map array of files to their packages
131
+
132
+ ## Dependency Injection Pattern
133
+
134
+ This plugin requires clients to be provided by the host application:
135
+
136
+ ```typescript
137
+ import type { WorkspaceDiscoveryConfig } from '@repo/dependency-graph';
138
+ import { readFile } from 'node:fs/promises';
139
+ import { glob } from 'glob';
140
+ import { parse as parseYaml } from 'yaml';
141
+
142
+ // Create config with real implementations
143
+ const config: WorkspaceDiscoveryConfig = {
144
+ fs: {
145
+ readFile: (path, encoding) => readFile(path, encoding),
146
+ exists: async (path) => {
147
+ try {
148
+ await readFile(path);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ },
154
+ },
155
+ glob: {
156
+ glob: (pattern, options) => glob(pattern, options),
157
+ },
158
+ yaml: {
159
+ parse: (content) => parseYaml(content),
160
+ },
161
+ };
162
+
163
+ // Use with dependency injection
164
+ const packages = await discoverWorkspaces(process.cwd(), config);
165
+ ```
166
+
167
+ ## Testing
168
+
169
+ For testing, provide mock implementations:
170
+
171
+ ```typescript
172
+ import { describe, it, expect, vi } from 'vitest';
173
+
174
+ const mockConfig = {
175
+ fs: {
176
+ readFile: vi.fn(),
177
+ exists: vi.fn(),
178
+ },
179
+ glob: {
180
+ glob: vi.fn(),
181
+ },
182
+ yaml: {
183
+ parse: vi.fn(),
184
+ },
185
+ };
186
+
187
+ // Mock implementations
188
+ mockConfig.fs.readFile.mockResolvedValue('{}');
189
+ mockConfig.glob.glob.mockResolvedValue(['apps/web', 'packages/ui']);
190
+ mockConfig.yaml.parse.mockReturnValue({ packages: ['apps/*', 'packages/*'] });
191
+
192
+ const packages = await discoverWorkspaces('/fake/root', mockConfig);
193
+ ```
194
+
195
+ ## Why Zero Dependencies?
196
+
197
+ - **Flexibility**: Use any file system, glob, or YAML library
198
+ - **Version Control**: Host controls all dependency versions
199
+ - **Testing**: Easy to mock with simple interfaces
200
+ - **Reusability**: Works across different environments (Node.js, Bun, Deno, etc.)
201
+
202
+ ## License
203
+
204
+ Apache-2.0
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@crossplatformai/dependency-graph",
3
+ "description": "Shared dependency graph plugin for CrossPlatform.ai projects",
4
+ "version": "0.9.2",
5
+ "private": false,
6
+ "type": "module",
7
+ "license": "SEE LICENSE IN LICENSE",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/crossplatformai/dependency-graph.git"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "import": "./src/index.ts"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "check-types": "tsc --noEmit --skipLibCheck",
28
+ "format": "prettier --write \"src/**/*.ts\" \"*.json\"",
29
+ "format:check": "prettier --check \"src/**/*.ts\" \"*.json\"",
30
+ "lint": "eslint . --cache --fix --max-warnings 0",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "publish:next": "npm publish --tag next",
34
+ "publish:production": "npm publish --tag latest"
35
+ },
36
+ "dependencies": {
37
+ "glob": "^11.0.1",
38
+ "yaml": "^2.7.0"
39
+ },
40
+ "devDependencies": {
41
+ "@crossplatformai/prettier-config": "^0.0.2",
42
+ "@crossplatformai/typescript-config": "^0.6.3",
43
+ "@eslint/js": "^9.39.2",
44
+ "@types/node": "^22.13.0",
45
+ "eslint": "^9.21.0",
46
+ "eslint-config-prettier": "^10.0.0",
47
+ "eslint-plugin-import": "^2.31.0",
48
+ "eslint-plugin-prettier": "^5.2.1",
49
+ "globals": "^15.13.0",
50
+ "prettier": "^3.4.2",
51
+ "typescript": "^5.8.2",
52
+ "typescript-eslint": "^8.18.0",
53
+ "vitest": "^3.0.6"
54
+ },
55
+ "prettier": "@crossplatformai/prettier-config",
56
+ "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc"
57
+ }
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { glob } from 'glob';
6
+ import { parse as parseYaml } from 'yaml';
7
+
8
+ // Import dependency graph functions from parent module
9
+ import {
10
+ discoverWorkspaces,
11
+ buildDependencyGraph,
12
+ findAffectedPackages,
13
+ type WorkspacePackage,
14
+ } from '../index.js';
15
+
16
+ interface ChangedFile {
17
+ path: string;
18
+ status: 'modified' | 'added' | 'deleted';
19
+ }
20
+
21
+ interface DeploymentIndicator {
22
+ file?: string;
23
+ field?: string;
24
+ dependency?: string;
25
+ }
26
+
27
+ interface PlatformDetection {
28
+ platform: string;
29
+ indicators: DeploymentIndicator[];
30
+ }
31
+
32
+ const WORKFLOW_MAPPING: Record<string, string> = {
33
+ 'deploy-web.yml': 'web',
34
+ 'deploy-api-origin.yml': 'api-origin',
35
+ 'deploy-api-edge.yml': 'api-edge',
36
+ 'release-mobile.yml': 'mobile',
37
+ 'release-desktop.yml': 'desktop',
38
+ 'release-cli.yml': 'cli',
39
+ };
40
+
41
+ // Convention-based deployment detection (order matters - most specific first)
42
+ const PLATFORM_INDICATORS: PlatformDetection[] = [
43
+ {
44
+ platform: 'cloudflare-workers',
45
+ indicators: [{ file: 'wrangler.toml' }],
46
+ },
47
+ {
48
+ platform: 'expo',
49
+ indicators: [{ file: 'app.json' }, { file: 'eas.json' }],
50
+ },
51
+ {
52
+ platform: 'npm-package',
53
+ indicators: [{ field: 'bin' }],
54
+ },
55
+ {
56
+ platform: 'electron',
57
+ indicators: [
58
+ { dependency: 'electron' },
59
+ { dependency: 'electron-builder' },
60
+ ],
61
+ },
62
+ {
63
+ platform: 'next.js',
64
+ indicators: [
65
+ { file: 'next.config.js' },
66
+ { file: 'next.config.mjs' },
67
+ { file: 'next.config.ts' },
68
+ { dependency: 'next' },
69
+ ],
70
+ },
71
+ {
72
+ platform: 'node.js',
73
+ indicators: [
74
+ { field: 'start' }, // has start script
75
+ ],
76
+ },
77
+ ];
78
+
79
+ const DEFAULT_APP_DIRECTORIES = ['apps'];
80
+
81
+ function isDeployableApp(pkg: WorkspacePackage): {
82
+ deployable: boolean;
83
+ platform?: string;
84
+ } {
85
+ // Check if package is in apps directory (configurable in future)
86
+ const isInAppDirectory = DEFAULT_APP_DIRECTORIES.some(
87
+ (dir) => pkg.path.includes(`/${dir}/`) || pkg.path.endsWith(`/${dir}`),
88
+ );
89
+
90
+ if (!isInAppDirectory) {
91
+ return { deployable: false };
92
+ }
93
+
94
+ // Detect platform based on indicators
95
+ for (const platformDetection of PLATFORM_INDICATORS) {
96
+ const hasIndicators = platformDetection.indicators.some((indicator) => {
97
+ if (indicator.file) {
98
+ try {
99
+ const filePath = resolve(pkg.path, indicator.file);
100
+ readFileSync(filePath, 'utf-8');
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ if (indicator.field) {
108
+ const scripts = pkg.packageJson.scripts as
109
+ | Record<string, string>
110
+ | undefined;
111
+ if (indicator.field === 'start' && scripts?.start) {
112
+ return true;
113
+ }
114
+ const packageJsonField =
115
+ pkg.packageJson[indicator.field as keyof typeof pkg.packageJson];
116
+ if (indicator.field !== 'start' && packageJsonField) {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ if (indicator.dependency) {
122
+ return !!(
123
+ pkg.dependencies[indicator.dependency] ||
124
+ pkg.devDependencies[indicator.dependency]
125
+ );
126
+ }
127
+
128
+ return false;
129
+ });
130
+
131
+ if (hasIndicators) {
132
+ return { deployable: true, platform: platformDetection.platform };
133
+ }
134
+ }
135
+
136
+ // If in apps directory but no specific platform detected, not deployable
137
+ return { deployable: false };
138
+ }
139
+
140
+ async function getChangedFiles(): Promise<ChangedFile[]> {
141
+ const { execSync } = await import('node:child_process');
142
+
143
+ try {
144
+ // Try local production first, then fall back to origin/production
145
+ let baseBranch = 'production';
146
+ try {
147
+ execSync('git rev-parse production', {
148
+ encoding: 'utf-8',
149
+ stdio: 'pipe',
150
+ });
151
+ } catch {
152
+ baseBranch = 'origin/production';
153
+ }
154
+
155
+ const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
156
+ encoding: 'utf-8',
157
+ });
158
+
159
+ return output
160
+ .trim()
161
+ .split('\n')
162
+ .filter((line) => line)
163
+ .map((line) => {
164
+ const [status, path] = line.split('\t');
165
+
166
+ return {
167
+ path: path ?? '',
168
+ status:
169
+ status === 'D' ? 'deleted' : status === 'A' ? 'added' : 'modified',
170
+ };
171
+ })
172
+ .filter((file): file is ChangedFile => file.path !== '');
173
+ } catch {
174
+ console.error(
175
+ 'Failed to get changed files. Ensure you are on a branch with commits compared to production.',
176
+ );
177
+ process.exit(1);
178
+ }
179
+ }
180
+
181
+ async function main() {
182
+ try {
183
+ const rootDir = resolve(process.cwd());
184
+ const changedFiles = await getChangedFiles();
185
+
186
+ console.log(
187
+ `\nšŸ“¦ Release Preview - Changed Files: ${changedFiles.length}\n`,
188
+ );
189
+
190
+ changedFiles.forEach((file) => {
191
+ console.log(` ${file.status === 'deleted' ? 'āŒ' : 'šŸ“'} ${file.path}`);
192
+ });
193
+
194
+ const packages = await discoverWorkspaces(rootDir, {
195
+ fs: {
196
+ readFile: (path) => {
197
+ try {
198
+ return Promise.resolve(readFileSync(path, 'utf-8'));
199
+ } catch {
200
+ return Promise.reject(new Error(`Failed to read file: ${path}`));
201
+ }
202
+ },
203
+ exists: (path) => {
204
+ try {
205
+ readFileSync(path, 'utf-8');
206
+ return Promise.resolve(true);
207
+ } catch {
208
+ return Promise.resolve(false);
209
+ }
210
+ },
211
+ },
212
+ glob: {
213
+ glob: async (pattern, options) => glob(pattern, options),
214
+ },
215
+ yaml: {
216
+ parse: (content) => parseYaml(content) as Record<string, unknown>,
217
+ },
218
+ });
219
+
220
+ const graph = buildDependencyGraph(packages);
221
+ const changedPackages = new Set<string>();
222
+
223
+ for (const file of changedFiles) {
224
+ if (file.status === 'deleted') continue;
225
+
226
+ // Convert absolute package paths to relative for comparison
227
+ const pkg = packages.find((p) => {
228
+ const relativePkgPath = p.path.replace(rootDir + '/', '');
229
+ return file.path.startsWith(relativePkgPath);
230
+ });
231
+
232
+ if (pkg) {
233
+ changedPackages.add(pkg.name);
234
+ }
235
+ }
236
+
237
+ const changedWorkflows = changedFiles.filter((f) =>
238
+ f.path.startsWith('.github/workflows/'),
239
+ );
240
+
241
+ const workflowAffectedApps = new Set<string>();
242
+ for (const workflow of changedWorkflows) {
243
+ const workflowName = workflow.path.split('/').pop();
244
+ if (workflowName && WORKFLOW_MAPPING[workflowName]) {
245
+ workflowAffectedApps.add(WORKFLOW_MAPPING[workflowName]);
246
+ }
247
+ }
248
+
249
+ console.log(
250
+ `\nšŸ“¦ Changed packages: ${Array.from(changedPackages).join(', ') || 'none'}\n`,
251
+ );
252
+
253
+ if (changedPackages.size === 0) {
254
+ console.log('\n✨ No packages changed - no deployments needed\n');
255
+ return;
256
+ }
257
+
258
+ const affected = findAffectedPackages(changedPackages, graph, {
259
+ direction: 'upstream',
260
+ respectAffectsUpstream: true,
261
+ });
262
+
263
+ // Find all deployable apps (affected and unaffected)
264
+ const allDeployableApps = packages
265
+ .map((pkg) => {
266
+ const detection = isDeployableApp(pkg);
267
+ return detection.deployable
268
+ ? { name: pkg.name, pkg, platform: detection.platform }
269
+ : null;
270
+ })
271
+ .filter(
272
+ (
273
+ item,
274
+ ): item is {
275
+ name: string;
276
+ pkg: WorkspacePackage;
277
+ platform: string | undefined;
278
+ } => item !== null,
279
+ );
280
+
281
+ // Categorize apps by deployment type
282
+ const affectedApps = allDeployableApps.filter(
283
+ (app): app is NonNullable<typeof app> =>
284
+ affected.has(app.name) || workflowAffectedApps.has(app.name),
285
+ );
286
+ const unaffectedApps = allDeployableApps.filter(
287
+ (app): app is NonNullable<typeof app> =>
288
+ !affected.has(app.name) && !workflowAffectedApps.has(app.name),
289
+ );
290
+
291
+ // Separate deploys (web-based) vs releases (installed)
292
+ const getAppCategory = (platform?: string) => {
293
+ switch (platform) {
294
+ case 'next.js':
295
+ case 'cloudflare-workers':
296
+ case 'node.js':
297
+ return 'deploy';
298
+ case 'expo':
299
+ case 'electron':
300
+ case 'npm-package':
301
+ return 'release';
302
+ default:
303
+ return 'deploy'; // default to deploy for generic
304
+ }
305
+ };
306
+
307
+ const getAppIcon = (platform?: string) => {
308
+ switch (platform) {
309
+ case 'next.js':
310
+ case 'cloudflare-workers':
311
+ case 'node.js':
312
+ return '🌐';
313
+ case 'expo':
314
+ return 'šŸ“±';
315
+ case 'electron':
316
+ return 'šŸ–„ļø';
317
+ case 'npm-package':
318
+ return '⚔';
319
+ default:
320
+ return '🌐';
321
+ }
322
+ };
323
+
324
+ const affectedDeploys = affectedApps.filter(
325
+ (app): app is NonNullable<typeof app> =>
326
+ getAppCategory(app.platform) === 'deploy',
327
+ );
328
+ const affectedReleases = affectedApps.filter(
329
+ (app): app is NonNullable<typeof app> =>
330
+ getAppCategory(app.platform) === 'release',
331
+ );
332
+
333
+ console.log(`\nšŸš€ PR Preview\n`);
334
+
335
+ if (affectedApps.length === 0) {
336
+ console.log('No apps affected by changes.\n');
337
+ } else {
338
+ if (affectedDeploys.length > 0) {
339
+ console.log('šŸ“‹ Apps that will be DEPLOYED:');
340
+ for (const item of affectedDeploys) {
341
+ const platformDisplay =
342
+ item.platform === 'generic' ? '' : ` (${item.platform})`;
343
+ const icon = getAppIcon(item.platform);
344
+ console.log(`${icon} ${item.name}${platformDisplay}`);
345
+ }
346
+ console.log('');
347
+ }
348
+
349
+ if (affectedReleases.length > 0) {
350
+ console.log('šŸ“‹ Apps that will be RELEASED:');
351
+ for (const item of affectedReleases) {
352
+ const platformDisplay =
353
+ item.platform === 'generic' ? '' : ` (${item.platform})`;
354
+ const icon = getAppIcon(item.platform);
355
+ console.log(`${icon} ${item.name}${platformDisplay}`);
356
+ }
357
+ console.log('');
358
+ }
359
+ }
360
+
361
+ if (unaffectedApps.length > 0) {
362
+ console.log(`šŸ“‹ Apps that will NOT be affected:`);
363
+ for (const item of unaffectedApps) {
364
+ const platformDisplay =
365
+ item.platform === 'generic' ? '' : ` (${item.platform})`;
366
+ console.log(`ā­ļø ${item.name}${platformDisplay}`);
367
+ }
368
+ console.log('');
369
+ }
370
+
371
+ console.log(`Legend:`);
372
+ console.log(`🌐 = Deploy (web-based, instant updates)`);
373
+ console.log(`šŸ“± = Release (mobile app, user installs)`);
374
+ console.log(`šŸ–„ļø = Release (desktop app, user installs)`);
375
+ console.log(`⚔ = Release (CLI tool, user installs)`);
376
+ console.log(`ā­ļø = Unaffected (no changes needed)`);
377
+ console.log(`šŸ“ = File changed`);
378
+ console.log(`āŒ = File deleted\n`);
379
+ } catch (error) {
380
+ console.error(
381
+ 'Error:',
382
+ error instanceof Error ? error.message : String(error),
383
+ );
384
+ process.exit(1);
385
+ }
386
+ }
387
+
388
+ void main();