@crossplatformai/dependency-graph 0.9.3 → 0.10.0

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.
Files changed (53) hide show
  1. package/README.md +36 -22
  2. package/dist/cli/index.d.ts +3 -0
  3. package/dist/cli/index.d.ts.map +1 -0
  4. package/dist/cli/pr-preview.d.ts +3 -0
  5. package/dist/cli/pr-preview.d.ts.map +1 -0
  6. package/dist/cli/validate-workflows.d.ts +3 -0
  7. package/dist/cli/validate-workflows.d.ts.map +1 -0
  8. package/dist/graph/analysis.d.ts +6 -0
  9. package/dist/graph/analysis.d.ts.map +1 -0
  10. package/dist/graph/builder.d.ts +3 -0
  11. package/dist/graph/builder.d.ts.map +1 -0
  12. package/dist/graph/traversal.d.ts +5 -0
  13. package/dist/graph/traversal.d.ts.map +1 -0
  14. package/dist/graph/types.d.ts +47 -0
  15. package/dist/graph/types.d.ts.map +1 -0
  16. package/dist/index-cli.js +1094 -0
  17. package/dist/index-cli.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +738 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/types/clients.d.ts +15 -0
  23. package/dist/types/clients.d.ts.map +1 -0
  24. package/dist/workflow/discovery.d.ts +4 -0
  25. package/dist/workflow/discovery.d.ts.map +1 -0
  26. package/dist/workflow/expected-paths.d.ts +13 -0
  27. package/dist/workflow/expected-paths.d.ts.map +1 -0
  28. package/dist/workflow/parser.d.ts +3 -0
  29. package/dist/workflow/parser.d.ts.map +1 -0
  30. package/dist/workflow/policy.d.ts +3 -0
  31. package/dist/workflow/policy.d.ts.map +1 -0
  32. package/dist/workflow/types.d.ts +34 -0
  33. package/dist/workflow/types.d.ts.map +1 -0
  34. package/dist/workflow/validator.d.ts +3 -0
  35. package/dist/workflow/validator.d.ts.map +1 -0
  36. package/dist/workspace/discovery.d.ts +12 -0
  37. package/dist/workspace/discovery.d.ts.map +1 -0
  38. package/dist/workspace/file-mapping.d.ts +4 -0
  39. package/dist/workspace/file-mapping.d.ts.map +1 -0
  40. package/dist/workspace/package-map.d.ts +12 -0
  41. package/dist/workspace/package-map.d.ts.map +1 -0
  42. package/package.json +49 -45
  43. package/src/cli/pr-preview.ts +0 -388
  44. package/src/cli/validate-workflows.ts +0 -847
  45. package/src/graph/analysis.ts +0 -147
  46. package/src/graph/builder.ts +0 -52
  47. package/src/graph/traversal.ts +0 -132
  48. package/src/graph/types.ts +0 -50
  49. package/src/index.test.ts +0 -94
  50. package/src/index.ts +0 -40
  51. package/src/types/clients.ts +0 -19
  52. package/src/workspace/discovery.ts +0 -94
  53. package/src/workspace/file-mapping.ts +0 -35
