@crossplatformai/dependency-graph 0.9.2

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.
@@ -0,0 +1,822 @@
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
+ */
167
+ function discoverPnpmDockerfiles(rootDir: string): string[] {
168
+ 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
+
216
+ // Check apps/ first (e.g., @repo/shared → apps/shared)
217
+ const appsPath = join(rootDir, 'apps', packageName);
218
+ if (existsSync(appsPath)) {
219
+ return appsPath;
220
+ }
221
+
222
+ // Check plugins/ next
223
+ const pluginPath = join(rootDir, 'plugins', packageName);
224
+ if (existsSync(pluginPath)) {
225
+ return pluginPath;
226
+ }
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
+ }
256
+
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);
264
+ }
265
+
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)) {
287
+ continue;
288
+ }
289
+
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);
307
+ }
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
+
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];
399
+ }
400
+ }
401
+
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();
468
+ }
469
+
470
+ /**
471
+ * Get pnpm version from package.json packageManager field
472
+ */
473
+ function getExpectedPnpmVersion(rootDir: string): string {
474
+ const packageJsonPath = join(rootDir, 'package.json');
475
+ const packageJson = JSON.parse(
476
+ readFileSync(packageJsonPath, 'utf-8'),
477
+ ) as PackageJson;
478
+ const packageManager = packageJson.packageManager;
479
+ if (!packageManager?.startsWith('pnpm@')) {
480
+ throw new Error('packageManager field must specify pnpm version');
481
+ }
482
+ return packageManager.replace('pnpm@', '');
483
+ }
484
+
485
+ /**
486
+ * Check if workflow has hardcoded pnpm version
487
+ */
488
+ function checkWorkflowPnpmVersion(
489
+ workflowFile: string,
490
+ rootDir: string,
491
+ ): { valid: boolean; issue?: string } {
492
+ const workflowPath = join(rootDir, '.github/workflows', workflowFile);
493
+
494
+ if (!existsSync(workflowPath)) {
495
+ return { valid: true }; // Skip non-existent workflows
496
+ }
497
+
498
+ const content = readFileSync(workflowPath, 'utf-8');
499
+ const workflow = parseYaml(content) as {
500
+ jobs?: Record<
501
+ string,
502
+ { steps?: Array<{ uses?: string; with?: { version?: string | number } }> }
503
+ >;
504
+ };
505
+
506
+ // Find all pnpm/action-setup steps
507
+ for (const job of Object.values(workflow.jobs ?? {})) {
508
+ 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
+ }
518
+ }
519
+ }
520
+ }
521
+ return { valid: true };
522
+ }
523
+
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
+ function checkDockerfilePnpmVersion(
529
+ dockerfile: string,
530
+ expectedVersion: string,
531
+ rootDir: string,
532
+ ): { valid: boolean; issue?: string } {
533
+ const dockerfilePath = join(rootDir, dockerfile);
534
+
535
+ if (!existsSync(dockerfilePath)) {
536
+ return { valid: true }; // Skip non-existent Dockerfiles
537
+ }
538
+
539
+ const content = readFileSync(dockerfilePath, 'utf-8');
540
+ const matches = content.matchAll(/npm install -g pnpm@([\d.]+)/g);
541
+
542
+ for (const match of matches) {
543
+ if (match[1] !== expectedVersion) {
544
+ return {
545
+ valid: false,
546
+ issue: `${dockerfile} uses pnpm@${match[1]} but package.json specifies pnpm@${expectedVersion}`,
547
+ };
548
+ }
549
+ }
550
+ return { valid: true };
551
+ }
552
+
553
+ /**
554
+ * Validate pnpm version consistency across workflows and Dockerfiles
555
+ */
556
+ function validatePnpmVersions(rootDir: string): PnpmValidationResult {
557
+ const result: PnpmValidationResult = {
558
+ valid: true,
559
+ workflowIssues: [],
560
+ dockerfileIssues: [],
561
+ };
562
+
563
+ // Dynamically discover and check workflows for hardcoded pnpm versions
564
+ const pnpmWorkflows = discoverPnpmWorkflows(rootDir);
565
+ for (const workflowFile of pnpmWorkflows) {
566
+ const check = checkWorkflowPnpmVersion(workflowFile, rootDir);
567
+ if (!check.valid && check.issue) {
568
+ result.valid = false;
569
+ result.workflowIssues.push({
570
+ workflow: workflowFile,
571
+ issue: check.issue,
572
+ });
573
+ }
574
+ }
575
+
576
+ // Dynamically discover and check Dockerfiles match package.json pnpm version
577
+ const expectedVersion = getExpectedPnpmVersion(rootDir);
578
+ const dockerfiles = discoverPnpmDockerfiles(rootDir);
579
+ for (const dockerfile of dockerfiles) {
580
+ const check = checkDockerfilePnpmVersion(
581
+ dockerfile,
582
+ expectedVersion,
583
+ rootDir,
584
+ );
585
+ if (!check.valid && check.issue) {
586
+ result.valid = false;
587
+ result.dockerfileIssues.push({ dockerfile, issue: check.issue });
588
+ }
589
+ }
590
+
591
+ return result;
592
+ }
593
+
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 {
701
+ const icon = result.valid ? 'āœ…' : 'āŒ';
702
+ console.log(`\n${icon} ${result.workflow} (${result.app})`);
703
+
704
+ if (result.valid) {
705
+ console.log(' All paths match dependencies');
706
+ return;
707
+ }
708
+
709
+ if (result.issues.length > 0) {
710
+ console.log(' Issues:');
711
+ for (const issue of result.issues) {
712
+ console.log(` āš ļø ${issue}`);
713
+ }
714
+ }
715
+
716
+ if (result.missing.length > 0) {
717
+ console.log(' Missing paths:');
718
+ for (const path of result.missing) {
719
+ console.log(` - ${path}`);
720
+ }
721
+ }
722
+
723
+ if (result.unnecessary.length > 0) {
724
+ console.log(' Unnecessary paths:');
725
+ for (const path of result.unnecessary) {
726
+ console.log(` - ${path}`);
727
+ }
728
+ }
729
+ }
730
+
731
+ function main(): void {
732
+ const rootDir = resolve(process.cwd());
733
+ let hasErrors = false;
734
+
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
+ console.log(
747
+ 'šŸ” Validating GitHub Actions workflows against dependencies...\n',
748
+ );
749
+ console.log(`Found ${workflowCount} workflow(s) to validate\n`);
750
+
751
+ if (workflowCount === 0) {
752
+ console.log('No deploy-*.yml or release-*.yml workflows found.\n');
753
+ }
754
+
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);
767
+ printResult(result);
768
+ }
769
+
770
+ const validCount = results.filter((r) => r.valid).length;
771
+ const invalidCount = results.filter((r) => !r.valid).length;
772
+
773
+ console.log('\n' + '='.repeat(60));
774
+ console.log(
775
+ `\nšŸ“Š Path validation: ${validCount} valid, ${invalidCount} invalid\n`,
776
+ );
777
+
778
+ if (invalidCount > 0) {
779
+ console.log('āŒ Some workflows need updates to match dependencies');
780
+ console.log('\nTo fix:');
781
+ console.log('1. Update workflow path filters to match missing paths');
782
+ console.log('2. Remove unnecessary paths');
783
+ console.log(
784
+ '3. Replace wildcards (plugins/**, packages/**) with specific paths\n',
785
+ );
786
+ hasErrors = true;
787
+ } else if (workflowCount > 0) {
788
+ console.log('āœ… All workflows match their dependencies!\n');
789
+ }
790
+
791
+ // Part 2: Validate pnpm version consistency
792
+ console.log('='.repeat(60));
793
+ console.log('\nšŸ” Validating pnpm version consistency...\n');
794
+
795
+ const pnpmResult = validatePnpmVersions(rootDir);
796
+
797
+ if (!pnpmResult.valid) {
798
+ console.log('āŒ PNPM version issues found:\n');
799
+ for (const { workflow, issue } of pnpmResult.workflowIssues) {
800
+ console.log(` āš ļø ${workflow}: ${issue}`);
801
+ }
802
+ for (const { issue } of pnpmResult.dockerfileIssues) {
803
+ console.log(` āš ļø ${issue}`);
804
+ }
805
+ console.log('\nTo fix:');
806
+ console.log(
807
+ '1. Remove hardcoded pnpm versions from workflows (let pnpm/action-setup auto-detect from packageManager)',
808
+ );
809
+ console.log(
810
+ '2. Update Dockerfile pnpm versions to match package.json packageManager field\n',
811
+ );
812
+ hasErrors = true;
813
+ } else {
814
+ console.log('āœ… PNPM versions are consistent!\n');
815
+ }
816
+
817
+ if (hasErrors) {
818
+ process.exit(1);
819
+ }
820
+ }
821
+
822
+ main();