@crossplatformai/dependency-graph 0.9.2 → 0.9.4

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.
@@ -1,4 +1,4 @@
1
- import type { DependencyGraph, GraphStats } from './types.js';
1
+ import type { DependencyGraph, GraphStats } from './types';
2
2
 
3
3
  export function analyzeGraph(graph: DependencyGraph): GraphStats {
4
4
  const leafNodes: string[] = [];
@@ -1,4 +1,4 @@
1
- import type { WorkspacePackage, DependencyGraph } from './types.js';
1
+ import type { WorkspacePackage, DependencyGraph } from './types';
2
2
 
3
3
  export function buildDependencyGraph(
4
4
  packages: WorkspacePackage[],
@@ -1,4 +1,4 @@
1
- import type { DependencyGraph, TraversalOptions } from './types.js';
1
+ import type { DependencyGraph, TraversalOptions } from './types';
2
2
 
3
3
  export function findAffectedPackages(
4
4
  startingPackages: Set<string>,
package/src/index.test.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { buildDependencyGraph } from './graph/builder.js';
2
+ import { buildDependencyGraph } from './graph/builder';
3
3
  import {
4
4
  findPackageForFile,
5
5
  mapFilesToPackages,
6
- } from './workspace/file-mapping.js';
7
- import type { WorkspacePackage } from './graph/types.js';
6
+ } from './workspace/file-mapping';
7
+ import type { WorkspacePackage } from './graph/types';
8
8
 
9
9
  describe('@repo/dependency-graph', () => {
10
10
  describe('buildDependencyGraph', () => {
package/src/index.ts CHANGED
@@ -4,37 +4,48 @@ export type {
4
4
  TraversalOptions,
5
5
  GraphStats,
6
6
  PackageJson,
7
- } from './graph/types.js';
7
+ } from './graph/types';
8
8
 
9
9
  export type {
10
10
  WorkspaceConfig,
11
11
  WorkspaceDiscoveryConfig,
12
- } from './workspace/discovery.js';
12
+ } from './workspace/discovery';
13
+
14
+ export type { FileSystemClient, GlobClient, YamlClient } from './types/clients';
13
15
 
14
16
  export type {
15
- FileSystemClient,
16
- GlobClient,
17
- YamlClient,
18
- } from './types/clients.js';
17
+ WorkflowValidationIssue,
18
+ WorkflowValidationPolicy,
19
+ WorkflowValidationResult,
20
+ } from './workflow/types';
19
21
 
20
- export { buildDependencyGraph } from './graph/builder.js';
22
+ export { buildDependencyGraph } from './graph/builder';
21
23
 
22
24
  export {
23
25
  findAffectedPackages,
24
26
  findDependencyPath,
25
27
  findAllPaths,
26
- } from './graph/traversal.js';
28
+ } from './graph/traversal';
27
29
 
28
30
  export {
29
31
  analyzeGraph,
30
32
  detectCycles,
31
33
  getTransitiveDependencies,
32
34
  getTransitiveDependents,
33
- } from './graph/analysis.js';
35
+ } from './graph/analysis';
36
+
37
+ export { discoverWorkspaces } from './workspace/discovery';
34
38
 
35
- export { discoverWorkspaces } from './workspace/discovery.js';
39
+ export {
40
+ buildPackageMap,
41
+ normalizeRelativePath,
42
+ } from './workspace/package-map';
36
43
 
37
44
  export {
38
45
  findPackageForFile,
39
46
  mapFilesToPackages,
40
- } from './workspace/file-mapping.js';
47
+ } from './workspace/file-mapping';
48
+
49
+ export { defaultWorkflowValidationPolicy } from './workflow/policy';
50
+
51
+ export { validateWorkflows } from './workflow/validator';
@@ -0,0 +1,58 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { WorkspacePackage } from '../graph/types';
4
+ import { buildPackageMap } from '../workspace/package-map';
5
+ import type { WorkflowTarget, WorkflowValidationPolicy } from './types';
6
+
7
+ function patternToRegExp(pattern: string): RegExp {
8
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
9
+ return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
10
+ }
11
+
12
+ export function discoverWorkflowTargets(
13
+ rootDir: string,
14
+ packages: WorkspacePackage[],
15
+ policy: WorkflowValidationPolicy,
16
+ ): WorkflowTarget[] {
17
+ const workflowsDir = join(rootDir, '.github/workflows');
18
+ if (!existsSync(workflowsDir)) {
19
+ return [];
20
+ }
21
+
22
+ const { packageMap } = buildPackageMap(packages, rootDir);
23
+ const appPackages = packages.filter((pkg) =>
24
+ packageMap.get(pkg.name)?.workflowPath?.startsWith('apps/'),
25
+ );
26
+ const bySlug = new Map<string, string>();
27
+
28
+ for (const pkg of appPackages) {
29
+ const relativePath = packageMap.get(pkg.name)?.workflowPath;
30
+ if (!relativePath) {
31
+ continue;
32
+ }
33
+
34
+ const slug = relativePath.split('/').at(-1);
35
+ if (slug) {
36
+ bySlug.set(slug, pkg.name);
37
+ }
38
+ }
39
+
40
+ const allowedPatterns = policy.workflowFilePatterns.map(patternToRegExp);
41
+ const files = readdirSync(workflowsDir)
42
+ .filter((file) => file.endsWith('.yml'))
43
+ .filter((file) => allowedPatterns.some((pattern) => pattern.test(file)));
44
+
45
+ return files
46
+ .map((workflowFile) => {
47
+ const match = /^(?:deploy|release)-(.+)\.yml$/.exec(workflowFile);
48
+ const targetSlug = match?.[1] ?? workflowFile.replace(/\.yml$/, '');
49
+
50
+ return {
51
+ workflowFile,
52
+ workflowPath: `.github/workflows/${workflowFile}`,
53
+ targetSlug,
54
+ targetPackage: bySlug.get(targetSlug) ?? null,
55
+ };
56
+ })
57
+ .sort((a, b) => a.workflowFile.localeCompare(b.workflowFile));
58
+ }
@@ -0,0 +1,112 @@
1
+ import type { WorkspacePackage } from '../graph/types';
2
+ import type { WorkflowTarget, WorkflowValidationPolicy } from './types';
3
+
4
+ interface ExpectedPathsOptions {
5
+ workflowTarget: WorkflowTarget;
6
+ packages: WorkspacePackage[];
7
+ packageMap: Map<string, { workflowPath: string | null }>;
8
+ policy: WorkflowValidationPolicy;
9
+ }
10
+
11
+ function uniqueSorted(values: string[]): string[] {
12
+ return Array.from(new Set(values)).sort();
13
+ }
14
+
15
+ function resolveWorkspaceDependency(
16
+ dependencyName: string,
17
+ packages: WorkspacePackage[],
18
+ ): WorkspacePackage | undefined {
19
+ let match = packages.find((pkg) => pkg.name === dependencyName);
20
+ if (match) {
21
+ return match;
22
+ }
23
+
24
+ match = packages.find((pkg) => pkg.name === `@repo/${dependencyName}`);
25
+ if (match) {
26
+ return match;
27
+ }
28
+
29
+ const nameWithoutRepo = dependencyName.replace(/^@repo\//, '');
30
+ return packages.find((pkg) => pkg.name === nameWithoutRepo);
31
+ }
32
+
33
+ function collectWorkspaceDependencyNames(
34
+ pkg: WorkspacePackage,
35
+ packages: WorkspacePackage[],
36
+ visited: Set<string>,
37
+ includeDevDependencies: boolean,
38
+ includeDevDependenciesTransitively: boolean,
39
+ ): Set<string> {
40
+ const collected = new Set<string>();
41
+ const dependencyEntries = Object.entries(pkg.dependencies);
42
+ const devDependencyEntries = includeDevDependencies
43
+ ? Object.entries(pkg.devDependencies)
44
+ : [];
45
+
46
+ for (const [dependencyName] of [
47
+ ...dependencyEntries,
48
+ ...devDependencyEntries,
49
+ ]) {
50
+ const dependency = resolveWorkspaceDependency(dependencyName, packages);
51
+ if (!dependency || visited.has(dependency.name)) {
52
+ continue;
53
+ }
54
+
55
+ visited.add(dependency.name);
56
+ collected.add(dependency.name);
57
+
58
+ const nestedDependencies = collectWorkspaceDependencyNames(
59
+ dependency,
60
+ packages,
61
+ visited,
62
+ includeDevDependenciesTransitively,
63
+ includeDevDependenciesTransitively,
64
+ );
65
+
66
+ for (const nestedDependency of nestedDependencies) {
67
+ collected.add(nestedDependency);
68
+ }
69
+ }
70
+
71
+ return collected;
72
+ }
73
+
74
+ export function getExpectedWorkflowPaths({
75
+ workflowTarget,
76
+ packages,
77
+ packageMap,
78
+ policy,
79
+ }: ExpectedPathsOptions): string[] {
80
+ const targetPackageName = workflowTarget.targetPackage;
81
+ if (!targetPackageName) {
82
+ return [];
83
+ }
84
+
85
+ const targetPackage = packages.find((pkg) => pkg.name === targetPackageName);
86
+ if (!targetPackage) {
87
+ return [];
88
+ }
89
+
90
+ const dependencyNames = collectWorkspaceDependencyNames(
91
+ targetPackage,
92
+ packages,
93
+ new Set<string>(),
94
+ policy.includeDevDependenciesForRootPackage,
95
+ policy.includeDevDependenciesTransitively,
96
+ );
97
+
98
+ const expectedPaths: string[] = [];
99
+ const targetPackagePath = packageMap.get(targetPackageName)?.workflowPath;
100
+ if (targetPackagePath) {
101
+ expectedPaths.push(`${targetPackagePath}/**`);
102
+ }
103
+
104
+ for (const dependencyName of dependencyNames) {
105
+ const dependencyPath = packageMap.get(dependencyName)?.workflowPath;
106
+ if (dependencyPath) {
107
+ expectedPaths.push(`${dependencyPath}/**`);
108
+ }
109
+ }
110
+
111
+ return uniqueSorted(expectedPaths);
112
+ }
@@ -0,0 +1,54 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { parseWorkflowFile } from './parser';
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function createTempRepo(): string {
10
+ const rootDir = mkdtempSync(join(tmpdir(), 'dependency-graph-parser-'));
11
+ tempDirs.push(rootDir);
12
+ mkdirSync(join(rootDir, '.github/workflows'), { recursive: true });
13
+ return rootDir;
14
+ }
15
+
16
+ afterEach(() => {
17
+ for (const dir of tempDirs.splice(0)) {
18
+ try {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ } catch {
21
+ // Best effort cleanup.
22
+ }
23
+ }
24
+ });
25
+
26
+ describe('parseWorkflowFile', () => {
27
+ it('unions push and pull request path filters', () => {
28
+ const rootDir = createTempRepo();
29
+ writeFileSync(
30
+ join(rootDir, '.github/workflows/deploy-web.yml'),
31
+ `name: Deploy Web
32
+ on:
33
+ push:
34
+ paths:
35
+ - 'package.json'
36
+ - 'apps/web/**'
37
+ pull_request:
38
+ paths:
39
+ - 'apps/shared/**'
40
+ - 'apps/web/**'
41
+ `,
42
+ );
43
+
44
+ const parsed = parseWorkflowFile('deploy-web.yml', rootDir);
45
+
46
+ expect(parsed.pushPaths).toEqual(['apps/web/**', 'package.json']);
47
+ expect(parsed.pullRequestPaths).toEqual(['apps/shared/**', 'apps/web/**']);
48
+ expect(parsed.paths).toEqual([
49
+ 'apps/shared/**',
50
+ 'apps/web/**',
51
+ 'package.json',
52
+ ]);
53
+ });
54
+ });
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import type { ParsedWorkflow } from './types';
5
+
6
+ interface WorkflowConfig {
7
+ name?: string;
8
+ on?: {
9
+ push?: {
10
+ paths?: string[];
11
+ };
12
+ pull_request?: {
13
+ paths?: string[];
14
+ };
15
+ };
16
+ }
17
+
18
+ function uniqueSorted(values: string[]): string[] {
19
+ return Array.from(new Set(values)).sort();
20
+ }
21
+
22
+ export function parseWorkflowFile(
23
+ workflowFile: string,
24
+ rootDir: string,
25
+ ): ParsedWorkflow {
26
+ const workflowPath = join(rootDir, '.github/workflows', workflowFile);
27
+
28
+ if (!existsSync(workflowPath)) {
29
+ throw new Error(`Workflow file not found: ${workflowFile}`);
30
+ }
31
+
32
+ const content = readFileSync(workflowPath, 'utf-8');
33
+ const workflow = parseYaml(content) as WorkflowConfig;
34
+ const pushPaths = workflow.on?.push?.paths ?? [];
35
+ const pullRequestPaths = workflow.on?.pull_request?.paths ?? [];
36
+
37
+ const parsedWorkflow: ParsedWorkflow = {
38
+ pushPaths: uniqueSorted(pushPaths),
39
+ pullRequestPaths: uniqueSorted(pullRequestPaths),
40
+ paths: uniqueSorted([...pushPaths, ...pullRequestPaths]),
41
+ };
42
+
43
+ if (workflow.name) {
44
+ parsedWorkflow.name = workflow.name;
45
+ }
46
+
47
+ return parsedWorkflow;
48
+ }
@@ -0,0 +1,13 @@
1
+ import type { WorkflowValidationPolicy } from './types';
2
+
3
+ export const defaultWorkflowValidationPolicy: WorkflowValidationPolicy = {
4
+ workflowFilePatterns: ['deploy-*.yml', 'release-*.yml'],
5
+ allowedRootPaths: [
6
+ 'package.json',
7
+ 'pnpm-lock.yaml',
8
+ 'pnpm-workspace.yaml',
9
+ 'turbo.json',
10
+ ],
11
+ includeDevDependenciesForRootPackage: true,
12
+ includeDevDependenciesTransitively: false,
13
+ };
@@ -0,0 +1,42 @@
1
+ export interface WorkflowValidationPolicy {
2
+ workflowFilePatterns: string[];
3
+ allowedRootPaths: string[];
4
+ includeDevDependenciesForRootPackage: boolean;
5
+ includeDevDependenciesTransitively: boolean;
6
+ }
7
+
8
+ export interface WorkflowValidationIssue {
9
+ kind:
10
+ | 'missing'
11
+ | 'unnecessary'
12
+ | 'broad-wildcard'
13
+ | 'parse-error'
14
+ | 'config-error';
15
+ message: string;
16
+ path?: string;
17
+ }
18
+
19
+ export interface WorkflowValidationResult {
20
+ workflow: string;
21
+ targetPackage: string;
22
+ valid: boolean;
23
+ expectedPaths: string[];
24
+ actualPaths: string[];
25
+ missing: string[];
26
+ unnecessary: string[];
27
+ issues: WorkflowValidationIssue[];
28
+ }
29
+
30
+ export interface ParsedWorkflow {
31
+ name?: string;
32
+ pushPaths: string[];
33
+ pullRequestPaths: string[];
34
+ paths: string[];
35
+ }
36
+
37
+ export interface WorkflowTarget {
38
+ workflowFile: string;
39
+ workflowPath: string;
40
+ targetSlug: string;
41
+ targetPackage: string | null;
42
+ }
@@ -0,0 +1,214 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { validateWorkflows } from './validator';
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function createTempRepo(): string {
10
+ const tempBaseDir = mkdtempSync(
11
+ join(tmpdir(), 'dependency-graph-validator-'),
12
+ );
13
+ tempDirs.push(tempBaseDir);
14
+ const rootDir = join(tempBaseDir, 'repo');
15
+ mkdirSync(join(rootDir, '.github/workflows'), { recursive: true });
16
+ return rootDir;
17
+ }
18
+
19
+ function writeJson(path: string, content: unknown): void {
20
+ writeFileSync(path, JSON.stringify(content, null, 2));
21
+ }
22
+
23
+ function writeWorkspacePackage(
24
+ rootDir: string,
25
+ relativeDir: string,
26
+ packageJson: Record<string, unknown>,
27
+ ): void {
28
+ const packageDir = join(rootDir, relativeDir);
29
+ mkdirSync(packageDir, { recursive: true });
30
+ writeJson(join(packageDir, 'package.json'), packageJson);
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const dir of tempDirs.splice(0)) {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ describe('validateWorkflows', () => {
40
+ it('uses pnpm-workspace packages including ui and ignores non-workspace paths', async () => {
41
+ const rootDir = createTempRepo();
42
+
43
+ writeJson(join(rootDir, 'package.json'), {
44
+ name: 'fixture',
45
+ packageManager: 'pnpm@10.28.0',
46
+ });
47
+
48
+ writeFileSync(
49
+ join(rootDir, 'pnpm-workspace.yaml'),
50
+ `packages:\n - 'apps/*'\n - 'ui/*'\n`,
51
+ );
52
+
53
+ writeWorkspacePackage(rootDir, 'apps/web', {
54
+ name: 'web',
55
+ dependencies: {
56
+ '@repo/shared': 'workspace:*',
57
+ },
58
+ devDependencies: {},
59
+ });
60
+ writeWorkspacePackage(rootDir, 'apps/shared', {
61
+ name: '@repo/shared',
62
+ dependencies: {
63
+ '@repo/components': 'workspace:*',
64
+ },
65
+ devDependencies: {},
66
+ });
67
+ writeWorkspacePackage(rootDir, 'ui/components', {
68
+ name: '@repo/components',
69
+ dependencies: {},
70
+ devDependencies: {},
71
+ });
72
+
73
+ mkdirSync(join(rootDir, 'scripts'), { recursive: true });
74
+ writeFileSync(join(rootDir, 'scripts/run-next.mjs'), 'export {}\n');
75
+
76
+ writeFileSync(
77
+ join(rootDir, '.github/workflows/deploy-web.yml'),
78
+ `name: Deploy Web
79
+ on:
80
+ pull_request:
81
+ paths:
82
+ - 'apps/web/**'
83
+ - 'apps/shared/**'
84
+ - 'ui/components/**'
85
+ - 'scripts/run-next.mjs'
86
+ - '.github/workflows/deploy-web.yml'
87
+ `,
88
+ );
89
+
90
+ const [result] = await validateWorkflows(rootDir);
91
+
92
+ expect(result?.valid).toBe(true);
93
+ expect(result?.expectedPaths).toEqual([
94
+ 'apps/shared/**',
95
+ 'apps/web/**',
96
+ 'ui/components/**',
97
+ ]);
98
+ expect(result?.actualPaths).toEqual([
99
+ 'apps/shared/**',
100
+ 'apps/web/**',
101
+ 'ui/components/**',
102
+ ]);
103
+ });
104
+
105
+ it('maps external workspaces to local mirrors when validating workflows', async () => {
106
+ const rootDir = createTempRepo();
107
+
108
+ writeJson(join(rootDir, 'package.json'), {
109
+ name: 'fixture',
110
+ packageManager: 'pnpm@10.28.0',
111
+ });
112
+
113
+ writeFileSync(
114
+ join(rootDir, 'pnpm-workspace.yaml'),
115
+ `packages:\n - 'apps/*'\n - '../crossplatform.ai/plugins/*'\n`,
116
+ );
117
+
118
+ writeWorkspacePackage(rootDir, 'apps/web', {
119
+ name: 'web',
120
+ dependencies: {
121
+ '@repo/design-system': 'workspace:*',
122
+ },
123
+ devDependencies: {},
124
+ });
125
+
126
+ mkdirSync(join(rootDir, 'plugins/design-system'), { recursive: true });
127
+ writeWorkspacePackage(
128
+ rootDir,
129
+ '../crossplatform.ai/plugins/design-system',
130
+ {
131
+ name: '@repo/design-system',
132
+ dependencies: {},
133
+ devDependencies: {},
134
+ },
135
+ );
136
+
137
+ writeFileSync(
138
+ join(rootDir, '.github/workflows/deploy-web.yml'),
139
+ `name: Deploy Web
140
+ on:
141
+ pull_request:
142
+ paths:
143
+ - 'apps/web/**'
144
+ - 'plugins/design-system/**'
145
+ - '.github/workflows/deploy-web.yml'
146
+ `,
147
+ );
148
+
149
+ const [result] = await validateWorkflows(rootDir);
150
+
151
+ expect(result?.valid).toBe(true);
152
+ expect(result?.expectedPaths).toEqual([
153
+ 'apps/web/**',
154
+ 'plugins/design-system/**',
155
+ ]);
156
+ });
157
+
158
+ it('flags stale package paths and broad workspace wildcards', async () => {
159
+ const rootDir = createTempRepo();
160
+
161
+ writeJson(join(rootDir, 'package.json'), {
162
+ name: 'fixture',
163
+ packageManager: 'pnpm@10.28.0',
164
+ });
165
+
166
+ writeFileSync(
167
+ join(rootDir, 'pnpm-workspace.yaml'),
168
+ `packages:\n - 'apps/*'\n - 'ui/*'\n`,
169
+ );
170
+
171
+ writeWorkspacePackage(rootDir, 'apps/web', {
172
+ name: 'web',
173
+ dependencies: {
174
+ '@repo/shared': 'workspace:*',
175
+ },
176
+ devDependencies: {},
177
+ });
178
+ writeWorkspacePackage(rootDir, 'apps/shared', {
179
+ name: '@repo/shared',
180
+ dependencies: {
181
+ '@repo/components': 'workspace:*',
182
+ },
183
+ devDependencies: {},
184
+ });
185
+ writeWorkspacePackage(rootDir, 'ui/components', {
186
+ name: '@repo/components',
187
+ dependencies: {},
188
+ devDependencies: {},
189
+ });
190
+
191
+ writeFileSync(
192
+ join(rootDir, '.github/workflows/deploy-web.yml'),
193
+ `name: Deploy Web
194
+ on:
195
+ pull_request:
196
+ paths:
197
+ - 'apps/web/**'
198
+ - 'apps/shared/**'
199
+ - 'packages/components/**'
200
+ - 'ui/**'
201
+ - '.github/workflows/deploy-web.yml'
202
+ `,
203
+ );
204
+
205
+ const [result] = await validateWorkflows(rootDir);
206
+
207
+ expect(result?.valid).toBe(false);
208
+ expect(result?.missing).toContain('ui/components/**');
209
+ expect(result?.unnecessary).toEqual(['ui/**']);
210
+ expect(
211
+ result?.issues.some((issue) => issue.kind === 'broad-wildcard'),
212
+ ).toBe(true);
213
+ });
214
+ });