@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
|
@@ -1,71 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { parse as parseYaml } from 'yaml';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* Parse .gitmodules file and return a Set of submodule paths.
|
|
9
|
-
* Example: plugins/auth, plugins/design-system
|
|
10
|
-
*/
|
|
11
|
-
function parseGitmodules(rootDir: string): Set<string> {
|
|
12
|
-
const gitmodulesPath = join(rootDir, '.gitmodules');
|
|
13
|
-
if (!existsSync(gitmodulesPath)) {
|
|
14
|
-
return new Set();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const content = readFileSync(gitmodulesPath, 'utf-8');
|
|
18
|
-
const paths = new Set<string>();
|
|
19
|
-
|
|
20
|
-
// Match lines like: path = plugins/auth
|
|
21
|
-
const pathMatches = content.matchAll(/^\s*path\s*=\s*(.+)$/gm);
|
|
22
|
-
for (const match of pathMatches) {
|
|
23
|
-
const matchedPath = match[1];
|
|
24
|
-
if (matchedPath) {
|
|
25
|
-
paths.add(matchedPath.trim());
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return paths;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if a given path is a git submodule.
|
|
34
|
-
*/
|
|
35
|
-
function isGitSubmodule(
|
|
36
|
-
pluginPath: string,
|
|
37
|
-
submodulePaths: Set<string>,
|
|
38
|
-
): boolean {
|
|
39
|
-
return submodulePaths.has(pluginPath);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ValidationResult {
|
|
43
|
-
workflow: string;
|
|
44
|
-
app: string;
|
|
45
|
-
valid: boolean;
|
|
46
|
-
missing: string[];
|
|
47
|
-
unnecessary: string[];
|
|
48
|
-
issues: string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface WorkflowConfig {
|
|
52
|
-
name: string;
|
|
53
|
-
on?: {
|
|
54
|
-
push?: {
|
|
55
|
-
paths?: string[];
|
|
56
|
-
};
|
|
57
|
-
pull_request?: {
|
|
58
|
-
paths?: string[];
|
|
59
|
-
};
|
|
60
|
-
workflow_dispatch?: unknown;
|
|
61
|
-
};
|
|
62
|
-
}
|
|
6
|
+
import { validateWorkflows } from '../workflow/validator';
|
|
7
|
+
import type { WorkflowValidationResult } from '../workflow/types';
|
|
63
8
|
|
|
64
9
|
interface PackageJson {
|
|
65
|
-
name: string;
|
|
66
10
|
packageManager?: string;
|
|
67
|
-
dependencies?: Record<string, string>;
|
|
68
|
-
devDependencies?: Record<string, string>;
|
|
69
11
|
}
|
|
70
12
|
|
|
71
13
|
interface PnpmValidationResult {
|
|
@@ -74,74 +16,6 @@ interface PnpmValidationResult {
|
|
|
74
16
|
dockerfileIssues: { dockerfile: string; issue: string }[];
|
|
75
17
|
}
|
|
76
18
|
|
|
77
|
-
/**
|
|
78
|
-
* Build a mapping of package names to their directory paths.
|
|
79
|
-
* Scans plugins/ and packages/ directories to discover actual package names.
|
|
80
|
-
* This handles any naming convention clients may use.
|
|
81
|
-
*/
|
|
82
|
-
function buildPackageMapping(rootDir: string): Map<string, string> {
|
|
83
|
-
const mapping = new Map<string, string>();
|
|
84
|
-
|
|
85
|
-
for (const dir of ['plugins', 'features', 'packages', 'apps']) {
|
|
86
|
-
const base = join(rootDir, dir);
|
|
87
|
-
if (!existsSync(base)) continue;
|
|
88
|
-
|
|
89
|
-
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
90
|
-
if (!entry.isDirectory()) continue;
|
|
91
|
-
const pkgPath = join(base, entry.name, 'package.json');
|
|
92
|
-
if (existsSync(pkgPath)) {
|
|
93
|
-
try {
|
|
94
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
|
|
95
|
-
if (pkg.name) {
|
|
96
|
-
mapping.set(pkg.name, `${dir}/${entry.name}`);
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Skip malformed package.json files
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return mapping;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Dynamically discover workflow mappings from file system.
|
|
110
|
-
* Looks for deploy-*.yml and release-*.yml patterns that match apps/ directories.
|
|
111
|
-
*/
|
|
112
|
-
function discoverWorkflowMappings(rootDir: string): Record<string, string> {
|
|
113
|
-
const workflowsDir = join(rootDir, '.github/workflows');
|
|
114
|
-
const appsDir = join(rootDir, 'apps');
|
|
115
|
-
|
|
116
|
-
if (!existsSync(workflowsDir) || !existsSync(appsDir)) {
|
|
117
|
-
return {};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const apps = readdirSync(appsDir, { withFileTypes: true })
|
|
121
|
-
.filter((d) => d.isDirectory())
|
|
122
|
-
.map((d) => d.name);
|
|
123
|
-
|
|
124
|
-
const mapping: Record<string, string> = {};
|
|
125
|
-
|
|
126
|
-
// Pattern: deploy-<app>.yml or release-<app>.yml
|
|
127
|
-
for (const app of apps) {
|
|
128
|
-
const deployWorkflow = `deploy-${app}.yml`;
|
|
129
|
-
const releaseWorkflow = `release-${app}.yml`;
|
|
130
|
-
|
|
131
|
-
if (existsSync(join(workflowsDir, deployWorkflow))) {
|
|
132
|
-
mapping[deployWorkflow] = app;
|
|
133
|
-
}
|
|
134
|
-
if (existsSync(join(workflowsDir, releaseWorkflow))) {
|
|
135
|
-
mapping[releaseWorkflow] = app;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return mapping;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Dynamically discover workflows that use pnpm/action-setup
|
|
144
|
-
*/
|
|
145
19
|
function discoverPnpmWorkflows(rootDir: string): string[] {
|
|
146
20
|
const workflowsDir = join(rootDir, '.github/workflows');
|
|
147
21
|
if (!existsSync(workflowsDir)) {
|
|
@@ -149,7 +23,9 @@ function discoverPnpmWorkflows(rootDir: string): string[] {
|
|
|
149
23
|
}
|
|
150
24
|
|
|
151
25
|
const workflows: string[] = [];
|
|
152
|
-
const files = readdirSync(workflowsDir).filter((
|
|
26
|
+
const files = readdirSync(workflowsDir).filter((file) =>
|
|
27
|
+
file.endsWith('.yml'),
|
|
28
|
+
);
|
|
153
29
|
|
|
154
30
|
for (const file of files) {
|
|
155
31
|
const content = readFileSync(join(workflowsDir, file), 'utf-8');
|
|
@@ -161,338 +37,64 @@ function discoverPnpmWorkflows(rootDir: string): string[] {
|
|
|
161
37
|
return workflows;
|
|
162
38
|
}
|
|
163
39
|
|
|
164
|
-
/**
|
|
165
|
-
* Dynamically discover Dockerfiles that install pnpm
|
|
166
|
-
*/
|
|
167
40
|
function discoverPnpmDockerfiles(rootDir: string): string[] {
|
|
168
41
|
const dockerfiles: string[] = [];
|
|
169
|
-
const entries = readdirSync(rootDir);
|
|
170
|
-
|
|
171
|
-
for (const entry of entries) {
|
|
172
|
-
if (entry.startsWith('Dockerfile')) {
|
|
173
|
-
const content = readFileSync(join(rootDir, entry), 'utf-8');
|
|
174
|
-
if (content.includes('pnpm@')) {
|
|
175
|
-
dockerfiles.push(entry);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return dockerfiles;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Convert a dependency name to its folder path.
|
|
185
|
-
* Uses package mapping for accurate lookup, with fallback to legacy heuristics.
|
|
186
|
-
*/
|
|
187
|
-
function convertDependencyToFolder(
|
|
188
|
-
dep: string,
|
|
189
|
-
rootDir: string,
|
|
190
|
-
packageMapping?: Map<string, string>,
|
|
191
|
-
): string {
|
|
192
|
-
// Try package mapping first (most accurate)
|
|
193
|
-
if (packageMapping) {
|
|
194
|
-
const relativePath = packageMapping.get(dep);
|
|
195
|
-
if (relativePath) {
|
|
196
|
-
return join(rootDir, relativePath);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Fallback: Generic handling for @crossplatformai/ packages
|
|
201
|
-
if (dep.startsWith('@crossplatformai/')) {
|
|
202
|
-
const packageName = dep.replace('@crossplatformai/', '');
|
|
203
|
-
|
|
204
|
-
// Check plugins/ first (most common), then packages/
|
|
205
|
-
const pluginPath = join(rootDir, 'plugins', packageName);
|
|
206
|
-
if (existsSync(pluginPath)) {
|
|
207
|
-
return pluginPath;
|
|
208
|
-
}
|
|
209
|
-
return join(rootDir, 'packages', packageName);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Fallback: Generic handling for @repo/ packages
|
|
213
|
-
if (dep.startsWith('@repo/')) {
|
|
214
|
-
const packageName = dep.replace('@repo/', '');
|
|
215
42
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return appsPath;
|
|
43
|
+
for (const entry of readdirSync(rootDir)) {
|
|
44
|
+
if (!entry.startsWith('Dockerfile')) {
|
|
45
|
+
continue;
|
|
220
46
|
}
|
|
221
47
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return pluginPath;
|
|
48
|
+
const content = readFileSync(join(rootDir, entry), 'utf-8');
|
|
49
|
+
if (content.includes('pnpm@')) {
|
|
50
|
+
dockerfiles.push(entry);
|
|
226
51
|
}
|
|
227
|
-
|
|
228
|
-
// Fall back to packages/
|
|
229
|
-
return join(rootDir, 'packages', packageName);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Unknown format, return as-is
|
|
233
|
-
return join(rootDir, dep);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Collect transitive dependencies recursively.
|
|
238
|
-
* @param packageJsonPath - Path to the package.json to analyze
|
|
239
|
-
* @param rootDir - Root directory of the monorepo
|
|
240
|
-
* @param visited - Set of already visited dependencies (prevents cycles)
|
|
241
|
-
* @param packageMapping - Mapping of package names to directory paths
|
|
242
|
-
* @param includeDevDeps - Whether to include devDependencies (true for root app, false for nested)
|
|
243
|
-
*/
|
|
244
|
-
function collectTransitiveDependencies(
|
|
245
|
-
packageJsonPath: string,
|
|
246
|
-
rootDir: string,
|
|
247
|
-
visited: Set<string>,
|
|
248
|
-
packageMapping: Map<string, string>,
|
|
249
|
-
includeDevDeps: boolean = true,
|
|
250
|
-
): Set<string> {
|
|
251
|
-
const allTransitiveDeps = new Set<string>();
|
|
252
|
-
|
|
253
|
-
if (!existsSync(packageJsonPath)) {
|
|
254
|
-
return allTransitiveDeps;
|
|
255
52
|
}
|
|
256
53
|
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const allDeps = { ...packageJson.dependencies };
|
|
262
|
-
if (includeDevDeps) {
|
|
263
|
-
Object.assign(allDeps, packageJson.devDependencies);
|
|
54
|
+
const appsDir = join(rootDir, 'apps');
|
|
55
|
+
if (!existsSync(appsDir)) {
|
|
56
|
+
return dockerfiles;
|
|
264
57
|
}
|
|
265
58
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// 1. workspace: protocol (e.g., workspace:*, workspace:^)
|
|
269
|
-
// 2. link: protocol (e.g., link:../../plugins/auth)
|
|
270
|
-
// 3. Legacy * syntax for @repo/ and @crossplatformai/ packages
|
|
271
|
-
// 4. Semver ranges that resolve to local packages in packageMapping
|
|
272
|
-
// (pnpm workspace links local packages when they satisfy the version range)
|
|
273
|
-
const workspaceDeps = Object.entries(allDeps)
|
|
274
|
-
.filter(
|
|
275
|
-
([name, version]) =>
|
|
276
|
-
version.startsWith('workspace:') ||
|
|
277
|
-
version.startsWith('link:') ||
|
|
278
|
-
(version === '*' &&
|
|
279
|
-
(name.startsWith('@repo/') ||
|
|
280
|
-
name.startsWith('@crossplatformai/'))) ||
|
|
281
|
-
packageMapping.has(name),
|
|
282
|
-
)
|
|
283
|
-
.map(([name]) => name);
|
|
284
|
-
|
|
285
|
-
for (const dep of workspaceDeps) {
|
|
286
|
-
if (visited.has(dep)) {
|
|
59
|
+
for (const entry of readdirSync(appsDir, { withFileTypes: true })) {
|
|
60
|
+
if (!entry.isDirectory()) {
|
|
287
61
|
continue;
|
|
288
62
|
}
|
|
289
63
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// Get the folder for this dependency
|
|
294
|
-
const depFolder = convertDependencyToFolder(dep, rootDir, packageMapping);
|
|
295
|
-
const depPackageJsonPath = join(depFolder, 'package.json');
|
|
296
|
-
|
|
297
|
-
// Never include devDependencies for transitive deps - they are local tooling
|
|
298
|
-
const nestedDeps = collectTransitiveDependencies(
|
|
299
|
-
depPackageJsonPath,
|
|
300
|
-
rootDir,
|
|
301
|
-
visited,
|
|
302
|
-
packageMapping,
|
|
303
|
-
false,
|
|
304
|
-
);
|
|
305
|
-
for (const nestedDep of nestedDeps) {
|
|
306
|
-
allTransitiveDeps.add(nestedDep);
|
|
64
|
+
const dockerfilePath = join(appsDir, entry.name, 'Dockerfile');
|
|
65
|
+
if (!existsSync(dockerfilePath)) {
|
|
66
|
+
continue;
|
|
307
67
|
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return allTransitiveDeps;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Get all transitive workspace dependencies for an app
|
|
315
|
-
*/
|
|
316
|
-
function getAppDependencies(
|
|
317
|
-
appName: string,
|
|
318
|
-
rootDir: string,
|
|
319
|
-
packageMapping: Map<string, string>,
|
|
320
|
-
): string[] {
|
|
321
|
-
const packageJsonPath = join(rootDir, 'apps', appName, 'package.json');
|
|
322
68
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const visited = new Set<string>();
|
|
328
|
-
const deps = collectTransitiveDependencies(
|
|
329
|
-
packageJsonPath,
|
|
330
|
-
rootDir,
|
|
331
|
-
visited,
|
|
332
|
-
packageMapping,
|
|
333
|
-
true,
|
|
334
|
-
);
|
|
335
|
-
return Array.from(deps);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Convert a dependency name to its workflow path patterns.
|
|
340
|
-
* Uses package mapping for accurate lookup, with fallback to legacy heuristics.
|
|
341
|
-
*
|
|
342
|
-
* Returns an array of paths because git submodules need TWO entries:
|
|
343
|
-
* - `plugins/auth` (catches submodule pointer changes in parent repo)
|
|
344
|
-
* - `plugins/auth/**` (catches file changes within submodule)
|
|
345
|
-
*/
|
|
346
|
-
function convertDependencyToPath(
|
|
347
|
-
dep: string,
|
|
348
|
-
rootDir: string,
|
|
349
|
-
packageMapping?: Map<string, string>,
|
|
350
|
-
submodulePaths?: Set<string>,
|
|
351
|
-
): string[] {
|
|
352
|
-
let relativePath: string | undefined;
|
|
353
|
-
|
|
354
|
-
// Try package mapping first (most accurate)
|
|
355
|
-
if (packageMapping) {
|
|
356
|
-
relativePath = packageMapping.get(dep);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Fallback heuristics if not in package mapping
|
|
360
|
-
if (!relativePath) {
|
|
361
|
-
// Generic handling for @crossplatformai/ packages
|
|
362
|
-
if (dep.startsWith('@crossplatformai/')) {
|
|
363
|
-
const packageName = dep.replace('@crossplatformai/', '');
|
|
364
|
-
|
|
365
|
-
// Check plugins/ first (most common), then packages/
|
|
366
|
-
const pluginPath = join(rootDir, 'plugins', packageName);
|
|
367
|
-
if (existsSync(pluginPath)) {
|
|
368
|
-
relativePath = `plugins/${packageName}`;
|
|
369
|
-
} else {
|
|
370
|
-
relativePath = `packages/${packageName}`;
|
|
371
|
-
}
|
|
372
|
-
} else if (dep.startsWith('@repo/')) {
|
|
373
|
-
// Generic handling for @repo/ packages
|
|
374
|
-
const packageName = dep.replace('@repo/', '');
|
|
375
|
-
|
|
376
|
-
// Check apps/ first (e.g., @repo/shared → apps/shared)
|
|
377
|
-
const appsPath = join(rootDir, 'apps', packageName);
|
|
378
|
-
if (existsSync(appsPath)) {
|
|
379
|
-
relativePath = `apps/${packageName}`;
|
|
380
|
-
} else {
|
|
381
|
-
// Check plugins/ next
|
|
382
|
-
const pluginPath = join(rootDir, 'plugins', packageName);
|
|
383
|
-
if (existsSync(pluginPath)) {
|
|
384
|
-
relativePath = `plugins/${packageName}`;
|
|
385
|
-
} else {
|
|
386
|
-
// Check features/ next
|
|
387
|
-
const featuresPath = join(rootDir, 'features', packageName);
|
|
388
|
-
if (existsSync(featuresPath)) {
|
|
389
|
-
relativePath = `features/${packageName}`;
|
|
390
|
-
} else {
|
|
391
|
-
// Fall back to packages/
|
|
392
|
-
relativePath = `packages/${packageName}`;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
} else {
|
|
397
|
-
// Unknown format, return as-is
|
|
398
|
-
return [dep];
|
|
69
|
+
const content = readFileSync(dockerfilePath, 'utf-8');
|
|
70
|
+
if (content.includes('pnpm@')) {
|
|
71
|
+
dockerfiles.push(`apps/${entry.name}/Dockerfile`);
|
|
399
72
|
}
|
|
400
73
|
}
|
|
401
74
|
|
|
402
|
-
|
|
403
|
-
// This catches both submodule pointer changes and file changes within the submodule
|
|
404
|
-
if (submodulePaths && isGitSubmodule(relativePath, submodulePaths)) {
|
|
405
|
-
return [relativePath, `${relativePath}/**`];
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// For regular packages, just return the glob path
|
|
409
|
-
return [`${relativePath}/**`];
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Get expected workflow paths for an app based on its transitive dependencies
|
|
414
|
-
*/
|
|
415
|
-
function getExpectedPaths(
|
|
416
|
-
appName: string,
|
|
417
|
-
rootDir: string,
|
|
418
|
-
workflowMapping: Record<string, string>,
|
|
419
|
-
packageMapping: Map<string, string>,
|
|
420
|
-
submodulePaths: Set<string>,
|
|
421
|
-
): string[] {
|
|
422
|
-
const dependencies = getAppDependencies(appName, rootDir, packageMapping);
|
|
423
|
-
// Use flatMap because convertDependencyToPath now returns string[]
|
|
424
|
-
// (submodules return both pointer path and glob path)
|
|
425
|
-
const paths = dependencies.flatMap((dep) =>
|
|
426
|
-
convertDependencyToPath(dep, rootDir, packageMapping, submodulePaths),
|
|
427
|
-
);
|
|
428
|
-
|
|
429
|
-
paths.push(`apps/${appName}/**`);
|
|
430
|
-
|
|
431
|
-
const workflowFile = Object.entries(workflowMapping).find(
|
|
432
|
-
([, app]) => app === appName,
|
|
433
|
-
)?.[0];
|
|
434
|
-
if (workflowFile) {
|
|
435
|
-
paths.push(`.github/workflows/${workflowFile}`);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Auto-discover Dockerfiles for this app
|
|
439
|
-
const dockerfileName = `Dockerfile.${appName}`;
|
|
440
|
-
if (existsSync(join(rootDir, dockerfileName))) {
|
|
441
|
-
paths.push(dockerfileName);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
return paths.sort();
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Get actual paths from a workflow file
|
|
449
|
-
*/
|
|
450
|
-
function getWorkflowPaths(workflowFile: string, rootDir: string): string[] {
|
|
451
|
-
const workflowPath = join(rootDir, '.github/workflows', workflowFile);
|
|
452
|
-
|
|
453
|
-
if (!existsSync(workflowPath)) {
|
|
454
|
-
throw new Error(`Workflow file not found: ${workflowFile}`);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const content = readFileSync(workflowPath, 'utf-8');
|
|
458
|
-
const workflow = parseYaml(content) as WorkflowConfig;
|
|
459
|
-
|
|
460
|
-
// Check both push and pull_request triggers for paths
|
|
461
|
-
const paths = workflow.on?.push?.paths ?? workflow.on?.pull_request?.paths;
|
|
462
|
-
|
|
463
|
-
if (!paths) {
|
|
464
|
-
return [];
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return paths.sort();
|
|
75
|
+
return dockerfiles;
|
|
468
76
|
}
|
|
469
77
|
|
|
470
|
-
/**
|
|
471
|
-
* Get pnpm version from package.json packageManager field
|
|
472
|
-
*/
|
|
473
78
|
function getExpectedPnpmVersion(rootDir: string): string {
|
|
474
|
-
const packageJsonPath = join(rootDir, 'package.json');
|
|
475
79
|
const packageJson = JSON.parse(
|
|
476
|
-
readFileSync(
|
|
80
|
+
readFileSync(join(rootDir, 'package.json'), 'utf-8'),
|
|
477
81
|
) as PackageJson;
|
|
478
82
|
const packageManager = packageJson.packageManager;
|
|
83
|
+
|
|
479
84
|
if (!packageManager?.startsWith('pnpm@')) {
|
|
480
85
|
throw new Error('packageManager field must specify pnpm version');
|
|
481
86
|
}
|
|
87
|
+
|
|
482
88
|
return packageManager.replace('pnpm@', '');
|
|
483
89
|
}
|
|
484
90
|
|
|
485
|
-
/**
|
|
486
|
-
* Check if workflow has hardcoded pnpm version
|
|
487
|
-
*/
|
|
488
91
|
function checkWorkflowPnpmVersion(
|
|
489
92
|
workflowFile: string,
|
|
490
93
|
rootDir: string,
|
|
491
94
|
): { valid: boolean; issue?: string } {
|
|
492
95
|
const workflowPath = join(rootDir, '.github/workflows', workflowFile);
|
|
493
|
-
|
|
494
96
|
if (!existsSync(workflowPath)) {
|
|
495
|
-
return { valid: true };
|
|
97
|
+
return { valid: true };
|
|
496
98
|
}
|
|
497
99
|
|
|
498
100
|
const content = readFileSync(workflowPath, 'utf-8');
|
|
@@ -503,37 +105,33 @@ function checkWorkflowPnpmVersion(
|
|
|
503
105
|
>;
|
|
504
106
|
};
|
|
505
107
|
|
|
506
|
-
// Find all pnpm/action-setup steps
|
|
507
108
|
for (const job of Object.values(workflow.jobs ?? {})) {
|
|
508
109
|
for (const step of job.steps ?? []) {
|
|
509
|
-
if (step.uses?.startsWith('pnpm/action-setup')) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
110
|
+
if (!step.uses?.startsWith('pnpm/action-setup')) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const version = step.with?.version;
|
|
115
|
+
if (version !== undefined && !/^\d+$/.test(String(version))) {
|
|
116
|
+
return {
|
|
117
|
+
valid: false,
|
|
118
|
+
issue: `Hardcoded pnpm version '${version}' - remove 'version' key to auto-detect from packageManager`,
|
|
119
|
+
};
|
|
518
120
|
}
|
|
519
121
|
}
|
|
520
122
|
}
|
|
123
|
+
|
|
521
124
|
return { valid: true };
|
|
522
125
|
}
|
|
523
126
|
|
|
524
|
-
/**
|
|
525
|
-
* Check all pnpm versions in Dockerfile match package.json
|
|
526
|
-
* Finds ALL occurrences of `npm install -g pnpm@X.Y.Z` and validates each one
|
|
527
|
-
*/
|
|
528
127
|
function checkDockerfilePnpmVersion(
|
|
529
128
|
dockerfile: string,
|
|
530
129
|
expectedVersion: string,
|
|
531
130
|
rootDir: string,
|
|
532
131
|
): { valid: boolean; issue?: string } {
|
|
533
132
|
const dockerfilePath = join(rootDir, dockerfile);
|
|
534
|
-
|
|
535
133
|
if (!existsSync(dockerfilePath)) {
|
|
536
|
-
return { valid: true };
|
|
134
|
+
return { valid: true };
|
|
537
135
|
}
|
|
538
136
|
|
|
539
137
|
const content = readFileSync(dockerfilePath, 'utf-8');
|
|
@@ -547,12 +145,10 @@ function checkDockerfilePnpmVersion(
|
|
|
547
145
|
};
|
|
548
146
|
}
|
|
549
147
|
}
|
|
148
|
+
|
|
550
149
|
return { valid: true };
|
|
551
150
|
}
|
|
552
151
|
|
|
553
|
-
/**
|
|
554
|
-
* Validate pnpm version consistency across workflows and Dockerfiles
|
|
555
|
-
*/
|
|
556
152
|
function validatePnpmVersions(rootDir: string): PnpmValidationResult {
|
|
557
153
|
const result: PnpmValidationResult = {
|
|
558
154
|
valid: true,
|
|
@@ -560,9 +156,7 @@ function validatePnpmVersions(rootDir: string): PnpmValidationResult {
|
|
|
560
156
|
dockerfileIssues: [],
|
|
561
157
|
};
|
|
562
158
|
|
|
563
|
-
|
|
564
|
-
const pnpmWorkflows = discoverPnpmWorkflows(rootDir);
|
|
565
|
-
for (const workflowFile of pnpmWorkflows) {
|
|
159
|
+
for (const workflowFile of discoverPnpmWorkflows(rootDir)) {
|
|
566
160
|
const check = checkWorkflowPnpmVersion(workflowFile, rootDir);
|
|
567
161
|
if (!check.valid && check.issue) {
|
|
568
162
|
result.valid = false;
|
|
@@ -573,10 +167,8 @@ function validatePnpmVersions(rootDir: string): PnpmValidationResult {
|
|
|
573
167
|
}
|
|
574
168
|
}
|
|
575
169
|
|
|
576
|
-
// Dynamically discover and check Dockerfiles match package.json pnpm version
|
|
577
170
|
const expectedVersion = getExpectedPnpmVersion(rootDir);
|
|
578
|
-
const
|
|
579
|
-
for (const dockerfile of dockerfiles) {
|
|
171
|
+
for (const dockerfile of discoverPnpmDockerfiles(rootDir)) {
|
|
580
172
|
const check = checkDockerfilePnpmVersion(
|
|
581
173
|
dockerfile,
|
|
582
174
|
expectedVersion,
|
|
@@ -591,125 +183,23 @@ function validatePnpmVersions(rootDir: string): PnpmValidationResult {
|
|
|
591
183
|
return result;
|
|
592
184
|
}
|
|
593
185
|
|
|
594
|
-
|
|
595
|
-
* Validate a single workflow against its app's dependencies
|
|
596
|
-
*/
|
|
597
|
-
function validateWorkflow(
|
|
598
|
-
workflowFile: string,
|
|
599
|
-
appName: string,
|
|
600
|
-
rootDir: string,
|
|
601
|
-
workflowMapping: Record<string, string>,
|
|
602
|
-
packageMapping: Map<string, string>,
|
|
603
|
-
submodulePaths: Set<string>,
|
|
604
|
-
): ValidationResult {
|
|
605
|
-
const result: ValidationResult = {
|
|
606
|
-
workflow: workflowFile,
|
|
607
|
-
app: appName,
|
|
608
|
-
valid: true,
|
|
609
|
-
missing: [],
|
|
610
|
-
unnecessary: [],
|
|
611
|
-
issues: [],
|
|
612
|
-
};
|
|
613
|
-
|
|
614
|
-
try {
|
|
615
|
-
const expectedPaths = getExpectedPaths(
|
|
616
|
-
appName,
|
|
617
|
-
rootDir,
|
|
618
|
-
workflowMapping,
|
|
619
|
-
packageMapping,
|
|
620
|
-
submodulePaths,
|
|
621
|
-
);
|
|
622
|
-
const actualPaths = getWorkflowPaths(workflowFile, rootDir);
|
|
623
|
-
|
|
624
|
-
if (actualPaths.includes('plugins/**')) {
|
|
625
|
-
result.issues.push(
|
|
626
|
-
"Uses wildcard 'plugins/**' which triggers on ALL plugin changes",
|
|
627
|
-
);
|
|
628
|
-
result.valid = false;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (actualPaths.includes('packages/**')) {
|
|
632
|
-
result.issues.push(
|
|
633
|
-
"Uses wildcard 'packages/**' which triggers on ALL package changes",
|
|
634
|
-
);
|
|
635
|
-
result.valid = false;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
const expectedPluginPaths = expectedPaths.filter((p) =>
|
|
639
|
-
p.startsWith('plugins/'),
|
|
640
|
-
);
|
|
641
|
-
const actualPluginPaths = actualPaths.filter(
|
|
642
|
-
(p) => p.startsWith('plugins/') && !p.includes('**/**'),
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
const expectedPackagePaths = expectedPaths.filter((p) =>
|
|
646
|
-
p.startsWith('packages/'),
|
|
647
|
-
);
|
|
648
|
-
const actualPackagePaths = actualPaths.filter(
|
|
649
|
-
(p) => p.startsWith('packages/') && !p.includes('**/**'),
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
// Also include apps/ paths in validation (e.g., apps/shared/**)
|
|
653
|
-
const expectedAppPaths = expectedPaths.filter((p) => p.startsWith('apps/'));
|
|
654
|
-
const actualAppPaths = actualPaths.filter(
|
|
655
|
-
(p) => p.startsWith('apps/') && !p.includes('**/**'),
|
|
656
|
-
);
|
|
657
|
-
|
|
658
|
-
for (const expectedPath of expectedPluginPaths) {
|
|
659
|
-
if (!actualPaths.includes(expectedPath)) {
|
|
660
|
-
result.missing.push(expectedPath);
|
|
661
|
-
result.valid = false;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
for (const expectedPath of expectedPackagePaths) {
|
|
666
|
-
if (!actualPaths.includes(expectedPath)) {
|
|
667
|
-
result.missing.push(expectedPath);
|
|
668
|
-
result.valid = false;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
for (const expectedPath of expectedAppPaths) {
|
|
673
|
-
if (!actualPaths.includes(expectedPath)) {
|
|
674
|
-
result.missing.push(expectedPath);
|
|
675
|
-
result.valid = false;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
for (const actualPath of [
|
|
680
|
-
...actualPluginPaths,
|
|
681
|
-
...actualPackagePaths,
|
|
682
|
-
...actualAppPaths,
|
|
683
|
-
]) {
|
|
684
|
-
if (!expectedPaths.includes(actualPath)) {
|
|
685
|
-
result.unnecessary.push(actualPath);
|
|
686
|
-
result.valid = false;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
} catch (error) {
|
|
690
|
-
result.valid = false;
|
|
691
|
-
result.issues.push(error instanceof Error ? error.message : String(error));
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
return result;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Print validation result to console
|
|
699
|
-
*/
|
|
700
|
-
function printResult(result: ValidationResult): void {
|
|
186
|
+
function printResult(result: WorkflowValidationResult): void {
|
|
701
187
|
const icon = result.valid ? '✅' : '❌';
|
|
702
|
-
console.log(`\n${icon} ${result.workflow} (${result.
|
|
188
|
+
console.log(`\n${icon} ${result.workflow} (${result.targetPackage})`);
|
|
703
189
|
|
|
704
190
|
if (result.valid) {
|
|
705
191
|
console.log(' All paths match dependencies');
|
|
706
192
|
return;
|
|
707
193
|
}
|
|
708
194
|
|
|
709
|
-
|
|
195
|
+
const extraIssues = result.issues.filter(
|
|
196
|
+
(issue) => issue.kind !== 'missing' && issue.kind !== 'unnecessary',
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (extraIssues.length > 0) {
|
|
710
200
|
console.log(' Issues:');
|
|
711
|
-
for (const issue of
|
|
712
|
-
console.log(` ⚠️ ${issue}`);
|
|
201
|
+
for (const issue of extraIssues) {
|
|
202
|
+
console.log(` ⚠️ ${issue.message}`);
|
|
713
203
|
}
|
|
714
204
|
}
|
|
715
205
|
|
|
@@ -728,47 +218,26 @@ function printResult(result: ValidationResult): void {
|
|
|
728
218
|
}
|
|
729
219
|
}
|
|
730
220
|
|
|
731
|
-
function main(): void {
|
|
221
|
+
async function main(): Promise<void> {
|
|
732
222
|
const rootDir = resolve(process.cwd());
|
|
733
223
|
let hasErrors = false;
|
|
734
224
|
|
|
735
|
-
// Build package mapping once at startup for efficient lookups
|
|
736
|
-
const packageMapping = buildPackageMapping(rootDir);
|
|
737
|
-
|
|
738
|
-
// Parse .gitmodules once to detect submodule plugins
|
|
739
|
-
const submodulePaths = parseGitmodules(rootDir);
|
|
740
|
-
|
|
741
|
-
// Auto-discover workflow mappings from file system
|
|
742
|
-
const workflowMapping = discoverWorkflowMappings(rootDir);
|
|
743
|
-
const workflowCount = Object.keys(workflowMapping).length;
|
|
744
|
-
|
|
745
|
-
// Part 1: Validate workflow path filters match dependencies
|
|
746
225
|
console.log(
|
|
747
226
|
'🔍 Validating GitHub Actions workflows against dependencies...\n',
|
|
748
227
|
);
|
|
749
|
-
|
|
228
|
+
const results = await validateWorkflows(rootDir);
|
|
229
|
+
console.log(`Found ${results.length} workflow(s) to validate\n`);
|
|
750
230
|
|
|
751
|
-
if (
|
|
231
|
+
if (results.length === 0) {
|
|
752
232
|
console.log('No deploy-*.yml or release-*.yml workflows found.\n');
|
|
753
233
|
}
|
|
754
234
|
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
for (const [workflowFile, appName] of Object.entries(workflowMapping)) {
|
|
758
|
-
const result = validateWorkflow(
|
|
759
|
-
workflowFile,
|
|
760
|
-
appName,
|
|
761
|
-
rootDir,
|
|
762
|
-
workflowMapping,
|
|
763
|
-
packageMapping,
|
|
764
|
-
submodulePaths,
|
|
765
|
-
);
|
|
766
|
-
results.push(result);
|
|
235
|
+
for (const result of results) {
|
|
767
236
|
printResult(result);
|
|
768
237
|
}
|
|
769
238
|
|
|
770
|
-
const validCount = results.filter((
|
|
771
|
-
const invalidCount = results.
|
|
239
|
+
const validCount = results.filter((result) => result.valid).length;
|
|
240
|
+
const invalidCount = results.length - validCount;
|
|
772
241
|
|
|
773
242
|
console.log('\n' + '='.repeat(60));
|
|
774
243
|
console.log(
|
|
@@ -780,20 +249,16 @@ function main(): void {
|
|
|
780
249
|
console.log('\nTo fix:');
|
|
781
250
|
console.log('1. Update workflow path filters to match missing paths');
|
|
782
251
|
console.log('2. Remove unnecessary paths');
|
|
783
|
-
console.log(
|
|
784
|
-
'3. Replace wildcards (plugins/**, packages/**) with specific paths\n',
|
|
785
|
-
);
|
|
252
|
+
console.log('3. Replace broad workspace wildcards with specific paths\n');
|
|
786
253
|
hasErrors = true;
|
|
787
|
-
} else if (
|
|
254
|
+
} else if (results.length > 0) {
|
|
788
255
|
console.log('✅ All workflows match their dependencies!\n');
|
|
789
256
|
}
|
|
790
257
|
|
|
791
|
-
// Part 2: Validate pnpm version consistency
|
|
792
258
|
console.log('='.repeat(60));
|
|
793
259
|
console.log('\n🔍 Validating pnpm version consistency...\n');
|
|
794
260
|
|
|
795
261
|
const pnpmResult = validatePnpmVersions(rootDir);
|
|
796
|
-
|
|
797
262
|
if (!pnpmResult.valid) {
|
|
798
263
|
console.log('❌ PNPM version issues found:\n');
|
|
799
264
|
for (const { workflow, issue } of pnpmResult.workflowIssues) {
|
|
@@ -819,4 +284,4 @@ function main(): void {
|
|
|
819
284
|
}
|
|
820
285
|
}
|
|
821
286
|
|
|
822
|
-
main();
|
|
287
|
+
void main();
|