@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.
- package/README.md +36 -22
- package/package.json +25 -28
- package/src/cli/pr-preview.ts +1 -1
- package/src/cli/validate-workflows.ts +63 -598
- package/src/graph/analysis.ts +1 -1
- package/src/graph/builder.ts +1 -1
- package/src/graph/traversal.ts +1 -1
- package/src/index.test.ts +3 -3
- package/src/index.ts +22 -11
- package/src/workflow/discovery.ts +58 -0
- package/src/workflow/expected-paths.ts +112 -0
- package/src/workflow/parser.test.ts +54 -0
- package/src/workflow/parser.ts +48 -0
- package/src/workflow/policy.ts +13 -0
- package/src/workflow/types.ts +42 -0
- package/src/workflow/validator.test.ts +214 -0
- package/src/workflow/validator.ts +230 -0
- package/src/workspace/discovery.ts +2 -2
- package/src/workspace/file-mapping.ts +1 -1
- package/src/workspace/package-map.test.ts +95 -0
- package/src/workspace/package-map.ts +74 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { discoverWorkspaces } from '../workspace/discovery';
|
|
6
|
+
import { buildPackageMap } from '../workspace/package-map';
|
|
7
|
+
import type {
|
|
8
|
+
WorkflowValidationIssue,
|
|
9
|
+
WorkflowValidationPolicy,
|
|
10
|
+
WorkflowValidationResult,
|
|
11
|
+
} from './types';
|
|
12
|
+
import { discoverWorkflowTargets } from './discovery';
|
|
13
|
+
import { getExpectedWorkflowPaths } from './expected-paths';
|
|
14
|
+
import { parseWorkflowFile } from './parser';
|
|
15
|
+
import { defaultWorkflowValidationPolicy } from './policy';
|
|
16
|
+
|
|
17
|
+
function uniqueSorted(values: string[]): string[] {
|
|
18
|
+
return Array.from(new Set(values)).sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildBroadWildcards(workspaceRoots: Set<string>): Set<string> {
|
|
22
|
+
const wildcards = new Set<string>();
|
|
23
|
+
|
|
24
|
+
for (const root of workspaceRoots) {
|
|
25
|
+
wildcards.add(`${root}/*`);
|
|
26
|
+
wildcards.add(`${root}/**`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return wildcards;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function splitActualPaths(
|
|
33
|
+
paths: string[],
|
|
34
|
+
workspaceRoots: Set<string>,
|
|
35
|
+
allowedRootPaths: string[],
|
|
36
|
+
workflowPath: string,
|
|
37
|
+
): { workspacePaths: string[]; ignoredPaths: string[] } {
|
|
38
|
+
const workspacePaths: string[] = [];
|
|
39
|
+
const ignoredPaths: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
if (path === workflowPath || allowedRootPaths.includes(path)) {
|
|
43
|
+
ignoredPaths.push(path);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [rootSegment] = path.split('/');
|
|
48
|
+
if (rootSegment && workspaceRoots.has(rootSegment)) {
|
|
49
|
+
workspacePaths.push(path);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ignoredPaths.push(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
workspacePaths: uniqueSorted(workspacePaths),
|
|
58
|
+
ignoredPaths: uniqueSorted(ignoredPaths),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCoveredByExpectedPath(
|
|
63
|
+
actualPath: string,
|
|
64
|
+
expectedPaths: string[],
|
|
65
|
+
): boolean {
|
|
66
|
+
return expectedPaths.some((expectedPath) => {
|
|
67
|
+
if (expectedPath === actualPath) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!expectedPath.endsWith('/**')) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const expectedPrefix = expectedPath.slice(0, -3);
|
|
76
|
+
return actualPath.startsWith(`${expectedPrefix}/`);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateWorkflowResult(
|
|
81
|
+
workflow: string,
|
|
82
|
+
targetPackage: string,
|
|
83
|
+
expectedPaths: string[],
|
|
84
|
+
actualPaths: string[],
|
|
85
|
+
broadWildcards: Set<string>,
|
|
86
|
+
): WorkflowValidationResult {
|
|
87
|
+
const issues: WorkflowValidationIssue[] = [];
|
|
88
|
+
const missing = expectedPaths.filter((path) => !actualPaths.includes(path));
|
|
89
|
+
const unnecessary = actualPaths.filter(
|
|
90
|
+
(path) => !isCoveredByExpectedPath(path, expectedPaths),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
for (const path of missing) {
|
|
94
|
+
issues.push({
|
|
95
|
+
kind: 'missing',
|
|
96
|
+
path,
|
|
97
|
+
message: `Missing path '${path}'`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const path of unnecessary) {
|
|
102
|
+
issues.push({
|
|
103
|
+
kind: 'unnecessary',
|
|
104
|
+
path,
|
|
105
|
+
message: `Unnecessary path '${path}'`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const path of actualPaths) {
|
|
110
|
+
if (broadWildcards.has(path)) {
|
|
111
|
+
issues.push({
|
|
112
|
+
kind: 'broad-wildcard',
|
|
113
|
+
path,
|
|
114
|
+
message: `Uses broad wildcard '${path}' which triggers on all workspace changes under that root`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
workflow,
|
|
121
|
+
targetPackage,
|
|
122
|
+
valid: issues.length === 0,
|
|
123
|
+
expectedPaths,
|
|
124
|
+
actualPaths,
|
|
125
|
+
missing,
|
|
126
|
+
unnecessary,
|
|
127
|
+
issues,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function validateWorkflows(
|
|
132
|
+
rootDir: string,
|
|
133
|
+
policyOverrides?: Partial<WorkflowValidationPolicy>,
|
|
134
|
+
): Promise<WorkflowValidationResult[]> {
|
|
135
|
+
const discoveredPackages = await discoverWorkspaces(rootDir, {
|
|
136
|
+
fs: {
|
|
137
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
138
|
+
exists: (path) => Promise.resolve(existsSync(path)),
|
|
139
|
+
},
|
|
140
|
+
glob: {
|
|
141
|
+
glob: (pattern, options) => glob(pattern, options),
|
|
142
|
+
},
|
|
143
|
+
yaml: {
|
|
144
|
+
parse: (content): unknown => parseYaml(content),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const policy: WorkflowValidationPolicy = {
|
|
149
|
+
...defaultWorkflowValidationPolicy,
|
|
150
|
+
...policyOverrides,
|
|
151
|
+
allowedRootPaths: uniqueSorted([
|
|
152
|
+
...defaultWorkflowValidationPolicy.allowedRootPaths,
|
|
153
|
+
...(policyOverrides?.allowedRootPaths ?? []),
|
|
154
|
+
]),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const { packageMap, workspaceRoots } = buildPackageMap(
|
|
158
|
+
discoveredPackages,
|
|
159
|
+
rootDir,
|
|
160
|
+
);
|
|
161
|
+
const workflowTargets = discoverWorkflowTargets(
|
|
162
|
+
rootDir,
|
|
163
|
+
discoveredPackages,
|
|
164
|
+
policy,
|
|
165
|
+
);
|
|
166
|
+
const broadWildcards = buildBroadWildcards(workspaceRoots);
|
|
167
|
+
|
|
168
|
+
return workflowTargets.map((workflowTarget) => {
|
|
169
|
+
if (!workflowTarget.targetPackage) {
|
|
170
|
+
return {
|
|
171
|
+
workflow: workflowTarget.workflowFile,
|
|
172
|
+
targetPackage: workflowTarget.targetSlug,
|
|
173
|
+
valid: false,
|
|
174
|
+
expectedPaths: [],
|
|
175
|
+
actualPaths: [],
|
|
176
|
+
missing: [],
|
|
177
|
+
unnecessary: [],
|
|
178
|
+
issues: [
|
|
179
|
+
{
|
|
180
|
+
kind: 'config-error',
|
|
181
|
+
message: `Could not resolve workflow target package for '${workflowTarget.workflowFile}'`,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const parsedWorkflow = parseWorkflowFile(
|
|
189
|
+
workflowTarget.workflowFile,
|
|
190
|
+
rootDir,
|
|
191
|
+
);
|
|
192
|
+
const { workspacePaths: actualPaths } = splitActualPaths(
|
|
193
|
+
parsedWorkflow.paths,
|
|
194
|
+
workspaceRoots,
|
|
195
|
+
policy.allowedRootPaths,
|
|
196
|
+
workflowTarget.workflowPath,
|
|
197
|
+
);
|
|
198
|
+
const expectedPaths = getExpectedWorkflowPaths({
|
|
199
|
+
workflowTarget,
|
|
200
|
+
packages: discoveredPackages,
|
|
201
|
+
packageMap,
|
|
202
|
+
policy,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return validateWorkflowResult(
|
|
206
|
+
workflowTarget.workflowFile,
|
|
207
|
+
workflowTarget.targetPackage,
|
|
208
|
+
expectedPaths,
|
|
209
|
+
actualPaths,
|
|
210
|
+
broadWildcards,
|
|
211
|
+
);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return {
|
|
214
|
+
workflow: workflowTarget.workflowFile,
|
|
215
|
+
targetPackage: workflowTarget.targetPackage,
|
|
216
|
+
valid: false,
|
|
217
|
+
expectedPaths: [],
|
|
218
|
+
actualPaths: [],
|
|
219
|
+
missing: [],
|
|
220
|
+
unnecessary: [],
|
|
221
|
+
issues: [
|
|
222
|
+
{
|
|
223
|
+
kind: 'parse-error',
|
|
224
|
+
message: error instanceof Error ? error.message : String(error),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path';
|
|
2
|
-
import type { WorkspacePackage } from '../graph/types
|
|
2
|
+
import type { WorkspacePackage } from '../graph/types';
|
|
3
3
|
import type {
|
|
4
4
|
FileSystemClient,
|
|
5
5
|
GlobClient,
|
|
6
6
|
YamlClient,
|
|
7
|
-
} from '../types/clients
|
|
7
|
+
} from '../types/clients';
|
|
8
8
|
|
|
9
9
|
export interface WorkspaceConfig {
|
|
10
10
|
packages: string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync } 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 type { WorkspacePackage } from '../graph/types';
|
|
6
|
+
import { buildPackageMap } from './package-map';
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
function createTempRepo(): string {
|
|
11
|
+
const tempBaseDir = mkdtempSync(
|
|
12
|
+
join(tmpdir(), 'dependency-graph-package-map-'),
|
|
13
|
+
);
|
|
14
|
+
tempDirs.push(tempBaseDir);
|
|
15
|
+
const rootDir = join(tempBaseDir, 'repo');
|
|
16
|
+
mkdirSync(rootDir, { recursive: true });
|
|
17
|
+
return rootDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createWorkspacePackage(path: string, name: string): WorkspacePackage {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
version: '0.0.0',
|
|
24
|
+
path,
|
|
25
|
+
packageJson: { name },
|
|
26
|
+
dependencies: {},
|
|
27
|
+
devDependencies: {},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const dir of tempDirs.splice(0)) {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('buildPackageMap', () => {
|
|
38
|
+
it('keeps in-repo workspace paths as workflow paths', () => {
|
|
39
|
+
const rootDir = createTempRepo();
|
|
40
|
+
const packageDir = join(rootDir, 'apps/web');
|
|
41
|
+
mkdirSync(packageDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const { packageMap, workspaceRoots } = buildPackageMap(
|
|
44
|
+
[createWorkspacePackage(packageDir, 'web')],
|
|
45
|
+
rootDir,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(packageMap.get('web')).toEqual({
|
|
49
|
+
filesystemPath: 'apps/web',
|
|
50
|
+
workflowPath: 'apps/web',
|
|
51
|
+
});
|
|
52
|
+
expect(Array.from(workspaceRoots)).toEqual(['apps']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rebases external workspace paths to in-repo mirrors', () => {
|
|
56
|
+
const rootDir = createTempRepo();
|
|
57
|
+
mkdirSync(join(rootDir, 'plugins/design-system'), { recursive: true });
|
|
58
|
+
const externalPackageDir = join(
|
|
59
|
+
rootDir,
|
|
60
|
+
'../crossplatform.ai/plugins/design-system',
|
|
61
|
+
);
|
|
62
|
+
mkdirSync(externalPackageDir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
const { packageMap, workspaceRoots } = buildPackageMap(
|
|
65
|
+
[createWorkspacePackage(externalPackageDir, '@repo/design-system')],
|
|
66
|
+
rootDir,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(packageMap.get('@repo/design-system')).toEqual({
|
|
70
|
+
filesystemPath: '../crossplatform.ai/plugins/design-system',
|
|
71
|
+
workflowPath: 'plugins/design-system',
|
|
72
|
+
});
|
|
73
|
+
expect(Array.from(workspaceRoots)).toEqual(['plugins']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips workflow paths for external workspaces without local mirrors', () => {
|
|
77
|
+
const rootDir = createTempRepo();
|
|
78
|
+
const externalPackageDir = join(
|
|
79
|
+
rootDir,
|
|
80
|
+
'../crossplatform.ai/plugins/ghost',
|
|
81
|
+
);
|
|
82
|
+
mkdirSync(externalPackageDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const { packageMap, workspaceRoots } = buildPackageMap(
|
|
85
|
+
[createWorkspacePackage(externalPackageDir, '@repo/ghost')],
|
|
86
|
+
rootDir,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(packageMap.get('@repo/ghost')).toEqual({
|
|
90
|
+
filesystemPath: '../crossplatform.ai/plugins/ghost',
|
|
91
|
+
workflowPath: null,
|
|
92
|
+
});
|
|
93
|
+
expect(Array.from(workspaceRoots)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import type { WorkspacePackage } from '../graph/types';
|
|
4
|
+
|
|
5
|
+
export interface MappedWorkspacePath {
|
|
6
|
+
filesystemPath: string;
|
|
7
|
+
workflowPath: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WorkspacePackageMap {
|
|
11
|
+
packageMap: Map<string, MappedWorkspacePath>;
|
|
12
|
+
workspaceRoots: Set<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeRelativePath(path: string): string {
|
|
16
|
+
return path.split(sep).join('/');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveWorkflowPath(
|
|
20
|
+
relativePath: string,
|
|
21
|
+
rootDir: string,
|
|
22
|
+
): string | null {
|
|
23
|
+
if (!relativePath.startsWith('../')) {
|
|
24
|
+
return relativePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const segments = relativePath.split('/');
|
|
28
|
+
|
|
29
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
30
|
+
const candidateSegments = segments.slice(index);
|
|
31
|
+
if (candidateSegments.length === 0 || candidateSegments[0] === '..') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const candidatePath = candidateSegments.join('/');
|
|
36
|
+
if (existsSync(join(rootDir, candidatePath))) {
|
|
37
|
+
return candidatePath;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildPackageMap(
|
|
45
|
+
packages: WorkspacePackage[],
|
|
46
|
+
rootDir: string,
|
|
47
|
+
): WorkspacePackageMap {
|
|
48
|
+
const packageMap = new Map<string, MappedWorkspacePath>();
|
|
49
|
+
const workspaceRoots = new Set<string>();
|
|
50
|
+
|
|
51
|
+
for (const pkg of packages) {
|
|
52
|
+
const relativePath = normalizeRelativePath(relative(rootDir, pkg.path));
|
|
53
|
+
const workflowPath = resolveWorkflowPath(relativePath, rootDir);
|
|
54
|
+
|
|
55
|
+
packageMap.set(pkg.name, {
|
|
56
|
+
filesystemPath: relativePath,
|
|
57
|
+
workflowPath,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!workflowPath) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [workspaceRoot] = workflowPath.split('/');
|
|
65
|
+
if (workspaceRoot) {
|
|
66
|
+
workspaceRoots.add(workspaceRoot);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
packageMap,
|
|
72
|
+
workspaceRoots,
|
|
73
|
+
};
|
|
74
|
+
}
|