From 3e612b975a59aa8f5961030b814a120f24ea5a6d Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 01:18:02 +0800 Subject: [PATCH] chore(validation): enforce package graph boundaries Reject internal package version drift, dependency cycles, and runtime-to-integration reverse dependencies before launching the external UPM consumer validation. --- Tools/UpmConsumerValidation/README.md | 15 ++++- .../Validate-UpmConsumer.ps1 | 62 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/Tools/UpmConsumerValidation/README.md b/Tools/UpmConsumerValidation/README.md index b3cd975..36c7c62 100644 --- a/Tools/UpmConsumerValidation/README.md +++ b/Tools/UpmConsumerValidation/README.md @@ -1,14 +1,17 @@ # UPM Consumer Validation `Validate-UpmConsumer.ps1` verifies the package graph from outside the main Unity -project. It performs three checks: +project. It performs five checks: 1. Every dependency on another local `com.cneicy.*` package must use that package's current `package.json` version. -2. A temporary Unity project is created under `Temp/UpmConsumerValidation`, all +2. The local package graph must be acyclic. +3. A normal package must not depend on a `*-integration-*` package. Integration + packages may depend on each other, and starter packages may aggregate them. +4. A temporary Unity project is created under `Temp/UpmConsumerValidation`, all local packages are installed through `file:` UPM dependencies, package tests are enabled, and Unity must load every package and asmdef. -3. The same temporary UPM consumer project runs all package EditMode tests. +5. The same temporary UPM consumer project runs all package EditMode tests. Run with the Unity version declared by this repository: @@ -16,6 +19,12 @@ Run with the Unity version declared by this repository: ./Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 ``` +Run only the package graph checks without starting Unity: + +```powershell +./Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 -GraphOnly +``` + Use `-UnityEditorPath` when the matching editor is not installed in a standard location. Pass `-KeepProject` to retain the temporary project, reports, and Unity logs for investigation. `-SkipTests` is only for a quick package-resolution diagnostic and is not the diff --git a/Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 b/Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 index 4dfa6c1..cdabf1b 100644 --- a/Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 +++ b/Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1 @@ -3,7 +3,8 @@ param( [string]$UnityEditorPath, [string]$ProjectRoot, [switch]$KeepProject, - [switch]$SkipTests + [switch]$SkipTests, + [switch]$GraphOnly ) $ErrorActionPreference = 'Stop' @@ -101,8 +102,12 @@ foreach ($record in $packageRecords) { $packageByName[$record.Name] = $record } -$versionErrors = @() +$graphErrors = @() +$localDependencies = @{} foreach ($record in $packageRecords) { + $dependencies = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + $localDependencies[$record.Name] = $dependencies if (-not $record.Manifest.dependencies) { continue } @@ -112,14 +117,60 @@ foreach ($record in $packageRecords) { continue } + [void]$dependencies.Add($dependency.Name) $actualVersion = $packageByName[$dependency.Name].Version if ([string]$dependency.Value -ne $actualVersion) { - $versionErrors += "$($record.Name) requires $($dependency.Name) $($dependency.Value), local version is $actualVersion" + $graphErrors += "$($record.Name) requires $($dependency.Name) $($dependency.Value), local version is $actualVersion" + } + + $dependsOnIntegration = $dependency.Name -like '*-integration-*' + $isIntegrationOrStarter = + $record.Name -like '*-integration-*' -or + $record.Name -like '*-starter-*' + if ($dependsOnIntegration -and -not $isIntegrationOrStarter) { + $graphErrors += "$($record.Name) must not depend on integration package $($dependency.Name)" } } } -if ($versionErrors.Count -gt 0) { - throw "Internal package versions are inconsistent:`n$($versionErrors -join "`n")" +if ($graphErrors.Count -gt 0) { + throw "Internal package graph is invalid:`n$($graphErrors -join "`n")" +} + +$resolvedPackages = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) +do { + $madeProgress = $false + foreach ($packageName in $localDependencies.Keys | Sort-Object) { + if ($resolvedPackages.Contains($packageName)) { + continue + } + + $unresolvedDependencies = @($localDependencies[$packageName] | + Where-Object { -not $resolvedPackages.Contains($_) }) + if ($unresolvedDependencies.Count -eq 0) { + [void]$resolvedPackages.Add($packageName) + $madeProgress = $true + } + } +} while ($madeProgress) + +if ($resolvedPackages.Count -ne $packageRecords.Count) { + $cycleDetails = $localDependencies.Keys | + Where-Object { -not $resolvedPackages.Contains($_) } | + Sort-Object | + ForEach-Object { + $packageName = $_ + $blockedBy = @($localDependencies[$packageName] | + Where-Object { -not $resolvedPackages.Contains($_) } | + Sort-Object) + "$packageName -> $($blockedBy -join ', ')" + } + throw "Circular internal package dependencies detected:`n$($cycleDetails -join "`n")" +} + +Write-Host "Package graph PASS packages=$($packageRecords.Count)" +if ($GraphOnly) { + return } if (Test-Path -LiteralPath $consumerRoot) { @@ -133,6 +184,7 @@ $dependencies = [ordered]@{ 'com.unity.test-framework' = '1.1.33' 'com.unity.textmeshpro' = '3.0.7' 'com.unity.ugui' = '1.0.0' + 'com.unity.modules.uielements' = '1.0.0' } foreach ($record in $packageRecords | Sort-Object Name) { $dependencies[$record.Name] = Convert-ToFileDependency $record.Directory