Sticky-dep UX: say when the existing tree, not this command, blocks an install#105
Conversation
When every vulnerable finding sits in the resolved tree beyond the named targets (and no named target is unverifiable), the refusal now says the existing dependency tree is the problem instead of implying the package the user typed is at fault. The text summary line gains a "(N from existing tree)" parenthetical on the vulnerable/unverifiable counts when the tree contributed findings. Messaging only — should_block_install semantics are unchanged.
| let named_findings = report.named_vulnerable_count() + report.named_unverifiable_count(); | ||
| if report.vulnerable_count() > 0 && named_findings == 0 { | ||
| eprintln!( | ||
| "Refusing to run install: your existing dependency tree has known-vulnerable packages (none were added by this command). Fix them or pass --force." |
There was a problem hiding this comment.
This message is not backed by the data the tree pass carries. TreeReport::Full is populated from the resolver’s full would-install set, and apply_verdicts classifies every package that does not match a parsed named (name, version) as transitive (src/precheck/mod.rs:557-588), not as pre-existing. Pip -r makes this worse: tree::covers_input allows the tree pass with no parsed targets (src/precheck/tree.rs:20-23), so outcomes is empty and every package from the requirements file is counted as tree-only even though the command is adding it. The new refusal and summary_segment text can therefore say “existing dependency tree” / “none were added by this command” / “from existing tree” for packages introduced by the install itself. Impact: supported installs get misleading remediation and users may chase unrelated existing dependencies. Fix by either tracking actual pre-existing packages (for example by comparing against the current lock/environment or preserving resolver metadata that distinguishes requested/new/existing) before using this wording, or soften the copy to something the current data supports, such as “resolved dependency tree” / “not a named command-line target.” Add coverage for pip install -r ... and for a vulnerable newly introduced transitive dependency.
| /// stays with `should_block_install`. | ||
| fn print_refusal(report: &PrecheckReport) { | ||
| let named_findings = report.named_vulnerable_count() + report.named_unverifiable_count(); | ||
| if report.vulnerable_count() > 0 && named_findings == 0 { |
There was a problem hiding this comment.
The guard ignores named recency blockers. should_block_install still blocks on (!opts.no_fail && report.recent_count() > 0) (src/precheck/mod.rs:620-626), but print_refusal only excludes named vulnerable/unverifiable findings. If the named package is recent and the tree also has a vulnerable finding, this branch emits the “existing tree” refusal even though the command’s own target is part of the block, and “Fix them or pass --force” omits that --no-fail or waiting out the threshold is also needed after fixing the vuln. Fix by passing opts into print_refusal and treating a blocking recent_count as a named/command-caused finding, or render an explicit combined message. Add a regression with a recent named target plus a tree vulnerability.
What
When
corgea <pm> installblocks and every vulnerable finding lives in the resolved tree beyond the named targets — and no named target is unverifiable — the refusal line now reads:A finding on a named target (vulnerable or unverifiable, fail-closed) keeps the generic refusal. The text summary line also attributes tree findings:
1 vulnerable (1 from existing tree).Why
Dogfood friction: installing a clean package on top of a lockfile with a pre-existing vulnerable transitive dep produced a refusal that implied the package the user just typed was at fault.
How
PrecheckReportgainsnamed_vulnerable_count/named_unverifiable_count/tree_vulnerable_count/tree_unverifiable_count; the existing totals are now sums of those. No new structs, no provenance labels — detection is named outcomes vs tree findings.print_refusalpicks the message;should_block_installsemantics (force/no-fail, fail-closed) are untouched, as is the exit code and JSON output.Tests
New hermetic e2e file
tests/cli_refusal_context.rs(fake pip on a private PATH + inline pypi registry stub + in-crate vuln-api stub):./harness checkclean: clippy strict, fmt, 255 tests, deps-skill drift. Staging smoke skipped per unit recipe (covered by stub tests).