From 90ac58eb4249fc33c5aa9771ec19d536f540d4b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?=
Date: Wed, 21 Oct 2026 21:34:09 +0300
Subject: [PATCH 1/2] feat(packagist): add PHP packages from Packagist as a
fifth registry
Versions come from the Composer v2 metadata on repo.packagist.org,
ordered by Composer's own rules; release notes come from GitHub like
PyPI. OSV covers Packagist advisories, the audit reads composer.json
and composer.lock, and the registry row is added on start-up.
Co-Authored-By: Claude Opus 5.5
---
.../PkgDiff.API/Extensions/DatabaseSeeder.cs | 16 ++
...OutboundHttpServiceCollectionExtensions.cs | 9 +
.../Extensions/ServiceCollectionExtensions.cs | 2 +
.../Interfaces/IPackagistService.cs | 10 +
.../Versioning/ComposerVersion.cs | 128 +++++++++++
.../Versioning/PackageVersion.cs | 13 +-
.../Versioning/VersionScheme.cs | 5 +-
.../Services/HttpClientNames.cs | 1 +
.../Services/ManifestParser.cs | 81 +++++++
.../Services/OsvClient.cs | 1 +
.../Services/PackagistRegistryClient.cs | 47 ++++
.../Services/PackagistService.cs | 217 ++++++++++++++++++
.../PkgDiff.Shared/Constants/RegistryNames.cs | 6 +-
.../Validation/PackageIdRules.cs | 6 +-
src/frontend/public/icons/packagist.svg | 6 +
.../src/app/core/config/app.config.ts | 2 +-
.../app/core/utils/install-command.utils.ts | 6 +
.../src/app/core/utils/release-link.utils.ts | 5 +
.../audit/audit-wizard/audit-wizard.ts | 12 +-
src/frontend/src/app/features/home/home.html | 4 +-
src/frontend/src/app/features/home/home.ts | 8 +-
.../src/app/features/not-found/not-found.ts | 2 +-
.../components/trust-panel/trust-panel.html | 4 +-
.../components/trust-panel/trust-panel.ts | 2 +-
.../upgrade-security/upgrade-security.html | 4 +-
.../upgrade-security/upgrade-security.ts | 7 +-
.../src/app/features/package/package.ts | 12 +-
.../app/features/packages/packages-list.ts | 7 +-
.../src/app/features/privacy/privacy.html | 2 +-
.../src/app/features/terms/terms.html | 4 +-
30 files changed, 601 insertions(+), 28 deletions(-)
create mode 100644 src/backend/PkgDiff.Application/Interfaces/IPackagistService.cs
create mode 100644 src/backend/PkgDiff.Application/Versioning/ComposerVersion.cs
create mode 100644 src/backend/PkgDiff.Infrastructure/Services/PackagistRegistryClient.cs
create mode 100644 src/backend/PkgDiff.Infrastructure/Services/PackagistService.cs
create mode 100644 src/frontend/public/icons/packagist.svg
diff --git a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
index e735f6c..26435c8 100644
--- a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
+++ b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
@@ -154,6 +154,22 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
}
}
+ var packagistSpellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(PkgDiff.Shared.Constants.RegistryNames.Packagist);
+ if (!await context.Registries.AnyAsync(r => packagistSpellings.Contains(r.Value.ToLower())))
+ {
+ var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
+ context.Registries.Add(new Registry
+ {
+ Value = PkgDiff.Shared.Constants.RegistryNames.Packagist,
+ Label = "Packagist",
+ Icon = "/icons/packagist.svg",
+ Disabled = false,
+ DisplayOrder = displayOrder
+ });
+ await context.SaveChangesAsync();
+ logger.LogInformation("Registry {Registry} added", PkgDiff.Shared.Constants.RegistryNames.Packagist);
+ }
+
var expectedPatterns = new[]
{
new { Id = 1, Pattern = "dev", Description = "Development builds (e.g., 1.0.0-dev-123)" },
diff --git a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
index ce415c6..24f66ff 100644
--- a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
@@ -52,6 +52,15 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
});
+ // Packagist asks API clients to name themselves with a way to reach them
+ services.AddHttpClient(HttpClientNames.PackagistRepository, client =>
+ {
+ client.BaseAddress = new Uri("https://repo.packagist.org/");
+ client.Timeout = FixedHostTimeout;
+ client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
+ client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
+ });
+
services.AddHttpClient(HttpClientNames.PubDev, client =>
{
client.BaseAddress = new Uri("https://pub.dev/");
diff --git a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
index aa15f96..025a161 100644
--- a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
@@ -22,6 +22,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// IPubDevService is the PubDev typed client registered by AddPkgDiffOutboundHttp
services.AddScoped();
services.AddSingleton();
@@ -44,6 +45,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/backend/PkgDiff.Application/Interfaces/IPackagistService.cs b/src/backend/PkgDiff.Application/Interfaces/IPackagistService.cs
new file mode 100644
index 0000000..14c8d7d
--- /dev/null
+++ b/src/backend/PkgDiff.Application/Interfaces/IPackagistService.cs
@@ -0,0 +1,10 @@
+using PkgDiff.Shared.DTOs;
+
+namespace PkgDiff.Application.Interfaces;
+
+public interface IPackagistService
+{
+ Task> GetPackageVersionsAsync(string packageId, bool includePrerelease = false);
+ Task GetPackagePublishedDateAsync(string packageId, string version);
+ Task GetPackageMetadataAsync(string packageId);
+}
diff --git a/src/backend/PkgDiff.Application/Versioning/ComposerVersion.cs b/src/backend/PkgDiff.Application/Versioning/ComposerVersion.cs
new file mode 100644
index 0000000..5ddc1f6
--- /dev/null
+++ b/src/backend/PkgDiff.Application/Versioning/ComposerVersion.cs
@@ -0,0 +1,128 @@
+using System.Text.RegularExpressions;
+
+namespace PkgDiff.Application.Versioning;
+
+// A Packagist version as Composer orders it: dev < alpha < beta < RC < stable < patch
+public sealed class ComposerVersion : IComparable, IComparable
+{
+ private const int MaxLength = 100;
+ private const int MaxReleaseSegments = 4;
+ private const int StableRank = 4;
+
+ private static readonly Regex Pattern = new(
+ @"^v?
+ (?[0-9]+(?:\.[0-9]+)*)
+ (?:[._-]?(?stable|beta|b|rc|alpha|a|patch|pl|p)(?[.-]?[0-9]+(?:[.-][0-9]+)*)?)?
+ (?[.-]?dev)?
+ (?:\+[0-9A-Za-z.-]+)?$",
+ RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.CultureInvariant,
+ TimeSpan.FromMilliseconds(250));
+
+ private readonly long[] _release;
+ private readonly int _rank;
+ private readonly long[] _number;
+ private readonly bool _dev;
+
+ private ComposerVersion(long[] release, int rank, long[] number, bool dev)
+ {
+ _release = release;
+ _rank = rank;
+ _number = number;
+ _dev = dev;
+ }
+
+ public bool IsPrerelease => _rank < StableRank || _dev;
+
+ public static bool TryParse(string? value, out ComposerVersion version)
+ {
+ version = null!;
+ if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength)
+ return false;
+
+ Match match;
+ try
+ {
+ match = Pattern.Match(value.Trim());
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return false;
+ }
+
+ if (!match.Success)
+ return false;
+
+ var segments = match.Groups["release"].Value.Split('.');
+ if (segments.Length > MaxReleaseSegments || !TryNumbers(segments, out var release))
+ return false;
+
+ var number = Array.Empty();
+ if (match.Groups["number"].Success
+ && !TryNumbers(match.Groups["number"].Value.Split(new[] { '.', '-' }, StringSplitOptions.RemoveEmptyEntries), out number))
+ return false;
+
+ var dev = match.Groups["dev"].Success;
+ var rank = match.Groups["stability"].Success
+ ? RankOf(match.Groups["stability"].Value)
+ : dev ? 0 : StableRank;
+
+ version = new ComposerVersion(release, rank, number, dev && match.Groups["stability"].Success);
+ return true;
+ }
+
+ public int CompareTo(ComposerVersion? other)
+ {
+ if (other is null)
+ return 1;
+
+ var result = CompareNumbers(_release, other._release);
+ if (result != 0)
+ return result;
+
+ result = _rank.CompareTo(other._rank);
+ if (result != 0)
+ return result;
+
+ result = CompareNumbers(_number, other._number);
+ if (result != 0)
+ return result;
+
+ // 1.0.0-beta1-dev comes before 1.0.0-beta1
+ return other._dev.CompareTo(_dev);
+ }
+
+ public int CompareTo(object? obj) => CompareTo(obj as ComposerVersion);
+
+ private static int RankOf(string stability) => stability.ToLowerInvariant() switch
+ {
+ "alpha" or "a" => 1,
+ "beta" or "b" => 2,
+ "rc" => 3,
+ "stable" => StableRank,
+ _ => 5
+ };
+
+ private static bool TryNumbers(string[] parts, out long[] numbers)
+ {
+ numbers = new long[parts.Length];
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (!long.TryParse(parts[i], out numbers[i]))
+ return false;
+ }
+ return true;
+ }
+
+ // Trailing zeros do not count: 1.0 and 1.0.0.0 are the same release
+ private static int CompareNumbers(long[] left, long[] right)
+ {
+ var length = Math.Max(left.Length, right.Length);
+ for (var i = 0; i < length; i++)
+ {
+ var result = (i < left.Length ? left[i] : 0).CompareTo(i < right.Length ? right[i] : 0);
+ if (result != 0)
+ return result;
+ }
+ return 0;
+ }
+}
diff --git a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
index 01c1ebd..4b2213e 100644
--- a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
+++ b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
@@ -5,7 +5,7 @@ namespace PkgDiff.Application.Versioning;
public sealed class PackageVersion : IComparable, IEquatable
{
- // Either a Pep440Version or a NuGetVersion; versions of one package always share the kind
+ // A Pep440Version, ComposerVersion or NuGetVersion; versions of one package always share the kind
private readonly IComparable _key;
private PackageVersion(string value, bool isPrerelease, IComparable key)
@@ -15,7 +15,7 @@ private PackageVersion(string value, bool isPrerelease, IComparable key)
_key = key;
}
- // The spelling the registry is asked with: its own for npm, pub.dev and PyPI, NuGetVersion.ToString() for NuGet
+ // The spelling the registry is asked with: its own for npm, pub.dev, PyPI and Packagist, NuGetVersion.ToString() for NuGet
public string Value { get; }
public bool IsPrerelease { get; }
@@ -37,6 +37,15 @@ public static bool TryParse(VersionScheme scheme, string? value, [NotNullWhen(tr
return true;
}
+ if (scheme == VersionScheme.Composer)
+ {
+ if (!ComposerVersion.TryParse(value, out var composer))
+ return false;
+
+ version = new PackageVersion(value, composer.IsPrerelease, composer);
+ return true;
+ }
+
if (!NuGetVersion.TryParse(value, out var semantic))
return false;
diff --git a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
index 5c85ec2..dd65a99 100644
--- a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
+++ b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
@@ -9,5 +9,8 @@ public enum VersionScheme
SemVer,
// PEP 440: PyPI
- Pep440
+ Pep440,
+
+ // Composer's rules: Packagist
+ Composer
}
diff --git a/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs b/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs
index 202372e..ff46fd6 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/HttpClientNames.cs
@@ -7,6 +7,7 @@ public static class HttpClientNames
public const string NpmRegistry = "NpmRegistry";
public const string PyPIRegistry = "PyPIRegistry";
public const string PubDev = "PubDev";
+ public const string PackagistRepository = "PackagistRepository";
public const string HealthProbe = "HealthProbe";
public const string Osv = "Osv";
public const string DepsDev = "DepsDev";
diff --git a/src/backend/PkgDiff.Infrastructure/Services/ManifestParser.cs b/src/backend/PkgDiff.Infrastructure/Services/ManifestParser.cs
index db209e7..17ed6a5 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/ManifestParser.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/ManifestParser.cs
@@ -31,6 +31,7 @@ public ManifestParseResult Parse(string registry, string content, string? fileNa
RegistryNames.NuGet => ParseNuGet(content),
RegistryNames.PyPI => ParsePyPI(content, fileName),
RegistryNames.PubDev => ParsePub(content),
+ RegistryNames.Packagist => ParseComposer(content),
_ => throw new ArgumentException("Unknown registry.")
};
result.Registry = canonical;
@@ -587,6 +588,86 @@ private static AuditDependency FromPubConstraint(string name, string? requiremen
};
}
+ private static ManifestParseResult ParseComposer(string content)
+ {
+ var root = ParseJson(content, "a composer.json or composer.lock");
+
+ if (root.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array)
+ {
+ var lockResult = new ManifestParseResult { Format = "composer.lock" };
+ ReadComposerLock(packages, null, lockResult);
+ if (root.TryGetProperty("packages-dev", out var devPackages) && devPackages.ValueKind == JsonValueKind.Array)
+ ReadComposerLock(devPackages, Dev, lockResult);
+ return lockResult;
+ }
+
+ if (!root.TryGetProperty("require", out _) && !root.TryGetProperty("require-dev", out _))
+ throw new ArgumentException("This JSON has neither require nor packages: paste a composer.json or a composer.lock.");
+
+ var result = new ManifestParseResult { Format = "composer.json" };
+ foreach (var (section, scope) in new[] { ("require", (string?)null), ("require-dev", Dev) })
+ {
+ if (!root.TryGetProperty(section, out var dependencies) || dependencies.ValueKind != JsonValueKind.Object)
+ continue;
+ foreach (var dependency in dependencies.EnumerateObject())
+ {
+ // php, ext-*, lib-* and composer-plugin-api name the platform, not a package
+ if (!dependency.Name.Contains('/'))
+ continue;
+ var requirement = dependency.Value.ValueKind == JsonValueKind.String ? dependency.Value.GetString() : null;
+ result.Dependencies.Add(FromComposerConstraint(dependency.Name.ToLowerInvariant(), requirement, scope, result.Warnings));
+ }
+ }
+ return result;
+ }
+
+ private static void ReadComposerLock(JsonElement packages, string? scope, ManifestParseResult result)
+ {
+ foreach (var package in packages.EnumerateArray())
+ {
+ var name = Text(package, "name");
+ var version = Text(package, "version");
+ if (name is null || !name.Contains('/'))
+ continue;
+ if (version is null || version.StartsWith("dev-", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Warnings.Add($"{name}: \"{version}\" is a branch, not a release; its latest version is checked.");
+ result.Dependencies.Add(new AuditDependency { Name = name.ToLowerInvariant(), Requirement = version, Scope = scope });
+ continue;
+ }
+ result.Dependencies.Add(Exact(name.ToLowerInvariant(), StripComposerV(version), scope));
+ }
+ }
+
+ private static AuditDependency FromComposerConstraint(string name, string? requirement, string? scope, List warnings)
+ {
+ var value = requirement?.Trim() ?? string.Empty;
+ if (value.Length == 0 || value == "*")
+ return new AuditDependency { Name = name, Requirement = requirement, Scope = scope };
+
+ if (value.Contains("dev-", StringComparison.OrdinalIgnoreCase))
+ {
+ warnings.Add($"{name}: \"{value}\" is a branch, not a release; its latest version is checked.");
+ return new AuditDependency { Name = name, Requirement = requirement, Scope = scope };
+ }
+
+ // "^1.0 || ^2.0" allows several ranges; the first one is checked, without its @stability flag
+ var first = (value.Split('|', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? value).Trim().Split('@')[0];
+ var exact = SemVerExact().IsMatch(first);
+ var match = SemVerStart().Match(first);
+ return new AuditDependency
+ {
+ Name = name,
+ Requirement = requirement,
+ Scope = scope,
+ IsExact = exact,
+ Version = match.Success ? Complete(match.Value) : null
+ };
+ }
+
+ private static string StripComposerV(string version) =>
+ version.Length > 1 && (version[0] == 'v' || version[0] == 'V') && char.IsDigit(version[1]) ? version[1..] : version;
+
private static JsonElement ParseJson(string content, string expected)
{
try
diff --git a/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs b/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs
index 76fc82a..2aa8bf2 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/OsvClient.cs
@@ -224,6 +224,7 @@ private static string SeverityOf(Vulnerability vuln)
RegistryNames.Npm => "npm",
RegistryNames.PyPI => "PyPI",
RegistryNames.PubDev => "Pub",
+ RegistryNames.Packagist => "Packagist",
_ => null
};
diff --git a/src/backend/PkgDiff.Infrastructure/Services/PackagistRegistryClient.cs b/src/backend/PkgDiff.Infrastructure/Services/PackagistRegistryClient.cs
new file mode 100644
index 0000000..e610aa6
--- /dev/null
+++ b/src/backend/PkgDiff.Infrastructure/Services/PackagistRegistryClient.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.Logging;
+using PkgDiff.Application.Interfaces;
+using PkgDiff.Application.Versioning;
+using PkgDiff.Domain.Interfaces;
+using PkgDiff.Shared.Constants;
+using PkgDiff.Shared.DTOs;
+
+namespace PkgDiff.Infrastructure.Services;
+
+public class PackagistRegistryClient : ListedVersionsRegistryClient
+{
+ private readonly IPackagistService _packagistService;
+ private readonly GitHubFirstScrapingStrategy _githubFirstStrategy;
+
+ public PackagistRegistryClient(
+ IPackagistService packagistService,
+ GitHubFirstScrapingStrategy githubFirstStrategy,
+ IVersionFilterService versionFilterService,
+ ILogger logger)
+ : base(versionFilterService, logger)
+ {
+ _packagistService = packagistService;
+ _githubFirstStrategy = githubFirstStrategy;
+ }
+
+ public override string Id => RegistryNames.Packagist;
+
+ public override string SourceLabel => "Packagist";
+
+ public override VersionScheme VersionScheme => VersionScheme.Composer;
+
+ public override bool PublishesReleaseNotes => false;
+
+ protected override Task> ListVersionsAsync(string packageId, bool includePrerelease) =>
+ _packagistService.GetPackageVersionsAsync(packageId, includePrerelease);
+
+ public override Task GetPublishedDateAsync(string packageId, string version) =>
+ _packagistService.GetPackagePublishedDateAsync(packageId, version);
+
+ protected override Task FetchMetadataAsync(string packageId) =>
+ _packagistService.GetPackageMetadataAsync(packageId);
+
+ public override Task GetOwnReleaseNotesAsync(string packageId, string version) =>
+ Task.FromResult(null);
+
+ public override IReleaseNotesScrapingStrategy GetScrapingStrategy(string? githubRepoUrl) => _githubFirstStrategy;
+}
diff --git a/src/backend/PkgDiff.Infrastructure/Services/PackagistService.cs b/src/backend/PkgDiff.Infrastructure/Services/PackagistService.cs
new file mode 100644
index 0000000..376ccfa
--- /dev/null
+++ b/src/backend/PkgDiff.Infrastructure/Services/PackagistService.cs
@@ -0,0 +1,217 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using PkgDiff.Application.Interfaces;
+using PkgDiff.Application.Versioning;
+using PkgDiff.Shared.DTOs;
+using PkgDiff.Shared.Validation;
+
+namespace PkgDiff.Infrastructure.Services;
+
+// Tagged releases from the Composer v2 metadata (repo.packagist.org/p2), versions without their "v"
+public class PackagistService : IPackagistService
+{
+ private const VersionScheme Scheme = VersionScheme.Composer;
+ private const string Unset = "__unset";
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ // An analysis asks for many versions of one package; its metadata is read once per scope
+ private readonly ConcurrentDictionary?>> _releases = new(StringComparer.Ordinal);
+
+ public PackagistService(IHttpClientFactory httpClientFactory, ILogger logger)
+ {
+ _httpClient = httpClientFactory.CreateClient(HttpClientNames.PackagistRepository);
+ _logger = logger;
+ }
+
+ public async Task> GetPackageVersionsAsync(string packageId, bool includePrerelease = false)
+ {
+ var releases = await GetReleasesAsync(packageId);
+ if (releases is null)
+ return Enumerable.Empty();
+
+ return releases
+ .Where(r => includePrerelease || !r.Version.IsPrerelease)
+ .Select(r => r.Version.Value)
+ .ToList();
+ }
+
+ public async Task GetPackagePublishedDateAsync(string packageId, string version)
+ {
+ var releases = await GetReleasesAsync(packageId);
+ if (releases is null || !PackageVersion.TryParse(Scheme, StripV(version), out var wanted))
+ return null;
+
+ return releases.FirstOrDefault(r => r.Version.Equals(wanted))?.PublishedDate;
+ }
+
+ public async Task GetPackageMetadataAsync(string packageId)
+ {
+ var releases = await GetReleasesAsync(packageId);
+ if (releases is null || releases.Count == 0)
+ throw new ArgumentException($"Package '{packageId}' not found on Packagist.");
+
+ var stable = releases.Where(r => !r.Version.IsPrerelease).ToList();
+ var latest = stable.Count > 0 ? stable[^1] : releases[^1];
+ var repository = releases.AsEnumerable().Reverse().Select(r => r.RepositoryUrl).FirstOrDefault(url => url is not null);
+
+ return new PackageMetadata
+ {
+ PackageId = NameOf(packageId),
+ LatestVersion = latest.Version.Value,
+ Description = latest.Description ?? string.Empty,
+ Authors = latest.Authors ?? "Unknown",
+ ProjectUrl = latest.Homepage ?? repository,
+ GitHubRepositoryUrl = repository,
+ Versions = stable
+ .AsEnumerable()
+ .Reverse()
+ .Select(r => new VersionSummary
+ {
+ Version = r.Version.Value,
+ PublishedDate = r.PublishedDate,
+ IsPrerelease = false
+ })
+ .ToList()
+ };
+ }
+
+ private Task?> GetReleasesAsync(string packageId) =>
+ _releases.GetOrAdd(NameOf(packageId), FetchReleasesAsync);
+
+ // Ascending by Composer's ordering
+ private async Task?> FetchReleasesAsync(string name)
+ {
+ if (name.Count(c => c == '/') != 1)
+ return null;
+
+ try
+ {
+ var path = string.Join('/', name.Split('/').Select(PackageUrlEncoder.EscapeSegment));
+ using var response = await _httpClient.GetAsync($"p2/{path}.json");
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ await using var stream = await response.Content.ReadAsStreamAsync();
+ using var document = await JsonDocument.ParseAsync(stream);
+ if (!document.RootElement.TryGetProperty("packages", out var packages)
+ || packages.ValueKind != JsonValueKind.Object
+ || !packages.TryGetProperty(name, out var entries)
+ || entries.ValueKind != JsonValueKind.Array)
+ return null;
+
+ var releases = new List();
+ var skipped = new List();
+ foreach (var fields in Expand(entries))
+ {
+ var spelled = Text(fields, "version");
+ if (spelled is null)
+ continue;
+ if (!PackageVersion.TryParse(Scheme, StripV(spelled), out var version))
+ {
+ skipped.Add(spelled);
+ continue;
+ }
+ releases.Add(ReleaseOf(version, fields));
+ }
+
+ if (skipped.Count > 0)
+ {
+ _logger.LogWarning(
+ "Skipped {Count} versions of {PackageId} that Composer cannot order: {Versions}",
+ skipped.Count, name, string.Join(", ", skipped.Take(10)));
+ }
+
+ // v1.0 and 1.0 can both be tagged; the first listed, the newest metadata, is kept
+ var ordered = releases.OrderBy(r => r.Version).ToList();
+ return ordered.Where((r, i) => i == 0 || !r.Version.Equals(ordered[i - 1].Version)).ToList();
+ }
+ catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
+ {
+ _logger.LogWarning(ex, "Packagist lookup failed for {PackageId}", name);
+ return null;
+ }
+ }
+
+ // Each entry after the first lists only the fields that changed; "__unset" removes one
+ private static IEnumerable> Expand(JsonElement entries)
+ {
+ var current = new Dictionary(StringComparer.Ordinal);
+ foreach (var entry in entries.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object)
+ continue;
+
+ foreach (var field in entry.EnumerateObject())
+ {
+ if (field.Value.ValueKind == JsonValueKind.String && field.Value.ValueEquals(Unset))
+ current.Remove(field.Name);
+ else
+ current[field.Name] = field.Value;
+ }
+ yield return current;
+ }
+ }
+
+ private static PackagistRelease ReleaseOf(PackageVersion version, Dictionary fields)
+ {
+ DateTime? published = DateTimeOffset.TryParse(Text(fields, "time"), CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal, out var time)
+ ? time.UtcDateTime
+ : null;
+
+ var authors = fields.TryGetValue("authors", out var list) && list.ValueKind == JsonValueKind.Array
+ ? list.EnumerateArray()
+ .Select(a => a.ValueKind == JsonValueKind.Object && a.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null)
+ .Where(n => !string.IsNullOrWhiteSpace(n))
+ .ToList()
+ : new List();
+
+ return new PackagistRelease(
+ version,
+ published,
+ Text(fields, "description"),
+ Text(fields, "homepage"),
+ authors.Count > 0 ? string.Join(", ", authors) : null,
+ RepositoryOf(fields));
+ }
+
+ private static string? RepositoryOf(Dictionary fields)
+ {
+ var candidates = new[] { Nested(fields, "source", "url"), Nested(fields, "support", "source"), Text(fields, "homepage") };
+ return candidates
+ .Where(GitHubUrls.IsGitHub)
+ .Select(url => GitHubUrls.Clean(GitHubUrls.FromRepositoryField(url)!))
+ .FirstOrDefault(url => url.Length > 0);
+ }
+
+ private static string? Text(Dictionary fields, string name) =>
+ fields.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ private static string? Nested(Dictionary fields, string name, string property) =>
+ fields.TryGetValue(name, out var value)
+ && value.ValueKind == JsonValueKind.Object
+ && value.TryGetProperty(property, out var inner)
+ && inner.ValueKind == JsonValueKind.String
+ ? inner.GetString()
+ : null;
+
+ private static string NameOf(string packageId) => packageId.Trim().ToLowerInvariant();
+
+ private static string StripV(string version)
+ {
+ version = version.Trim();
+ return version.Length > 1 && (version[0] == 'v' || version[0] == 'V') && char.IsDigit(version[1]) ? version[1..] : version;
+ }
+
+ private sealed record PackagistRelease(
+ PackageVersion Version,
+ DateTime? PublishedDate,
+ string? Description,
+ string? Homepage,
+ string? Authors,
+ string? RepositoryUrl);
+}
diff --git a/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs b/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs
index e79ae27..7148498 100644
--- a/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs
+++ b/src/backend/PkgDiff.Shared/Constants/RegistryNames.cs
@@ -6,13 +6,15 @@ public static class RegistryNames
public const string Npm = "npm";
public const string PyPI = "pypi";
public const string PubDev = "pub.dev";
+ public const string Packagist = "packagist";
- public static readonly IReadOnlyList All = new[] { NuGet, Npm, PyPI, PubDev };
+ public static readonly IReadOnlyList All = new[] { NuGet, Npm, PyPI, PubDev, Packagist };
private static readonly IReadOnlyDictionary Aliases =
new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- ["pub"] = PubDev
+ ["pub"] = PubDev,
+ ["composer"] = Packagist
};
public static string? Canonical(string? registry)
diff --git a/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs b/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs
index 5a40965..852cc79 100644
--- a/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs
+++ b/src/backend/PkgDiff.Shared/Validation/PackageIdRules.cs
@@ -24,12 +24,16 @@ public static class PackageIdRules
private static readonly Regex PubId = new(
@"^[a-z0-9_]+\z", RegexOptions.CultureInvariant, MatchTimeout);
+ private static readonly Regex PackagistId = new(
+ @"^[a-z0-9]+(?:[_.-][a-z0-9]+)*/[a-z0-9]+(?:(?:[_.]|--?)[a-z0-9]+)*\z", RegexOptions.CultureInvariant, MatchTimeout);
+
private static readonly IReadOnlyDictionary Grammars = new Dictionary
{
[RegistryNames.NuGet] = NuGetId,
[RegistryNames.Npm] = NpmId,
[RegistryNames.PyPI] = PyPIId,
- [RegistryNames.PubDev] = PubId
+ [RegistryNames.PubDev] = PubId,
+ [RegistryNames.Packagist] = PackagistId
};
private static readonly Regex Version = new(
diff --git a/src/frontend/public/icons/packagist.svg b/src/frontend/public/icons/packagist.svg
new file mode 100644
index 0000000..448b449
--- /dev/null
+++ b/src/frontend/public/icons/packagist.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/frontend/src/app/core/config/app.config.ts b/src/frontend/src/app/core/config/app.config.ts
index 0c1f603..51b1a3e 100644
--- a/src/frontend/src/app/core/config/app.config.ts
+++ b/src/frontend/src/app/core/config/app.config.ts
@@ -3,7 +3,7 @@ import { environment } from '../../../environments/environment';
export const APP_CONFIG = {
name: 'pkgdiff',
shortName: 'pkgdiff',
- description: 'Compare and analyze package versions across NuGet, npm, PyPI and Pub registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool.',
+ description: 'Compare and analyze package versions across NuGet, npm, PyPI, Pub and Packagist registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool.',
version: '1.0.0',
contact: {
diff --git a/src/frontend/src/app/core/utils/install-command.utils.ts b/src/frontend/src/app/core/utils/install-command.utils.ts
index 4824393..91f8843 100644
--- a/src/frontend/src/app/core/utils/install-command.utils.ts
+++ b/src/frontend/src/app/core/utils/install-command.utils.ts
@@ -26,6 +26,12 @@ export function installSnippets(registry: string, packageId: string, version: st
{ label: 'dart pub', code: `dart pub add ${packageId}:${version}` },
{ label: 'pubspec.yaml', code: `${packageId}: ${version}` },
];
+ case 'packagist':
+ case 'composer':
+ return [
+ { label: 'Composer', code: `composer require ${packageId}:${version}` },
+ { label: 'composer.json', code: `"${packageId}": "${version}"` },
+ ];
default:
return [];
}
diff --git a/src/frontend/src/app/core/utils/release-link.utils.ts b/src/frontend/src/app/core/utils/release-link.utils.ts
index 20d2f24..bf95728 100644
--- a/src/frontend/src/app/core/utils/release-link.utils.ts
+++ b/src/frontend/src/app/core/utils/release-link.utils.ts
@@ -25,6 +25,9 @@ export function getReleaseLink(
case 'pub':
case 'pub.dev':
return `https://pub.dev/packages/${packageId}/versions/${version}`;
+ case 'packagist':
+ case 'composer':
+ return `https://packagist.org/packages/${packageId}`;
case 'nuget':
return `https://www.nuget.org/packages/${packageId}/${version}`;
default:
@@ -39,6 +42,8 @@ const registryLabels: Record = {
maven: 'Maven',
pub: 'pub.dev',
'pub.dev': 'pub.dev',
+ packagist: 'Packagist',
+ composer: 'Packagist',
};
export function getSourceLabel(source: string | null, registry: string = 'nuget'): string {
diff --git a/src/frontend/src/app/features/audit/audit-wizard/audit-wizard.ts b/src/frontend/src/app/features/audit/audit-wizard/audit-wizard.ts
index 480fe45..da5387f 100644
--- a/src/frontend/src/app/features/audit/audit-wizard/audit-wizard.ts
+++ b/src/frontend/src/app/features/audit/audit-wizard/audit-wizard.ts
@@ -53,6 +53,14 @@ const ECOSYSTEMS: Ecosystem[] = [
files: ['pubspec.yaml', 'pubspec.lock'],
placeholder: 'dependencies:\n http: ^0.13.0\n provider: ^6.0.0',
},
+ {
+ registry: 'packagist',
+ label: 'PHP',
+ registryLabel: 'Packagist',
+ icon: '/icons/packagist.svg',
+ files: ['composer.json', 'composer.lock'],
+ placeholder: '{\n "require": {\n "laravel/framework": "^10.0",\n "guzzlehttp/guzzle": "7.4.0"\n }\n}',
+ },
];
@Component({
@@ -90,8 +98,8 @@ export class AuditWizardComponent {
constructor() {
inject(SeoService).updateMeta({
- title: 'Dependency Security Audit — Check NuGet, npm, PyPI & pub.dev Packages | pkgdiff',
- description: 'Paste a package.json, .csproj, requirements.txt, pubspec.yaml or lock file and get a report of the known vulnerabilities of every dependency, with the versions that fix them.',
+ title: 'Dependency Security Audit — Check NuGet, npm, PyPI, pub.dev & Packagist Packages | pkgdiff',
+ description: 'Paste a package.json, .csproj, requirements.txt, pubspec.yaml, composer.json or lock file and get a report of the known vulnerabilities of every dependency, with the versions that fix them.',
url: '/audit',
});
}
diff --git a/src/frontend/src/app/features/home/home.html b/src/frontend/src/app/features/home/home.html
index 434b122..8cba28a 100644
--- a/src/frontend/src/app/features/home/home.html
+++ b/src/frontend/src/app/features/home/home.html
@@ -1,7 +1,7 @@
- Compare package versions across NuGet, npm, PyPI & Pub. Track breaking changes, analyze release notes, and
+ Compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, analyze release notes, and
understand version differences.
@@ -157,7 +157,7 @@
Track Breaking
4 Registries Supported
- Works with NuGet (.NET), npm (JavaScript), PyPI (Python), and Pub (Dart / Flutter).
+ Works with NuGet (.NET), npm (JavaScript), PyPI (Python), Pub (Dart / Flutter), and Packagist (PHP).
diff --git a/src/frontend/src/app/features/home/home.ts b/src/frontend/src/app/features/home/home.ts
index b66f4df..64ab2ec 100644
--- a/src/frontend/src/app/features/home/home.ts
+++ b/src/frontend/src/app/features/home/home.ts
@@ -20,6 +20,7 @@ const EXAMPLES: Record = {
pypi: ['requests', 'numpy', 'django'],
pub: ['riverpod', 'lints', 'code_builder'],
'pub.dev': ['riverpod', 'lints', 'code_builder'],
+ packagist: ['laravel/framework', 'symfony/console', 'guzzlehttp/guzzle'],
};
@Component({
@@ -57,8 +58,8 @@ export class HomeComponent {
constructor() {
this.seo.updateMeta({
- title: 'pkgdiff — Compare NuGet, npm, PyPI & Pub Packages',
- description: 'Free tool to compare package versions across NuGet, npm, PyPI & Pub. Track breaking changes, view release notes, and analyze version differences between any two versions.',
+ title: 'pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages',
+ description: 'Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.',
url: '/',
});
@@ -87,7 +88,8 @@ export class HomeComponent {
handleSearch(): void {
const registry = this.selectedRegistry();
- const term = this.searchTerm().trim();
+ const typed = this.searchTerm().trim();
+ const term = registry?.value === 'packagist' ? typed.toLowerCase() : typed;
if (this.loading() || !registry || !term) return;
this.rememberRegistry();
diff --git a/src/frontend/src/app/features/not-found/not-found.ts b/src/frontend/src/app/features/not-found/not-found.ts
index 4a1ae48..4596e79 100644
--- a/src/frontend/src/app/features/not-found/not-found.ts
+++ b/src/frontend/src/app/features/not-found/not-found.ts
@@ -15,6 +15,6 @@ export class NotFoundComponent {
inject(HttpStatusService).notFound();
inject(SeoService).setNotFound(
'404 - Page Not Found | pkgdiff',
- 'The requested page could not be found. Return to pkgdiff to compare package versions across NuGet, npm, PyPI and Pub registries.');
+ 'The requested page could not be found. Return to pkgdiff to compare package versions across NuGet, npm, PyPI, Pub and Packagist registries.');
}
}
diff --git a/src/frontend/src/app/features/package/components/trust-panel/trust-panel.html b/src/frontend/src/app/features/package/components/trust-panel/trust-panel.html
index ea6232b..7da6876 100644
--- a/src/frontend/src/app/features/package/components/trust-panel/trust-panel.html
+++ b/src/frontend/src/app/features/package/components/trust-panel/trust-panel.html
@@ -90,8 +90,8 @@
Project health
} @else {
- @if (isPub()) {
- Scorecard is available only for pub.dev packages with a GitHub repository.
+ @if (scorecardOnly()) {
+ Scorecard is available only for packages with a GitHub repository.
} @else {
Not available.
}
diff --git a/src/frontend/src/app/features/package/components/trust-panel/trust-panel.ts b/src/frontend/src/app/features/package/components/trust-panel/trust-panel.ts
index c9ba69b..5fbc6bb 100644
--- a/src/frontend/src/app/features/package/components/trust-panel/trust-panel.ts
+++ b/src/frontend/src/app/features/package/components/trust-panel/trust-panel.ts
@@ -34,5 +34,5 @@ export class TrustPanelComponent {
if (score >= 4) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
});
- isPub = computed(() => this.registry().toLowerCase().startsWith('pub'));
+ scorecardOnly = computed(() => /^(pub|packagist|composer)/.test(this.registry().toLowerCase()));
}
diff --git a/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.html b/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.html
index ac558a6..8347a56 100644
--- a/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.html
+++ b/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.html
@@ -81,8 +81,8 @@
Behaviour
- @if (isPub()) {
-
Behaviour analysis is not available for pub.dev packages.
+ @if (behaviorUnsupportedIn(); as registryLabel) {
+
Behaviour analysis is not available for {{ registryLabel }} packages.
} @else if (!behaviorTo()) {
No behaviour analysis for v{{ to() }} yet.
} @else {
diff --git a/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.ts b/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.ts
index f4cc0dd..ca59eba 100644
--- a/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.ts
+++ b/src/frontend/src/app/features/package/components/upgrade-security/upgrade-security.ts
@@ -47,7 +47,12 @@ export class UpgradeSecurityComponent {
// null while the Socket.dev provider is switched off
behavior = computed(() => this.trust()?.behavior ?? null);
- isPub = computed(() => this.registry().toLowerCase().startsWith('pub'));
+ behaviorUnsupportedIn = computed(() => {
+ const registry = this.registry().toLowerCase();
+ if (registry.startsWith('pub')) return 'pub.dev';
+ if (registry === 'packagist' || registry === 'composer') return 'Packagist';
+ return null;
+ });
behaviorFrom = computed(() => this.behavior()?.[this.from()] ?? null);
behaviorTo = computed(() => this.behavior()?.[this.to()] ?? null);
newAlerts = computed(() => {
diff --git a/src/frontend/src/app/features/package/package.ts b/src/frontend/src/app/features/package/package.ts
index ce9ea1b..b8625cf 100644
--- a/src/frontend/src/app/features/package/package.ts
+++ b/src/frontend/src/app/features/package/package.ts
@@ -32,7 +32,11 @@ interface PackageTarget {
version: string | undefined;
}
-// A scoped npm name can arrive split across route params; a lone scope takes the next segment
+function isPackagist(registry: string): boolean {
+ return ['packagist', 'composer'].includes(registry.toLowerCase());
+}
+
+// A scoped npm or a Packagist vendor/name can arrive split across route params; a lone scope takes the next segment
function targetOf(params: ParamMap): PackageTarget {
const registry = params.get('registry') ?? 'nuget';
const version = params.get('version') ?? undefined;
@@ -42,7 +46,8 @@ function targetOf(params: ParamMap): PackageTarget {
}
const packageId = params.get('packageId') ?? '';
- if (packageId.startsWith('@') && !packageId.includes('/') && version) {
+ const namespaced = packageId.startsWith('@') || isPackagist(registry);
+ if (namespaced && !packageId.includes('/') && version) {
return { registry, packageId: `${packageId}/${version}`, version: undefined };
}
return { registry, packageId, version };
@@ -153,6 +158,9 @@ export class PackageComponent {
case 'pub':
case 'pub.dev':
return 'border-purple-300 dark:border-purple-700 text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20';
+ case 'packagist':
+ case 'composer':
+ return 'border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20';
default:
return 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400';
}
diff --git a/src/frontend/src/app/features/packages/packages-list.ts b/src/frontend/src/app/features/packages/packages-list.ts
index 0d3b607..d7ec504 100644
--- a/src/frontend/src/app/features/packages/packages-list.ts
+++ b/src/frontend/src/app/features/packages/packages-list.ts
@@ -93,8 +93,8 @@ export class PackagesListComponent {
constructor() {
this.seo.updateMeta({
- title: 'Browse Packages — Compare Versions Across NuGet, npm, PyPI & Pub | pkgdiff',
- description: 'Browse and compare packages from NuGet, npm, PyPI and Pub registries. Search through thousands of packages, view detailed version history, track breaking changes, and analyze comprehensive release notes.',
+ title: 'Browse Packages — Compare Versions Across NuGet, npm, PyPI, Pub & Packagist | pkgdiff',
+ description: 'Browse and compare packages from NuGet, npm, PyPI, Pub and Packagist registries. Search through thousands of packages, view detailed version history, track breaking changes, and analyze comprehensive release notes.',
url: '/packages',
});
@@ -173,6 +173,9 @@ export class PackagesListComponent {
case 'pub':
case 'pub.dev':
return 'border-purple-300 dark:border-purple-700 text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20';
+ case 'packagist':
+ case 'composer':
+ return 'border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20';
default:
return 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400';
}
diff --git a/src/frontend/src/app/features/privacy/privacy.html b/src/frontend/src/app/features/privacy/privacy.html
index 2852742..5c36688 100644
--- a/src/frontend/src/app/features/privacy/privacy.html
+++ b/src/frontend/src/app/features/privacy/privacy.html
@@ -158,7 +158,7 @@
6. Service
Commission's Standard Contractual Clauses.
- To build release notes, our server fetches public data from NuGet.org, npmjs.com, PyPI, pub.dev and
+ To build release notes, our server fetches public data from NuGet.org, npmjs.com, PyPI, pub.dev, Packagist and
GitHub, and security data from sources such as OSV and deps.dev. These requests come from our server
and contain package names and versions only — never your IP address or any other data about you.
{{ appName }} is a free, open-source tool that allows users to compare package versions across
- registries (NuGet, npm, PyPI and pub.dev), view release notes, and analyze differences between versions.
+ registries (NuGet, npm, PyPI, pub.dev and Packagist), view release notes, and analyze differences between versions.
The Service retrieves publicly available data from package registries and GitHub repositories.
@@ -42,7 +42,7 @@
3. Accepta
4. Data & Content
The release notes, version information, and package metadata displayed on {{ appName }} are sourced
- from third-party registries (NuGet.org, npmjs.com, PyPI and pub.dev) and GitHub. We do not claim
+ from third-party registries (NuGet.org, npmjs.com, PyPI, pub.dev and Packagist) and GitHub. We do not claim
ownership of this content; it belongs to its authors and is shown with a link to its source. The accuracy, completeness, and timeliness of this data depend on the upstream sources.
{{ appName }} is not responsible for errors or omissions in third-party data. If you own content
shown here and want it corrected or removed, contact us and we will act on it promptly.
From 81ce92d056b08bce09c13c65f2ccd985974d45ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?=
Date: Wed, 21 Oct 2026 21:41:06 +0300
Subject: [PATCH 2/2] fix(packagist): register Packagist in the test registries
and the home page copy
The registry tests build their own client list and checked a package
id that is not a vendor/name; the SSR smoke test and index.html still
named four registries.
Co-Authored-By: Claude Opus 5.5
---
.../Registries/RegistryClientTests.cs | 4 +++-
.../TestDoubles/TestRegistries.cs | 6 ++++--
src/frontend/src/index.html | 21 ++++++++++---------
src/frontend/ssr-smoke.test.mjs | 4 ++--
4 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs b/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs
index 5569ec9..17979c6 100644
--- a/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs
+++ b/src/backend/PkgDiff.Tests/Registries/RegistryClientTests.cs
@@ -22,6 +22,7 @@ public class RegistryClientTests
[InlineData("PyPI", "pypi", "PyPI", VersionScheme.Pep440, true, false)]
[InlineData("pub", "pub.dev", "PubDev", VersionScheme.SemVer, true, true)]
[InlineData("pub.dev", "pub.dev", "PubDev", VersionScheme.SemVer, true, true)]
+ [InlineData("composer", "packagist", "Packagist", VersionScheme.Composer, true, false)]
public void Every_spelling_of_a_registry_reaches_its_one_client(
string spelling, string id, string label, VersionScheme scheme, bool includesStart, bool publishesNotes)
{
@@ -65,7 +66,8 @@ public void Every_registry_name_has_a_client_and_a_package_id_grammar()
{
Assert.Equal(name, factory.GetClient(name).Id);
Assert.Equal(name, RegistryNames.Canonical(name.ToUpperInvariant()));
- Assert.True(PkgDiff.Shared.Validation.PackageIdRules.IsValidPackageId(name, "abc"));
+ var sample = name == RegistryNames.Packagist ? "abc/def" : "abc";
+ Assert.True(PkgDiff.Shared.Validation.PackageIdRules.IsValidPackageId(name, sample));
});
Assert.Equal(new[] { "pub.dev", "pub" }, RegistryNames.SpellingsOf("PUB"));
Assert.Equal(new[] { "npm" }, RegistryNames.SpellingsOf("npm"));
diff --git a/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs b/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs
index 74d06e0..11f2b77 100644
--- a/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs
+++ b/src/backend/PkgDiff.Tests/TestDoubles/TestRegistries.cs
@@ -18,7 +18,8 @@ public static RegistryServiceFactory Create(
IVersionFilterService? versionFilter = null,
GitHubFirstScrapingStrategy? githubFirst = null,
NuGetFirstScrapingStrategy? nugetFirst = null,
- PubDevScrapingStrategy? pubDevStrategy = null)
+ PubDevScrapingStrategy? pubDevStrategy = null,
+ IPackagistService? packagist = null)
{
versionFilter ??= new VersionFilterService(
new FakeUnitOfWork(), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance);
@@ -28,7 +29,8 @@ public static RegistryServiceFactory Create(
new NuGetRegistryClient(nuget!, nugetPage!, githubFirst!, nugetFirst!),
new NpmRegistryClient(npm!, githubFirst!, versionFilter, NullLogger.Instance),
new PyPIRegistryClient(pypi!, githubFirst!, versionFilter, NullLogger.Instance),
- new PubDevRegistryClient(pubDev!, pubDevStrategy!, versionFilter, NullLogger.Instance)
+ new PubDevRegistryClient(pubDev!, pubDevStrategy!, versionFilter, NullLogger.Instance),
+ new PackagistRegistryClient(packagist!, githubFirst!, versionFilter, NullLogger.Instance)
]);
}
}
diff --git a/src/frontend/src/index.html b/src/frontend/src/index.html
index f837e7b..28959db 100644
--- a/src/frontend/src/index.html
+++ b/src/frontend/src/index.html
@@ -15,11 +15,11 @@
})();
- pkgdiff — Compare NuGet, npm, PyPI & Pub Packages
+ pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages
+ content="Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.">
@@ -35,9 +35,9 @@
+ content="pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages">
+ content="Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.">
@@ -45,7 +45,7 @@
-
+
@@ -53,11 +53,11 @@
+ content="pkgdiff — Compare NuGet, npm, PyPI, Pub & Packagist Packages">
+ content="Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.">
-
+
@@ -76,7 +76,7 @@
"name": "pkgdiff",
"alternateName": "Package Diff",
"url": "https://pkgdiff.dev",
- "description": "Free tool to compare package versions across NuGet, npm, PyPI & Pub. Track breaking changes, view release notes, and analyze version differences between any two versions.",
+ "description": "Free tool to compare package versions across NuGet, npm, PyPI, Pub & Packagist. Track breaking changes, view release notes, and analyze version differences between any two versions.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Web",
"browserRequirements": "Requires JavaScript. Requires HTML5.",
@@ -91,6 +91,7 @@
"Compare npm package versions",
"Compare PyPI package versions",
"Compare pub.dev package versions",
+ "Compare Packagist package versions",
"View release notes and changelogs",
"Track version history",
"Analyze package differences"
@@ -126,7 +127,7 @@
style="font-family: Inter, sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 16px; font-size: 16px; line-height: 1.6; color: #111418;">
pkgdiff — Compare Package Versions Online
- Compare and analyze package versions across NuGet, npm, PyPI and Pub registries. Track breaking changes,
+ Compare and analyze package versions across NuGet, npm, PyPI, Pub and Packagist registries. Track breaking changes,
view
detailed release notes, and understand version differences with our free online diff tool. Perfect for
developers upgrading dependencies and managing package updates.
diff --git a/src/frontend/ssr-smoke.test.mjs b/src/frontend/ssr-smoke.test.mjs
index c8e631e..3b7e8a2 100644
--- a/src/frontend/ssr-smoke.test.mjs
+++ b/src/frontend/ssr-smoke.test.mjs
@@ -121,9 +121,9 @@ test('the home page arrives rendered, with its meta and ready to be hydrated', a
const { status, html } = await page('/');
assert.equal(status, 200);
- assert.match(html, /