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
34 changes: 31 additions & 3 deletions crates/uv-resolver/src/lock/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::fmt::Write;
use either::Either;
use itertools::Itertools;
use owo_colors::OwoColorize;
use petgraph::algo::tarjan_scc;
use petgraph::graph::{EdgeIndex, NodeIndex};
use petgraph::prelude::EdgeRef;
use petgraph::{Direction, Graph};
Expand Down Expand Up @@ -436,16 +437,43 @@ impl<'env> TreeDisplay<'env> {
} else {
let mut roots = if invert {
// For inverted trees, find leaf packages (nodes with no incoming
// edges).
graph
// edges after reversal).
let mut roots: Vec<_> = graph
.node_indices()
.filter(|index| {
graph
.edges_directed(*index, Direction::Incoming)
.next()
.is_none()
})
.collect::<Vec<_>>()
.collect();

// 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);
}
}
}
}
Comment on lines +451 to +474

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.


roots
} else {
// For non-inverted trees, use the root node directly.
graph
Expand Down
86 changes: 86 additions & 0 deletions crates/uv/tests/project/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2998,3 +2998,89 @@ fn workspace_circular_dependencies() -> Result<()> {

Ok(())
}

#[test]
fn invert_leaf_cycle() -> Result<()> {
let context = uv_test::test_context!("3.12");

// Create a lockfile with a leaf cycle: bar ↔ baz form a cycle where no
// node in the reversed graph has zero incoming edges. Without the SCC
// fallback, `--invert` would produce no output.
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["bar==1.0.0"]
"#})?;

context.temp_dir.child("uv.lock").write_str(indoc! {r#"
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "foo"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "bar" },
]
[[package]]
name = "bar"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "baz" },
]
[[package]]
name = "baz"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bar" },
]
"#})?;

// Normal tree — no issue; the cycle is displayed as usual.
uv_snapshot!(context.filters(), context.tree().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
foo v1.0.0
└── bar v1.0.0
└── baz v1.0.0
└── bar v1.0.0 (*)
(*) Package tree already displayed
----- stderr -----
"
);

// Inverted tree — without the SCC fallback every node in the reversed
// graph has incoming edges, yielding an empty root set and no output.
// With the fix, the SCC {bar, baz} is detected as a root SCC.
uv_snapshot!(context.filters(), context.tree().arg("--frozen").arg("--invert"), @r"
success: true
exit_code: 0
----- stdout -----
bar v1.0.0
├── baz v1.0.0
│ └── bar v1.0.0 (*)
└── foo v1.0.0
(*) Package tree already displayed
----- stderr -----
"
);

Ok(())
}
Loading