From 9d3e70b1faa849ece10c88b75ebd6e4492745546 Mon Sep 17 00:00:00 2001 From: Matthias Hempel <7202441+fotopixel@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:08:44 +0200 Subject: [PATCH 1/2] feat(cli): add fwiz validate for pre-deploy manifest checks Adds validate command with registry/local manifest comparison, shared dependency validation (requiredVersion/strictVersion), breaking change detection, and CI-friendly exit codes. Closes #5 Co-authored-by: Cursor --- apps/cli/src/commands/validate.spec.ts | 105 +++++++++ apps/cli/src/commands/validate.ts | 106 +++++++++ apps/cli/src/program.ts | 2 + libs/core/src/index.ts | 1 + libs/core/src/lib/config/schema.ts | 1 + libs/core/src/lib/config/types.ts | 1 + libs/core/src/lib/registry/client.ts | 81 +++++++ libs/core/src/lib/registry/index.ts | 1 + .../core/src/lib/validate/breaking-changes.ts | 55 +++++ libs/core/src/lib/validate/index.ts | 199 ++++++++++++++++ .../core/src/lib/validate/manifest-compare.ts | 66 ++++++ libs/core/src/lib/validate/shared-deps.ts | 206 +++++++++++++++++ libs/core/src/lib/validate/types.ts | 21 ++ libs/core/src/lib/validate/validate.spec.ts | 218 ++++++++++++++++++ 14 files changed, 1063 insertions(+) create mode 100644 apps/cli/src/commands/validate.spec.ts create mode 100644 apps/cli/src/commands/validate.ts create mode 100644 libs/core/src/lib/registry/client.ts create mode 100644 libs/core/src/lib/validate/breaking-changes.ts create mode 100644 libs/core/src/lib/validate/index.ts create mode 100644 libs/core/src/lib/validate/manifest-compare.ts create mode 100644 libs/core/src/lib/validate/shared-deps.ts create mode 100644 libs/core/src/lib/validate/types.ts create mode 100644 libs/core/src/lib/validate/validate.spec.ts diff --git a/apps/cli/src/commands/validate.spec.ts b/apps/cli/src/commands/validate.spec.ts new file mode 100644 index 0000000..9622335 --- /dev/null +++ b/apps/cli/src/commands/validate.spec.ts @@ -0,0 +1,105 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createLocalRegistryBackend, + publishManifest, + validateManifests, +} from '@federation-wizards/core'; +import { stringify as stringifyYaml } from 'yaml'; + +import { resolveValidateExitCode } from './validate.js'; + +function createWorkspace(): string { + const dir = mkdtempSync(join(tmpdir(), 'fwiz-cli-validate-')); + + const config = { + version: '1', + workspace: { type: 'plain' }, + hosts: [{ name: 'shell', port: 4200 }], + remotes: [{ name: 'checkout', port: 4201 }], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, + registry: { + type: 'http', + baseUrl: 'https://cdn.example.com', + prefix: 'mf', + }, + }; + + writeFileSync( + join(dir, 'fwiz.config.yaml'), + `${stringifyYaml(config)}\n`, + 'utf8', + ); + + return dir; +} + +describe('fwiz validate command', () => { + it('resolves CI exit codes for errors and warnings', () => { + expect( + resolveValidateExitCode( + { version: '1.0.0', remotes: ['checkout'], errors: [], warnings: [] }, + true, + ), + ).toBe(0); + expect( + resolveValidateExitCode( + { + version: '1.0.0', + remotes: ['checkout'], + errors: [{ level: 'error', code: 'x', message: 'bad' }], + warnings: [], + }, + true, + ), + ).toBe(1); + expect( + resolveValidateExitCode( + { + version: '2.0.0', + remotes: ['checkout'], + errors: [], + warnings: [{ level: 'warning', code: 'x', message: 'warn' }], + }, + true, + ), + ).toBe(2); + expect( + resolveValidateExitCode( + { + version: '2.0.0', + remotes: ['checkout'], + errors: [], + warnings: [{ level: 'warning', code: 'x', message: 'warn' }], + }, + false, + ), + ).toBe(0); + }); + + it('validates a published remote through core APIs', async () => { + const dir = createWorkspace(); + const backend = createLocalRegistryBackend(join(dir, '.registry')); + + await publishManifest( + { cwd: dir, version: '1.0.0', remote: 'checkout' }, + backend, + ); + + const result = await validateManifests( + { cwd: dir, version: '1.0.0', remote: 'checkout' }, + backend, + ); + + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); +}); diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts new file mode 100644 index 0000000..3782265 --- /dev/null +++ b/apps/cli/src/commands/validate.ts @@ -0,0 +1,106 @@ +import type { Command } from 'commander'; + +import { + ValidateManifestError, + validateManifests, + type ValidateManifestResult, +} from '@federation-wizards/core'; + +export function resolveValidateExitCode( + result: ValidateManifestResult, + ci: boolean, +): number { + if (result.errors.length > 0) { + return 1; + } + + if (ci && result.warnings.length > 0) { + return 2; + } + + return 0; +} + +export function registerValidateCommand(program: Command): void { + program + .command('validate') + .description( + 'Validate local manifests against the registry before deployment', + ) + .summary('Compare manifests, shared deps, and breaking changes') + .addHelpText( + 'after', + ` +Examples: + $ fwiz validate + $ fwiz validate --version 2.0.0 + $ fwiz validate --remote checkout + $ fwiz validate --ci + +Compares locally generated manifests with the current registry versions, +validates shared dependency rules from fwiz.config.yaml, and reports +breaking changes such as major version bumps or removed exposes. + +Exit codes: + 0 validation passed + 1 validation errors + 2 validation warnings (with --ci only) +`, + ) + .option( + '--cwd ', + 'Workspace directory (defaults to current working directory)', + process.cwd(), + ) + .option('--version ', 'Manifest version to validate') + .option('--remote ', 'Validate a single remote') + .option( + '--ci', + 'Use CI exit codes (warnings exit with code 2 instead of 0)', + ) + .action( + async (options: { + cwd: string; + version?: string; + remote?: string; + ci?: boolean; + }) => { + try { + const result = await validateManifests({ + cwd: options.cwd, + version: options.version, + remote: options.remote, + }); + + if (result.errors.length === 0 && result.warnings.length === 0) { + console.log( + `Validation passed for version ${result.version} (${result.remotes.join(', ')}).`, + ); + } else { + if (result.errors.length > 0) { + console.error('Validation errors:'); + for (const issue of result.errors) { + console.error(` - ${issue.message}`); + } + } + + if (result.warnings.length > 0) { + console.warn('Validation warnings:'); + for (const issue of result.warnings) { + console.warn(` - ${issue.message}`); + } + } + } + + process.exitCode = resolveValidateExitCode(result, options.ci ?? false); + } catch (error) { + const message = + error instanceof ValidateManifestError || error instanceof Error + ? error.message + : String(error); + console.error(message); + process.exitCode = 1; + } + }, + ); +} diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 2db7010..003760d 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { registerInitCommand } from './commands/init.js'; import { registerPublishManifestCommand } from './commands/publish-manifest.js'; +import { registerValidateCommand } from './commands/validate.js'; export function createProgram(): Command { const program = new Command(); @@ -13,6 +14,7 @@ export function createProgram(): Command { registerInitCommand(program); registerPublishManifestCommand(program); + registerValidateCommand(program); return program; } diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 067afa9..ee1fe1b 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -7,3 +7,4 @@ export * from './lib/workspace/detect.js'; export * from './lib/workspace/patch.js'; export * from './lib/manifest/generate.js'; export * from './lib/registry/index.js'; +export * from './lib/validate/index.js'; diff --git a/libs/core/src/lib/config/schema.ts b/libs/core/src/lib/config/schema.ts index 2f6fde3..27c8738 100644 --- a/libs/core/src/lib/config/schema.ts +++ b/libs/core/src/lib/config/schema.ts @@ -5,6 +5,7 @@ import type { FwizConfig } from './types.js'; const sharedDependencySchema = Joi.object({ singleton: Joi.boolean().required(), requiredVersion: Joi.string().required(), + strictVersion: Joi.boolean().optional(), eager: Joi.boolean().required(), }); diff --git a/libs/core/src/lib/config/types.ts b/libs/core/src/lib/config/types.ts index 7edf4d8..c73ed5e 100644 --- a/libs/core/src/lib/config/types.ts +++ b/libs/core/src/lib/config/types.ts @@ -3,6 +3,7 @@ export type WorkspaceType = 'nx' | 'turbo' | 'plain'; export interface SharedDependencyConfig { singleton: boolean; requiredVersion: string; + strictVersion?: boolean; eager: boolean; } diff --git a/libs/core/src/lib/registry/client.ts b/libs/core/src/lib/registry/client.ts new file mode 100644 index 0000000..b9c676f --- /dev/null +++ b/libs/core/src/lib/registry/client.ts @@ -0,0 +1,81 @@ +import type { RemoteConfig, RegistryConfig } from '../config/types.js'; +import type { RegistryBackend } from './backends/types.js'; +import { getManifestStorageKey } from './paths.js'; +import { loadRemotesRegistry } from './registry-io.js'; +import type { MfManifest, RemotesRegistry } from './types.js'; + +export class RegistryClientError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryClientError'; + } +} + +export interface FetchedRemoteManifest { + remote: string; + version: string; + manifestUrl: string; + manifest: MfManifest; +} + +export interface RegistryClientResult { + remotesRegistry: RemotesRegistry; + manifests: FetchedRemoteManifest[]; +} + +function parseManifest(raw: string, context: string): MfManifest { + try { + return JSON.parse(raw) as MfManifest; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new RegistryClientError( + `Failed to parse manifest for ${context}: ${message}`, + ); + } +} + +export async function fetchRegistryManifests( + backend: RegistryBackend, + registry: RegistryConfig, + remotes: RemoteConfig[], +): Promise { + const remotesRegistry = await loadRemotesRegistry(backend, registry); + const manifests: FetchedRemoteManifest[] = []; + + for (const remote of remotes) { + const entry = remotesRegistry.remotes[remote.name]; + + if (!entry?.current) { + throw new RegistryClientError( + `Remote "${remote.name}" has no published version in the registry.`, + ); + } + + const version = entry.current; + const versionEntry = entry.versions[version]; + + if (!versionEntry) { + throw new RegistryClientError( + `Remote "${remote.name}" registry entry is missing manifest metadata for version ${version}.`, + ); + } + + const storageKey = getManifestStorageKey(registry, remote.name, version); + const raw = await backend.get(storageKey); + + if (!raw) { + throw new RegistryClientError( + `Manifest not found for remote "${remote.name}" at version ${version} (${storageKey}).`, + ); + } + + manifests.push({ + remote: remote.name, + version, + manifestUrl: versionEntry.manifestUrl, + manifest: parseManifest(raw, remote.name), + }); + } + + return { remotesRegistry, manifests }; +} diff --git a/libs/core/src/lib/registry/index.ts b/libs/core/src/lib/registry/index.ts index f9723c9..bf18950 100644 --- a/libs/core/src/lib/registry/index.ts +++ b/libs/core/src/lib/registry/index.ts @@ -3,6 +3,7 @@ export * from './paths.js'; export * from './publish.js'; export * from './rollback.js'; export * from './registry-io.js'; +export * from './client.js'; export * from './backends/types.js'; export * from './backends/factory.js'; export * from './backends/http.js'; diff --git a/libs/core/src/lib/validate/breaking-changes.ts b/libs/core/src/lib/validate/breaking-changes.ts new file mode 100644 index 0000000..aa415b6 --- /dev/null +++ b/libs/core/src/lib/validate/breaking-changes.ts @@ -0,0 +1,55 @@ +import type { MfManifest } from '../registry/types.js'; +import { isMajorVersionBump } from './shared-deps.js'; +import type { ValidationIssue } from './types.js'; + +export function detectBreakingChanges( + local: MfManifest, + registry: MfManifest, + remoteName: string, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const localVersion = local.metaData.buildInfo.buildVersion; + const registryVersion = registry.metaData.buildInfo.buildVersion; + + if (isMajorVersionBump(registryVersion, localVersion)) { + issues.push({ + level: 'warning', + code: 'major-version-bump', + message: `Remote "${remoteName}" has a major version bump from ${registryVersion} to ${localVersion}.`, + remote: remoteName, + }); + } + + const localExposeNames = new Set(local.exposes.map((expose) => expose.name)); + + for (const expose of registry.exposes) { + if (!localExposeNames.has(expose.name)) { + issues.push({ + level: 'error', + code: 'removed-expose', + message: `Remote "${remoteName}" removed expose "${expose.name}" published in registry version ${registryVersion}.`, + remote: remoteName, + }); + } + } + + for (const shared of local.shared) { + const registryShared = registry.shared.find( + (entry) => entry.name === shared.name, + ); + + if ( + registryShared && + isMajorVersionBump(registryShared.version, shared.version) + ) { + issues.push({ + level: 'warning', + code: 'shared-major-bump', + message: `Remote "${remoteName}" shared dependency "${shared.name}" has a major version bump from ${registryShared.version} to ${shared.version}.`, + remote: remoteName, + }); + } + } + + return issues; +} diff --git a/libs/core/src/lib/validate/index.ts b/libs/core/src/lib/validate/index.ts new file mode 100644 index 0000000..796ef2e --- /dev/null +++ b/libs/core/src/lib/validate/index.ts @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { generateMfManifest } from '../manifest/generate.js'; +import { FwizConfigError, loadFwizConfig } from '../config/load.js'; +import type { RemoteConfig } from '../config/types.js'; +import { createRegistryBackend } from '../registry/backends/factory.js'; +import type { RegistryBackend } from '../registry/backends/types.js'; +import { + fetchRegistryManifests, + RegistryClientError, +} from '../registry/client.js'; +import { detectBreakingChanges } from './breaking-changes.js'; +import { compareManifests } from './manifest-compare.js'; +import { + validateCrossRemoteSharedDependencies, + validateSharedDependenciesForManifest, +} from './shared-deps.js'; +import type { + ValidateManifestOptions, + ValidateManifestResult, + ValidationIssue, +} from './types.js'; + +export class ValidateManifestError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidateManifestError'; + } +} + +function resolveVersion(cwd: string, explicitVersion?: string): string { + if (explicitVersion) { + return explicitVersion; + } + + try { + const packageJson = JSON.parse( + readFileSync(join(cwd, 'package.json'), 'utf8'), + ) as { version?: string }; + + if (packageJson.version) { + return packageJson.version; + } + } catch { + // Fall back to timestamp-based version below. + } + + return `0.0.0-${Date.now()}`; +} + +function selectRemotes( + remotes: RemoteConfig[], + remoteName?: string, +): RemoteConfig[] { + if (!remoteName) { + return remotes; + } + + const selected = remotes.filter((remote) => remote.name === remoteName); + + if (selected.length === 0) { + throw new ValidateManifestError( + `Remote "${remoteName}" is not defined in fwiz.config.yaml.`, + ); + } + + return selected; +} + +function requireRegistryConfig( + config: ReturnType, +): NonNullable['registry']> { + if (!config.registry) { + throw new ValidateManifestError( + 'fwiz.config.yaml is missing a registry section. Add registry settings before running validate.', + ); + } + + return config.registry; +} + +function collectIssues(...groups: ValidationIssue[][]): { + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +} { + const issues = groups.flat(); + + return { + errors: issues.filter((issue) => issue.level === 'error'), + warnings: issues.filter((issue) => issue.level === 'warning'), + }; +} + +export async function validateManifests( + options: ValidateManifestOptions, + backendOverride?: RegistryBackend, +): Promise { + const config = loadFwizConfig(options.cwd); + const registry = requireRegistryConfig(config); + const backend = backendOverride ?? createRegistryBackend(registry); + const version = resolveVersion(options.cwd, options.version); + const remotes = selectRemotes(config.remotes, options.remote); + + if (remotes.length === 0) { + throw new ValidateManifestError( + 'No remotes configured in fwiz.config.yaml.', + ); + } + + let registryManifests; + + try { + registryManifests = await fetchRegistryManifests(backend, registry, remotes); + } catch (error) { + if (error instanceof RegistryClientError) { + throw new ValidateManifestError(error.message); + } + + throw error; + } + + const localManifests = remotes.map((remote) => ({ + remote: remote.name, + manifest: generateMfManifest({ + remote, + version, + shared: config.shared, + registry, + }), + })); + + const perRemoteIssues = remotes.flatMap((remote) => { + const registryManifest = registryManifests.manifests.find( + (entry) => entry.remote === remote.name, + ); + + if (!registryManifest) { + return [ + { + level: 'error' as const, + code: 'missing-registry-manifest', + message: `Remote "${remote.name}" has no published manifest in the registry.`, + remote: remote.name, + }, + ]; + } + + const localManifest = localManifests.find( + (entry) => entry.remote === remote.name, + )!.manifest; + + return [ + ...compareManifests( + localManifest, + registryManifest.manifest, + remote.name, + ), + ...detectBreakingChanges( + localManifest, + registryManifest.manifest, + remote.name, + ), + ...validateSharedDependenciesForManifest( + config, + localManifest, + remote.name, + ), + ]; + }); + + const crossRemoteIssues = validateCrossRemoteSharedDependencies( + localManifests, + config, + ); + const { errors, warnings } = collectIssues(perRemoteIssues, crossRemoteIssues); + + return { + version, + remotes: remotes.map((remote) => remote.name), + errors, + warnings, + }; +} + +export function assertRegistryConfigured(cwd: string): void { + const config = loadFwizConfig(cwd); + + if (!config.registry) { + throw new FwizConfigError( + 'fwiz.config.yaml is missing a registry section required for validate.', + ); + } +} + +export * from './types.js'; +export * from './manifest-compare.js'; +export * from './shared-deps.js'; +export * from './breaking-changes.js'; diff --git a/libs/core/src/lib/validate/manifest-compare.ts b/libs/core/src/lib/validate/manifest-compare.ts new file mode 100644 index 0000000..cb99024 --- /dev/null +++ b/libs/core/src/lib/validate/manifest-compare.ts @@ -0,0 +1,66 @@ +import type { MfManifest } from '../registry/types.js'; +import type { ValidationIssue } from './types.js'; + +export function compareManifests( + local: MfManifest, + registry: MfManifest, + remoteName: string, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const registryExposeNames = new Set( + registry.exposes.map((expose) => expose.name), + ); + + for (const localShared of local.shared) { + const registryShared = registry.shared.find( + (shared) => shared.name === localShared.name, + ); + + if (!registryShared) { + issues.push({ + level: 'warning', + code: 'new-shared-dependency', + message: `Remote "${remoteName}" adds shared dependency "${localShared.name}" not present in the registry manifest.`, + remote: remoteName, + }); + continue; + } + + if (localShared.requiredVersion !== registryShared.requiredVersion) { + issues.push({ + level: 'warning', + code: 'shared-required-version-drift', + message: `Remote "${remoteName}" shared dependency "${localShared.name}" requiredVersion changed from "${registryShared.requiredVersion}" to "${localShared.requiredVersion}".`, + remote: remoteName, + }); + } + } + + for (const registryShared of registry.shared) { + const localShared = local.shared.find( + (shared) => shared.name === registryShared.name, + ); + + if (!localShared) { + issues.push({ + level: 'error', + code: 'removed-shared-dependency', + message: `Remote "${remoteName}" removed shared dependency "${registryShared.name}" from the registry manifest.`, + remote: remoteName, + }); + } + } + + for (const expose of local.exposes) { + if (!registryExposeNames.has(expose.name)) { + issues.push({ + level: 'warning', + code: 'new-expose', + message: `Remote "${remoteName}" adds new expose "${expose.name}".`, + remote: remoteName, + }); + } + } + + return issues; +} diff --git a/libs/core/src/lib/validate/shared-deps.ts b/libs/core/src/lib/validate/shared-deps.ts new file mode 100644 index 0000000..33a9803 --- /dev/null +++ b/libs/core/src/lib/validate/shared-deps.ts @@ -0,0 +1,206 @@ +import type { FwizConfig } from '../config/types.js'; +import type { MfManifest } from '../registry/types.js'; +import type { ValidationIssue } from './types.js'; + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +export function parseVersion(version: string): ParsedVersion | null { + const normalized = version.trim().replace(/^[\^~>=<]+/, ''); + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(normalized); + + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +export function normalizeRequiredVersion(required: string): string { + return required.trim().replace(/^[\^~>=<]+/, ''); +} + +export function isMajorVersionBump(from: string, to: string): boolean { + const source = parseVersion(from); + const target = parseVersion(to); + + return !!(source && target && target.major > source.major); +} + +export function satisfiesRequired(actual: string, required: string): boolean { + const actualParsed = parseVersion(actual); + + if (!actualParsed) { + return false; + } + + const trimmed = required.trim(); + + if (trimmed.startsWith('^')) { + const req = parseVersion(trimmed.slice(1)); + + if (!req || actualParsed.major !== req.major) { + return false; + } + + if (actualParsed.minor > req.minor) { + return true; + } + + if (actualParsed.minor < req.minor) { + return false; + } + + return actualParsed.patch >= req.patch; + } + + if (trimmed.startsWith('~')) { + const req = parseVersion(trimmed.slice(1)); + + if (!req) { + return false; + } + + if (actualParsed.major !== req.major || actualParsed.minor !== req.minor) { + return false; + } + + return actualParsed.patch >= req.patch; + } + + if (trimmed.startsWith('>=')) { + const req = parseVersion(trimmed.slice(2)); + + if (!req) { + return false; + } + + if (actualParsed.major > req.major) { + return true; + } + + if (actualParsed.major < req.major) { + return false; + } + + if (actualParsed.minor > req.minor) { + return true; + } + + if (actualParsed.minor < req.minor) { + return false; + } + + return actualParsed.patch >= req.patch; + } + + return normalizeRequiredVersion(actual) === normalizeRequiredVersion(trimmed); +} + +export function validateSharedDependenciesForManifest( + config: FwizConfig, + manifest: MfManifest, + remoteName: string, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const [name, depConfig] of Object.entries(config.shared)) { + const manifestShared = manifest.shared.find((shared) => shared.name === name); + + if (!manifestShared) { + issues.push({ + level: 'error', + code: 'missing-shared-dependency', + message: `Remote "${remoteName}" manifest is missing shared dependency "${name}" required by fwiz.config.yaml.`, + remote: remoteName, + }); + continue; + } + + if (manifestShared.requiredVersion !== depConfig.requiredVersion) { + issues.push({ + level: 'error', + code: 'shared-required-version-mismatch', + message: `Remote "${remoteName}" manifest requiredVersion for "${name}" is "${manifestShared.requiredVersion}" but fwiz.config.yaml specifies "${depConfig.requiredVersion}".`, + remote: remoteName, + }); + } + + if (depConfig.strictVersion) { + const expected = normalizeRequiredVersion(depConfig.requiredVersion); + const actual = normalizeRequiredVersion(manifestShared.version); + + if (actual !== expected) { + issues.push({ + level: 'error', + code: 'strict-version-mismatch', + message: `Remote "${remoteName}" shared dependency "${name}" has version "${manifestShared.version}" but strictVersion requires "${depConfig.requiredVersion}".`, + remote: remoteName, + }); + } + } else if ( + !satisfiesRequired(manifestShared.version, depConfig.requiredVersion) + ) { + issues.push({ + level: 'error', + code: 'shared-version-out-of-range', + message: `Remote "${remoteName}" shared dependency "${name}" version "${manifestShared.version}" does not satisfy requiredVersion "${depConfig.requiredVersion}".`, + remote: remoteName, + }); + } + } + + return issues; +} + +export function validateCrossRemoteSharedDependencies( + manifests: Array<{ remote: string; manifest: MfManifest }>, + config: FwizConfig, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + if (manifests.length <= 1) { + return issues; + } + + for (const [name, depConfig] of Object.entries(config.shared)) { + if (!depConfig.singleton) { + continue; + } + + const versions = manifests.flatMap(({ remote, manifest }) => { + const shared = manifest.shared.find((entry) => entry.name === name); + + if (!shared) { + return []; + } + + return [{ remote, version: shared.version }]; + }); + + const uniqueVersions = new Set( + versions.map((entry) => normalizeRequiredVersion(entry.version)), + ); + + if (uniqueVersions.size > 1) { + const detail = versions + .map((entry) => `${entry.remote}=${entry.version}`) + .join(', '); + + issues.push({ + level: 'error', + code: 'singleton-version-mismatch', + message: `Singleton shared dependency "${name}" has inconsistent versions across remotes: ${detail}.`, + }); + } + } + + return issues; +} diff --git a/libs/core/src/lib/validate/types.ts b/libs/core/src/lib/validate/types.ts new file mode 100644 index 0000000..87f283e --- /dev/null +++ b/libs/core/src/lib/validate/types.ts @@ -0,0 +1,21 @@ +export type ValidationLevel = 'error' | 'warning'; + +export interface ValidationIssue { + level: ValidationLevel; + code: string; + message: string; + remote?: string; +} + +export interface ValidateManifestOptions { + cwd: string; + version?: string; + remote?: string; +} + +export interface ValidateManifestResult { + version: string; + remotes: string[]; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +} diff --git a/libs/core/src/lib/validate/validate.spec.ts b/libs/core/src/lib/validate/validate.spec.ts new file mode 100644 index 0000000..7a73ec0 --- /dev/null +++ b/libs/core/src/lib/validate/validate.spec.ts @@ -0,0 +1,218 @@ +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { generateMfManifest } from '../manifest/generate.js'; +import { createLocalRegistryBackend } from '../registry/backends/local.js'; +import { publishManifest } from '../registry/publish.js'; +import { createTestWorkspace } from '../registry/test-helpers.js'; +import { detectBreakingChanges } from './breaking-changes.js'; +import { validateManifests } from './index.js'; +import { compareManifests } from './manifest-compare.js'; +import { + isMajorVersionBump, + satisfiesRequired, + validateCrossRemoteSharedDependencies, + validateSharedDependenciesForManifest, +} from './shared-deps.js'; + +const registry = { + type: 'http' as const, + baseUrl: 'https://cdn.example.com', + prefix: 'mf', +}; + +describe('shared dependency validation', () => { + it('checks requiredVersion and strictVersion rules', () => { + const config = { + version: '1', + workspace: { type: 'plain' as const }, + hosts: [{ name: 'shell', port: 4200 }], + remotes: [{ name: 'checkout', port: 4201 }], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + strictVersion: true, + eager: false, + }, + }, + }; + + const manifest = generateMfManifest({ + remote: { name: 'checkout', port: 4201 }, + version: '1.0.0', + shared: config.shared, + registry, + buildId: 'test', + }); + + manifest.shared[0]!.version = '19.1.0'; + + const issues = validateSharedDependenciesForManifest( + config, + manifest, + 'checkout', + ); + + expect(issues.some((issue) => issue.code === 'strict-version-mismatch')).toBe( + true, + ); + }); + + it('detects singleton version mismatches across remotes', () => { + const config = { + version: '1', + workspace: { type: 'plain' as const }, + hosts: [{ name: 'shell', port: 4200 }], + remotes: [ + { name: 'checkout', port: 4201 }, + { name: 'catalog', port: 4202 }, + ], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, + }; + + const checkout = generateMfManifest({ + remote: { name: 'checkout', port: 4201 }, + version: '1.0.0', + shared: config.shared, + registry, + buildId: 'checkout', + }); + const catalog = generateMfManifest({ + remote: { name: 'catalog', port: 4202 }, + version: '1.0.0', + shared: config.shared, + registry, + buildId: 'catalog', + }); + + checkout.shared[0]!.version = '19.0.0'; + catalog.shared[0]!.version = '19.1.0'; + + const issues = validateCrossRemoteSharedDependencies( + [ + { remote: 'checkout', manifest: checkout }, + { remote: 'catalog', manifest: catalog }, + ], + config, + ); + + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('singleton-version-mismatch'); + }); + + it('evaluates semver ranges', () => { + expect(satisfiesRequired('19.1.0', '^19.0.0')).toBe(true); + expect(satisfiesRequired('18.0.0', '^19.0.0')).toBe(false); + expect(isMajorVersionBump('1.0.0', '2.0.0')).toBe(true); + expect(isMajorVersionBump('1.9.0', '1.10.0')).toBe(false); + }); +}); + +describe('manifest comparison and breaking changes', () => { + it('flags removed exposes and major version bumps', () => { + const registryManifest = generateMfManifest({ + remote: { name: 'checkout', port: 4201 }, + version: '1.0.0', + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, + registry, + buildId: 'registry', + }); + + const localManifest = generateMfManifest({ + remote: { name: 'checkout', port: 4201 }, + version: '2.0.0', + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, + registry, + buildId: 'local', + }); + + localManifest.exposes = []; + localManifest.shared[0]!.version = '20.0.0'; + + const breakingIssues = detectBreakingChanges( + localManifest, + registryManifest, + 'checkout', + ); + + expect( + breakingIssues.some((issue) => issue.code === 'removed-expose'), + ).toBe(true); + expect( + breakingIssues.some((issue) => issue.code === 'major-version-bump'), + ).toBe(true); + expect( + breakingIssues.some((issue) => issue.code === 'shared-major-bump'), + ).toBe(true); + + const compareIssues = compareManifests( + localManifest, + registryManifest, + 'checkout', + ); + + expect( + compareIssues.some((issue) => issue.code === 'removed-shared-dependency'), + ).toBe(false); + }); +}); + +describe('validateManifests', () => { + it('passes when local manifests match the registry', async () => { + const registryDir = createTestWorkspace(registry); + const backend = createLocalRegistryBackend(join(registryDir, '.registry')); + + await publishManifest( + { cwd: registryDir, version: '1.0.0', remote: 'checkout' }, + backend, + ); + + const result = await validateManifests( + { cwd: registryDir, version: '1.0.0', remote: 'checkout' }, + backend, + ); + + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it('reports breaking changes when validating a major version bump', async () => { + const registryDir = createTestWorkspace(registry); + const backend = createLocalRegistryBackend(join(registryDir, '.registry')); + + await publishManifest( + { cwd: registryDir, version: '1.0.0', remote: 'checkout' }, + backend, + ); + + const result = await validateManifests( + { cwd: registryDir, version: '2.0.0', remote: 'checkout' }, + backend, + ); + + expect(result.errors).toHaveLength(0); + expect( + result.warnings.some((issue) => issue.code === 'major-version-bump'), + ).toBe(true); + }); +}); From b769c6bcc669d21108588dbdc929f9f39b97df2d Mon Sep 17 00:00:00 2001 From: Matthias Hempel <7202441+fotopixel@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:18:01 +0200 Subject: [PATCH 2/2] fix(core): drop duplicate assertRegistryConfigured export Avoid TS2308 when validate and publish both re-export the same symbol from core. Co-authored-by: Cursor --- libs/core/src/lib/validate/index.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/libs/core/src/lib/validate/index.ts b/libs/core/src/lib/validate/index.ts index 796ef2e..1a582ac 100644 --- a/libs/core/src/lib/validate/index.ts +++ b/libs/core/src/lib/validate/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { generateMfManifest } from '../manifest/generate.js'; -import { FwizConfigError, loadFwizConfig } from '../config/load.js'; +import { loadFwizConfig } from '../config/load.js'; import type { RemoteConfig } from '../config/types.js'; import { createRegistryBackend } from '../registry/backends/factory.js'; import type { RegistryBackend } from '../registry/backends/types.js'; @@ -183,16 +183,6 @@ export async function validateManifests( }; } -export function assertRegistryConfigured(cwd: string): void { - const config = loadFwizConfig(cwd); - - if (!config.registry) { - throw new FwizConfigError( - 'fwiz.config.yaml is missing a registry section required for validate.', - ); - } -} - export * from './types.js'; export * from './manifest-compare.js'; export * from './shared-deps.js';