fix(dependency-doctor): tighten findings precision per review

- Make stdlib-shadowing and abandoned-backport mutually exclusive so a
  name that is both (e.g. pathlib, dataclasses) yields one root-cause
  finding instead of two or three.
- Skip conflicting-constraints between same-package entries carrying
  differing non-python_version markers (e.g. platform_system Linux vs
  Darwin), which are idiomatic platform splits, not conflicts.
- Soften the SKILL.md/README pitch to what the tool actually does
  (surface-level direct-manifest footguns), not diagnosing failed resolves.
This commit is contained in:
Matt Van Horn
2026-07-18 12:27:03 -07:00
parent 6d2f60b929
commit 6671b43e20
4 changed files with 110 additions and 39 deletions
+5 -3
View File
@@ -1,8 +1,10 @@
# Dependency Doctor Agent Skill
Dependency Doctor inspects one dependency manifest and explains common sources
of install drift and runtime breakage. It is a local, user-invoked development
tool, not a repository CI rule.
Dependency Doctor inspects one dependency manifest for surface-level,
direct-manifest footguns. It catches unpinned versions, standard-library
shadowing, obsolete backports, and obvious intra-manifest conflicts; it does
not diagnose a failed pip or uv dependency resolution. It is a local,
user-invoked development tool, not a repository CI rule.
![demo](https://github.com/mvanhorn/awesome-llm-apps/releases/download/demo-assets/dependency-doctor.gif)
+11 -12
View File
@@ -1,14 +1,13 @@
---
name: dependency-doctor
description: >-
Diagnoses requirements.txt, pyproject.toml, and package.json dependency
manifests for standard-library shadowing pins, abandoned backports, unpinned
dependencies, duplicate or conflicting constraints, and opt-in PyPI yanked
releases. Use when the user says "check my requirements.txt for dependency
problems", asks "why won't my deps install" or "is anything wrong with my
dependencies", wants a dependency autopsy, or suspects dependency manifest
rot. Runs offline by default as a local tool for the user's own project, not
repository CI.
Checks requirements.txt, pyproject.toml, and package.json dependency manifests
for surface-level direct-dependency footguns: standard-library shadowing pins,
abandoned backports, unpinned dependencies, and obvious intra-manifest
conflicts, plus opt-in PyPI yanked releases. Use when the user asks to check a
manifest for dependency problems, wants a dependency autopsy, or suspects
dependency manifest rot. Runs offline by default as a local tool for the
user's own project, not repository CI.
license: Apache-2.0
compatibility: "Python 3.11+. Offline by default. Network access to pypi.org occurs only when the user explicitly approves --online."
metadata:
@@ -19,9 +18,9 @@ metadata:
# Dependency Doctor
Autopsy one dependency manifest on the user's machine. Find quiet sources of
install drift and runtime breakage, explain each finding in plain language,
then offer a small, reviewable fix.
Inspect one dependency manifest on the user's machine for direct, surface-level
footguns. Explain each finding in plain language, then offer a small,
reviewable fix. This does not diagnose a failed pip or uv resolution.
This is a local developer tool for a project the user chooses. It is not a
repository-wide lint rule, a CI gate, or a proposal to enforce dependency
@@ -30,7 +29,7 @@ policy across unrelated apps.
## When to use
- The user asks to check, audit, diagnose, or autopsy a dependency manifest
- A `requirements.txt` install fails for unclear dependency reasons
- The user wants to rule out direct-manifest issues before deeper install debugging
- The user suspects stale pins, backports, duplicate entries, or dependency rot
- The user asks whether anything looks wrong with their dependencies
@@ -10,6 +10,7 @@ PyPI lookups for exact Python pins so fully yanked releases can be reported.
import argparse
from collections import Counter, defaultdict
from itertools import combinations
import json
from pathlib import Path
import re
@@ -229,6 +230,26 @@ def marker_applies(entry):
return True
def non_python_version_marker(entry):
"""Return normalized marker clauses that describe a non-Python axis."""
if entry["ecosystem"] != "python" or not entry["marker"]:
return ""
clauses = re.split(r"\s+(?:and|or)\s+", entry["marker"], flags=re.IGNORECASE)
non_python_clauses = [
" ".join(clause.split()).lower()
for clause in clauses
if not PYTHON_MARKER_RE.match(clause.strip())
]
return " && ".join(sorted(non_python_clauses))
def has_differing_non_python_markers(left, right):
"""Return whether entries are split across distinct non-Python markers."""
left_marker = non_python_version_marker(left)
right_marker = non_python_version_marker(right)
return bool(left_marker and right_marker and left_marker != right_marker)
def exact_pin(entry):
"""Return an exact version, or None for ranges and direct references."""
spec = entry["spec"].strip()
@@ -264,7 +285,18 @@ def offline_findings(entries):
remove_instead_of_pin = False
if entry["ecosystem"] == "python":
module_name = package.replace("-", "_")
if module_name in stdlib_names:
if package in BACKPORTS:
# A known backport is the more specific, actionable root cause.
remove_instead_of_pin = True
findings.append(make_finding(
"high",
"abandoned-backport",
entry,
BACKPORTS[package] + " The old backport can conflict with supported runtimes.",
"Remove %s on supported Python versions. If an older runtime is required, guard the backport with a Python-version marker."
% package,
))
elif module_name in stdlib_names:
remove_instead_of_pin = True
findings.append(make_finding(
"high",
@@ -275,16 +307,6 @@ def offline_findings(entries):
"Remove %s from the manifest and import the standard-library module directly."
% package,
))
if package in BACKPORTS:
remove_instead_of_pin = True
findings.append(make_finding(
"high",
"abandoned-backport",
entry,
BACKPORTS[package] + " The old backport can conflict with supported runtimes.",
"Remove %s on supported Python versions. If an older runtime is required, guard the backport with a Python-version marker."
% package,
))
if not has_version_spec(entry):
if remove_instead_of_pin:
@@ -313,16 +335,25 @@ def offline_findings(entries):
for (_ecosystem, package), package_entries in sorted(grouped.items()):
if len(package_entries) < 2:
continue
pins = {pin for pin in (exact_pin(item) for item in package_entries) if pin}
conflicting_pairs = [
(left, right)
for left, right in combinations(package_entries, 2)
if exact_pin(left)
and exact_pin(right)
and exact_pin(left) != exact_pin(right)
and not has_differing_non_python_markers(left, right)
]
lines = ", ".join(str(item["line"]) for item in package_entries)
representative = package_entries[1]
if len(pins) > 1:
if conflicting_pairs:
conflict_entries = conflicting_pairs[0]
conflict_lines = ", ".join(str(item["line"]) for item in conflict_entries)
findings.append(make_finding(
"high",
"conflicting-constraints",
representative,
conflict_entries[1],
"%s is pinned to incompatible exact versions on lines %s."
% (package, lines),
% (package, conflict_lines),
"Choose one compatible version for %s and keep a single constraint."
% package,
))
@@ -65,6 +65,14 @@ def finding(report, kind, package):
)
def finding_count(report, kinds, package):
"""Return the number of findings for a package in the supplied kinds."""
return sum(
item.get("kind") in kinds and item.get("package") == package
for item in report.get("findings", [])
)
def check_finding(report, kind, package, severity, fix_fragment):
"""Assert classification, severity, location, explanation, and fix."""
item = finding(report, kind, package)
@@ -77,7 +85,7 @@ def check_finding(report, kind, package, severity, fix_fragment):
check("%s suggests a fix" % kind, fix_fragment.lower() in item["fix"].lower())
def test_requirements(root):
def check_requirements(root):
manifest = root / "requirements.txt"
manifest.write_text(
"pathlib==1.0.1\n"
@@ -93,7 +101,11 @@ def test_requirements(root):
report = run_json(manifest)
check("report names the input file", report.get("file") == str(manifest))
check_finding(report, "stdlib-shadowing", "pathlib", "high", "remove")
check_finding(report, "abandoned-backport", "pathlib", "high", "remove")
check(
"known backports do not also produce stdlib-shadowing findings",
finding_count(report, {"stdlib-shadowing", "abandoned-backport"}, "pathlib") == 1,
)
check_finding(report, "abandoned-backport", "enum34", "high", "remove")
check_finding(report, "unpinned", "requests", "medium", "pin")
check_finding(report, "duplicate-constraint", "requests", "medium", "one")
@@ -113,7 +125,7 @@ def test_requirements(root):
)
def test_pyproject(root):
def check_pyproject(root):
manifest = root / "pyproject.toml"
manifest.write_text(
"[project]\n"
@@ -132,9 +144,13 @@ def test_pyproject(root):
check_finding(report, "unpinned", "httpx", "medium", "pin")
check("Poetry range is treated as a constraint", finding(report, "unpinned", "rich") is None)
check_finding(report, "unpinned", "dataclasses", "medium", "do not add")
check(
"Poetry backports produce one root-cause finding",
finding_count(report, {"stdlib-shadowing", "abandoned-backport"}, "dataclasses") == 1,
)
def test_version_marker(root):
def check_version_marker(root):
manifest = root / "guarded-requirements.txt"
manifest.write_text(
"dataclasses; python_version < \"3.7\"\n",
@@ -147,7 +163,24 @@ def test_version_marker(root):
)
def test_package_json(root):
def check_platform_markers(root):
manifest = root / "platform-requirements.txt"
manifest.write_text(
"torch==2.1.0; platform_system == \"Linux\"\n"
"torch==2.0.0; platform_system == \"Darwin\"\n"
"requests==2.31.0; platform_system == \"Linux\"\n"
"requests==2.32.0; platform_system == \"Linux\"\n",
encoding="utf-8",
)
report = run_json(manifest)
check(
"platform-split pins are not conflicts",
finding(report, "conflicting-constraints", "torch") is None,
)
check_finding(report, "conflicting-constraints", "requests", "high", "choose")
def check_package_json(root):
manifest = root / "package.json"
manifest.write_text(
json.dumps({"dependencies": {"left-pad": "*", "chalk": "5.3.0"}}, indent=2) + "\n",
@@ -165,10 +198,11 @@ def main():
print("dependency-doctor eval:")
with tempfile.TemporaryDirectory(prefix="dependency-doctor-eval-") as temp_dir:
root = Path(temp_dir)
test_requirements(root)
test_pyproject(root)
test_version_marker(root)
test_package_json(root)
check_requirements(root)
check_pyproject(root)
check_version_marker(root)
check_platform_markers(root)
check_package_json(root)
print()
passed = sum(CHECKS)
@@ -181,3 +215,8 @@ def main():
if __name__ == "__main__":
sys.exit(main())
def test_dependency_doctor_eval():
"""Expose the standalone eval harness to pytest."""
assert main() == 0