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
67 changes: 63 additions & 4 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,10 +441,18 @@ def _extract_packages_from_package_json(content: str) -> list[tuple[str, str | N
def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None, int]]:
"""Extract (package_name, version_or_None, line_number) from pyproject.toml.

Only PEP 621 ``[project]`` ``dependencies`` / ``optional-dependencies`` and
PEP 735 ``[dependency-groups]`` hold real packages. Standard metadata keys
(``requires-python``, ``name``, ``version``, ...) are not dependencies and
must not be looked up as packages.
Reads all standard and tool-specific dependency tables:

* PEP 621 ``[project].dependencies`` / ``[project.optional-dependencies]``
* PEP 735 ``[dependency-groups]``
* ``[tool.poetry.dependencies]``, ``[tool.poetry.dev-dependencies]``,
``[tool.poetry.group.<name>.dependencies]``
* ``[tool.pdm.dev-dependencies]``
* ``[tool.hatch.envs.<name>.dependencies]``
* ``[tool.uv.dev-dependencies]``

Standard metadata keys (``requires-python``, ``name``, ``version``, …) and
Poetry's special ``python`` key are not packages and are never yielded.
"""
try:
data = tomllib.loads(content)
Expand All @@ -468,6 +476,57 @@ def _extract_packages_from_pyproject(content: str) -> list[tuple[str, str | None
if isinstance(group, list):
specs.extend(d for d in group if isinstance(d, str))

tool = data.get("tool")
if isinstance(tool, dict):
# Poetry: keys are package names, values are version strings or config dicts.
poetry = tool.get("poetry")
if isinstance(poetry, dict):
for table_name in ("dependencies", "dev-dependencies"):
table = poetry.get(table_name)
if isinstance(table, dict):
specs.extend(
pkg for pkg in table if isinstance(pkg, str) and pkg != "python"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Automated SkillSpector Review]

Non-blocking: only the Poetry dependency name (the table key) is collected here; the version constraint (the value, e.g. "^2.28" or {version = "..."}) is dropped, so SC-rules that key off versions can't evaluate Poetry-declared deps. The PEP 508 paths (PDM/Hatch/uv) retain versions. Fine as a follow-up.

)
# [tool.poetry.group.<name>.dependencies]
poetry_groups = poetry.get("group")
if isinstance(poetry_groups, dict):
for group_data in poetry_groups.values():
if isinstance(group_data, dict):
group_deps = group_data.get("dependencies")
if isinstance(group_deps, dict):
specs.extend(
pkg
for pkg in group_deps
if isinstance(pkg, str) and pkg != "python"
)

# PDM: [tool.pdm.dev-dependencies] is a dict of lists of PEP 508 strings.
pdm = tool.get("pdm")
if isinstance(pdm, dict):
pdm_dev = pdm.get("dev-dependencies")
if isinstance(pdm_dev, dict):
for group in pdm_dev.values():
if isinstance(group, list):
specs.extend(d for d in group if isinstance(d, str))

# Hatch: [tool.hatch.envs.<name>.dependencies] is a list of PEP 508 strings.
hatch = tool.get("hatch")
if isinstance(hatch, dict):
hatch_envs = hatch.get("envs")
if isinstance(hatch_envs, dict):
for env in hatch_envs.values():
if isinstance(env, dict):
env_deps = env.get("dependencies")
if isinstance(env_deps, list):
specs.extend(d for d in env_deps if isinstance(d, str))

# uv: [tool.uv.dev-dependencies] is a list of PEP 508 strings.
uv = tool.get("uv")
if isinstance(uv, dict):
uv_dev = uv.get("dev-dependencies")
if isinstance(uv_dev, list):
specs.extend(d for d in uv_dev if isinstance(d, str))

results: list[tuple[str, str | None, int]] = []
for spec in specs:
m = re.match(r"^([a-zA-Z][a-zA-Z0-9._-]*)(?:\[.*?\])?\s*(?:([=<>!~]=?)\s*([\d.*]+))?", spec)
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/test_patterns_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,80 @@ def test_pyproject_skips_non_pep508_and_include_group_entries(self) -> None:
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
assert names == ["pytest", "ruff"]

def test_pyproject_poetry_deps_extracted(self) -> None:
"""[tool.poetry.dependencies] and [tool.poetry.dev-dependencies] are scanned."""
content = (
'[tool.poetry]\nname = "mypkg"\nversion = "1.0.0"\n'
"[tool.poetry.dependencies]\n"
'python = "^3.11"\n'
'requests = "^2.28"\n'
'pycrypto = "*"\n'
"[tool.poetry.dev-dependencies]\n"
'pytest = "^7"\n'
)
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
# python is the special marker key, not a package
assert "python" not in names
assert "requests" in names
assert "pycrypto" in names
assert "pytest" in names

def test_pyproject_poetry_groups_extracted(self) -> None:
"""[tool.poetry.group.<name>.dependencies] (new-style groups) are scanned."""
content = (
'[tool.poetry]\nname = "mypkg"\n'
"[tool.poetry.group.dev.dependencies]\n"
'black = "^23"\n'
"[tool.poetry.group.docs.dependencies]\n"
'sphinx = ">=5"\n'
)
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
assert names == ["black", "sphinx"]

def test_pyproject_pdm_dev_deps_extracted(self) -> None:
"""[tool.pdm.dev-dependencies] groups of PEP 508 strings are scanned."""
content = (
'[project]\nname = "mypkg"\ndependencies = ["httpx"]\n'
"[tool.pdm.dev-dependencies]\n"
'test = ["pytest>=7", "coverage"]\n'
'lint = ["ruff"]\n'
)
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
assert names == ["coverage", "httpx", "pytest", "ruff"]

def test_pyproject_hatch_env_deps_extracted(self) -> None:
"""[tool.hatch.envs.<name>.dependencies] lists are scanned."""
content = (
'[project]\nname = "mypkg"\n'
"[tool.hatch.envs.default]\n"
'dependencies = ["pytest", "pytest-cov"]\n'
"[tool.hatch.envs.lint]\n"
'dependencies = ["ruff"]\n'
)
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
assert names == ["pytest", "pytest-cov", "ruff"]

def test_pyproject_uv_dev_deps_extracted(self) -> None:
"""[tool.uv] dev-dependencies list of PEP 508 strings is scanned."""
content = (
'[project]\nname = "mypkg"\ndependencies = ["httpx"]\n'
"[tool.uv]\n"
'dev-dependencies = ["ruff>=0.3", "pytest"]\n'
)
names = sorted(p[0] for p in sc_mod._extract_packages_from_pyproject(content))
assert names == ["httpx", "pytest", "ruff"]

def test_pyproject_tool_tables_no_false_positives_on_config_keys(self) -> None:
"""Non-dependency tool config sections do not produce package findings."""
content = (
"[tool.black]\n"
"line-length = 88\n"
"[tool.mypy]\n"
'python_version = "3.11"\n'
"strict = true\n"
)
assert sc_mod._extract_packages_from_pyproject(content) == []


# ── Supply Chain Safe Patterns (SC2) ───────────────────────────────────

Expand Down