Skip to content

fix(uv): tree --invert silently drops leaf cycles#3

Open
beast-ofcourse wants to merge 3 commits into
mainfrom
fix/tree-invert-leaf-cycles
Open

fix(uv): tree --invert silently drops leaf cycles#3
beast-ofcourse wants to merge 3 commits into
mainfrom
fix/tree-invert-leaf-cycles

Conversation

@beast-ofcourse

@beast-ofcourse beast-ofcourse commented Jul 8, 2026

Copy link
Copy Markdown
Owner

When the dependency graph has a leaf cycle (a group of packages that depend on each other with nothing depending on them from outside), \uv tree --invert\ would produce no output at all because every node in the reversed graph has at least one incoming edge, leaving the root set empty.

This adds a fallback: when no root is found via the standard zero-incoming-edges check, strongly connected components (SCCs) are computed using Tarjan's algorithm. Each SCC with no incoming edges from outside the SCC is treated as a root SCC, and one representative node is picked as a root in the inverted display.

Summary by CodeRabbit

  • Bug Fixes

    • Improved tree --invert so the tree no longer renders empty when cycles prevent clear root selection.
    • When roots can’t be determined, the view now picks representative cycle entries so output remains complete and includes cycle markers.
  • Tests

    • Added a regression test covering inverted tree rendering for a leaf SCC cycle to ensure stable, non-empty snapshots in the future.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40729e6a-4e65-4e5e-b989-a7ebcd67ef53

📥 Commits

Reviewing files that changed from the base of the PR and between d856a5f and a64c71e.

📒 Files selected for processing (1)
  • crates/uv/tests/project/tree.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/uv/tests/project/tree.rs

📝 Walkthrough

Walkthrough

Modifies inverted-mode dependency tree root computation to fall back to Tarjan SCCs when no zero-incoming-edge roots exist, and adds a regression test for an inverted tree containing a leaf cycle.

Changes

Cycle-aware Root Detection

