scripts/dep_doctor.py
#!/usr/bin/env python3
"""Diagnose dependency manifest footguns with an offline-first core.
Supported inputs are requirements.txt, pyproject.toml, and package.json.
The default run reads only the selected file. Passing --online explicitly adds
PyPI lookups for exact Python pins so fully yanked releases can be reported.
"""
# SPDX-License-Identifier: Apache-2.0
import argparse
from collections import Counter, defaultdict
from itertools import combinations
import json
from pathlib import Path
import re
import sys
PYTHON_NAME_RE = re.compile(
r"^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?\s*(.*)$"
)
URL_REQUIREMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
EGG_NAME_RE = re.compile(r"[#&]egg=([A-Za-z0-9][A-Za-z0-9._-]*)")
EXACT_PIN_RE = re.compile(r"(?:^|,)\s*==\s*([^,;\s]+)")
NPM_UNPINNED_RE = re.compile(r"^(?:|\*|latest|[xX](?:\.[xX*]){0,2})$")
PYTHON_MARKER_RE = re.compile(
r"^python_(full_)?version\s*(==|!=|<=|>=|<|>)\s*['\"](\d+(?:\.\d+){1,2})['\"]$",
re.IGNORECASE,
)
BACKPORTS = {
"asyncio": "Python 3.4 and newer include asyncio in the standard library.",
"configparser": "Python 3 includes configparser in the standard library.",
"dataclasses": "Python 3.7 and newer include dataclasses in the standard library.",
"enum34": "Python 3.4 and newer include enum in the standard library.",
"functools32": "Modern Python includes the relevant functools features.",
"futures": "Python 3.2 and newer include concurrent.futures.",
"ipaddress": "Python 3.3 and newer include ipaddress in the standard library.",
"pathlib": "Python 3.4 and newer include pathlib in the standard library.",
"singledispatch": "Python 3.4 and newer include functools.singledispatch.",
"subprocess32": "Modern Python includes the maintained subprocess implementation.",
"typing": "Python 3.5 and newer include typing in the standard library.",
}
def normalize_name(name):
"""Return the normalized package name used for grouping."""
return re.sub(r"[-_.]+", "-", name).lower()
def new_entry(name, spec, line, ecosystem, marker=""):
"""Build the internal representation shared by all manifest parsers."""
return {
"package": normalize_name(name),
"spec": spec.strip(),
"line": line,
"ecosystem": ecosystem,
"marker": marker.strip(),
}
def parse_python_requirement(value, line, ecosystem="python"):
"""Parse the useful subset of a PEP 508 requirement without packaging."""
value = re.split(r"\s+#", value, maxsplit=1)[0].strip()
if not value or value.startswith(("#", "-")):
return None
parts = value.split(";", 1)
requirement = parts[0].strip()
marker = parts[1].strip() if len(parts) == 2 else ""
if URL_REQUIREMENT_RE.match(requirement):
# A bare VCS or archive URL names its package only in #egg=.
egg = EGG_NAME_RE.search(requirement)
if not egg:
return None
return new_entry(egg.group(1), requirement, line, ecosystem, marker)
match = PYTHON_NAME_RE.match(requirement)
if not match:
return None
name, spec = match.groups()
spec = spec.strip()
if spec.startswith("(") and spec.endswith(")"):
spec = spec[1:-1].strip()
return new_entry(name, spec, line, ecosystem, marker)
def line_locator(text):
"""Return a function that locates repeated package names in source order."""
lines = text.splitlines()
cursors = defaultdict(int)
match_cache = {}
def locate(name):
key = name.lower()
if key not in match_cache:
package_pattern = re.compile(
r"(?<![A-Za-z0-9_.@/-])%s(?![A-Za-z0-9_.@/-])" % re.escape(name),
re.IGNORECASE,
)
match_cache[key] = [
number
for number, source_line in enumerate(lines, 1)
if package_pattern.search(source_line)
]
matches = match_cache[key]
index = cursors[key]
cursors[key] += 1
if index < len(matches):
return matches[index]
return matches[-1] if matches else 1
return locate
def load_requirements(text):
"""Read one requirement per physical line."""
entries = []
for line_number, line in enumerate(text.splitlines(), 1):
entry = parse_python_requirement(line, line_number)
if entry:
entries.append(entry)
return entries
def load_pyproject(text):
"""Read PEP 621 and common Poetry dependency tables with tomllib."""
try:
import tomllib
data = tomllib.loads(text)
except (ImportError, ValueError) as error:
raise ValueError("could not parse pyproject.toml: %s" % error) from error
locate = line_locator(text)
values = []
project = data.get("project", {})
values.extend(project.get("dependencies", []) or [])
for group in (project.get("optional-dependencies", {}) or {}).values():
values.extend(group or [])
entries = []
for value in values:
if isinstance(value, str):
entry = parse_python_requirement(value, locate(value.split("[", 1)[0]))
if entry:
entries.append(entry)
poetry = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) or {}
for name, value in poetry.items():
if normalize_name(name) == "python":
continue
if isinstance(value, str):
spec = value
elif isinstance(value, dict):
spec = str(value.get("version", ""))
else:
spec = ""
if re.match(r"^[0-9]+(?:\.[0-9A-Za-z-]+){0,3}$", spec):
spec = "==" + spec
entries.append(new_entry(name, spec, locate(name), "python"))
return entries
def load_package_json(text):
"""Read dependency sections from package.json."""
try:
data = json.loads(text)
except json.JSONDecodeError as error:
raise ValueError("could not parse package.json: %s" % error) from error
locate = line_locator(text)
entries = []
sections = (
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
)
for section in sections:
dependencies = data.get(section, {}) or {}
if not isinstance(dependencies, dict):
continue
for name, value in dependencies.items():
spec = value if isinstance(value, str) else ""
entries.append(new_entry(name, spec, locate(name), "npm"))
return entries
def load_manifest(path):
"""Select a parser from the manifest name."""
try:
text = path.read_text(encoding="utf-8")
except OSError as error:
raise ValueError("could not read %s: %s" % (path, error)) from error
if path.name == "package.json":
return load_package_json(text)
if path.name == "pyproject.toml":
return load_pyproject(text)
if path.name == "requirements.txt" or path.suffix == ".txt":
return load_requirements(text)
raise ValueError("unsupported manifest; use requirements.txt, pyproject.toml, or package.json")
def has_version_spec(entry):
"""Return whether an entry constrains or directly identifies a version."""
spec = entry["spec"].strip()
if entry["ecosystem"] == "npm":
return not NPM_UNPINNED_RE.match(spec)
return bool(spec and spec != "*")
def marker_applies(entry):
"""Evaluate simple Python-version markers, conservatively keeping others."""
marker = entry["marker"]
if not marker or entry["ecosystem"] != "python" or re.search(r"\s+or\s+", marker, re.I):
return True
for clause in re.split(r"\s+and\s+", marker, flags=re.IGNORECASE):
match = PYTHON_MARKER_RE.match(clause.strip())
if not match:
continue
full_version, comparison, requested = match.groups()
width = 3 if full_version else 2
current = tuple(sys.version_info[:width])
requested_parts = tuple(int(part) for part in requested.split("."))
target = (requested_parts + (0,) * width)[:width]
comparisons = {
"==": current == target,
"!=": current != target,
"<=": current <= target,
">=": current >= target,
"<": current < target,
">": current > target,
}
if not comparisons[comparison]:
return False
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()
if entry["ecosystem"] == "npm":
if re.match(r"^[0-9]+(?:\.[0-9A-Za-z-]+){0,3}$", spec):
return spec
return None
match = EXACT_PIN_RE.search(spec)
if match and "*" not in match.group(1):
return match.group(1)
return None
def make_finding(severity, kind, entry, why, fix):
"""Create one finding in the public JSON shape."""
return {
"severity": severity,
"kind": kind,
"package": entry["package"],
"line": entry["line"],
"why": why,
"fix": fix,
}
def offline_findings(entries):
"""Run checks that read no state beyond the selected manifest."""
findings = []
stdlib_names = set(getattr(sys, "stdlib_module_names", ()))
for entry in entries:
package = entry["package"]
remove_instead_of_pin = False
if entry["ecosystem"] == "python":
module_name = package.replace("-", "_")
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",
"stdlib-shadowing",
entry,
"%s is provided by this Python runtime. Installing a package with the same name can shadow the maintained standard-library module."
% package,
"Remove %s from the manifest and import the standard-library module directly."
% package,
))
if not has_version_spec(entry):
if remove_instead_of_pin:
fix = (
"Do not add a pin for %s. Remove it on supported Python versions, "
"or guard a required backport with a Python-version marker." % package
)
else:
fix = (
"Pin %s to a reviewed version after checking the version installed "
"in the working environment." % package
)
findings.append(make_finding(
"medium",
"unpinned",
entry,
"%s has no usable version constraint, so a future install can resolve to different code."
% package,
fix,
))
grouped = defaultdict(list)
for entry in entries:
grouped[(entry["ecosystem"], entry["package"])].append(entry)
for (_ecosystem, package), package_entries in sorted(grouped.items()):
if len(package_entries) < 2:
continue
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 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",
conflict_entries[1],
"%s is pinned to incompatible exact versions on lines %s."
% (package, conflict_lines),
"Choose one compatible version for %s and keep a single constraint."
% package,
))
else:
specs = {item["spec"] for item in package_entries}
detail = "the same constraint" if len(specs) == 1 else "separate constraints"
findings.append(make_finding(
"medium",
"duplicate-constraint",
representative,
"%s appears on lines %s with %s. Multiple entries are easy to update inconsistently."
% (package, lines, detail),
"Keep one %s entry and combine any compatible constraints on that line."
% package,
))
return findings
def online_findings(entries):
"""Query PyPI only for exact pins after the user passes --online."""
import urllib.parse
import urllib.request
findings = []
checked = set()
for entry in entries:
version = exact_pin(entry)
key = (entry["package"], version)
if entry["ecosystem"] != "python" or not version or key in checked:
continue
checked.add(key)
url = "https://pypi.org/pypi/%s/json" % urllib.parse.quote(entry["package"], safe="")
request = urllib.request.Request(url, headers={"User-Agent": "dependency-doctor/1.0"})
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.load(response)
except (OSError, ValueError) as error:
findings.append(make_finding(
"low",
"online-check-failed",
entry,
"PyPI could not be checked for %s==%s: %s" % (entry["package"], version, error),
"Retry with --online when PyPI is reachable, or inspect the release on PyPI manually.",
))
continue
files = payload.get("releases", {}).get(version, [])
if files and all(file_info.get("yanked", False) for file_info in files):
reasons = sorted({
file_info.get("yanked_reason", "").strip()
for file_info in files
if file_info.get("yanked_reason", "").strip()
})
reason = (" PyPI reason: " + "; ".join(reasons)) if reasons else ""
findings.append(make_finding(
"high",
"yanked-release",
entry,
"%s==%s is fully yanked on PyPI.%s" % (entry["package"], version, reason),
"Choose a non-yanked release of %s, test it, and update the exact pin."
% entry["package"],
))
return findings
def build_report(path, entries, use_online=False):
"""Run the checks and assemble the deterministic report."""
active_entries = [entry for entry in entries if marker_applies(entry)]
findings = offline_findings(active_entries)
if use_online:
findings.extend(online_findings(active_entries))
findings.sort(key=lambda item: (item["line"], item["kind"], item["package"]))
severity_counts = Counter(item["severity"] for item in findings)
kind_counts = Counter(item["kind"] for item in findings)
return {
"file": str(path),
"findings": findings,
"summary": {
"total": len(findings),
"by_severity": dict(sorted(severity_counts.items())),
"by_kind": dict(sorted(kind_counts.items())),
"online": bool(use_online),
},
}
def print_human(report):
"""Render a compact report for direct terminal use."""
print("Dependency Doctor: %s" % report["file"])
if not report["findings"]:
print("No findings.")
for item in report["findings"]:
print("\n%s [%s] %s (line %d)" % (
item["severity"].upper(), item["kind"], item["package"], item["line"]
))
print(" Why: %s" % item["why"])
print(" Fix: %s" % item["fix"])
print("\nSummary: %d finding(s)" % report["summary"]["total"])
def main(argv=None):
parser = argparse.ArgumentParser(
description="Diagnose dependency manifests locally; PyPI checks are opt-in."
)
parser.add_argument("manifest", help="requirements.txt, pyproject.toml, or package.json")
parser.add_argument("--json", action="store_true", dest="as_json", help="emit JSON")
parser.add_argument(
"--online",
action="store_true",
help="query PyPI for fully yanked exact Python releases",
)
args = parser.parse_args(argv)
path = Path(args.manifest)
try:
entries = load_manifest(path)
report = build_report(path, entries, use_online=args.online)
except ValueError as error:
parser.error(str(error))
if args.as_json:
print(json.dumps(report, indent=2, sort_keys=False))
else:
print_human(report)
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: dependency-doctor
description: >-
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, asks why dependencies won't install or
whether anything is wrong with their 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.
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:
author: "Matt Van Horn"
version: "1.0.0"
source: "https://github.com/Shubhamsaboo/awesome-llm-apps"
---
# Dependency Doctor
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
policy across unrelated apps.
## When to use
- The user asks to check, audit, diagnose, or autopsy a dependency manifest
- 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
## When not to use
- Installing the current dependencies without diagnosing them
- Upgrading every package or adding a new package
- A full vulnerability audit. Use `pip-audit`, `npm audit`, or the project's
approved security scanner for CVE coverage
- Creating a repo-wide CI check. This skill is user-invoked and local
## Choose the manifest
Use the path the user names. If no path is given and several manifests exist,
ask which one to inspect. Do not sweep the repository or edit anything merely
because the skill was triggered.
Supported inputs:
- `requirements.txt`
- `pyproject.toml` using PEP 621 or common Poetry dependency tables
- `package.json` dependency sections
## Run the offline diagnosis
From this skill directory:
```bash
python3 scripts/dep_doctor.py /path/to/requirements.txt --json
```
The default path is fully offline. It reads only the selected manifest. The
report shape is:
```json
{
"file": "/path/to/requirements.txt",
"findings": [
{
"severity": "high",
"kind": "stdlib-shadowing",
"package": "pathlib",
"line": 4,
"why": "...",
"fix": "..."
}
],
"summary": {
"total": 1,
"by_severity": {"high": 1},
"by_kind": {"stdlib-shadowing": 1},
"online": false
}
}
```
The offline checks cover:
- Python standard-library names published as packages
- Known backports that should not be installed on supported Python versions
- Dependencies without a usable version constraint
- Repeated package entries
- Conflicting exact pins for the same package
For `package.json`, Python-specific standard-library and backport checks do not
apply. The doctor still checks unpinned values and repeated dependency entries.
## Explain the diagnosis
Read `references/dependency-pitfalls.md` before presenting findings. Lead with
high severity items, then medium and low. For each finding, include:
1. Package and source line
2. What can break
3. The suggested fix
Do not call every range a conflict. The deterministic core reports conflicting
constraints only when exact pins disagree. Compatible constraints split across
multiple lines are duplicate entries that should be combined.
If there are no findings, say what was checked and note the limits. A clean
report is not a CVE audit or a full dependency resolver.
## Optional PyPI yank check
The online check sends package names and exact pinned versions to `pypi.org`.
Ask for permission before enabling it, even if the user previously requested an
offline diagnosis.
```bash
python3 scripts/dep_doctor.py /path/to/requirements.txt --json --online
```
It reports an exact Python release only when every file for that release is
marked yanked. Network failures become low-severity findings instead of hiding
the offline diagnosis.
## Offer fixes, do not apply them silently
After explaining the report, offer a focused edit. Wait for approval before
changing the manifest.
- Remove standard-library packages from supported Python projects
- Remove obsolete backports, or add a Python-version marker when an old runtime
genuinely needs one
- For an unpinned dependency, inspect the working environment's installed
version, confirm it is intended, and propose an exact reviewed pin
- Keep one entry for duplicates and combine compatible constraints
- For conflicting exact pins, inspect dependents before choosing a version
- Replace a yanked pin with a tested, non-yanked release
After any approved edit, rerun the offline diagnosis and the project's existing
install or test command. Do not introduce a new CI gate.
## Files
- `scripts/dep_doctor.py`: stdlib-only manifest parser and diagnosis engine
- `references/dependency-pitfalls.md`: reasoning guide for the reported risks