From dde12af553c62adc5cdb8aee6a31f7e20c4abc09 Mon Sep 17 00:00:00 2001 From: Matthias Hempel <7202441+fotopixel@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:54:16 +0200 Subject: [PATCH] feat(cli): implement fwiz analyze-shared command Add workspace package scanning, shared dependency analysis with version conflict detection, console/JSON reporting, and --fix support for fwiz.config.yaml. Closes #4 Co-authored-by: Cursor --- apps/cli/src/commands/analyze-shared.spec.ts | 53 ++++ apps/cli/src/commands/analyze-shared.ts | 72 ++++++ apps/cli/src/program.ts | 2 + libs/core/package.json | 1 + libs/core/src/index.ts | 1 + libs/core/src/lib/shared/analyze.spec.ts | 126 ++++++++++ libs/core/src/lib/shared/analyze.ts | 248 +++++++++++++++++++ libs/core/src/lib/shared/index.ts | 3 + libs/core/src/lib/shared/recommend.spec.ts | 52 ++++ libs/core/src/lib/shared/recommend.ts | 126 ++++++++++ libs/core/src/lib/shared/types.ts | 36 +++ libs/core/tsconfig.lib.json | 6 +- libs/utils/src/index.ts | 1 + libs/utils/src/lib/package-scan.spec.ts | 86 +++++++ libs/utils/src/lib/package-scan.ts | 116 +++++++++ pnpm-lock.yaml | 5 +- 16 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/commands/analyze-shared.spec.ts create mode 100644 apps/cli/src/commands/analyze-shared.ts create mode 100644 libs/core/src/lib/shared/analyze.spec.ts create mode 100644 libs/core/src/lib/shared/analyze.ts create mode 100644 libs/core/src/lib/shared/index.ts create mode 100644 libs/core/src/lib/shared/recommend.spec.ts create mode 100644 libs/core/src/lib/shared/recommend.ts create mode 100644 libs/core/src/lib/shared/types.ts create mode 100644 libs/utils/src/lib/package-scan.spec.ts create mode 100644 libs/utils/src/lib/package-scan.ts diff --git a/apps/cli/src/commands/analyze-shared.spec.ts b/apps/cli/src/commands/analyze-shared.spec.ts new file mode 100644 index 0000000..55fca37 --- /dev/null +++ b/apps/cli/src/commands/analyze-shared.spec.ts @@ -0,0 +1,53 @@ +import { + mkdirSync, + mkdtempSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + analyzeSharedDependencies, + applySharedRecommendations, + initFwizConfig, +} from '@federation-wizards/core'; + +describe('fwiz analyze-shared command', () => { + it('analyzes workspace dependencies and applies fixes', () => { + const dir = mkdtempSync(join(tmpdir(), 'fwiz-cli-analyze-')); + + writeFileSync(join(dir, 'nx.json'), '{}'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + + mkdirSync(join(dir, 'apps', 'shell'), { recursive: true }); + mkdirSync(join(dir, 'apps', 'checkout'), { recursive: true }); + writeFileSync(join(dir, 'apps', 'shell', 'project.json'), '{}'); + writeFileSync(join(dir, 'apps', 'checkout', 'project.json'), '{}'); + writeFileSync( + join(dir, 'apps', 'shell', 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + writeFileSync( + join(dir, 'apps', 'checkout', 'package.json'), + JSON.stringify({ dependencies: { react: '^18.2.0' } }), + ); + + initFwizConfig(dir); + + const analysis = analyzeSharedDependencies(dir); + + expect(analysis.conflicts).toHaveLength(1); + expect(analysis.recommendedShared.react.requiredVersion).toBe('^19.0.0'); + + const fixResult = applySharedRecommendations( + dir, + analysis.recommendedShared, + ); + + expect(fixResult.changes.length).toBeGreaterThan(0); + expect(analysis.scannedPackages.length).toBeGreaterThan(1); + }); +}); diff --git a/apps/cli/src/commands/analyze-shared.ts b/apps/cli/src/commands/analyze-shared.ts new file mode 100644 index 0000000..aaa7c04 --- /dev/null +++ b/apps/cli/src/commands/analyze-shared.ts @@ -0,0 +1,72 @@ +import type { Command } from 'commander'; + +import { + analyzeSharedDependencies, + applySharedRecommendations, + formatSharedAnalysisTable, +} from '@federation-wizards/core'; + +export function registerAnalyzeSharedCommand(program: Command): void { + program + .command('analyze-shared') + .description( + 'Analyze workspace dependencies and recommend Module Federation shared config', + ) + .summary('Scan package.json files and suggest shared dependency settings') + .addHelpText( + 'after', + ` +Examples: + $ fwiz analyze-shared + $ fwiz analyze-shared --json + $ fwiz analyze-shared --fix + $ fwiz analyze-shared --cwd ./my-monorepo + +The command scans root, apps/*, and libs/* package.json files, detects +version conflicts, and recommends singleton/eager settings for shared deps. +Use --fix to apply recommendations to fwiz.config.yaml. +`, + ) + .option( + '--cwd ', + 'Workspace directory to analyze (defaults to current working directory)', + process.cwd(), + ) + .option('--json', 'Output analysis as JSON') + .option('--fix', 'Apply recommended shared settings to fwiz.config.yaml') + .action((options: { cwd: string; json?: boolean; fix?: boolean }) => { + const result = analyzeSharedDependencies(options.cwd); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatSharedAnalysisTable(result)); + console.log(''); + console.log( + `Scanned ${result.scannedPackages.length} package.json file(s).`, + ); + } + + if (options.fix) { + if (Object.keys(result.recommendedShared).length === 0) { + console.warn('No shared dependency recommendations to apply.'); + return; + } + + const fixResult = applySharedRecommendations( + options.cwd, + result.recommendedShared, + ); + + if (fixResult.changes.length === 0) { + console.log(`No changes needed in ${fixResult.configPath}`); + return; + } + + console.log(`Updated ${fixResult.configPath}`); + for (const change of fixResult.changes) { + console.log(` - ${change}`); + } + } + }); +} diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 1537071..661d82a 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; +import { registerAnalyzeSharedCommand } from './commands/analyze-shared.js'; import { registerInitCommand } from './commands/init.js'; export function createProgram(): Command { @@ -11,6 +12,7 @@ export function createProgram(): Command { .version('0.0.1'); registerInitCommand(program); + registerAnalyzeSharedCommand(program); return program; } diff --git a/libs/core/package.json b/libs/core/package.json index 5f66821..008b09a 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -16,6 +16,7 @@ } }, "dependencies": { + "@federation-wizards/utils": "workspace:*", "joi": "^18.2.1", "tslib": "^2.3.0", "yaml": "^2.9.0" diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 3485403..7caad8b 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/config/init.js'; export * from './lib/config/load.js'; export * from './lib/workspace/detect.js'; export * from './lib/workspace/patch.js'; +export * from './lib/shared/index.js'; diff --git a/libs/core/src/lib/shared/analyze.spec.ts b/libs/core/src/lib/shared/analyze.spec.ts new file mode 100644 index 0000000..69ac4a7 --- /dev/null +++ b/libs/core/src/lib/shared/analyze.spec.ts @@ -0,0 +1,126 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { describe, expect, it } from 'vitest'; + +import { createDefaultConfig } from '../config/defaults.js'; +import { FWIZ_CONFIG_FILENAME } from '../config/init.js'; +import { + analyzeSharedDependencies, + applySharedRecommendations, + formatSharedAnalysisTable, +} from './analyze.js'; + +function createConflictingWorkspace(): string { + const dir = mkdtempSync(join(tmpdir(), 'fwiz-analyze-conflict-')); + + writeFileSync(join(dir, 'nx.json'), '{}'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + + mkdirSync(join(dir, 'apps', 'shell'), { recursive: true }); + mkdirSync(join(dir, 'apps', 'checkout'), { recursive: true }); + mkdirSync(join(dir, 'libs', 'ui'), { recursive: true }); + + writeFileSync( + join(dir, 'apps', 'shell', 'package.json'), + JSON.stringify({ + dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' }, + }), + ); + writeFileSync( + join(dir, 'apps', 'checkout', 'package.json'), + JSON.stringify({ + dependencies: { react: '^18.2.0', 'react-dom': '^18.2.0' }, + }), + ); + writeFileSync( + join(dir, 'libs', 'ui', 'package.json'), + JSON.stringify({ + peerDependencies: { react: '^19.0.0' }, + }), + ); + + const config = createDefaultConfig({ + type: 'nx', + appProjects: ['shell', 'checkout'], + reactVersion: '^18.2.0', + }); + + writeFileSync( + join(dir, FWIZ_CONFIG_FILENAME), + `${stringifyYaml(config)}\n`, + 'utf8', + ); + + return dir; +} + +describe('analyzeSharedDependencies', () => { + it('detects common dependencies and version conflicts', () => { + const dir = createConflictingWorkspace(); + const result = analyzeSharedDependencies(dir); + + expect(result.scannedPackages).toEqual([ + 'apps/checkout/package.json', + 'apps/shell/package.json', + 'libs/ui/package.json', + 'package.json', + ]); + + const react = result.sharedDependencies.find( + (dependency) => dependency.name === 'react', + ); + + expect(react?.projectCount).toBe(4); + expect(react?.hasConflict).toBe(true); + expect(react?.recommended.singleton).toBe(true); + expect(react?.recommended.requiredVersion).toBe('^19.0.0'); + + expect(result.conflicts).toHaveLength(2); + expect(result.recommendedShared.react.requiredVersion).toBe('^19.0.0'); + }); + + it('formats a readable console table', () => { + const dir = createConflictingWorkspace(); + const result = analyzeSharedDependencies(dir); + const table = formatSharedAnalysisTable(result); + + expect(table).toContain('Package'); + expect(table).toContain('react'); + expect(table).toContain('Version conflicts:'); + expect(table).toContain('Align all projects to ^19.0.0'); + }); +}); + +describe('applySharedRecommendations', () => { + it('updates fwiz.config.yaml with recommended shared settings', () => { + const dir = createConflictingWorkspace(); + const analysis = analyzeSharedDependencies(dir); + const fixResult = applySharedRecommendations( + dir, + analysis.recommendedShared, + ); + + expect(fixResult.changes.length).toBeGreaterThan(0); + + const config = parseYaml( + readFileSync(join(dir, FWIZ_CONFIG_FILENAME), 'utf8'), + ) as { + shared: Record; + }; + + expect(config.shared.react.requiredVersion).toBe('^19.0.0'); + expect(config.shared.react.singleton).toBe(true); + expect(config.shared['react-dom'].requiredVersion).toBe('^19.0.0'); + }); +}); diff --git a/libs/core/src/lib/shared/analyze.ts b/libs/core/src/lib/shared/analyze.ts new file mode 100644 index 0000000..d6aa7d0 --- /dev/null +++ b/libs/core/src/lib/shared/analyze.ts @@ -0,0 +1,248 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { scanWorkspacePackages } from '@federation-wizards/utils'; +import type { PackageManifest } from '@federation-wizards/utils'; +import { stringify as stringifyYaml } from 'yaml'; + +import { FWIZ_CONFIG_FILENAME } from '../config/init.js'; +import { loadFwizConfig } from '../config/load.js'; +import { validateFwizConfig } from '../config/schema.js'; +import type { FwizConfig, SharedDependencyConfig } from '../config/types.js'; +import { + buildSharedRecommendation, + shouldShareDependency, +} from './recommend.js'; +import type { + AnalyzeSharedResult, + ApplySharedFixResult, + DependencyOccurrence, + SharedDependencyAnalysis, + VersionConflict, +} from './types.js'; + +function collectDependencyOccurrences( + manifests: PackageManifest[], +): Map { + const occurrences = new Map(); + + for (const manifest of manifests) { + const sections = [ + ['dependencies', manifest.dependencies] as const, + ['devDependencies', manifest.devDependencies] as const, + ['peerDependencies', manifest.peerDependencies] as const, + ]; + + for (const [kind, dependencies] of sections) { + for (const [name, version] of Object.entries(dependencies)) { + if (!shouldShareDependency(name)) { + continue; + } + + const existing = occurrences.get(name) ?? []; + existing.push({ + packagePath: manifest.path, + version, + kind, + }); + occurrences.set(name, existing); + } + } + } + + return occurrences; +} + +function uniquePackagePaths(occurrences: DependencyOccurrence[]): string[] { + return [...new Set(occurrences.map((occurrence) => occurrence.packagePath))]; +} + +function uniqueVersions(occurrences: DependencyOccurrence[]): string[] { + return [...new Set(occurrences.map((occurrence) => occurrence.version))].sort(); +} + +function buildVersionConflict( + name: string, + occurrences: DependencyOccurrence[], + manifests: PackageManifest[], +): VersionConflict { + const versions: Record = {}; + + for (const occurrence of occurrences) { + const paths = versions[occurrence.version] ?? []; + paths.push(occurrence.packagePath); + versions[occurrence.version] = paths; + } + + const recommendedVersion = buildSharedRecommendation( + name, + uniqueVersions(occurrences), + manifests, + ).requiredVersion; + + return { + name, + versions, + recommendedVersion, + resolution: `Align all projects to ${recommendedVersion}`, + }; +} + +function buildSharedDependencyAnalysis( + name: string, + occurrences: DependencyOccurrence[], + manifests: PackageManifest[], +): SharedDependencyAnalysis { + const versions = uniqueVersions(occurrences); + + return { + name, + occurrences, + projectCount: uniquePackagePaths(occurrences).length, + hasConflict: versions.length > 1, + versions, + recommended: buildSharedRecommendation(name, versions, manifests), + }; +} + +export function analyzeSharedDependencies(cwd: string): AnalyzeSharedResult { + const manifests = scanWorkspacePackages(cwd); + const occurrencesByName = collectDependencyOccurrences(manifests); + const sharedDependencies: SharedDependencyAnalysis[] = []; + const conflicts: VersionConflict[] = []; + const recommendedShared: Record = {}; + + for (const [name, occurrences] of [...occurrencesByName.entries()].sort()) { + const packagePaths = uniquePackagePaths(occurrences); + + if (packagePaths.length < 2) { + continue; + } + + const analysis = buildSharedDependencyAnalysis(name, occurrences, manifests); + sharedDependencies.push(analysis); + recommendedShared[name] = analysis.recommended; + + if (analysis.hasConflict) { + conflicts.push(buildVersionConflict(name, occurrences, manifests)); + } + } + + return { + scannedPackages: manifests.map((manifest) => manifest.path), + sharedDependencies, + conflicts, + recommendedShared, + }; +} + +function serializeConfig(config: FwizConfig): string { + return stringifyYaml(config, { + indent: 2, + lineWidth: 0, + }); +} + +export function applySharedRecommendations( + cwd: string, + recommendations: Record, +): ApplySharedFixResult { + const configPath = join(cwd, FWIZ_CONFIG_FILENAME); + const config = loadFwizConfig(cwd); + const changes: string[] = []; + + for (const [name, recommendation] of Object.entries(recommendations).sort()) { + const existing = config.shared[name]; + + if (!existing) { + config.shared[name] = recommendation; + changes.push(`Added shared dependency ${name}`); + continue; + } + + if (existing.singleton !== recommendation.singleton) { + existing.singleton = recommendation.singleton; + changes.push(`Updated ${name}.singleton to ${recommendation.singleton}`); + } + + if (existing.eager !== recommendation.eager) { + existing.eager = recommendation.eager; + changes.push(`Updated ${name}.eager to ${recommendation.eager}`); + } + + if (existing.requiredVersion !== recommendation.requiredVersion) { + existing.requiredVersion = recommendation.requiredVersion; + changes.push( + `Updated ${name}.requiredVersion to ${recommendation.requiredVersion}`, + ); + } + } + + const validation = validateFwizConfig(config); + + if (!validation.valid) { + throw new Error( + `Updated config failed validation: ${validation.warnings.join('; ')}`, + ); + } + + writeFileSync(configPath, `${serializeConfig(config)}\n`, 'utf8'); + + return { + configPath, + changes, + }; +} + +export function formatSharedAnalysisTable(result: AnalyzeSharedResult): string { + if (result.sharedDependencies.length === 0) { + return 'No common shared dependencies found across workspace packages.'; + } + + const headers = [ + 'Package', + 'Projects', + 'Versions', + 'Singleton', + 'Eager', + 'Required Version', + ]; + + const rows = result.sharedDependencies.map((dependency) => [ + dependency.name, + String(dependency.projectCount), + dependency.hasConflict + ? `${dependency.versions.join(', ')} (conflict)` + : dependency.versions.join(', '), + String(dependency.recommended.singleton), + String(dependency.recommended.eager), + dependency.recommended.requiredVersion, + ]); + + const widths = headers.map((header, index) => + Math.max( + header.length, + ...rows.map((row) => row[index]?.length ?? 0), + ), + ); + + const formatRow = (cells: string[]) => + cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' '); + + const lines = [ + formatRow(headers), + widths.map((width) => '-'.repeat(width)).join(' '), + ...rows.map((row) => formatRow(row)), + ]; + + if (result.conflicts.length > 0) { + lines.push(''); + lines.push('Version conflicts:'); + + for (const conflict of result.conflicts) { + lines.push(` ${conflict.name}: ${conflict.resolution}`); + } + } + + return lines.join('\n'); +} diff --git a/libs/core/src/lib/shared/index.ts b/libs/core/src/lib/shared/index.ts new file mode 100644 index 0000000..02c72dc --- /dev/null +++ b/libs/core/src/lib/shared/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './recommend.js'; +export * from './analyze.js'; diff --git a/libs/core/src/lib/shared/recommend.spec.ts b/libs/core/src/lib/shared/recommend.spec.ts new file mode 100644 index 0000000..a91b1bc --- /dev/null +++ b/libs/core/src/lib/shared/recommend.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import type { PackageManifest } from '@federation-wizards/utils'; + +import { + buildSharedRecommendation, + recommendRequiredVersion, + recommendSingleton, +} from './recommend.js'; + +describe('recommend', () => { + it('marks react packages as singletons', () => { + expect(recommendSingleton('react')).toBe(true); + expect(recommendSingleton('react-dom')).toBe(true); + expect(recommendSingleton('lodash')).toBe(false); + }); + + it('prefers the root package.json version when versions conflict', () => { + const manifests: PackageManifest[] = [ + { + path: 'package.json', + dependencies: { react: '^19.0.0' }, + devDependencies: {}, + peerDependencies: {}, + }, + { + path: 'apps/shell/package.json', + dependencies: { react: '^18.2.0' }, + devDependencies: {}, + peerDependencies: {}, + }, + ]; + + expect( + recommendRequiredVersion('react', ['^18.2.0', '^19.0.0'], manifests), + ).toBe('^19.0.0'); + }); + + it('builds shared recommendations with eager defaults for react', () => { + const recommendation = buildSharedRecommendation( + 'react', + ['^19.0.0'], + [], + ); + + expect(recommendation).toEqual({ + singleton: true, + eager: true, + requiredVersion: '^19.0.0', + }); + }); +}); diff --git a/libs/core/src/lib/shared/recommend.ts b/libs/core/src/lib/shared/recommend.ts new file mode 100644 index 0000000..61f2955 --- /dev/null +++ b/libs/core/src/lib/shared/recommend.ts @@ -0,0 +1,126 @@ +import type { PackageManifest } from '@federation-wizards/utils'; + +import type { SharedDependencyConfig } from '../config/types.js'; + +const SINGLETON_PACKAGES = new Set([ + 'react', + 'react-dom', + 'react-router', + 'react-router-dom', + '@reduxjs/toolkit', + 'redux', + 'zustand', + 'styled-components', + '@emotion/react', + '@emotion/styled', + 'rxjs', + '@angular/core', + '@angular/common', + '@angular/router', +]); + +const EAGER_PACKAGES = new Set(['react', 'react-dom']); + +export function shouldShareDependency(name: string): boolean { + if (name.startsWith('workspace:')) { + return false; + } + + if (name.startsWith('@types/')) { + return false; + } + + return true; +} + +export function recommendSingleton(name: string): boolean { + return SINGLETON_PACKAGES.has(name); +} + +export function recommendEager(name: string): boolean { + return EAGER_PACKAGES.has(name); +} + +function parseMajorVersion(version: string): number { + const match = version.match(/(\d+)/); + + return match ? Number.parseInt(match[1], 10) : 0; +} + +function compareVersionPreference(left: string, right: string): number { + const majorDifference = + parseMajorVersion(right) - parseMajorVersion(left); + + if (majorDifference !== 0) { + return majorDifference; + } + + return right.localeCompare(left); +} + +export function recommendRequiredVersion( + name: string, + versions: string[], + manifests: PackageManifest[], +): string { + if (versions.length === 1) { + return versions[0]; + } + + const rootManifest = manifests.find((manifest) => manifest.path === 'package.json'); + + if (rootManifest) { + const rootVersion = + rootManifest.dependencies[name] ?? + rootManifest.devDependencies[name] ?? + rootManifest.peerDependencies[name]; + + if (rootVersion) { + return rootVersion; + } + + if (name === 'react-dom') { + const reactVersion = + rootManifest.dependencies.react ?? + rootManifest.devDependencies.react ?? + rootManifest.peerDependencies.react; + + if (reactVersion) { + const reactMajor = parseMajorVersion(reactVersion); + const alignedVersion = versions.find( + (version) => parseMajorVersion(version) === reactMajor, + ); + + if (alignedVersion) { + return alignedVersion; + } + } + } + } + + const versionCounts = new Map(); + + for (const version of versions) { + versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1); + } + + const [preferredVersion] = [...versionCounts.entries()].sort((left, right) => + right[1] - left[1] !== 0 + ? right[1] - left[1] + : compareVersionPreference(left[0], right[0]), + ); + + return preferredVersion?.[0] ?? versions[0]; +} + +export function buildSharedRecommendation( + name: string, + versions: string[], + manifests: PackageManifest[], +): SharedDependencyConfig { + return { + singleton: recommendSingleton(name), + eager: recommendEager(name), + requiredVersion: recommendRequiredVersion(name, versions, manifests), + }; +} diff --git a/libs/core/src/lib/shared/types.ts b/libs/core/src/lib/shared/types.ts new file mode 100644 index 0000000..7a3ad80 --- /dev/null +++ b/libs/core/src/lib/shared/types.ts @@ -0,0 +1,36 @@ +import type { SharedDependencyConfig } from '../config/types.js'; +import type { DependencyKind } from '@federation-wizards/utils'; + +export interface DependencyOccurrence { + packagePath: string; + version: string; + kind: DependencyKind; +} + +export interface SharedDependencyAnalysis { + name: string; + occurrences: DependencyOccurrence[]; + projectCount: number; + hasConflict: boolean; + versions: string[]; + recommended: SharedDependencyConfig; +} + +export interface VersionConflict { + name: string; + versions: Record; + recommendedVersion: string; + resolution: string; +} + +export interface AnalyzeSharedResult { + scannedPackages: string[]; + sharedDependencies: SharedDependencyAnalysis[]; + conflicts: VersionConflict[]; + recommendedShared: Record; +} + +export interface ApplySharedFixResult { + configPath: string; + changes: string[]; +} diff --git a/libs/core/tsconfig.lib.json b/libs/core/tsconfig.lib.json index d30b75b..05a5a99 100644 --- a/libs/core/tsconfig.lib.json +++ b/libs/core/tsconfig.lib.json @@ -10,7 +10,11 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "references": [], + "references": [ + { + "path": "../utils/tsconfig.lib.json" + } + ], "exclude": [ "vite.config.ts", "vite.config.mts", diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index 715e968..5f187db 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -1 +1,2 @@ export * from './lib/utils.js'; +export * from './lib/package-scan.js'; diff --git a/libs/utils/src/lib/package-scan.spec.ts b/libs/utils/src/lib/package-scan.spec.ts new file mode 100644 index 0000000..9aa05d3 --- /dev/null +++ b/libs/utils/src/lib/package-scan.spec.ts @@ -0,0 +1,86 @@ +import { + mkdirSync, + mkdtempSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + discoverPackageJsonPaths, + scanWorkspacePackages, +} from './package-scan.js'; + +function createNxFixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'fwiz-scan-nx-')); + + writeFileSync( + join(dir, 'nx.json'), + JSON.stringify({ + workspaceLayout: { appsDir: 'applications', libsDir: 'packages' }, + }), + ); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'root', dependencies: { react: '^19.0.0' } }), + ); + + mkdirSync(join(dir, 'applications', 'shell'), { recursive: true }); + mkdirSync(join(dir, 'applications', 'checkout'), { recursive: true }); + mkdirSync(join(dir, 'packages', 'ui'), { recursive: true }); + + writeFileSync( + join(dir, 'applications', 'shell', 'package.json'), + JSON.stringify({ + name: 'shell', + dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' }, + }), + ); + writeFileSync( + join(dir, 'applications', 'checkout', 'package.json'), + JSON.stringify({ + name: 'checkout', + dependencies: { react: '^18.2.0', 'react-dom': '^18.2.0' }, + }), + ); + writeFileSync( + join(dir, 'packages', 'ui', 'package.json'), + JSON.stringify({ + name: '@acme/ui', + peerDependencies: { react: '^19.0.0' }, + }), + ); + + return dir; +} + +describe('package-scan', () => { + it('discovers root, apps, and libs package.json files', () => { + const dir = createNxFixture(); + + expect(discoverPackageJsonPaths(dir)).toEqual([ + 'applications/checkout/package.json', + 'applications/shell/package.json', + 'package.json', + 'packages/ui/package.json', + ]); + }); + + it('parses dependency sections from workspace packages', () => { + const dir = createNxFixture(); + const manifests = scanWorkspacePackages(dir); + + expect(manifests).toHaveLength(4); + + const shell = manifests.find( + (manifest) => manifest.path === 'applications/shell/package.json', + ); + + expect(shell?.dependencies).toEqual({ + react: '^19.0.0', + 'react-dom': '^19.0.0', + }); + }); +}); diff --git a/libs/utils/src/lib/package-scan.ts b/libs/utils/src/lib/package-scan.ts new file mode 100644 index 0000000..33e249e --- /dev/null +++ b/libs/utils/src/lib/package-scan.ts @@ -0,0 +1,116 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +export type DependencyKind = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies'; + +export interface PackageManifest { + path: string; + name?: string; + dependencies: Record; + devDependencies: Record; + peerDependencies: Record; +} + +export interface WorkspaceLayout { + appsDir: string; + libsDir: string; +} + +function readNxWorkspaceLayout(cwd: string): WorkspaceLayout { + const nxJsonPath = join(cwd, 'nx.json'); + const defaults: WorkspaceLayout = { appsDir: 'apps', libsDir: 'libs' }; + + if (!existsSync(nxJsonPath)) { + return defaults; + } + + try { + const nxJson = JSON.parse(readFileSync(nxJsonPath, 'utf8')) as { + workspaceLayout?: { appsDir?: string; libsDir?: string }; + }; + + return { + appsDir: nxJson.workspaceLayout?.appsDir ?? defaults.appsDir, + libsDir: nxJson.workspaceLayout?.libsDir ?? defaults.libsDir, + }; + } catch { + return defaults; + } +} + +function listPackageJsonInDir(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(dir, entry.name, 'package.json')) + .filter((packageJsonPath) => existsSync(packageJsonPath)); +} + +export function discoverPackageJsonPaths(cwd: string): string[] { + const paths: string[] = []; + const rootPackageJson = join(cwd, 'package.json'); + + if (existsSync(rootPackageJson)) { + paths.push(relative(cwd, rootPackageJson)); + } + + const layout = readNxWorkspaceLayout(cwd); + + for (const packageJsonPath of [ + ...listPackageJsonInDir(join(cwd, layout.appsDir)), + ...listPackageJsonInDir(join(cwd, layout.libsDir)), + ]) { + paths.push(relative(cwd, packageJsonPath)); + } + + return paths.sort(); +} + +function readDependencySection( + packageJson: Record, + key: DependencyKind, +): Record { + const section = packageJson[key]; + + if (!section || typeof section !== 'object') { + return {}; + } + + const result: Record = {}; + + for (const [name, version] of Object.entries(section)) { + if (typeof version === 'string') { + result[name] = version; + } + } + + return result; +} + +export function parsePackageJson( + cwd: string, + filePath: string, +): PackageManifest { + const raw = readFileSync(filePath, 'utf8'); + const packageJson = JSON.parse(raw) as Record; + + return { + path: relative(cwd, filePath), + name: typeof packageJson.name === 'string' ? packageJson.name : undefined, + dependencies: readDependencySection(packageJson, 'dependencies'), + devDependencies: readDependencySection(packageJson, 'devDependencies'), + peerDependencies: readDependencySection(packageJson, 'peerDependencies'), + }; +} + +export function scanWorkspacePackages(cwd: string): PackageManifest[] { + return discoverPackageJsonPaths(cwd).map((relativePath) => + parsePackageJson(cwd, join(cwd, relativePath)), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2639d3c..41d3347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: libs/core: dependencies: + '@federation-wizards/utils': + specifier: workspace:* + version: link:../utils joi: specifier: ^18.2.1 version: 18.2.1 @@ -11373,7 +11376,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.1 '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - optional: true escalade@3.2.0: {} @@ -14380,7 +14382,6 @@ snapshots: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - optional: true type-check@0.4.0: dependencies: