@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.
@@ -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,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
- // Check apps/ first (e.g., @repo/shared → apps/shared)
217
- const appsPath = join(rootDir, 'apps', packageName);
218
- if (existsSync(appsPath)) {
219
- return appsPath;
43
+ for (const entry of readdirSync(rootDir)) {
44
+ if (!entry.startsWith('Dockerfile')) {
45
+ continue;
220
46
  }
221
47
 
222
- // Check plugins/ next
223
- const pluginPath = join(rootDir, 'plugins', packageName);
224
- if (existsSync(pluginPath)) {
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 packageJson = JSON.parse(
258
- readFileSync(packageJsonPath, 'utf-8'),
259
- ) as PackageJson;
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
- // Detect workspace dependencies by their version specifier or presence in local workspace
267
- // Supports:
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
- visited.add(dep);
291
- allTransitiveDeps.add(dep);
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
- if (!existsSync(packageJsonPath)) {
324
- throw new Error(`Package.json not found for app: ${appName}`);
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
- // For submodules, return both the pointer path AND the glob path
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(packageJsonPath, 'utf-8'),
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 }; // Skip non-existent workflows
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
- const version = step.with?.version;
511
- // Allow major-only versions like "10" (deploy-api-edge.yml uses this)
512
- if (version !== undefined && !/^\d+$/.test(String(version))) {
513
- return {
514
- valid: false,
515
- issue: `Hardcoded pnpm version '${version}' - remove 'version' key to auto-detect from packageManager`,
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 }; // Skip non-existent Dockerfiles
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
- // Dynamically discover and check workflows for hardcoded pnpm versions
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 dockerfiles = discoverPnpmDockerfiles(rootDir);
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.app})`);
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
- 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) {
710
200
  console.log(' Issues:');
711
- for (const issue of result.issues) {
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
- 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`);
750
230
 
751
- if (workflowCount === 0) {
231
+ if (results.length === 0) {
752
232
  console.log('No deploy-*.yml or release-*.yml workflows found.\n');
753
233
  }
754
234
 
755
- const results: ValidationResult[] = [];
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((r) => r.valid).length;
771
- const invalidCount = results.filter((r) => !r.valid).length;
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 (workflowCount > 0) {
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();