@@ -1,847 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
4
- import { resolve, join } from 'node:path';
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
- }
63
-
64
- interface PackageJson {
65
- name: string;
66
- packageManager?: string;
67
- dependencies?: Record<string, string>;
68
- devDependencies?: Record<string, string>;
69
- }
70
-
71
- interface PnpmValidationResult {
72
- valid: boolean;
73
- workflowIssues: { workflow: string; issue: string }[];
74
- dockerfileIssues: { dockerfile: string; issue: string }[];
75
- }
76
-
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
- function discoverPnpmWorkflows(rootDir: string): string[] {
146
- const workflowsDir = join(rootDir, '.github/workflows');
147
- if (!existsSync(workflowsDir)) {
148
- return [];
149
- }
150
-
151
- const workflows: string[] = [];
152
- const files = readdirSync(workflowsDir).filter((f) => f.endsWith('.yml'));
153
-
154
- for (const file of files) {
155
- const content = readFileSync(join(workflowsDir, file), 'utf-8');
156
- if (content.includes('pnpm/action-setup')) {
157
- workflows.push(file);
158
- }
159
- }
160
-
161
- return workflows;
162
- }
163
-
164
- /**
165
- * Dynamically discover Dockerfiles that install pnpm
166
- * Scans both root directory and app directories
167
- */
168
- function discoverPnpmDockerfiles(rootDir: string): string[] {
169
- const dockerfiles: string[] = [];
170
-
171
- // Check root-level Dockerfiles
172
- const rootEntries = readdirSync(rootDir);
173
- for (const entry of rootEntries) {
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;
239
- }
240
-
241
- // Check plugins/ next
242
- const pluginPath = join(rootDir, 'plugins', packageName);
243
- if (existsSync(pluginPath)) {
244
- return pluginPath;
245
- }
246
-
247
- // Fall back to packages/
248
- return join(rootDir, 'packages', packageName);
249
- }
250
-
251
- // Unknown format, return as-is
252
- return join(rootDir, dep);
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);
283
- }
284
-
285
- // Detect workspace dependencies by their version specifier or presence in local workspace
286
- // Supports:
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)) {
306
- continue;
307
- }
308
-
309
- visited.add(dep);
310
- allTransitiveDeps.add(dep);
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);
326
- }
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
-
378
- // Fallback heuristics if not in package mapping
379
- if (!relativePath) {
380
- // Generic handling for @crossplatformai/ packages
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];
418
- }
419
- }
420
-
421
- // For submodules, return both the pointer path AND the glob path
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();
493
- }
494
-
495
- /**
496
- * Get pnpm version from package.json packageManager field
497
- */
498
- function getExpectedPnpmVersion(rootDir: string): string {
499
- const packageJsonPath = join(rootDir, 'package.json');
500
- const packageJson = JSON.parse(
501
- readFileSync(packageJsonPath, 'utf-8'),
502
- ) as PackageJson;
503
- const packageManager = packageJson.packageManager;
504
- if (!packageManager?.startsWith('pnpm@')) {
505
- throw new Error('packageManager field must specify pnpm version');
506
- }
507
- return packageManager.replace('pnpm@', '');
508
- }
509
-
510
- /**
511
- * Check if workflow has hardcoded pnpm version
512
- */
513
- function checkWorkflowPnpmVersion(
514
- workflowFile: string,
515
- rootDir: string,
516
- ): { valid: boolean; issue?: string } {
517
- const workflowPath = join(rootDir, '.github/workflows', workflowFile);
518
-
519
- if (!existsSync(workflowPath)) {
520
- return { valid: true }; // Skip non-existent workflows
521
- }
522
-
523
- const content = readFileSync(workflowPath, 'utf-8');
524
- const workflow = parseYaml(content) as {
525
- jobs?: Record<
526
- string,
527
- { steps?: Array<{ uses?: string; with?: { version?: string | number } }> }
528
- >;
529
- };
530
-
531
- // Find all pnpm/action-setup steps
532
- for (const job of Object.values(workflow.jobs ?? {})) {
533
- for (const step of job.steps ?? []) {
534
- if (step.uses?.startsWith('pnpm/action-setup')) {
535
- const version = step.with?.version;
536
- // Allow major-only versions like "10" (deploy-api-edge.yml uses this)
537
- if (version !== undefined && !/^\d+$/.test(String(version))) {
538
- return {
539
- valid: false,
540
- issue: `Hardcoded pnpm version '${version}' - remove 'version' key to auto-detect from packageManager`,
541
- };
542
- }
543
- }
544
- }
545
- }
546
- return { valid: true };
547
- }
548
-
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
- function checkDockerfilePnpmVersion(
554
- dockerfile: string,
555
- expectedVersion: string,
556
- rootDir: string,
557
- ): { valid: boolean; issue?: string } {
558
- const dockerfilePath = join(rootDir, dockerfile);
559
-
560
- if (!existsSync(dockerfilePath)) {
561
- return { valid: true }; // Skip non-existent Dockerfiles
562
- }
563
-
564
- const content = readFileSync(dockerfilePath, 'utf-8');
565
- const matches = content.matchAll(/npm install -g pnpm@([\d.]+)/g);
566
-
567
- for (const match of matches) {
568
- if (match[1] !== expectedVersion) {
569
- return {
570
- valid: false,
571
- issue: `${dockerfile} uses pnpm@${match[1]} but package.json specifies pnpm@${expectedVersion}`,
572
- };
573
- }
574
- }
575
- return { valid: true };
576
- }
577
-
578
- /**
579
- * Validate pnpm version consistency across workflows and Dockerfiles
580
- */
581
- function validatePnpmVersions(rootDir: string): PnpmValidationResult {
582
- const result: PnpmValidationResult = {
583
- valid: true,
584
- workflowIssues: [],
585
- dockerfileIssues: [],
586
- };
587
-
588
- // Dynamically discover and check workflows for hardcoded pnpm versions
589
- const pnpmWorkflows = discoverPnpmWorkflows(rootDir);
590
- for (const workflowFile of pnpmWorkflows) {
591
- const check = checkWorkflowPnpmVersion(workflowFile, rootDir);
592
- if (!check.valid && check.issue) {
593
- result.valid = false;
594
- result.workflowIssues.push({
595
- workflow: workflowFile,
596
- issue: check.issue,
597
- });
598
- }
599
- }
600
-
601
- // Dynamically discover and check Dockerfiles match package.json pnpm version
602
- const expectedVersion = getExpectedPnpmVersion(rootDir);
603
- const dockerfiles = discoverPnpmDockerfiles(rootDir);
604
- for (const dockerfile of dockerfiles) {
605
- const check = checkDockerfilePnpmVersion(
606
- dockerfile,
607
- expectedVersion,
608
- rootDir,
609
- );
610
- if (!check.valid && check.issue) {
611
- result.valid = false;
612
- result.dockerfileIssues.push({ dockerfile, issue: check.issue });
613
- }
614
- }
615
-
616
- return result;
617
- }
618
-
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 {
726
- const icon = result.valid ? '✅' : '❌';
727
- console.log(`\n${icon} ${result.workflow} (${result.app})`);
728
-
729
- if (result.valid) {
730
- console.log(' All paths match dependencies');
731
- return;
732
- }
733
-
734
- if (result.issues.length > 0) {
735
- console.log(' Issues:');
736
- for (const issue of result.issues) {
737
- console.log(` ⚠️ ${issue}`);
738
- }
739
- }
740
-
741
- if (result.missing.length > 0) {
742
- console.log(' Missing paths:');
743
- for (const path of result.missing) {
744
- console.log(` - ${path}`);
745
- }
746
- }
747
-
748
- if (result.unnecessary.length > 0) {
749
- console.log(' Unnecessary paths:');
750
- for (const path of result.unnecessary) {
751
- console.log(` - ${path}`);
752
- }
753
- }
754
- }
755
-
756
- function main(): void {
757
- const rootDir = resolve(process.cwd());
758
- let hasErrors = false;
759
-
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
- console.log(
772
- '🔍 Validating GitHub Actions workflows against dependencies...\n',
773
- );
774
- console.log(`Found ${workflowCount} workflow(s) to validate\n`);
775
-
776
- if (workflowCount === 0) {
777
- console.log('No deploy-*.yml or release-*.yml workflows found.\n');
778
- }
779
-
780
- const results: ValidationResult[] = [];
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);
792
- printResult(result);
793
- }
794
-
795
- const validCount = results.filter((r) => r.valid).length;
796
- const invalidCount = results.filter((r) => !r.valid).length;
797
-
798
- console.log('\n' + '='.repeat(60));
799
- console.log(
800
- `\n📊 Path validation: ${validCount} valid, ${invalidCount} invalid\n`,
801
- );
802
-
803
- if (invalidCount > 0) {
804
- console.log('❌ Some workflows need updates to match dependencies');
805
- console.log('\nTo fix:');
806
- console.log('1. Update workflow path filters to match missing paths');
807
- console.log('2. Remove unnecessary paths');
808
- console.log(
809
- '3. Replace wildcards (plugins/**, packages/**) with specific paths\n',
810
- );
811
- hasErrors = true;
812
- } else if (workflowCount > 0) {
813
- console.log('✅ All workflows match their dependencies!\n');
814
- }
815
-
816
- // Part 2: Validate pnpm version consistency
817
- console.log('='.repeat(60));
818
- console.log('\n🔍 Validating pnpm version consistency...\n');
819
-
820
- const pnpmResult = validatePnpmVersions(rootDir);
821
-
822
- if (!pnpmResult.valid) {
823
- console.log('❌ PNPM version issues found:\n');
824
- for (const { workflow, issue } of pnpmResult.workflowIssues) {
825
- console.log(` ⚠️ ${workflow}: ${issue}`);
826
- }
827
- for (const { issue } of pnpmResult.dockerfileIssues) {
828
- console.log(` ⚠️ ${issue}`);
829
- }
830
- console.log('\nTo fix:');
831
- console.log(
832
- '1. Remove hardcoded pnpm versions from workflows (let pnpm/action-setup auto-detect from packageManager)',
833
- );
834
- console.log(
835
- '2. Update Dockerfile pnpm versions to match package.json packageManager field\n',
836
- );
837
- hasErrors = true;
838
- } else {
839
- console.log('✅ PNPM versions are consistent!\n');
840
- }
841
-
842
- if (hasErrors) {
843
- process.exit(1);
844
- }
845
- }
846
-
847
- main();