Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/detectors/uv.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# uv Detection

## Requirements

[uv](https://docs.astral.sh/uv/) detection relies on a [uv.lock](https://docs.astral.sh/uv/concepts/projects/layout/#the-lockfile) file being present.

## Detection strategy

uv detection is performed by parsing a <em>uv.lock</em> found under the scan directory.

Full dependency graph generation is supported.

Dev dependencies across all dependency groups (e.g., `dev`, `lint`, `test`) are identified via transitive reachability analysis. A package reachable from both production and dev roots is classified as non-dev.

Git-sourced packages are registered as `GitComponent` with the repository URL and commit hash extracted from the lockfile.

## Known limitations

1. Editable (`source = { editable = "..." }`) and non-root workspace member packages are registered as regular components rather than being filtered out.
2. Lockfile version validation is not performed; only lockfile version 1 has been tested.
8 changes: 6 additions & 2 deletions src/Microsoft.ComponentDetection.Detectors/uv/UvLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ internal static List<UvPackage> ParsePackagesFromModel(object? model)
{
Registry = sourceTable.TryGetValue("registry", out var regObj) && regObj is string reg ? reg : null,
Virtual = sourceTable.TryGetValue("virtual", out var virtObj) && virtObj is string virt ? virt : null,
Git = sourceTable.TryGetValue("git", out var gitObj) && gitObj is string git ? git : null,
};
uvPackage.Source = source;
}
Expand Down Expand Up @@ -133,9 +134,12 @@ internal static void ParseMetadata(TomlTable? metadataTable, UvPackage uvPackage

if (metadataTable.TryGetValue("requires-dev", out var requiresDevObj) && requiresDevObj is TomlTable requiresDevTable)
{
if (requiresDevTable.TryGetValue("dev", out var devObj) && devObj is TomlArray devArr)
foreach (var kvp in requiresDevTable)
{
uvPackage.MetadataRequiresDev = ParseDependenciesArray(devArr);
if (kvp.Value is TomlArray groupArr)
{
uvPackage.MetadataRequiresDev.AddRange(ParseDependenciesArray(groupArr));
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

public override IList<string> SearchPatterns { get; } = ["uv.lock"];

public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.Pip];
public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.Pip, ComponentType.Git];

public override int Version => 1;

Expand All @@ -37,6 +37,40 @@
return pck.Source?.Virtual != null;
}

internal static (Uri RepositoryUrl, string CommitHash) ParseGitUrl(string gitUrl)
{
var uri = new Uri(gitUrl);
var repoUrl = new Uri(uri.GetLeftPart(UriPartial.Path));
var commitHash = uri.Fragment.TrimStart('#');
Comment on lines +42 to +44
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseGitUrl assumes the git URL is always a valid absolute URI and always contains a non-empty fragment. If the URL is malformed or doesn’t include a commit hash fragment, new Uri(...) or GitComponent construction will throw, causing the detector to bail out for the entire uv.lock. Consider using Uri.TryCreate plus an explicit check for a non-empty commit hash (and falling back to logging + treating it as a non-git package or skipping just that package) to avoid a single bad entry breaking detection.

Suggested change
var uri = new Uri(gitUrl);
var repoUrl = new Uri(uri.GetLeftPart(UriPartial.Path));
var commitHash = uri.Fragment.TrimStart('#');
if (!Uri.TryCreate(gitUrl, UriKind.Absolute, out var uri))
{
// Malformed git URL; signal failure to the caller.
return (null, string.Empty);
}
var repoPart = uri.GetLeftPart(UriPartial.Path);
if (!Uri.TryCreate(repoPart, UriKind.Absolute, out var repoUrl))
{
// Unable to construct a valid repository URL; signal failure.
return (null, string.Empty);
}
var commitHash = uri.Fragment.TrimStart('#');
// If there is no fragment or it is empty/whitespace, treat as "no commit hash".
if (string.IsNullOrWhiteSpace(commitHash))
{
return (repoUrl, string.Empty);
}

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is false and not a concern we should take care of at this level. The uv.lock files are machine generated and follow the strict specs which for git sources afaik always includes the commit hash. This means that this could potentially take effect in cases where we have malformed uv.lock but then it should fail because it's malformed 😄

return (repoUrl, commitHash);
}

internal static HashSet<string> GetTransitivePackages(IEnumerable<string> roots, List<UvPackage> packages)
{
var lookup = packages.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>(roots);

while (queue.Count > 0)
{
var name = queue.Dequeue();
if (!visited.Add(name))
{
continue;
}

if (lookup.TryGetValue(name, out var pkg))
{
foreach (var dep in pkg.Dependencies)
{
queue.Enqueue(dep.Name);
}
}
}

return visited;
}

protected override Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default)
{
var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder;
Expand All @@ -49,8 +83,8 @@
var uvLock = UvLock.Parse(file.Stream);

var rootPackage = uvLock.Packages.FirstOrDefault(IsRootPackage);
var explicitPackages = new HashSet<string>();
var devPackages = new HashSet<string>();
var explicitPackages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var devRootNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (rootPackage != null)
{
Expand All @@ -61,30 +95,57 @@

foreach (var devDep in rootPackage.MetadataRequiresDev)
{
devPackages.Add(devDep.Name);
devRootNames.Add(devDep.Name);
}
}

// Compute dev-only packages via transitive reachability analysis.
// A package is dev-only if it is reachable from dev roots but NOT from production roots.
var prodRoots = rootPackage?.Dependencies.Select(d => d.Name) ?? [];
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prodRoots is currently derived from rootPackage.Dependencies. However, in this codebase/tests the root package can omit the dependencies array while still declaring production dependencies in [package.metadata].requires-dist (which you already parse into explicitPackages). In that case prodRoots becomes empty, prodTransitive stays empty, and shared transitive dependencies can be incorrectly classified as dev-only. Consider using explicitPackages (or rootPackage.MetadataRequiresDist.Select(d => d.Name)) as the production root set for the reachability analysis instead of rootPackage.Dependencies.

Suggested change
var prodRoots = rootPackage?.Dependencies.Select(d => d.Name) ?? [];
var prodRoots = explicitPackages;

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@theztefan - This is a valid recommendation to investigate, just because a package is a root it doesn't mean it is a runtime dependency, if the manifest explicitly says is it a dev dependency it should be classified as such, so will its children dependencies.

var prodTransitive = GetTransitivePackages(prodRoots, uvLock.Packages);
var devTransitive = GetTransitivePackages(devRootNames, uvLock.Packages);
var devOnlyPackages = new HashSet<string>(devTransitive.Except(prodTransitive), StringComparer.OrdinalIgnoreCase);

foreach (var pkg in uvLock.Packages)
{
if (IsRootPackage(pkg))
{
continue;
}

var pipComponent = new PipComponent(pkg.Name, pkg.Version);
var isExplicit = explicitPackages.Contains(pkg.Name);
var isDev = devPackages.Contains(pkg.Name);
var detectedComponent = new DetectedComponent(pipComponent);
var isDev = devOnlyPackages.Contains(pkg.Name);

TypedComponent component;
if (pkg.Source?.Git != null)
{
var (repoUrl, commitHash) = ParseGitUrl(pkg.Source.Git);
component = new GitComponent(repoUrl, commitHash);
}
Comment on lines 40 to 124
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseGitUrl/GitComponent creation can throw (e.g., invalid/relative URL, or missing #<commit> fragment). Because this happens inside the single try/catch around the whole file, one bad git URL will abort processing of the entire uv.lock and result in no components being recorded. Consider using Uri.TryCreate + validating a non-empty (and ideally hex/length-checked) commit hash, and if parsing fails log a warning and either skip just that package or fall back to a PipComponent instead of failing the whole detector run.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comment from above is valid here as well. Don't think we should be handling this here.

else
{
component = new PipComponent(pkg.Name, pkg.Version);
}

var detectedComponent = new DetectedComponent(component);
singleFileComponentRecorder.RegisterUsage(detectedComponent, isDevelopmentDependency: isDev, isExplicitReferencedDependency: isExplicit);

foreach (var dep in pkg.Dependencies)
{
var depPkg = uvLock.Packages.FirstOrDefault(p => p.Name.Equals(dep.Name, StringComparison.OrdinalIgnoreCase));
if (depPkg != null)
{
var depComponentWithVersion = new PipComponent(depPkg.Name, depPkg.Version);
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(depComponentWithVersion), parentComponentId: pipComponent.Id);
TypedComponent depComponent;
if (depPkg.Source?.Git != null)
{
var (depRepoUrl, depCommitHash) = ParseGitUrl(depPkg.Source.Git);
depComponent = new GitComponent(depRepoUrl, depCommitHash);
}
else
{
depComponent = new PipComponent(depPkg.Name, depPkg.Version);
}

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / verify (macos-latest)

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / verify (ubuntu-latest)

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Pip

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Maven

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Rust

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / verify (windows-latest)

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / CocoaPods

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Poetry

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Yarn

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Gradle

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / NuGet

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / NPM

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Go

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Pnpm

Check failure on line 147 in src/Microsoft.ComponentDetection.Detectors/uv/UvLockComponentDetector.cs

View workflow job for this annotation

GitHub Actions / Ruby

singleFileComponentRecorder.RegisterUsage(new DetectedComponent(depComponent), parentComponentId: component.Id, isDevelopmentDependency: isDev);
Comment on lines 133 to +148
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency edge registration does a linear search for every dependency (uvLock.Packages.FirstOrDefault(...)). For larger uv.lock files this becomes O(packages * deps) and you already build a name→package lookup for the transitive-closure calculation. Consider building a single case-insensitive lookup dictionary once (e.g., at the start of OnFileFoundAsync) and reusing it both for transitive reachability and for resolving depPkg when adding edges.

Copilot uses AI. Check for mistakes.
}
else
{
Expand Down
2 changes: 2 additions & 0 deletions src/Microsoft.ComponentDetection.Detectors/uv/UvSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public class UvSource
public string? Registry { get; set; }

public string? Virtual { get; set; }

public string? Git { get; set; }
}
Loading
Loading