Layer / File(s) Summary
SCC fallback root selection
crates/uv-resolver/src/lock/tree.rs
Imports tarjan_scc and extends TreeDisplay::new to fall back to SCC-based root selection when the initial no-incoming-edges search yields no roots, preferring Node::Package nodes over synthetic Root nodes.
Inverted tree regression test
crates/uv/tests/project/tree.rs
Adds a snapshot test for uv tree --frozen --invert over a bar/baz cycle, asserting the inverted tree still renders with the expected (*) cycle annotation.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: uv tree --invert no longer drops leaf cycles.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tree-invert-leaf-cycles

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/uv-resolver/src/lock/tree.rs`:
- Around line 451-474: The root selection logic in tree.rs currently only runs
the SCC fallback when roots is empty, so a graph with any zero-incoming node can
still skip a separate source SCC like a leaf cycle. Update the root discovery in
the invert-mode path around tarjan_scc/roots so every SCC with no external
incoming edges is added as a root regardless of whether other roots already
exist, and keep the package-over-Root preference when choosing the
representative node. Add a mixed acyclic plus leaf-cycle test covering render()
to verify the cycle component is not dropped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 410539fd-9b2e-467c-99aa-b1a528f33b2d

📥 Commits

Reviewing files that changed from the base of the PR and between 51a283b and f6a47db.

📒 Files selected for processing (1)
  • crates/uv-resolver/src/lock/tree.rs

Comment on lines +451 to +474
// If no node has zero incoming edges (e.g., a leaf cycle), fall
// back to treating each root SCC as a root. A root SCC is one
// whose nodes have no incoming edges from outside the SCC.
if roots.is_empty() {
let sccs = tarjan_scc(&graph);
for scc in &sccs {
let has_external_incoming = scc.iter().any(|node| {
graph
.edges_directed(*node, Direction::Incoming)
.any(|edge| !scc.contains(&edge.source()))
});
if !has_external_incoming {
// Pick the best node in the SCC as root: prefer
// packages over Root nodes.
let root = scc
.iter()
.find(|n| matches!(graph[**n], Node::Package(_)))
.or_else(|| scc.first());
if let Some(root) = root {
roots.push(*root);
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,260p' crates/uv-resolver/src/lock/tree.rs

Repository: beast-ofcourse/uv

Length of output: 10717


🏁 Script executed:

rg -n "tarjan_scc|roots\.is_empty\(|render\(|leaf cycle|SCC|zero incoming|incoming edges" crates/uv-resolver/src/lock/tree.rs crates/uv-resolver/src -g '!target'

Repository: beast-ofcourse/uv

Length of output: 2059


🏁 Script executed:

fd -a -t f crates/uv-resolver/src/lock

Repository: beast-ofcourse/uv

Length of output: 569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- tree.rs outline ---'
ast-grep outline crates/uv-resolver/src/lock/tree.rs --view expanded || true

echo '--- relevant lines ---'
nl -ba crates/uv-resolver/src/lock/tree.rs | sed -n '380,520p'

echo '--- tests and references ---'
rg -n "tree::|render\(|tarjan_scc|leaf cycle|roots\.is_empty\(|source SCC|zero incoming" crates/uv-resolver/src -g '!target'

Repository: beast-ofcourse/uv

Length of output: 2372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- surrounding code ---'
nl -ba crates/uv-resolver/src/lock/tree.rs | sed -n '420,500p'

echo '--- search for tests covering mixed graphs / leaf cycles ---'
rg -n "leaf cycle|cycle.*leaf|mixed graph|source SCC|tarjan_scc|roots\.is_empty\(|zero incoming" crates/uv-resolver -g '!target' -g '*.rs'

Repository: beast-ofcourse/uv

Length of output: 220


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path('crates/uv-resolver/src/lock/tree.rs')
text = path.read_text()

# Extract the root-selection block for inspection
m = re.search(r'let mut roots: Vec<_> = graph.*?roots\s*$', text, re.S | re.M)
print('found_block=', bool(m))
print('contains_roots_is_empty=', 'roots.is_empty()' in text)
print('contains_tarjan_scc=', 'tarjan_scc(&graph)' in text)
print('contains_scc_contains=', '.contains(&edge.source())' in text)
PY

Repository: beast-ofcourse/uv

Length of output: 257


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

path = Path('crates/uv-resolver/src/lock/tree.rs')
lines = path.read_text().splitlines()

for start, end, label in [
    (420, 490, 'root selection'),
    (700, 760, 'render'),
    (920, 1030, 'fmt + tests tail'),
]:
    print(f'\n--- {label} ({start}-{end}) ---')
    for i in range(start-1, min(end, len(lines))):
        print(f'{i+1:4d}: {lines[i]}')
PY

Repository: beast-ofcourse/uv

Length of output: 6839


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

path = Path('crates/uv-resolver/src/lock/tree.rs')
lines = path.read_text().splitlines()

print('--- test function names in tree.rs ---')
for i, line in enumerate(lines, 1):
    if re.match(r'\s*fn\s+test_|^\s*#\[test\]', line):
        print(f'{i}: {line}')

print('\n--- occurrences of render() in tests section ---')
for i, line in enumerate(lines, 1):
    if 'render()' in line or 'TreeDisplay::new' in line:
        print(f'{i}: {line}')
PY

Repository: beast-ofcourse/uv

Length of output: 285


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

lines = Path('crates/uv-resolver/src/lock/tree.rs').read_text().splitlines()

for start, end in [(420, 490), (700, 760), (980, 1120)]:
    print(f'\n--- lines {start}-{end} ---')
    for i in range(start-1, min(end, len(lines))):
        print(f'{i+1:4d}: {lines[i]}')
PY

Repository: beast-ofcourse/uv

Length of output: 6182


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

lines = Path('crates/uv-resolver/src/lock/tree.rs').read_text().splitlines()

# Print the root-selection block and render() implementation.
for start, end, label in [
    (435, 490, 'root-selection block'),
    (707, 760, 'render'),
    (1000, 1180, 'tests/nearby examples'),
]:
    print(f'\n--- {label} ({start}-{end}) ---')
    for i in range(start-1, min(end, len(lines))):
        print(f'{i+1:4d}: {lines[i]}')

print('\n--- test markers in file ---')
for i, line in enumerate(lines, 1):
    if '#[test]' in line:
        print(f'{i}: {line}')
PY

Repository: beast-ofcourse/uv

Length of output: 5408


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

text = Path('crates/uv-resolver/src/lock/tree.rs').read_text()

# Look for tests or fixtures exercising rendering of disconnected / cyclic graphs.
patterns = [
    r'leaf cycle',
    r'mixed graph',
    r'roots\.is_empty\(',
    r'tarjan_scc',
    r'Render',
    r'TreeDisplay::new\(',
]
for pat in patterns:
    print(f'\n--- matches for {pat!r} ---')
    for m in re.finditer(pat, text):
        line = text.count('\n', 0, m.start()) + 1
        print(line)
PY

Repository: beast-ofcourse/uv

Length of output: 388


Treat every source SCC as a root in invert mode
roots.is_empty() skips the SCC fallback as soon as any zero-incoming node exists, so a separate leaf-cycle component in a mixed graph never becomes a root and render() drops it. Merge the source-SCC pass into the initial root selection and add a mixed acyclic + leaf-cycle test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/uv-resolver/src/lock/tree.rs` around lines 451 - 474, The root
selection logic in tree.rs currently only runs the SCC fallback when roots is
empty, so a graph with any zero-incoming node can still skip a separate source
SCC like a leaf cycle. Update the root discovery in the invert-mode path around
tarjan_scc/roots so every SCC with no external incoming edges is added as a root
regardless of whether other roots already exist, and keep the package-over-Root
preference when choosing the representative node. Add a mixed acyclic plus
leaf-cycle test covering render() to verify the cycle component is not dropped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant