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