@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.
@@ -1,71 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
4
- import { resolve, join } from 'node:path';
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((f) => f.endsWith('.yml'));
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
- // 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;
43
+ for (const entry of readdirSync(rootDir)) {
44
+ if (!entry.startsWith('Dockerfile')) {
45
+ continue;
239
46
  }
240
47
 
241
- // Check plugins/ next
242
- const pluginPath = join(rootDir, 'plugins', packageName);
243
- if (existsSync(pluginPath)) {
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
- // 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);
54
+ const appsDir = join(rootDir, 'apps');
55
+ if (!existsSync(appsDir)) {
56
+ return dockerfiles;
283
57
  }
284
58
 
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)) {
59
+ for (const entry of readdirSync(appsDir, { withFileTypes: true })) {
60
+ if (!entry.isDirectory()) {
306
61
  continue;
307
62
  }
308
63
 
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);
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
- // 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];
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
- // 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();
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(packageJsonPath, 'utf-8'),
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 }; // Skip non-existent workflows
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
- 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
- }
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 }; // Skip non-existent Dockerfiles
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
- // Dynamically discover and check workflows for hardcoded pnpm versions
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 dockerfiles = discoverPnpmDockerfiles(rootDir);
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.app})`);
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
- if (result.issues.length > 0) {
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 result.issues) {
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
- console.log(`Found ${workflowCount} workflow(s) to validate\n`);
228
+ const results = await validateWorkflows(rootDir);
229
+ console.log(`Found ${results.length} workflow(s) to validate\n`);
775
230
 
776
- if (workflowCount === 0) {
231
+ if (results.length === 0) {
777
232
  console.log('No deploy-*.yml or release-*.yml workflows found.\n');
778
233
  }
779
234
 
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);
235
+ for (const result of results) {
792
236
  printResult(result);
793
237
  }
794
238
 
795
- const validCount = results.filter((r) => r.valid).length;
796
- const invalidCount = results.filter((r) => !r.valid).length;
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 (workflowCount > 0) {
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();