From 3d0ea869a8d7005a41955758b1af88a1ef28c556 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 10:22:18 +0900 Subject: [PATCH 01/18] fix(ssp): discover modern dependency sources for pip-audit --- docs/dogfooding_findings_tracker.md | 6 +- docs/dogfooding_scale_and_security.md | 14 +- docs/ssp_usage_guide.md | 20 +- src/semantic_ci_code/cli/commands/ssp.py | 10 +- .../ssp/adapters/dependency_sources.py | 203 ++++++++++++++++++ .../ssp/adapters/pip_audit.py | 85 ++++++-- tests/cli/test_ssp_cli.py | 112 +++++++++- tests/ssp/adapters/test_dependency_sources.py | 169 +++++++++++++++ tests/ssp/adapters/test_pip_audit.py | 191 +++++++++++++++- 9 files changed, 759 insertions(+), 51 deletions(-) create mode 100644 src/semantic_ci_code/ssp/adapters/dependency_sources.py create mode 100644 tests/ssp/adapters/test_dependency_sources.py diff --git a/docs/dogfooding_findings_tracker.md b/docs/dogfooding_findings_tracker.md index b00e855..dc000e0 100644 --- a/docs/dogfooding_findings_tracker.md +++ b/docs/dogfooding_findings_tracker.md @@ -24,7 +24,7 @@ Status taxonomy: | D5 | 2026-05-07 Session 5 dogfood (FINDING-1) | set operator partial-match semantics | partial-dict `expected` items canonicalised as different elements from full extractor records — false positive on `includes_*` / `subset_of` / `superset_of`, **false negative (CI bypass) on `excludes_all`** | **解決** | PR #65 (CSCI-35c) — Match Schema partial-record matching + flat-projection aliases + `evidence.matched`; schema_version v4→v5 | | D6 | 2026-05-28 real-PR complexity dogfood (FINDING-F1) | vacuous PASS (extractor coverage gap) — **重複・関連 = sibling of D4** | nested function bodies are excluded from `ComplexityEntry` by `python_complexity_extractor` spec (`api_surface` parity); refactor that nests outer-function body into nested helpers reports large CC drop while real complexity is unchanged | **未解決** | Candidate paths: (a) `docs/target_yaml_guide.md` new Hazard 4 + `ADVISORY-D6` detector mirroring D4; (b) extractor spec change to emit nested-function entries (long-term, schema-impacting). Reproduction: langgraph PR #3700 (8/1 vacuous PASS in real-PR pass) | | D7 | 2026-05-28 real-PR complexity dogfood (FINDING-F2) | authoring mismatch (operator / constraint pairing) | `extract-method` refactor is mathematically guaranteed to **micro-increase cyclomatic** (each extracted function adds base 1), even with `_` prefix discipline and api_surface preserved. Cognitive is the metric that drops. Authors declaring `complexity_delta.cyclomatic ≤ 0` for extract-method refactors hit a structural false-FAIL | **未解決** | Candidate paths: (a) authoring guide section "Choosing complexity metric per refactor pattern" recommending `cognitive_delta` for extract-method; (b) future `ADVISORY-D7` detector emitted when a `change.primary_kind=refactor` target uses `cyclomatic_delta ≤ 0` and the diff matches extract-method shape. Low priority: this is authoring advice, not a CI integrity hazard | -| D8 | 2026-06-07 scale + security dogfood (SCA gap) | SCA sensor dependency-source discovery gap | SSP SCA auto-discovery (`_requirements_file` in `src/semantic_ci_code/cli/commands/ssp.py`) only finds `requirements.txt` at repo root; the `--locked` fallback only accepts `pylock.toml` / requirements lockfiles. PEP 621 pyproject-only projects (litellm) and `pdm.lock` projects (pdm) declare deps in unrecognised formats → `pip-audit --locked .` errors "no lockfiles found" → empty JSON → adapter degrades to `unknown` (exit 3). Correct graceful degradation (no silent false PASS, honours `unknown > fail > pass`) but a real usability gap that blocks SCA on most modern Python projects | **未解決** | Candidate fix: extend `_requirements_file` / the pip-audit adapter to recognise PEP 621 `pyproject.toml`, `poetry.lock`, and `pdm.lock` (e.g. `pip-audit` against the resolved env or a generated lock). Genuine fixable `semantic-ci` defect, not an inherent sensor limitation | +| D8 | 2026-06-07 scale + security dogfood (SCA gap) | SCA sensor dependency-source discovery gap | SSP SCA auto-discovery (`_requirements_file` in `src/semantic_ci_code/cli/commands/ssp.py`) only found `requirements.txt` at repo root; the `--locked` fallback only accepted `pylock.toml` / requirements lockfiles. PEP 621 pyproject-only projects (litellm) and `pdm.lock` projects (pdm) declared deps in unrecognised formats → `pip-audit --locked .` errors "no lockfiles found" → empty JSON → adapter degraded to `unknown` (exit 3). Correct graceful degradation (no silent false PASS, honours `unknown > fail > pass`) but a real usability gap that blocked SCA on most modern Python projects | **解決** | CSCI-55 — dependency source discovery now recognises `requirements.txt`, `pylock.toml`, `uv.lock`, `pdm.lock`, `poetry.lock`, and static PEP 621 `[project].dependencies`; lock sources are converted deterministically to pinned temp requirements and malformed recognized sources fail closed to SSP `unknown` | ## Reading order @@ -37,8 +37,8 @@ Status taxonomy: ## Classification at a glance - **重複・関連 pairs**: D4 ↔ D6 (both are "vacuous PASS" via extractor coverage gap, distinct mechanism — D4 is "diff outside Python scope", D6 is "diff inside scope but inside nested function") -- **解決 (5 of 8)**: D1, D2, D3, D4, D5 -- **未解決 (3 of 8)**: D6 (mitigation path open), D7 (authoring advice, low priority), D8 (SCA discovery gap, fixable defect) +- **解決 (6 of 8)**: D1, D2, D3, D4, D5, D8 +- **未解決 (2 of 8)**: D6 (mitigation path open), D7 (authoring advice, low priority) - **observation-only (not a D#)**: F6 (pattern-SAST logic-vuln blindspot) — **UNTESTED HYPOTHESIS, not a demonstrated observation in the 2026-06-07 pass**: the Semgrep registry rulesets returned HTTP 403, so Semgrep ran with 0 loaded rules over 0 paths and produced no valid SAST measurement. F6 records the *a-priori* expectation that deterministic SAST misses semantic / business-logic vulns, cross-linked to Phase H (`docs/llm_sensor_adapter_planning.md`) as **motivation** — it is **not** empirically validated by this pass. Recorded in `docs/dogfooding_scale_and_security.md` (which now carries a validity warning + repro note for redoing the SAST sub-pass under a network policy allowing `semgrep.dev`). Distinct from the demonstrated observations of the same pass: real vulns merged-then-fixed (git evidence) and SCA clean-on-litellm (pip-audit positive-controlled with `jinja2==2.11.2` → 5 CVEs) ## Source pass index diff --git a/docs/dogfooding_scale_and_security.md b/docs/dogfooding_scale_and_security.md index 152b6fb..c7ee921 100644 --- a/docs/dogfooding_scale_and_security.md +++ b/docs/dogfooding_scale_and_security.md @@ -197,6 +197,12 @@ found"* → empty JSON → the adapter degrades to `unknown`. This is (`unknown > fail > pass`) — there is **no silent false PASS** — but it is a real usability gap (registered as a D# below). +**Resolution.** CSCI-55 closes D8 by replacing the `requirements.txt`-only +lookup with deterministic dependency-source discovery for `requirements.txt`, +`pylock.toml`, `uv.lock`, `pdm.lock`, `poetry.lock`, and static PEP 621 +`[project].dependencies`. Lock sources are converted to pinned temporary +requirements and malformed recognized sources fail closed to SSP `unknown`. + ## Headline / conclusion **Scale & robustness.** 16 runs total (5 scale + 5 random + 3 litellm @@ -372,10 +378,10 @@ Findings classification (hazard D# vs observation) for this pass and all prior passes is consolidated in **`docs/dogfooding_findings_tracker.md`**. This pass registered: -- **D8** (SCA auto-discovery gap, 未解決) — `_requirements_file` ignores - PEP 621 pyproject / `poetry.lock` / `pdm.lock`, so modern dependency - declarations degrade to `unknown`. A genuine, fixable `semantic-ci` - defect. +- **D8** (SCA auto-discovery gap, resolved by CSCI-55) — SSP SCA now recognises + PEP 621 pyproject / `uv.lock` / `poetry.lock` / `pdm.lock` dependency sources, + translating lockfiles to deterministic pinned temporary requirements and + keeping malformed recognized sources fail-closed as `unknown`. - **F6** (SAST logic-vuln blindspot) — **UNTESTED HYPOTHESIS in this pass**, not a demonstrated observation: the Semgrep registry rulesets returned HTTP 403, so Semgrep never ran with real rules (0 rules / 0 diff --git a/docs/ssp_usage_guide.md b/docs/ssp_usage_guide.md index 85eb6f0..2b0a458 100644 --- a/docs/ssp_usage_guide.md +++ b/docs/ssp_usage_guide.md @@ -50,10 +50,22 @@ semantic-ci ssp scan \ --candidate-dir /tmp/candidate ``` -If `requirements.txt` exists in a directory, it is passed to pip-audit -via `--requirement`. Otherwise pip-audit audits the project directory -directly (using `--locked` when supported, or the directory path as -fallback). +Dependency source discovery is deterministic and independent for the baseline +and candidate directories: + +| Priority | Root file | Handling | pip-audit argv | +|---:|---|---|---| +| 1 | `requirements.txt` | Existing behavior. | `--requirement ` | +| 2 | `pylock.toml` | Locked project scan. | `--locked ` when supported, otherwise `` | +| 3 | `uv.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | +| 4 | `pdm.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | +| 5 | `poetry.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | +| 6 | `pyproject.toml` with static `[project].dependencies` | Copy dependency specifiers into a temporary requirements file. | `--requirement ` | +| 7 | No recognized source | Preserve fallback behavior. | `--locked ` when supported, otherwise `` | + +Malformed recognized dependency sources fail closed as a pip-audit sensor error, +which produces SSP `unknown` rather than silently falling back to a lower +priority source. ### 3. Fixture mode (no scanner required) diff --git a/src/semantic_ci_code/cli/commands/ssp.py b/src/semantic_ci_code/cli/commands/ssp.py index 653dceb..2a143ae 100644 --- a/src/semantic_ci_code/cli/commands/ssp.py +++ b/src/semantic_ci_code/cli/commands/ssp.py @@ -15,6 +15,7 @@ ) from semantic_ci_code.cli.exit_codes import ENGINE_ERROR, FAIL, SUCCESS from semantic_ci_code.cli.output.json_formatter import dump_json +from semantic_ci_code.ssp.adapters.dependency_sources import discover_dependency_source from semantic_ci_code.ssp.adapters.pip_audit import PipAuditAdapter from semantic_ci_code.ssp.adapters.semgrep import SemgrepAdapter from semantic_ci_code.ssp.delta import compute_delta @@ -69,11 +70,11 @@ def _scan_envelope(args: Namespace) -> SSPEnvelope: ) elif sensor == "pip-audit": baseline_result = PipAuditAdapter().scan( - requirements=_requirements_file(baseline_dir), + source=discover_dependency_source(baseline_dir), repo_root=baseline_dir, ) candidate_result = PipAuditAdapter().scan( - requirements=_requirements_file(candidate_dir), + source=discover_dependency_source(candidate_dir), repo_root=candidate_dir, ) else: @@ -169,8 +170,3 @@ def _existing_file(path: Path, *, label: str) -> Path: def _resolve_package_root(root: Path, package_root: Path) -> Path: candidate = package_root if package_root.is_absolute() else root / package_root return _existing_dir(candidate, label="--package-root") - - -def _requirements_file(root: Path) -> Path | None: - path = root / "requirements.txt" - return path if path.exists() else None diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py new file mode 100644 index 0000000..c96454c --- /dev/null +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -0,0 +1,203 @@ +"""Dependency source discovery for pip-audit SSP scans.""" + +from __future__ import annotations + +import re +import tomllib +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +DependencySourceKind = Literal[ + "requirements", + "pylock", + "uv-lock", + "pdm-lock", + "poetry-lock", + "pyproject", + "fallback", + "error", +] + +_LOCK_SOURCE_FILES: tuple[tuple[str, DependencySourceKind], ...] = ( + ("uv.lock", "uv-lock"), + ("pdm.lock", "pdm-lock"), + ("poetry.lock", "poetry-lock"), +) + + +class DependencySourceError(ValueError): + """Dependency source was recognized but could not be converted safely.""" + + +@dataclass(frozen=True) +class DependencySource: + """A single dependency source selected by deterministic precedence.""" + + kind: DependencySourceKind + root: Path + path: Path | None = None + error_message: str | None = None + + +@dataclass(frozen=True) +class GeneratedRequirements: + """Requirements file content generated from a structured dependency source.""" + + lines: tuple[str, ...] + no_deps: bool + + +def discover_dependency_source(root: Path) -> DependencySource: + """Discover the highest-precedence dependency source for a scan directory.""" + + resolved_root = root.resolve() + requirements = resolved_root / "requirements.txt" + if requirements.exists(): + return DependencySource(kind="requirements", root=resolved_root, path=requirements) + + pylock = resolved_root / "pylock.toml" + if pylock.exists(): + return DependencySource(kind="pylock", root=resolved_root, path=pylock) + + for filename, kind in _LOCK_SOURCE_FILES: + path = resolved_root / filename + if path.exists(): + return DependencySource(kind=kind, root=resolved_root, path=path) + + pyproject = resolved_root / "pyproject.toml" + if pyproject.exists(): + return _discover_pyproject(resolved_root, pyproject) + + return DependencySource(kind="fallback", root=resolved_root, path=None) + + +def generated_requirements_for_source(source: DependencySource) -> GeneratedRequirements: + """Convert a recognized structured source into deterministic requirements lines.""" + + if source.kind == "error": + raise DependencySourceError(source.error_message or "dependency source parse failed") + if source.path is None: + raise DependencySourceError(f"{source.kind} does not have a dependency file") + if source.kind in {"uv-lock", "pdm-lock", "poetry-lock"}: + return GeneratedRequirements( + lines=_pinned_lines_from_lock(source.path, root=source.root), + no_deps=True, + ) + if source.kind == "pyproject": + return GeneratedRequirements( + lines=_dependency_lines_from_pyproject(source.path), + no_deps=False, + ) + raise DependencySourceError(f"{source.kind} cannot be converted to requirements") + + +def _discover_pyproject(root: Path, path: Path) -> DependencySource: + try: + payload = _load_toml(path) + except DependencySourceError as exc: + return DependencySource(kind="error", root=root, path=path, error_message=str(exc)) + + project = payload.get("project") + if not isinstance(project, Mapping): + return DependencySource(kind="fallback", root=root, path=None) + + dynamic = project.get("dynamic") + if _contains_dynamic_dependencies(dynamic): + return DependencySource(kind="fallback", root=root, path=None) + + if "dependencies" not in project: + return DependencySource(kind="fallback", root=root, path=None) + + dependencies = project.get("dependencies") + if not _is_string_sequence(dependencies): + return DependencySource( + kind="error", + root=root, + path=path, + error_message=f"{path.name}: [project].dependencies must be a list of strings", + ) + return DependencySource(kind="pyproject", root=root, path=path) + + +def _dependency_lines_from_pyproject(path: Path) -> tuple[str, ...]: + payload = _load_toml(path) + project = payload.get("project") + if not isinstance(project, Mapping): + raise DependencySourceError(f"{path.name}: missing [project] table") + dependencies = project.get("dependencies") + if not _is_string_sequence(dependencies): + raise DependencySourceError( + f"{path.name}: [project].dependencies must be a list of strings" + ) + return tuple(str(item) for item in dependencies) + + +def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: + payload = _load_toml(path) + packages = payload.get("package") + if packages is None: + return () + if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)): + raise DependencySourceError(f"{path.name}: package entries must be a list") + + self_name = _project_name(root) + pinned: set[tuple[str, str]] = set() + for index, package in enumerate(packages): + if not isinstance(package, Mapping): + raise DependencySourceError(f"{path.name}: package entry {index} must be a table") + name = package.get("name") + version = package.get("version") + if not isinstance(name, str) or not name: + raise DependencySourceError(f"{path.name}: package entry {index} is missing name") + if not isinstance(version, str) or not version: + raise DependencySourceError(f"{path.name}: package {name} is missing version") + if self_name is not None and _normalize_name(name) == _normalize_name(self_name): + continue + pinned.add((name, version)) + return tuple(f"{name}=={version}" for name, version in sorted(pinned)) + + +def _project_name(root: Path) -> str | None: + pyproject = root / "pyproject.toml" + if not pyproject.exists(): + return None + try: + payload = _load_toml(pyproject) + except DependencySourceError: + return None + project = payload.get("project") + if not isinstance(project, Mapping): + return None + name = project.get("name") + return name if isinstance(name, str) and name else None + + +def _load_toml(path: Path) -> Mapping[str, Any]: + try: + with path.open("rb") as handle: + payload = tomllib.load(handle) + except tomllib.TOMLDecodeError as exc: + raise DependencySourceError(f"{path.name}: malformed TOML: {exc}") from exc + if not isinstance(payload, Mapping): + raise DependencySourceError(f"{path.name}: TOML root must be a table") + return payload + + +def _contains_dynamic_dependencies(value: object) -> bool: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return False + return any(item == "dependencies" for item in value) + + +def _is_string_sequence(value: object) -> bool: + return ( + isinstance(value, Sequence) + and not isinstance(value, (str, bytes)) + and all(isinstance(item, str) for item in value) + ) + + +def _normalize_name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() diff --git a/src/semantic_ci_code/ssp/adapters/pip_audit.py b/src/semantic_ci_code/ssp/adapters/pip_audit.py index 4f3ec64..66c479a 100644 --- a/src/semantic_ci_code/ssp/adapters/pip_audit.py +++ b/src/semantic_ci_code/ssp/adapters/pip_audit.py @@ -4,6 +4,7 @@ import json import subprocess +import tempfile from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path @@ -11,6 +12,11 @@ from pydantic import ValidationError +from semantic_ci_code.ssp.adapters.dependency_sources import ( + DependencySource, + DependencySourceError, + generated_requirements_for_source, +) from semantic_ci_code.ssp.models import SCAFinding, SensorOutput, SensorSpec, Severity _PIP_AUDIT_COMMAND = "pip-audit" @@ -33,34 +39,31 @@ class PipAuditAdapter: def scan( self, *, - requirements: Path | None, + source: DependencySource, repo_root: Path, ) -> PipAuditScanResult: """Invoke pip-audit and return deterministic SSP sensor output.""" resolved_repo_root = repo_root.resolve() - resolved_requirements = ( - None - if requirements is None - else _resolve_against(requirements, base=resolved_repo_root) - ) + resolved_requirements = source.path if source.kind == "requirements" else None version = _detect_version(cwd=resolved_repo_root) - command = [_PIP_AUDIT_COMMAND, "--format=json", "--desc"] - if requirements is not None: - command.extend(("--requirement", str(requirements))) - elif _supports_locked_project_scan(version): - command.extend(("--locked", str(resolved_repo_root))) - else: - command.append(str(resolved_repo_root)) + try: + command, temp_dir = _command_for_source(source, version=version) + except DependencySourceError as exc: + return self._error_result(str(exc), version=version) try: - completed = subprocess.run( - command, - cwd=resolved_repo_root, - capture_output=True, - text=True, - check=False, - ) + try: + completed = subprocess.run( + command, + cwd=resolved_repo_root, + capture_output=True, + text=True, + check=False, + ) + finally: + if temp_dir is not None: + temp_dir.cleanup() except FileNotFoundError as exc: return self._error_result("pip-audit executable not found", error=exc) @@ -191,8 +194,46 @@ def _supports_locked_project_scan(version: str) -> bool: return major > 2 or (major == 2 and minor >= 9) -def _resolve_against(path: Path, *, base: Path) -> Path: - return path if path.is_absolute() else base / path +def _command_for_source( + source: DependencySource, + *, + version: str, +) -> tuple[list[str], tempfile.TemporaryDirectory[str] | None]: + command = [_PIP_AUDIT_COMMAND, "--format=json", "--desc"] + if source.kind == "error": + raise DependencySourceError(source.error_message or "dependency source parse failed") + if source.kind == "requirements": + if source.path is None: + raise DependencySourceError("requirements source missing path") + command.extend(("--requirement", str(source.path))) + return command, None + if source.kind in {"pylock", "fallback"}: + if _supports_locked_project_scan(version): + command.extend(("--locked", str(source.root))) + else: + command.append(str(source.root)) + return command, None + if source.kind in {"uv-lock", "pdm-lock", "poetry-lock", "pyproject"}: + return _command_for_generated_requirements(command, source) + raise DependencySourceError(f"unsupported dependency source kind: {source.kind}") + + +def _command_for_generated_requirements( + command: list[str], + source: DependencySource, +) -> tuple[list[str], tempfile.TemporaryDirectory[str]]: + generated = generated_requirements_for_source(source) + temp_dir = tempfile.TemporaryDirectory() + requirements = Path(temp_dir.name) / "requirements.txt" + requirements.write_text(_requirements_text(generated.lines), encoding="utf-8") + command.extend(("--requirement", str(requirements))) + if generated.no_deps: + command.append("--no-deps") + return command, temp_dir + + +def _requirements_text(lines: tuple[str, ...]) -> str: + return "".join(f"{line}\n" for line in lines) def _package_items(payload: Any) -> tuple[Mapping[str, Any], ...]: diff --git a/tests/cli/test_ssp_cli.py b/tests/cli/test_ssp_cli.py index 35af519..352e115 100644 --- a/tests/cli/test_ssp_cli.py +++ b/tests/cli/test_ssp_cli.py @@ -204,11 +204,11 @@ def test_ssp_scan_pip_audit_uses_requirements_when_present( for root in (baseline, candidate): root.mkdir() (root / "requirements.txt").write_text("django==3.2.0\n", encoding="utf-8") - calls: list[tuple[Path | None, Path]] = [] + calls: list[tuple[str, Path | None, Path]] = [] - def fake_scan(self, *, requirements: Path | None, repo_root: Path) -> PipAuditScanResult: + def fake_scan(self, *, source, repo_root: Path) -> PipAuditScanResult: del self - calls.append((requirements, repo_root)) + calls.append((source.kind, source.path, repo_root)) finding = () if repo_root == candidate.resolve(): finding = ( @@ -241,8 +241,110 @@ def fake_scan(self, *, requirements: Path | None, repo_root: Path) -> PipAuditSc assert result.returncode == 1, result.stderr assert calls == [ - (baseline.resolve() / "requirements.txt", baseline.resolve()), - (candidate.resolve() / "requirements.txt", candidate.resolve()), + ("requirements", baseline.resolve() / "requirements.txt", baseline.resolve()), + ("requirements", candidate.resolve() / "requirements.txt", candidate.resolve()), ] payload = parse_json(result.stdout) assert payload["deltas_by_sensor"]["pip-audit"]["added"][0]["advisory_id"] == "PYSEC-1" + + +def test_ssp_scan_pip_audit_discovers_pdm_lock_source( + monkeypatch, + tmp_path: Path, +): + baseline = tmp_path / "baseline" + candidate = tmp_path / "candidate" + for root in (baseline, candidate): + root.mkdir() + (root / "pdm.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +""".lstrip(), + encoding="utf-8", + ) + calls: list[tuple[str, Path | None, Path]] = [] + + def fake_scan(self, *, source, repo_root: Path) -> PipAuditScanResult: + del self + calls.append((source.kind, source.path, repo_root)) + finding = () + if repo_root == candidate.resolve(): + finding = ( + SCAFinding( + package_name="django", + installed_version="3.2.0", + advisory_id="PYSEC-1", + severity="high", + message="new vuln", + ), + ) + return PipAuditScanResult( + output=SensorOutput(sensor_id="pip-audit", sensor_version="2.9.0", findings=finding), + sensor_spec=SensorSpec(id="pip-audit", version="2.9.0", advisory_db_hash=None), + ) + + monkeypatch.setattr(ssp_command.PipAuditAdapter, "scan", fake_scan) + + result = run_semantic_ci( + tmp_path, + "ssp", + "scan", + "--sensor", + "pip-audit", + "--baseline-dir", + str(baseline), + "--candidate-dir", + str(candidate), + ) + + assert result.returncode == 1, result.stderr + assert calls == [ + ("pdm-lock", baseline.resolve() / "pdm.lock", baseline.resolve()), + ("pdm-lock", candidate.resolve() / "pdm.lock", candidate.resolve()), + ] + payload = parse_json(result.stdout) + assert payload["aggregate_verdict"] == "fail" + + +def test_ssp_scan_pip_audit_fallback_unknown_exit_is_preserved( + monkeypatch, + tmp_path: Path, +): + baseline = tmp_path / "baseline" + candidate = tmp_path / "candidate" + baseline.mkdir() + candidate.mkdir() + + def fake_scan(self, *, source, repo_root: Path) -> PipAuditScanResult: + del self, repo_root + assert source.kind == "fallback" + return PipAuditScanResult( + output=SensorOutput( + sensor_id="pip-audit", + sensor_version="2.9.0", + status="error", + findings=(), + error_message="no recognized dependency source", + ), + sensor_spec=SensorSpec(id="pip-audit", version="2.9.0", advisory_db_hash=None), + ) + + monkeypatch.setattr(ssp_command.PipAuditAdapter, "scan", fake_scan) + + result = run_semantic_ci( + tmp_path, + "ssp", + "scan", + "--sensor", + "pip-audit", + "--baseline-dir", + str(baseline), + "--candidate-dir", + str(candidate), + ) + + assert result.returncode == 3 + payload = parse_json(result.stdout) + assert payload["aggregate_verdict"] == "unknown" diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py new file mode 100644 index 0000000..fb46b0e --- /dev/null +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from pathlib import Path + +from semantic_ci_code.ssp.adapters.dependency_sources import ( + DependencySourceError, + discover_dependency_source, + generated_requirements_for_source, +) + + +def test_discovery_precedence_selects_highest_ranked_source(tmp_path: Path): + for filename in ( + "pyproject.toml", + "poetry.lock", + "pdm.lock", + "uv.lock", + "pylock.toml", + "requirements.txt", + ): + (tmp_path / filename).write_text(_content_for(filename), encoding="utf-8") + + source = discover_dependency_source(tmp_path) + + assert source.kind == "requirements" + assert source.path == tmp_path.resolve() / "requirements.txt" + + +def test_discovery_precedence_selects_lock_before_pyproject(tmp_path: Path): + (tmp_path / "uv.lock").write_text(_lock_content(("django", "3.2.0")), encoding="utf-8") + (tmp_path / "pyproject.toml").write_text( + """ +[project] +dependencies = ["requests==2.32.0"] +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + + assert source.kind == "uv-lock" + assert source.path == tmp_path.resolve() / "uv.lock" + + +def test_baseline_and_candidate_discovery_are_independent(tmp_path: Path): + baseline = tmp_path / "baseline" + candidate = tmp_path / "candidate" + baseline.mkdir() + candidate.mkdir() + (baseline / "requirements.txt").write_text("django==3.2.0\n", encoding="utf-8") + (candidate / "pdm.lock").write_text(_lock_content(("requests", "2.32.0")), encoding="utf-8") + + assert discover_dependency_source(baseline).kind == "requirements" + assert discover_dependency_source(candidate).kind == "pdm-lock" + + +def test_lock_translation_sorts_dedups_and_excludes_project_package(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "pdm.lock").write_text( + _lock_content( + ("zlib-ng", "1.0.0"), + ("example_app", "0.1.0"), + ("django", "3.2.0"), + ("django", "3.2.0"), + ), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.no_deps is True + assert generated.lines == ("django==3.2.0", "zlib-ng==1.0.0") + + +def test_lock_translation_missing_version_is_fail_closed(tmp_path: Path): + (tmp_path / "poetry.lock").write_text( + """ +[[package]] +name = "django" +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + + assert source.kind == "poetry-lock" + try: + generated_requirements_for_source(source) + except DependencySourceError as exc: + assert "poetry.lock" in str(exc) + assert "missing version" in str(exc) + else: # pragma: no cover - assertion guard + raise AssertionError("expected DependencySourceError") + + +def test_pyproject_static_dependencies_are_used(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +dependencies = [ + "django>=3.2", + "requests==2.32.0", +] +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + generated = generated_requirements_for_source(source) + + assert source.kind == "pyproject" + assert generated.no_deps is False + assert generated.lines == ("django>=3.2", "requests==2.32.0") + + +def test_pyproject_dynamic_dependencies_are_not_recognized(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +dynamic = ["dependencies"] +""".lstrip(), + encoding="utf-8", + ) + + assert discover_dependency_source(tmp_path).kind == "fallback" + + +def test_pyproject_without_project_table_is_not_recognized(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[tool.example] +dependencies = ["django"] +""".lstrip(), + encoding="utf-8", + ) + + assert discover_dependency_source(tmp_path).kind == "fallback" + + +def test_malformed_pyproject_is_error_source(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text("[project\n", encoding="utf-8") + + source = discover_dependency_source(tmp_path) + + assert source.kind == "error" + assert source.path == tmp_path.resolve() / "pyproject.toml" + assert "pyproject.toml" in (source.error_message or "") + + +def _content_for(filename: str) -> str: + if filename == "requirements.txt": + return "django==3.2.0\n" + if filename == "pylock.toml": + return "[packages]\n" + if filename.endswith(".lock"): + return _lock_content(("django", "3.2.0")) + return "[project]\ndependencies = ['django==3.2.0']\n" + + +def _lock_content(*packages: tuple[str, str]) -> str: + return "\n".join( + f'[[package]]\nname = "{name}"\nversion = "{version}"\n' for name, version in packages + ) diff --git a/tests/ssp/adapters/test_pip_audit.py b/tests/ssp/adapters/test_pip_audit.py index 984500d..db9214f 100644 --- a/tests/ssp/adapters/test_pip_audit.py +++ b/tests/ssp/adapters/test_pip_audit.py @@ -8,6 +8,7 @@ import pytest +from semantic_ci_code.ssp.adapters.dependency_sources import discover_dependency_source from semantic_ci_code.ssp.adapters.pip_audit import PipAuditAdapter REPO_ROOT = Path(__file__).resolve().parents[3] @@ -86,7 +87,7 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=requirements, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert calls[0] == ["pip-audit", "--version"] assert calls[1] == [ @@ -123,7 +124,7 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=None, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert calls[0] == ["pip-audit", "--version"] assert calls[1] == [ @@ -158,7 +159,7 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=None, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert calls[0] == ["pip-audit", "--version"] assert calls[1] == [ @@ -171,6 +172,184 @@ def fake_run( assert result.output.status == "complete" +@pytest.mark.parametrize( + ("filename", "kind"), + [ + ("uv.lock", "uv-lock"), + ("pdm.lock", "pdm-lock"), + ("poetry.lock", "poetry-lock"), + ], +) +def test_pip_audit_lock_sources_generate_pinned_requirements_with_no_deps( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + filename: str, + kind: str, +): + calls: list[list[str]] = [] + (tmp_path / filename).write_text( + """ +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "requests" +version = "2.32.0" +""".lstrip(), + encoding="utf-8", + ) + + def fake_run( + command: list[str], + *, + cwd: Path, + capture_output: bool, + text: bool, + check: bool, + ) -> subprocess.CompletedProcess[str]: + del cwd, capture_output, text, check + calls.append(command) + if command == ["pip-audit", "--version"]: + return subprocess.CompletedProcess(command, 0, "pip-audit 2.9.0\n", "") + requirement_path = Path(command[command.index("--requirement") + 1]) + assert requirement_path.read_text(encoding="utf-8") == ("django==3.2.0\nrequests==2.32.0\n") + return subprocess.CompletedProcess(command, 0, "[]", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + source = discover_dependency_source(tmp_path) + result = PipAuditAdapter().scan(source=source, repo_root=tmp_path) + + assert source.kind == kind + assert calls[1][:4] == [ + "pip-audit", + "--format=json", + "--desc", + "--requirement", + ] + assert calls[1][-1] == "--no-deps" + assert result.output.status == "complete" + + +def test_pip_audit_pyproject_static_dependencies_generate_requirements_without_no_deps( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + calls: list[list[str]] = [] + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example" +dependencies = [ + "django>=3.2", + "requests==2.32.0", +] +""".lstrip(), + encoding="utf-8", + ) + + def fake_run( + command: list[str], + *, + cwd: Path, + capture_output: bool, + text: bool, + check: bool, + ) -> subprocess.CompletedProcess[str]: + del cwd, capture_output, text, check + calls.append(command) + if command == ["pip-audit", "--version"]: + return subprocess.CompletedProcess(command, 0, "pip-audit 2.9.0\n", "") + requirement_path = Path(command[command.index("--requirement") + 1]) + assert requirement_path.read_text(encoding="utf-8") == ("django>=3.2\nrequests==2.32.0\n") + return subprocess.CompletedProcess(command, 0, "[]", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) + + assert calls[1][:4] == [ + "pip-audit", + "--format=json", + "--desc", + "--requirement", + ] + assert "--no-deps" not in calls[1] + assert result.output.status == "complete" + + +def test_pip_audit_pylock_source_uses_locked_project_scan( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + calls: list[list[str]] = [] + (tmp_path / "pylock.toml").write_text("[packages]\n", encoding="utf-8") + + def fake_run( + command: list[str], + *, + cwd: Path, + capture_output: bool, + text: bool, + check: bool, + ) -> subprocess.CompletedProcess[str]: + del cwd, capture_output, text, check + calls.append(command) + if command == ["pip-audit", "--version"]: + return subprocess.CompletedProcess(command, 0, "pip-audit 2.9.0\n", "") + return subprocess.CompletedProcess(command, 0, "[]", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) + + assert calls[1] == [ + "pip-audit", + "--format=json", + "--desc", + "--locked", + str(tmp_path.resolve()), + ] + assert result.output.status == "complete" + + +def test_pip_audit_malformed_recognized_source_returns_error_without_silent_fallback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + calls: list[list[str]] = [] + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "django" +""".lstrip(), + encoding="utf-8", + ) + + def fake_run( + command: list[str], + *, + cwd: Path, + capture_output: bool, + text: bool, + check: bool, + ) -> subprocess.CompletedProcess[str]: + del cwd, capture_output, text, check + calls.append(command) + return subprocess.CompletedProcess(command, 0, "pip-audit 2.9.0\n", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) + + assert calls[0] == ["pip-audit", "--version"] + assert len(calls) == 1 + assert result.output.status == "error" + assert "pdm.lock" in (result.output.error_message or "") + assert "missing version" in (result.output.error_message or "") + + @pytest.mark.parametrize( ("score", "expected"), [ @@ -267,7 +446,7 @@ def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=None, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert result.output.status == "error" assert result.output.findings == () @@ -283,7 +462,7 @@ def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=None, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert result.output.status == "error" assert "invalid JSON" in (result.output.error_message or "") @@ -298,7 +477,7 @@ def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str monkeypatch.setattr(subprocess, "run", fake_run) - result = PipAuditAdapter().scan(requirements=None, repo_root=tmp_path) + result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path) assert result.output.status == "error" assert "not found" in (result.output.error_message or "") From 89de7f35ce37c43bcafd1415f72e5bc1fd01a3d7 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 10:49:13 +0900 Subject: [PATCH 02/18] fix(ssp): respect lockfile markers in pip-audit discovery --- docs/ssp_usage_guide.md | 4 +- .../ssp/adapters/dependency_sources.py | 134 ++++++++++++++++++ tests/ssp/adapters/test_dependency_sources.py | 57 ++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/docs/ssp_usage_guide.md b/docs/ssp_usage_guide.md index 2b0a458..10c3e7b 100644 --- a/docs/ssp_usage_guide.md +++ b/docs/ssp_usage_guide.md @@ -65,7 +65,9 @@ and candidate directories: Malformed recognized dependency sources fail closed as a pip-audit sensor error, which produces SSP `unknown` rather than silently falling back to a lower -priority source. +priority source. Lockfile translation skips optional packages and packages whose +environment markers do not apply to the current scan environment; unsupported +markers also fail closed instead of being guessed. ### 3. Fixture mode (no scanner required) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index c96454c..fc9f72b 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -2,7 +2,11 @@ from __future__ import annotations +import ast +import os +import platform import re +import sys import tomllib from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -153,6 +157,12 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: raise DependencySourceError(f"{path.name}: package entry {index} is missing name") if not isinstance(version, str) or not version: raise DependencySourceError(f"{path.name}: package {name} is missing version") + if _is_optional_package(package): + continue + if not _marker_allows_current_environment( + package, source_name=path.name, package_name=name + ): + continue if self_name is not None and _normalize_name(name) == _normalize_name(self_name): continue pinned.add((name, version)) @@ -201,3 +211,127 @@ def _is_string_sequence(value: object) -> bool: def _normalize_name(value: str) -> str: return re.sub(r"[-_.]+", "-", value).lower() + + +def _is_optional_package(package: Mapping[str, Any]) -> bool: + return package.get("optional") is True + + +def _marker_allows_current_environment( + package: Mapping[str, Any], + *, + source_name: str, + package_name: str, +) -> bool: + markers = _marker_values(package) + for marker in markers: + try: + if not _evaluate_marker(marker): + return False + except (SyntaxError, ValueError) as exc: + raise DependencySourceError( + f"{source_name}: package {package_name} has unsupported marker: {marker}" + ) from exc + return True + + +def _marker_values(package: Mapping[str, Any]) -> tuple[str, ...]: + values: list[str] = [] + for key in ("marker", "markers"): + raw = package.get(key) + if isinstance(raw, str) and raw.strip(): + values.append(raw.strip()) + elif isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)): + for item in raw: + if isinstance(item, str) and item.strip(): + values.append(item.strip()) + return tuple(values) + + +def _evaluate_marker(marker: str) -> bool: + tree = ast.parse(marker, mode="eval") + return _eval_marker_node(tree.body) + + +def _eval_marker_node(node: ast.AST) -> bool: + if isinstance(node, ast.BoolOp): + values = [_eval_marker_node(value) for value in node.values] + if isinstance(node.op, ast.And): + return all(values) + if isinstance(node.op, ast.Or): + return any(values) + if isinstance(node, ast.Compare): + left = _eval_marker_value(node.left) + for op, comparator in zip(node.ops, node.comparators, strict=True): + right = _eval_marker_value(comparator) + if not _compare_marker_values(left, op, right): + return False + left = right + return True + raise ValueError(f"unsupported marker expression: {ast.dump(node)}") + + +def _eval_marker_value(node: ast.AST) -> str: + if isinstance(node, ast.Name): + env = _marker_environment() + if node.id not in env: + raise ValueError(f"unsupported marker variable: {node.id}") + return env[node.id] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + raise ValueError(f"unsupported marker value: {ast.dump(node)}") + + +def _compare_marker_values(left: str, op: ast.cmpop, right: str) -> bool: + if isinstance(op, ast.Eq): + return left == right + if isinstance(op, ast.NotEq): + return left != right + if isinstance(op, ast.In): + return left in right + if isinstance(op, ast.NotIn): + return left not in right + if isinstance(op, ast.Lt): + return _versionish(left) < _versionish(right) + if isinstance(op, ast.LtE): + return _versionish(left) <= _versionish(right) + if isinstance(op, ast.Gt): + return _versionish(left) > _versionish(right) + if isinstance(op, ast.GtE): + return _versionish(left) >= _versionish(right) + raise ValueError(f"unsupported marker operator: {op.__class__.__name__}") + + +def _marker_environment() -> dict[str, str]: + version = sys.version_info + implementation_version = getattr(sys.implementation, "version", version) + return { + "os_name": os.name, + "sys_platform": sys.platform, + "platform_machine": platform.machine(), + "platform_python_implementation": platform.python_implementation(), + "platform_release": platform.release(), + "platform_system": platform.system(), + "platform_version": platform.version(), + "python_version": f"{version.major}.{version.minor}", + "python_full_version": platform.python_version(), + "implementation_name": sys.implementation.name, + "implementation_version": ".".join( + str(part) + for part in ( + implementation_version.major, + implementation_version.minor, + implementation_version.micro, + ) + ), + "extra": "", + } + + +def _versionish(value: str) -> tuple[int | str, ...]: + parts: list[int | str] = [] + for part in re.split(r"[.\-+_]", value): + if not part: + continue + parts.append(int(part) if part.isdigit() else part) + return tuple(parts) or (value,) diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index fb46b0e..e38d454 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -78,6 +78,41 @@ def test_lock_translation_sorts_dedups_and_excludes_project_package(tmp_path: Pa assert generated.lines == ("django==3.2.0", "zlib-ng==1.0.0") +def test_lock_translation_respects_optional_packages_and_environment_markers(tmp_path: Path): + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "current" +version = "1.0.0" +marker = "sys_platform != '__semantic_ci_never__'" + +[[package]] +name = "windows-only" +version = "1.0.0" +marker = "sys_platform == '__semantic_ci_never__'" + +[[package]] +name = "extra-only" +version = "1.0.0" +marker = "extra == 'speedups'" + +[[package]] +name = "optional-pkg" +version = "1.0.0" +optional = true +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("current==1.0.0", "django==3.2.0") + + def test_lock_translation_missing_version_is_fail_closed(tmp_path: Path): (tmp_path / "poetry.lock").write_text( """ @@ -99,6 +134,28 @@ def test_lock_translation_missing_version_is_fail_closed(tmp_path: Path): raise AssertionError("expected DependencySourceError") +def test_lock_translation_unsupported_marker_is_fail_closed(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +marker = "unknown_marker_name == 'x'" +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + + try: + generated_requirements_for_source(source) + except DependencySourceError as exc: + assert "pdm.lock" in str(exc) + assert "unsupported marker" in str(exc) + else: # pragma: no cover - assertion guard + raise AssertionError("expected DependencySourceError") + + def test_pyproject_static_dependencies_are_used(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ From d82d9feb44ac89648a6b1c0dd57177900b17423d Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 11:02:02 +0900 Subject: [PATCH 03/18] fix(ssp): handle prerelease lockfile markers --- .../ssp/adapters/dependency_sources.py | 15 ++++++++------- tests/ssp/adapters/test_dependency_sources.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index fc9f72b..74d6074 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -328,10 +328,11 @@ def _marker_environment() -> dict[str, str]: } -def _versionish(value: str) -> tuple[int | str, ...]: - parts: list[int | str] = [] - for part in re.split(r"[.\-+_]", value): - if not part: - continue - parts.append(int(part) if part.isdigit() else part) - return tuple(parts) or (value,) +def _versionish(value: str) -> tuple[tuple[int, int | str], ...]: + tokens: list[tuple[int, int | str]] = [] + for part in re.findall(r"\d+|[A-Za-z]+", value): + if part.isdigit(): + tokens.append((0, int(part))) + else: + tokens.append((1, part.lower())) + return tuple(tokens) or ((1, value),) diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index e38d454..3ab4a95 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -156,6 +156,22 @@ def test_lock_translation_unsupported_marker_is_fail_closed(tmp_path: Path): raise AssertionError("expected DependencySourceError") +def test_lock_translation_prerelease_marker_comparison_does_not_crash(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +marker = "python_full_version >= '3.0.0a0'" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0",) + + def test_pyproject_static_dependencies_are_used(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ From 8d925298c8ebb9fc461702db2b0e2f6e39a0eb02 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 11:15:15 +0900 Subject: [PATCH 04/18] fix(ssp): discover named pylock files --- docs/ssp_usage_guide.md | 2 +- .../ssp/adapters/dependency_sources.py | 12 +++++++-- tests/ssp/adapters/test_dependency_sources.py | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/ssp_usage_guide.md b/docs/ssp_usage_guide.md index 10c3e7b..8fc6d3c 100644 --- a/docs/ssp_usage_guide.md +++ b/docs/ssp_usage_guide.md @@ -56,7 +56,7 @@ and candidate directories: | Priority | Root file | Handling | pip-audit argv | |---:|---|---|---| | 1 | `requirements.txt` | Existing behavior. | `--requirement ` | -| 2 | `pylock.toml` | Locked project scan. | `--locked ` when supported, otherwise `` | +| 2 | `pylock.toml` / `pylock.*.toml` | Locked project scan. | `--locked ` when supported, otherwise `` | | 3 | `uv.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | | 4 | `pdm.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | | 5 | `poetry.lock` | Translate pinned packages to a temporary requirements file. | `--requirement ` + `--no-deps` | diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 74d6074..334f8dd 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -61,8 +61,8 @@ def discover_dependency_source(root: Path) -> DependencySource: if requirements.exists(): return DependencySource(kind="requirements", root=resolved_root, path=requirements) - pylock = resolved_root / "pylock.toml" - if pylock.exists(): + pylock = _pylock_source(resolved_root) + if pylock is not None: return DependencySource(kind="pylock", root=resolved_root, path=pylock) for filename, kind in _LOCK_SOURCE_FILES: @@ -77,6 +77,14 @@ def discover_dependency_source(root: Path) -> DependencySource: return DependencySource(kind="fallback", root=resolved_root, path=None) +def _pylock_source(root: Path) -> Path | None: + pylock = root / "pylock.toml" + if pylock.exists(): + return pylock + named = sorted(path for path in root.glob("pylock.*.toml") if path.is_file()) + return named[0] if named else None + + def generated_requirements_for_source(source: DependencySource) -> GeneratedRequirements: """Convert a recognized structured source into deterministic requirements lines.""" diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 3ab4a95..5a55251 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -42,6 +42,32 @@ def test_discovery_precedence_selects_lock_before_pyproject(tmp_path: Path): assert source.path == tmp_path.resolve() / "uv.lock" +def test_discovery_selects_named_pylock_before_pyproject(tmp_path: Path): + (tmp_path / "pylock.prod.toml").write_text("[packages]\n", encoding="utf-8") + (tmp_path / "pyproject.toml").write_text( + """ +[project] +dependencies = ["requests==2.32.0"] +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + + assert source.kind == "pylock" + assert source.path == tmp_path.resolve() / "pylock.prod.toml" + + +def test_discovery_prefers_default_pylock_over_named_pylock(tmp_path: Path): + (tmp_path / "pylock.z.toml").write_text("[packages]\n", encoding="utf-8") + (tmp_path / "pylock.toml").write_text("[packages]\n", encoding="utf-8") + + source = discover_dependency_source(tmp_path) + + assert source.kind == "pylock" + assert source.path == tmp_path.resolve() / "pylock.toml" + + def test_baseline_and_candidate_discovery_are_independent(tmp_path: Path): baseline = tmp_path / "baseline" candidate = tmp_path / "candidate" From 85c28dc50336a935c4417dce6d615bbf35bac7cd Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 11:31:50 +0900 Subject: [PATCH 05/18] fix(ssp): filter non-default lockfile groups --- docs/dogfooding_findings_tracker.md | 2 +- docs/dogfooding_scale_and_security.md | 8 +- docs/ssp_usage_guide.md | 7 +- .../ssp/adapters/dependency_sources.py | 40 +++++++++ tests/ssp/adapters/test_dependency_sources.py | 84 +++++++++++++++++++ 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/docs/dogfooding_findings_tracker.md b/docs/dogfooding_findings_tracker.md index dc000e0..24e0dbd 100644 --- a/docs/dogfooding_findings_tracker.md +++ b/docs/dogfooding_findings_tracker.md @@ -24,7 +24,7 @@ Status taxonomy: | D5 | 2026-05-07 Session 5 dogfood (FINDING-1) | set operator partial-match semantics | partial-dict `expected` items canonicalised as different elements from full extractor records — false positive on `includes_*` / `subset_of` / `superset_of`, **false negative (CI bypass) on `excludes_all`** | **解決** | PR #65 (CSCI-35c) — Match Schema partial-record matching + flat-projection aliases + `evidence.matched`; schema_version v4→v5 | | D6 | 2026-05-28 real-PR complexity dogfood (FINDING-F1) | vacuous PASS (extractor coverage gap) — **重複・関連 = sibling of D4** | nested function bodies are excluded from `ComplexityEntry` by `python_complexity_extractor` spec (`api_surface` parity); refactor that nests outer-function body into nested helpers reports large CC drop while real complexity is unchanged | **未解決** | Candidate paths: (a) `docs/target_yaml_guide.md` new Hazard 4 + `ADVISORY-D6` detector mirroring D4; (b) extractor spec change to emit nested-function entries (long-term, schema-impacting). Reproduction: langgraph PR #3700 (8/1 vacuous PASS in real-PR pass) | | D7 | 2026-05-28 real-PR complexity dogfood (FINDING-F2) | authoring mismatch (operator / constraint pairing) | `extract-method` refactor is mathematically guaranteed to **micro-increase cyclomatic** (each extracted function adds base 1), even with `_` prefix discipline and api_surface preserved. Cognitive is the metric that drops. Authors declaring `complexity_delta.cyclomatic ≤ 0` for extract-method refactors hit a structural false-FAIL | **未解決** | Candidate paths: (a) authoring guide section "Choosing complexity metric per refactor pattern" recommending `cognitive_delta` for extract-method; (b) future `ADVISORY-D7` detector emitted when a `change.primary_kind=refactor` target uses `cyclomatic_delta ≤ 0` and the diff matches extract-method shape. Low priority: this is authoring advice, not a CI integrity hazard | -| D8 | 2026-06-07 scale + security dogfood (SCA gap) | SCA sensor dependency-source discovery gap | SSP SCA auto-discovery (`_requirements_file` in `src/semantic_ci_code/cli/commands/ssp.py`) only found `requirements.txt` at repo root; the `--locked` fallback only accepted `pylock.toml` / requirements lockfiles. PEP 621 pyproject-only projects (litellm) and `pdm.lock` projects (pdm) declared deps in unrecognised formats → `pip-audit --locked .` errors "no lockfiles found" → empty JSON → adapter degraded to `unknown` (exit 3). Correct graceful degradation (no silent false PASS, honours `unknown > fail > pass`) but a real usability gap that blocked SCA on most modern Python projects | **解決** | CSCI-55 — dependency source discovery now recognises `requirements.txt`, `pylock.toml`, `uv.lock`, `pdm.lock`, `poetry.lock`, and static PEP 621 `[project].dependencies`; lock sources are converted deterministically to pinned temp requirements and malformed recognized sources fail closed to SSP `unknown` | +| D8 | 2026-06-07 scale + security dogfood (SCA gap) | SCA sensor dependency-source discovery gap | SSP SCA auto-discovery (`_requirements_file` in `src/semantic_ci_code/cli/commands/ssp.py`) only found `requirements.txt` at repo root; the `--locked` fallback only accepted `pylock.toml` / requirements lockfiles. PEP 621 pyproject-only projects (litellm) and `pdm.lock` projects (pdm) declared deps in unrecognised formats → `pip-audit --locked .` errors "no lockfiles found" → empty JSON → adapter degraded to `unknown` (exit 3). Correct graceful degradation (no silent false PASS, honours `unknown > fail > pass`) but a real usability gap that blocked SCA on most modern Python projects | **解決** | CSCI-55 — dependency source discovery now recognises `requirements.txt`, `pylock.toml` / `pylock.*.toml`, `uv.lock`, `pdm.lock`, `poetry.lock`, and static PEP 621 `[project].dependencies`; lock sources are converted deterministically to pinned temp requirements, optional/non-default-group/marker-inactive packages are filtered, and malformed recognized sources fail closed to SSP `unknown` | ## Reading order diff --git a/docs/dogfooding_scale_and_security.md b/docs/dogfooding_scale_and_security.md index c7ee921..6ee10a5 100644 --- a/docs/dogfooding_scale_and_security.md +++ b/docs/dogfooding_scale_and_security.md @@ -201,7 +201,8 @@ a real usability gap (registered as a D# below). lookup with deterministic dependency-source discovery for `requirements.txt`, `pylock.toml`, `uv.lock`, `pdm.lock`, `poetry.lock`, and static PEP 621 `[project].dependencies`. Lock sources are converted to pinned temporary -requirements and malformed recognized sources fail closed to SSP `unknown`. +requirements, optional/non-default-group/marker-inactive packages are filtered, +and malformed recognized sources fail closed to SSP `unknown`. ## Headline / conclusion @@ -380,8 +381,9 @@ prior passes is consolidated in - **D8** (SCA auto-discovery gap, resolved by CSCI-55) — SSP SCA now recognises PEP 621 pyproject / `uv.lock` / `poetry.lock` / `pdm.lock` dependency sources, - translating lockfiles to deterministic pinned temporary requirements and - keeping malformed recognized sources fail-closed as `unknown`. + translating lockfiles to deterministic pinned temporary requirements, filtering + optional/non-default-group/marker-inactive packages, and keeping malformed + recognized sources fail-closed as `unknown`. - **F6** (SAST logic-vuln blindspot) — **UNTESTED HYPOTHESIS in this pass**, not a demonstrated observation: the Semgrep registry rulesets returned HTTP 403, so Semgrep never ran with real rules (0 rules / 0 diff --git a/docs/ssp_usage_guide.md b/docs/ssp_usage_guide.md index 8fc6d3c..aa468c3 100644 --- a/docs/ssp_usage_guide.md +++ b/docs/ssp_usage_guide.md @@ -66,8 +66,11 @@ and candidate directories: Malformed recognized dependency sources fail closed as a pip-audit sensor error, which produces SSP `unknown` rather than silently falling back to a lower priority source. Lockfile translation skips optional packages and packages whose -environment markers do not apply to the current scan environment; unsupported -markers also fail closed instead of being guessed. +environment markers do not apply to the current scan environment. It also keeps +only default/main dependency groups when lock metadata exposes package groups, so +docs/test/dev-only packages are not audited as production dependencies. +Unsupported markers or malformed group metadata fail closed instead of being +guessed. ### 3. Fixture mode (no scanner required) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 334f8dd..765bf2b 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -167,6 +167,12 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: raise DependencySourceError(f"{path.name}: package {name} is missing version") if _is_optional_package(package): continue + if not _is_selected_dependency_group( + package, + source_name=path.name, + package_name=name, + ): + continue if not _marker_allows_current_environment( package, source_name=path.name, package_name=name ): @@ -225,6 +231,40 @@ def _is_optional_package(package: Mapping[str, Any]) -> bool: return package.get("optional") is True +def _is_selected_dependency_group( + package: Mapping[str, Any], + *, + source_name: str, + package_name: str, +) -> bool: + selected = {"default", "main"} + + groups = package.get("groups") + if groups is not None: + if not isinstance(groups, Sequence) or isinstance(groups, (str, bytes)): + raise DependencySourceError(f"{source_name}: package {package_name} has invalid groups") + normalized = {_normalize_group(group) for group in groups} + if not all(normalized): + raise DependencySourceError(f"{source_name}: package {package_name} has invalid groups") + return bool(normalized & selected) + + for key in ("group", "category"): + raw = package.get(key) + if raw is None: + continue + if not isinstance(raw, str) or not raw: + raise DependencySourceError(f"{source_name}: package {package_name} has invalid {key}") + return _normalize_group(raw) in selected + + return True + + +def _normalize_group(value: object) -> str: + if not isinstance(value, str): + return "" + return value.strip().lower() + + def _marker_allows_current_environment( package: Mapping[str, Any], *, diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 5a55251..77f516a 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -139,6 +139,90 @@ def test_lock_translation_respects_optional_packages_and_environment_markers(tmp assert generated.lines == ("current==1.0.0", "django==3.2.0") +def test_lock_translation_respects_selected_dependency_groups(tmp_path: Path): + (tmp_path / "poetry.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +groups = ["main"] + +[[package]] +name = "requests" +version = "2.32.0" +groups = ["default"] + +[[package]] +name = "mkdocs" +version = "1.6.0" +groups = ["docs"] + +[[package]] +name = "pytest" +version = "8.3.0" +groups = ["test"] +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0", "requests==2.32.0") + + +def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +group = "default" + +[[package]] +name = "requests" +version = "2.32.0" +category = "main" + +[[package]] +name = "sphinx" +version = "8.1.0" +group = "docs" + +[[package]] +name = "pytest" +version = "8.3.0" +category = "dev" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0", "requests==2.32.0") + + +def test_lock_translation_invalid_group_metadata_is_fail_closed(tmp_path: Path): + (tmp_path / "poetry.lock").write_text( + """ +[[package]] +name = "django" +version = "3.2.0" +groups = "main" +""".lstrip(), + encoding="utf-8", + ) + + source = discover_dependency_source(tmp_path) + + try: + generated_requirements_for_source(source) + except DependencySourceError as exc: + assert "poetry.lock" in str(exc) + assert "invalid groups" in str(exc) + else: # pragma: no cover - assertion guard + raise AssertionError("expected DependencySourceError") + + def test_lock_translation_missing_version_is_fail_closed(tmp_path: Path): (tmp_path / "poetry.lock").write_text( """ From 64eb224469e564aabd0f70eab279d2fc68ca7601 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 11:45:27 +0900 Subject: [PATCH 06/18] fix(ssp): support wildcard version markers in lockfiles --- .../ssp/adapters/dependency_sources.py | 19 +++++++- tests/ssp/adapters/test_dependency_sources.py | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 765bf2b..52e6437 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -332,9 +332,9 @@ def _eval_marker_value(node: ast.AST) -> str: def _compare_marker_values(left: str, op: ast.cmpop, right: str) -> bool: if isinstance(op, ast.Eq): - return left == right + return _marker_values_equal(left, right) if isinstance(op, ast.NotEq): - return left != right + return not _marker_values_equal(left, right) if isinstance(op, ast.In): return left in right if isinstance(op, ast.NotIn): @@ -384,3 +384,18 @@ def _versionish(value: str) -> tuple[tuple[int, int | str], ...]: else: tokens.append((1, part.lower())) return tuple(tokens) or ((1, value),) + + +def _marker_values_equal(left: str, right: str) -> bool: + return ( + left == right + or _version_wildcard_matches(pattern=left, value=right) + or _version_wildcard_matches(pattern=right, value=left) + ) + + +def _version_wildcard_matches(*, pattern: str, value: str) -> bool: + if not re.fullmatch(r"\d+(?:\.\d+)*\.\*", pattern): + return False + prefix = pattern.removesuffix(".*") + return value == prefix or value.startswith(f"{prefix}.") diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 77f516a..288064b 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -282,6 +282,53 @@ def test_lock_translation_prerelease_marker_comparison_does_not_crash(tmp_path: assert generated.lines == ("django==3.2.0",) +def test_lock_translation_respects_wildcard_version_marker_equality(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "included" +version = "1.0.0" +marker = "python_version == '3.*'" + +[[package]] +name = "also-included" +version = "1.0.0" +marker = "'3.*' == python_version" + +[[package]] +name = "excluded" +version = "1.0.0" +marker = "python_version == '999.*'" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("also-included==1.0.0", "included==1.0.0") + + +def test_lock_translation_respects_wildcard_version_marker_inequality(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "included" +version = "1.0.0" +marker = "python_version != '999.*'" + +[[package]] +name = "excluded" +version = "1.0.0" +marker = "python_version != '3.*'" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("included==1.0.0",) + + def test_pyproject_static_dependencies_are_used(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ From 9e1cc0d70bb1399444d6bec0db0eef7281765cda Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 11:59:29 +0900 Subject: [PATCH 07/18] fix(ssp): exclude uv dev dependencies from audit input --- .../ssp/adapters/dependency_sources.py | 102 ++++++++++++++++++ tests/ssp/adapters/test_dependency_sources.py | 76 +++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 52e6437..0d73e20 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -155,6 +155,11 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: raise DependencySourceError(f"{path.name}: package entries must be a list") self_name = _project_name(root) + excluded_names = _excluded_lock_package_names( + payload, + source_name=path.name, + project_name=self_name, + ) pinned: set[tuple[str, str]] = set() for index, package in enumerate(packages): if not isinstance(package, Mapping): @@ -165,6 +170,8 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: raise DependencySourceError(f"{path.name}: package entry {index} is missing name") if not isinstance(version, str) or not version: raise DependencySourceError(f"{path.name}: package {name} is missing version") + if _normalize_name(name) in excluded_names: + continue if _is_optional_package(package): continue if not _is_selected_dependency_group( @@ -183,6 +190,47 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]: return tuple(f"{name}=={version}" for name, version in sorted(pinned)) +def _excluded_lock_package_names( + payload: Mapping[str, Any], + *, + source_name: str, + project_name: str | None, +) -> frozenset[str]: + if source_name != "uv.lock": + return frozenset() + + packages = payload.get("package") + if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)): + return frozenset() + + excluded: set[str] = set() + for package in packages: + if not isinstance(package, Mapping): + continue + package_name = package.get("name") + if ( + project_name is not None + and isinstance(package_name, str) + and _normalize_name(package_name) != _normalize_name(project_name) + ): + continue + excluded.update( + _dependency_names_from_object( + package.get("dev-dependencies"), + source_name=source_name, + context="dev-dependencies", + ) + ) + excluded.update( + _dependency_names_from_object( + package.get("dependency-groups"), + source_name=source_name, + context="dependency-groups", + ) + ) + return frozenset(excluded) + + def _project_name(root: Path) -> str | None: pyproject = root / "pyproject.toml" if not pyproject.exists(): @@ -231,6 +279,60 @@ def _is_optional_package(package: Mapping[str, Any]) -> bool: return package.get("optional") is True +def _dependency_names_from_object( + value: object, + *, + source_name: str, + context: str, +) -> frozenset[str]: + if value is None: + return frozenset() + if isinstance(value, Mapping): + names: set[str] = set() + for group_name, dependencies in value.items(): + if not isinstance(group_name, str) or not group_name: + raise DependencySourceError(f"{source_name}: invalid {context} group") + names.update( + _dependency_names_from_object( + dependencies, + source_name=source_name, + context=f"{context}.{group_name}", + ) + ) + return frozenset(names) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + names = set() + for item in value: + names.add(_dependency_name_from_item(item, source_name=source_name, context=context)) + return frozenset(names) + raise DependencySourceError(f"{source_name}: invalid {context}") + + +def _dependency_name_from_item( + item: object, + *, + source_name: str, + context: str, +) -> str: + if isinstance(item, str): + name = _requirement_name(item) + elif isinstance(item, Mapping): + raw = item.get("name") + if not isinstance(raw, str): + raise DependencySourceError(f"{source_name}: invalid {context} dependency") + name = raw + else: + raise DependencySourceError(f"{source_name}: invalid {context} dependency") + if not name: + raise DependencySourceError(f"{source_name}: invalid {context} dependency") + return _normalize_name(name) + + +def _requirement_name(value: str) -> str: + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", value) + return match.group(1) if match else "" + + def _is_selected_dependency_group( package: Mapping[str, Any], *, diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 288064b..e23bd09 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -170,6 +170,82 @@ def test_lock_translation_respects_selected_dependency_groups(tmp_path: Path): assert generated.lines == ("django==3.2.0", "requests==2.32.0") +def test_uv_lock_translation_excludes_root_dev_dependencies(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dev-dependencies = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "pytest" +version = "8.3.0" + +[[package]] +name = "ruff" +version = "0.9.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0",) + + +def test_uv_lock_translation_excludes_root_dependency_groups(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" + +[package.dependency-groups] +docs = ["mkdocs>=1.6"] +test = [{ name = "pytest" }] + +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "mkdocs" +version = "1.6.0" + +[[package]] +name = "pytest" +version = "8.3.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0",) + + def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): (tmp_path / "pdm.lock").write_text( """ From 861eccf17673dce2e41af6ba18e6a4d9f3c0e707 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 12:12:31 +0900 Subject: [PATCH 08/18] fix(ssp): exclude uv dev dependency closures --- .../ssp/adapters/dependency_sources.py | 61 +++++++++++++++++-- tests/ssp/adapters/test_dependency_sources.py | 42 +++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 0d73e20..d9f7ecc 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -203,7 +203,9 @@ def _excluded_lock_package_names( if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)): return frozenset() - excluded: set[str] = set() + package_by_name = _lock_package_by_name(packages, source_name=source_name) + default_roots: set[str] = set() + dev_roots: set[str] = set() for package in packages: if not isinstance(package, Mapping): continue @@ -214,21 +216,72 @@ def _excluded_lock_package_names( and _normalize_name(package_name) != _normalize_name(project_name) ): continue - excluded.update( + default_roots.update( + _dependency_names_from_object( + package.get("dependencies"), + source_name=source_name, + context="dependencies", + ) + ) + dev_roots.update( _dependency_names_from_object( package.get("dev-dependencies"), source_name=source_name, context="dev-dependencies", ) ) - excluded.update( + dev_roots.update( _dependency_names_from_object( package.get("dependency-groups"), source_name=source_name, context="dependency-groups", ) ) - return frozenset(excluded) + + default_closure = _dependency_closure(default_roots, package_by_name, source_name=source_name) + dev_closure = _dependency_closure(dev_roots, package_by_name, source_name=source_name) + return frozenset(dev_closure - default_closure) + + +def _lock_package_by_name( + packages: Sequence[object], + *, + source_name: str, +) -> Mapping[str, Mapping[str, Any]]: + package_by_name: dict[str, Mapping[str, Any]] = {} + for index, package in enumerate(packages): + if not isinstance(package, Mapping): + raise DependencySourceError(f"{source_name}: package entry {index} must be a table") + raw_name = package.get("name") + if isinstance(raw_name, str) and raw_name: + package_by_name[_normalize_name(raw_name)] = package + return package_by_name + + +def _dependency_closure( + roots: set[str], + packages: Mapping[str, Mapping[str, Any]], + *, + source_name: str, +) -> set[str]: + seen: set[str] = set() + stack = list(roots) + while stack: + name = stack.pop() + if name in seen: + continue + seen.add(name) + package = packages.get(name) + if package is None: + continue + for dependency in _dependency_names_from_object( + package.get("dependencies"), + source_name=source_name, + context=f"package {name} dependencies", + ): + if dependency not in seen: + stack.append(dependency) + return seen def _project_name(root: Path) -> str | None: diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index e23bd09..8889cdf 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -246,6 +246,48 @@ def test_uv_lock_translation_excludes_root_dependency_groups(tmp_path: Path): assert generated.lines == ("django==3.2.0",) +def test_uv_lock_translation_excludes_dev_dependency_closure(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [{ name = "django" }] +dev-dependencies = [{ name = "pytest" }] + +[[package]] +name = "django" +version = "3.2.0" +dependencies = [{ name = "shared" }] + +[[package]] +name = "pytest" +version = "8.3.0" +dependencies = [{ name = "pluggy" }, { name = "shared" }] + +[[package]] +name = "pluggy" +version = "1.5.0" + +[[package]] +name = "shared" +version = "1.0.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0", "shared==1.0.0") + + def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): (tmp_path / "pdm.lock").write_text( """ From a571d17fa1933414fd188ed2288e057ece011be2 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 12:32:15 +0900 Subject: [PATCH 09/18] fix(ssp): exclude uv optional dependency closures --- .../ssp/adapters/dependency_sources.py | 7 +++ tests/ssp/adapters/test_dependency_sources.py | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index d9f7ecc..ef94383 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -237,6 +237,13 @@ def _excluded_lock_package_names( context="dependency-groups", ) ) + dev_roots.update( + _dependency_names_from_object( + package.get("optional-dependencies"), + source_name=source_name, + context="optional-dependencies", + ) + ) default_closure = _dependency_closure(default_roots, package_by_name, source_name=source_name) dev_closure = _dependency_closure(dev_roots, package_by_name, source_name=source_name) diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 8889cdf..44ac5bd 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -288,6 +288,50 @@ def test_uv_lock_translation_excludes_dev_dependency_closure(tmp_path: Path): assert generated.lines == ("django==3.2.0", "shared==1.0.0") +def test_uv_lock_translation_excludes_optional_dependency_closure(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [{ name = "django" }] + +[package.optional-dependencies] +speedups = [{ name = "orjson" }] + +[[package]] +name = "django" +version = "3.2.0" +dependencies = [{ name = "shared" }] + +[[package]] +name = "orjson" +version = "3.10.0" +dependencies = [{ name = "extra-helper" }, { name = "shared" }] + +[[package]] +name = "extra-helper" +version = "1.0.0" + +[[package]] +name = "shared" +version = "1.0.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0", "shared==1.0.0") + + def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): (tmp_path / "pdm.lock").write_text( """ From 727a9fd5bb15c48727050cb88b96bd5c42a6843e Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 12:47:31 +0900 Subject: [PATCH 10/18] fix(ssp): honor markers on uv dependency edges --- .../ssp/adapters/dependency_sources.py | 32 ++++++-- tests/ssp/adapters/test_dependency_sources.py | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index ef94383..ad731d6 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -205,7 +205,7 @@ def _excluded_lock_package_names( package_by_name = _lock_package_by_name(packages, source_name=source_name) default_roots: set[str] = set() - dev_roots: set[str] = set() + non_default_roots: set[str] = set() for package in packages: if not isinstance(package, Mapping): continue @@ -223,21 +223,21 @@ def _excluded_lock_package_names( context="dependencies", ) ) - dev_roots.update( + non_default_roots.update( _dependency_names_from_object( package.get("dev-dependencies"), source_name=source_name, context="dev-dependencies", ) ) - dev_roots.update( + non_default_roots.update( _dependency_names_from_object( package.get("dependency-groups"), source_name=source_name, context="dependency-groups", ) ) - dev_roots.update( + non_default_roots.update( _dependency_names_from_object( package.get("optional-dependencies"), source_name=source_name, @@ -246,8 +246,14 @@ def _excluded_lock_package_names( ) default_closure = _dependency_closure(default_roots, package_by_name, source_name=source_name) - dev_closure = _dependency_closure(dev_roots, package_by_name, source_name=source_name) - return frozenset(dev_closure - default_closure) + if project_name is not None: + return frozenset(package_by_name.keys() - default_closure - {_normalize_name(project_name)}) + non_default_closure = _dependency_closure( + non_default_roots, + package_by_name, + source_name=source_name, + ) + return frozenset(non_default_closure - default_closure) def _lock_package_by_name( @@ -363,7 +369,13 @@ def _dependency_names_from_object( if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): names = set() for item in value: - names.add(_dependency_name_from_item(item, source_name=source_name, context=context)) + name = _dependency_name_from_item( + item, + source_name=source_name, + context=context, + ) + if name: + names.add(name) return frozenset(names) raise DependencySourceError(f"{source_name}: invalid {context}") @@ -380,6 +392,12 @@ def _dependency_name_from_item( raw = item.get("name") if not isinstance(raw, str): raise DependencySourceError(f"{source_name}: invalid {context} dependency") + if not _marker_allows_current_environment( + item, + source_name=source_name, + package_name=raw, + ): + return "" name = raw else: raise DependencySourceError(f"{source_name}: invalid {context} dependency") diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index 44ac5bd..bfae9eb 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -183,6 +183,7 @@ def test_uv_lock_translation_excludes_root_dev_dependencies(tmp_path: Path): [[package]] name = "example-app" version = "0.1.0" +dependencies = [{ name = "django" }] dev-dependencies = [ { name = "pytest" }, { name = "ruff" }, @@ -221,6 +222,7 @@ def test_uv_lock_translation_excludes_root_dependency_groups(tmp_path: Path): [[package]] name = "example-app" version = "0.1.0" +dependencies = [{ name = "django" }] [package.dependency-groups] docs = ["mkdocs>=1.6"] @@ -332,6 +334,84 @@ def test_uv_lock_translation_excludes_optional_dependency_closure(tmp_path: Path assert generated.lines == ("django==3.2.0", "shared==1.0.0") +def test_uv_lock_translation_honors_markers_on_default_dependency_edges(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [ + { name = "django" }, + { name = "colorama", marker = "sys_platform == '__semantic_ci_never__'" }, +] + +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "colorama" +version = "0.4.6" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0",) + + +def test_uv_lock_translation_honors_markers_on_optional_dependency_edges(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [ + { name = "django" }, + { name = "shared-extra-helper" }, +] + +[package.optional-dependencies] +speedups = [ + { name = "orjson", marker = "sys_platform == '__semantic_ci_never__'" }, + { name = "shared-extra-helper", marker = "sys_platform != '__semantic_ci_never__'" }, +] + +[[package]] +name = "django" +version = "3.2.0" + +[[package]] +name = "orjson" +version = "3.10.0" + +[[package]] +name = "shared-extra-helper" +version = "1.0.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("django==3.2.0", "shared-extra-helper==1.0.0") + + def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): (tmp_path / "pdm.lock").write_text( """ From 1f5f4c4650f129973d9d82b417e4c504a4ec9530 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 13:00:35 +0900 Subject: [PATCH 11/18] fix(ssp): support PEP 508 marker operators --- .../ssp/adapters/dependency_sources.py | 25 ++++++++++ tests/ssp/adapters/test_dependency_sources.py | 49 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index ad731d6..bc94323 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -477,10 +477,35 @@ def _marker_values(package: Mapping[str, Any]) -> tuple[str, ...]: def _evaluate_marker(marker: str) -> bool: + marker = _normalize_pep508_marker_syntax(marker) tree = ast.parse(marker, mode="eval") return _eval_marker_node(tree.body) +def _normalize_pep508_marker_syntax(marker: str) -> str: + marker = re.sub(r"\s+===\s+", " == ", marker) + return re.sub( + r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*~=\s*(?P['\"])(?P[^'\"]+)(?P=quote)", + _compatible_release_replacement, + marker, + ) + + +def _compatible_release_replacement(match: re.Match[str]) -> str: + name = match.group("name") + quote = match.group("quote") + version = match.group("version") + wildcard = _compatible_release_wildcard(version) + return f"({name} >= {quote}{version}{quote} and {name} == {quote}{wildcard}{quote})" + + +def _compatible_release_wildcard(version: str) -> str: + parts = version.split(".") + if len(parts) <= 2: + return f"{parts[0]}.*" + return f"{'.'.join(parts[:-1])}.*" + + def _eval_marker_node(node: ast.AST) -> bool: if isinstance(node, ast.BoolOp): values = [_eval_marker_node(value) for value in node.values] diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index bfae9eb..bbeee1d 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -349,6 +349,7 @@ def test_uv_lock_translation_honors_markers_on_default_dependency_edges(tmp_path version = "0.1.0" dependencies = [ { name = "django" }, + { name = "compatible", marker = "python_version ~= '3.0'" }, { name = "colorama", marker = "sys_platform == '__semantic_ci_never__'" }, ] @@ -356,6 +357,10 @@ def test_uv_lock_translation_honors_markers_on_default_dependency_edges(tmp_path name = "django" version = "3.2.0" +[[package]] +name = "compatible" +version = "1.0.0" + [[package]] name = "colorama" version = "0.4.6" @@ -365,7 +370,7 @@ def test_uv_lock_translation_honors_markers_on_default_dependency_edges(tmp_path generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) - assert generated.lines == ("django==3.2.0",) + assert generated.lines == ("compatible==1.0.0", "django==3.2.0") def test_uv_lock_translation_honors_markers_on_optional_dependency_edges(tmp_path: Path): @@ -571,6 +576,48 @@ def test_lock_translation_respects_wildcard_version_marker_inequality(tmp_path: assert generated.lines == ("included==1.0.0",) +def test_lock_translation_respects_compatible_release_marker_operator(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "included" +version = "1.0.0" +marker = "python_version ~= '3.0'" + +[[package]] +name = "excluded" +version = "1.0.0" +marker = "python_version ~= '999.0'" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("included==1.0.0",) + + +def test_lock_translation_respects_arbitrary_equality_marker_operator(tmp_path: Path): + (tmp_path / "pdm.lock").write_text( + """ +[[package]] +name = "included" +version = "1.0.0" +marker = "python_version === '3.11'" + +[[package]] +name = "excluded" +version = "1.0.0" +marker = "python_version === '999.0'" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("included==1.0.0",) + + def test_pyproject_static_dependencies_are_used(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ From 66660989edb9d71d7217f9179eabe90b844f78f9 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 13:14:38 +0900 Subject: [PATCH 12/18] fix(ssp): honor uv resolution markers --- .../ssp/adapters/dependency_sources.py | 20 +++++++++- tests/ssp/adapters/test_dependency_sources.py | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index bc94323..e488612 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -460,12 +460,30 @@ def _marker_allows_current_environment( raise DependencySourceError( f"{source_name}: package {package_name} has unsupported marker: {marker}" ) from exc + resolution_markers = _marker_values_for_keys(package, ("resolution-markers",)) + if resolution_markers: + for marker in resolution_markers: + try: + if _evaluate_marker(marker): + return True + except (SyntaxError, ValueError) as exc: + raise DependencySourceError( + f"{source_name}: package {package_name} has unsupported marker: {marker}" + ) from exc + return False return True def _marker_values(package: Mapping[str, Any]) -> tuple[str, ...]: + return _marker_values_for_keys(package, ("marker", "markers")) + + +def _marker_values_for_keys( + package: Mapping[str, Any], + keys: tuple[str, ...], +) -> tuple[str, ...]: values: list[str] = [] - for key in ("marker", "markers"): + for key in keys: raw = package.get(key) if isinstance(raw, str) and raw.strip(): values.append(raw.strip()) diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index bbeee1d..f422667 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -618,6 +618,46 @@ def test_lock_translation_respects_arbitrary_equality_marker_operator(tmp_path: assert generated.lines == ("included==1.0.0",) +def test_lock_translation_respects_uv_resolution_markers(tmp_path: Path): + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "active" +version = "1.0.0" +resolution-markers = ["python_version == '3.*'"] + +[[package]] +name = "inactive" +version = "2.0.0" +resolution-markers = ["python_version == '999.*'"] +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("active==1.0.0",) + + +def test_lock_translation_resolution_markers_are_or_semantics(tmp_path: Path): + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "active" +version = "1.0.0" +resolution-markers = [ + "python_version == '999.*'", + "python_version == '3.*'", +] +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("active==1.0.0",) + + def test_pyproject_static_dependencies_are_used(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ From 5d5454e8777543c0198d201e83e8858794dfaa24 Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 13:30:01 +0900 Subject: [PATCH 13/18] fix(ssp): include uv requested extra closures --- .../ssp/adapters/dependency_sources.py | 142 ++++++++++++++---- tests/ssp/adapters/test_dependency_sources.py | 83 ++++++++++ 2 files changed, 196 insertions(+), 29 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index e488612..76ffd1a 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -53,6 +53,12 @@ class GeneratedRequirements: no_deps: bool +@dataclass(frozen=True) +class _DependencyEdge: + name: str + extras: frozenset[str] = frozenset() + + def discover_dependency_source(root: Path) -> DependencySource: """Discover the highest-precedence dependency source for a scan directory.""" @@ -204,8 +210,8 @@ def _excluded_lock_package_names( return frozenset() package_by_name = _lock_package_by_name(packages, source_name=source_name) - default_roots: set[str] = set() - non_default_roots: set[str] = set() + default_roots: set[_DependencyEdge] = set() + non_default_roots: set[_DependencyEdge] = set() for package in packages: if not isinstance(package, Mapping): continue @@ -217,28 +223,28 @@ def _excluded_lock_package_names( ): continue default_roots.update( - _dependency_names_from_object( + _dependency_edges_from_object( package.get("dependencies"), source_name=source_name, context="dependencies", ) ) non_default_roots.update( - _dependency_names_from_object( + _dependency_edges_from_object( package.get("dev-dependencies"), source_name=source_name, context="dev-dependencies", ) ) non_default_roots.update( - _dependency_names_from_object( + _dependency_edges_from_object( package.get("dependency-groups"), source_name=source_name, context="dependency-groups", ) ) non_default_roots.update( - _dependency_names_from_object( + _dependency_edges_from_object( package.get("optional-dependencies"), source_name=source_name, context="optional-dependencies", @@ -272,29 +278,40 @@ def _lock_package_by_name( def _dependency_closure( - roots: set[str], + roots: set[_DependencyEdge], packages: Mapping[str, Mapping[str, Any]], *, source_name: str, ) -> set[str]: - seen: set[str] = set() + included: set[str] = set() + processed: set[_DependencyEdge] = set() stack = list(roots) while stack: - name = stack.pop() - if name in seen: + edge = stack.pop() + name = edge.name + included.add(name) + if edge in processed: continue - seen.add(name) + processed.add(edge) package = packages.get(name) if package is None: continue - for dependency in _dependency_names_from_object( + for dependency in _dependency_edges_from_object( package.get("dependencies"), source_name=source_name, context=f"package {name} dependencies", ): - if dependency not in seen: + if dependency not in processed: stack.append(dependency) - return seen + for dependency in _optional_dependency_edges( + package, + extras=edge.extras, + source_name=source_name, + package_name=name, + ): + if dependency not in processed: + stack.append(dependency) + return included def _project_name(root: Path) -> str | None: @@ -345,49 +362,50 @@ def _is_optional_package(package: Mapping[str, Any]) -> bool: return package.get("optional") is True -def _dependency_names_from_object( +def _dependency_edges_from_object( value: object, *, source_name: str, context: str, -) -> frozenset[str]: +) -> frozenset[_DependencyEdge]: if value is None: return frozenset() if isinstance(value, Mapping): - names: set[str] = set() + edges: set[_DependencyEdge] = set() for group_name, dependencies in value.items(): if not isinstance(group_name, str) or not group_name: raise DependencySourceError(f"{source_name}: invalid {context} group") - names.update( - _dependency_names_from_object( + edges.update( + _dependency_edges_from_object( dependencies, source_name=source_name, context=f"{context}.{group_name}", ) ) - return frozenset(names) + return frozenset(edges) if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): - names = set() + edges = set() for item in value: - name = _dependency_name_from_item( + edge = _dependency_edge_from_item( item, source_name=source_name, context=context, ) - if name: - names.add(name) - return frozenset(names) + if edge is not None: + edges.add(edge) + return frozenset(edges) raise DependencySourceError(f"{source_name}: invalid {context}") -def _dependency_name_from_item( +def _dependency_edge_from_item( item: object, *, source_name: str, context: str, -) -> str: +) -> _DependencyEdge | None: if isinstance(item, str): name = _requirement_name(item) + extras = frozenset() elif isinstance(item, Mapping): raw = item.get("name") if not isinstance(raw, str): @@ -397,13 +415,79 @@ def _dependency_name_from_item( source_name=source_name, package_name=raw, ): - return "" + return None name = raw + extras = _requested_extras(item, source_name=source_name, context=context) else: raise DependencySourceError(f"{source_name}: invalid {context} dependency") if not name: raise DependencySourceError(f"{source_name}: invalid {context} dependency") - return _normalize_name(name) + return _DependencyEdge(name=_normalize_name(name), extras=extras) + + +def _requested_extras( + item: Mapping[str, Any], + *, + source_name: str, + context: str, +) -> frozenset[str]: + extras: set[str] = set() + for key in ("extra", "extras"): + raw = item.get(key) + if raw is None: + continue + if isinstance(raw, str): + if not raw: + raise DependencySourceError(f"{source_name}: invalid {context} extra") + extras.add(_normalize_name(raw)) + continue + if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)): + for value in raw: + if not isinstance(value, str) or not value: + raise DependencySourceError(f"{source_name}: invalid {context} extra") + extras.add(_normalize_name(value)) + continue + raise DependencySourceError(f"{source_name}: invalid {context} extra") + return frozenset(extras) + + +def _optional_dependency_edges( + package: Mapping[str, Any], + *, + extras: frozenset[str], + source_name: str, + package_name: str, +) -> frozenset[_DependencyEdge]: + if not extras: + return frozenset() + optional = package.get("optional-dependencies") + if optional is None: + return frozenset() + if not isinstance(optional, Mapping): + raise DependencySourceError( + f"{source_name}: package {package_name} has invalid optional-dependencies" + ) + + edges: set[_DependencyEdge] = set() + optional_by_extra: dict[str, object] = {} + for raw_extra, dependencies in optional.items(): + if not isinstance(raw_extra, str) or not raw_extra: + raise DependencySourceError( + f"{source_name}: package {package_name} has invalid optional-dependencies" + ) + optional_by_extra[_normalize_name(raw_extra)] = dependencies + for extra in extras: + dependencies = optional_by_extra.get(extra) + if dependencies is None: + continue + edges.update( + _dependency_edges_from_object( + dependencies, + source_name=source_name, + context=f"package {package_name} optional-dependencies.{extra}", + ) + ) + return frozenset(edges) def _requirement_name(value: str) -> str: diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index f422667..b872f42 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -417,6 +417,89 @@ def test_uv_lock_translation_honors_markers_on_optional_dependency_edges(tmp_pat assert generated.lines == ("django==3.2.0", "shared-extra-helper==1.0.0") +def test_uv_lock_translation_includes_requested_extra_dependency_closure(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [ + { name = "lib", extra = ["speed"] }, +] + +[[package]] +name = "lib" +version = "1.0.0" + +[package.optional-dependencies] +speed = [{ name = "fast-helper" }] +docs = [{ name = "mkdocs" }] + +[[package]] +name = "fast-helper" +version = "2.0.0" +dependencies = [{ name = "shared" }] + +[[package]] +name = "mkdocs" +version = "1.6.0" + +[[package]] +name = "shared" +version = "1.0.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("fast-helper==2.0.0", "lib==1.0.0", "shared==1.0.0") + + +def test_uv_lock_translation_processes_later_requested_extra_for_seen_package(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "example-app" +""".lstrip(), + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + """ +[[package]] +name = "example-app" +version = "0.1.0" +dependencies = [ + { name = "lib" }, + { name = "lib", extra = ["speed"] }, +] + +[[package]] +name = "lib" +version = "1.0.0" + +[package.optional-dependencies] +speed = [{ name = "fast-helper" }] + +[[package]] +name = "fast-helper" +version = "2.0.0" +""".lstrip(), + encoding="utf-8", + ) + + generated = generated_requirements_for_source(discover_dependency_source(tmp_path)) + + assert generated.lines == ("fast-helper==2.0.0", "lib==1.0.0") + + def test_lock_translation_respects_legacy_group_and_category_fields(tmp_path: Path): (tmp_path / "pdm.lock").write_text( """ From f50692304b604e124a5632dc8eac99b6bd21b1af Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 13:40:59 +0900 Subject: [PATCH 14/18] test(ssp): make marker equality fixture version-neutral --- tests/ssp/adapters/test_dependency_sources.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py index b872f42..06b89e8 100644 --- a/tests/ssp/adapters/test_dependency_sources.py +++ b/tests/ssp/adapters/test_dependency_sources.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from pathlib import Path from semantic_ci_code.ssp.adapters.dependency_sources import ( @@ -681,12 +682,13 @@ def test_lock_translation_respects_compatible_release_marker_operator(tmp_path: def test_lock_translation_respects_arbitrary_equality_marker_operator(tmp_path: Path): + current_python = f"{sys.version_info.major}.{sys.version_info.minor}" (tmp_path / "pdm.lock").write_text( - """ + f""" [[package]] name = "included" version = "1.0.0" -marker = "python_version === '3.11'" +marker = "python_version === '{current_python}'" [[package]] name = "excluded" From 484a2e795fd2f198ba3a4a5fb46bd571fdd1544a Mon Sep 17 00:00:00 2001 From: yuu Date: Thu, 11 Jun 2026 13:50:05 +0900 Subject: [PATCH 15/18] fix(ssp): order prerelease markers with PEP 440 semantics --- .../ssp/adapters/dependency_sources.py | 63 +++++++++++++++---- tests/ssp/adapters/test_dependency_sources.py | 41 ++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py index 76ffd1a..598a1fa 100644 --- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -647,13 +647,13 @@ def _compare_marker_values(left: str, op: ast.cmpop, right: str) -> bool: if isinstance(op, ast.NotIn): return left not in right if isinstance(op, ast.Lt): - return _versionish(left) < _versionish(right) + return _compare_marker_versions(left, right) < 0 if isinstance(op, ast.LtE): - return _versionish(left) <= _versionish(right) + return _compare_marker_versions(left, right) <= 0 if isinstance(op, ast.Gt): - return _versionish(left) > _versionish(right) + return _compare_marker_versions(left, right) > 0 if isinstance(op, ast.GtE): - return _versionish(left) >= _versionish(right) + return _compare_marker_versions(left, right) >= 0 raise ValueError(f"unsupported marker operator: {op.__class__.__name__}") @@ -683,14 +683,53 @@ def _marker_environment() -> dict[str, str]: } -def _versionish(value: str) -> tuple[tuple[int, int | str], ...]: - tokens: list[tuple[int, int | str]] = [] - for part in re.findall(r"\d+|[A-Za-z]+", value): - if part.isdigit(): - tokens.append((0, int(part))) - else: - tokens.append((1, part.lower())) - return tuple(tokens) or ((1, value),) +def _compare_marker_versions(left: str, right: str) -> int: + left_version = _pep440ish_version(left) + right_version = _pep440ish_version(right) + if left_version is None or right_version is None: + return (left > right) - (left < right) + + left_release, left_phase = left_version + right_release, right_phase = right_version + width = max(len(left_release), len(right_release)) + padded_left = left_release + (0,) * (width - len(left_release)) + padded_right = right_release + (0,) * (width - len(right_release)) + if padded_left != padded_right: + return (padded_left > padded_right) - (padded_left < padded_right) + return (left_phase > right_phase) - (left_phase < right_phase) + + +def _pep440ish_version(value: str) -> tuple[tuple[int, ...], tuple[int, int, int]] | None: + match = re.match( + r"^\s*v?(?P\d+(?:\.\d+)*)" + r"(?:(?P
a|alpha|b|beta|rc|c|pre|preview)(?P\d*))?"
+        r"(?:(?:\.?post)(?P\d+))?"
+        r"(?:(?:\.?dev)(?P\d+))?",
+        value,
+        flags=re.IGNORECASE,
+    )
+    if match is None:
+        return None
+
+    release = tuple(int(part) for part in match.group("release").split("."))
+    if match.group("dev_n") is not None and match.group("pre") is None:
+        return release, (-1, 0, int(match.group("dev_n") or 0))
+    if match.group("pre") is not None:
+        pre = match.group("pre").lower()
+        pre_rank = {
+            "a": 0,
+            "alpha": 0,
+            "b": 1,
+            "beta": 1,
+            "rc": 2,
+            "c": 2,
+            "pre": 2,
+            "preview": 2,
+        }[pre]
+        return release, (0, pre_rank, int(match.group("pre_n") or 0))
+    if match.group("post_n") is not None:
+        return release, (2, 0, int(match.group("post_n") or 0))
+    return release, (1, 0, 0)
 
 
 def _marker_values_equal(left: str, right: str) -> bool:
diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py
index 06b89e8..df99dc9 100644
--- a/tests/ssp/adapters/test_dependency_sources.py
+++ b/tests/ssp/adapters/test_dependency_sources.py
@@ -613,6 +613,47 @@ def test_lock_translation_prerelease_marker_comparison_does_not_crash(tmp_path:
     assert generated.lines == ("django==3.2.0",)
 
 
+def test_lock_translation_orders_final_release_after_matching_prerelease(
+    tmp_path: Path,
+):
+    current_python = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
+    (tmp_path / "pdm.lock").write_text(
+        f"""
+[[package]]
+name = "included"
+version = "1.0.0"
+marker = "python_full_version >= '{current_python}a0'"
+
+[[package]]
+name = "excluded"
+version = "1.0.0"
+marker = "python_full_version < '{current_python}a0'"
+""".lstrip(),
+        encoding="utf-8",
+    )
+
+    generated = generated_requirements_for_source(discover_dependency_source(tmp_path))
+
+    assert generated.lines == ("included==1.0.0",)
+
+
+def test_lock_translation_treats_release_segments_as_zero_padded(tmp_path: Path):
+    current_python = f"{sys.version_info.major}.{sys.version_info.minor}"
+    (tmp_path / "pdm.lock").write_text(
+        f"""
+[[package]]
+name = "included"
+version = "1.0.0"
+marker = "python_version >= '{current_python}.0'"
+""".lstrip(),
+        encoding="utf-8",
+    )
+
+    generated = generated_requirements_for_source(discover_dependency_source(tmp_path))
+
+    assert generated.lines == ("included==1.0.0",)
+
+
 def test_lock_translation_respects_wildcard_version_marker_equality(tmp_path: Path):
     (tmp_path / "pdm.lock").write_text(
         """

From 0fbd5fe8d826c4877893a4c9eaaae8d0f711b742 Mon Sep 17 00:00:00 2001
From: yuu 
Date: Thu, 11 Jun 2026 14:02:02 +0900
Subject: [PATCH 16/18] fix(ssp): keep uv lock variants distinct in closures

---
 .../ssp/adapters/dependency_sources.py        | 74 ++++++++++++-------
 tests/ssp/adapters/test_dependency_sources.py | 42 +++++++++++
 2 files changed, 91 insertions(+), 25 deletions(-)

diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py
index 598a1fa..faf4905 100644
--- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py
+++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py
@@ -209,7 +209,7 @@ def _excluded_lock_package_names(
     if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)):
         return frozenset()
 
-    package_by_name = _lock_package_by_name(packages, source_name=source_name)
+    packages_by_name = _lock_packages_by_name(packages, source_name=source_name)
     default_roots: set[_DependencyEdge] = set()
     non_default_roots: set[_DependencyEdge] = set()
     for package in packages:
@@ -251,35 +251,37 @@ def _excluded_lock_package_names(
             )
         )
 
-    default_closure = _dependency_closure(default_roots, package_by_name, source_name=source_name)
+    default_closure = _dependency_closure(default_roots, packages_by_name, source_name=source_name)
     if project_name is not None:
-        return frozenset(package_by_name.keys() - default_closure - {_normalize_name(project_name)})
+        return frozenset(
+            packages_by_name.keys() - default_closure - {_normalize_name(project_name)}
+        )
     non_default_closure = _dependency_closure(
         non_default_roots,
-        package_by_name,
+        packages_by_name,
         source_name=source_name,
     )
     return frozenset(non_default_closure - default_closure)
 
 
-def _lock_package_by_name(
+def _lock_packages_by_name(
     packages: Sequence[object],
     *,
     source_name: str,
-) -> Mapping[str, Mapping[str, Any]]:
-    package_by_name: dict[str, Mapping[str, Any]] = {}
+) -> Mapping[str, tuple[Mapping[str, Any], ...]]:
+    packages_by_name: dict[str, list[Mapping[str, Any]]] = {}
     for index, package in enumerate(packages):
         if not isinstance(package, Mapping):
             raise DependencySourceError(f"{source_name}: package entry {index} must be a table")
         raw_name = package.get("name")
         if isinstance(raw_name, str) and raw_name:
-            package_by_name[_normalize_name(raw_name)] = package
-    return package_by_name
+            packages_by_name.setdefault(_normalize_name(raw_name), []).append(package)
+    return {name: tuple(variants) for name, variants in packages_by_name.items()}
 
 
 def _dependency_closure(
     roots: set[_DependencyEdge],
-    packages: Mapping[str, Mapping[str, Any]],
+    packages: Mapping[str, tuple[Mapping[str, Any], ...]],
     *,
     source_name: str,
 ) -> set[str]:
@@ -289,29 +291,51 @@ def _dependency_closure(
     while stack:
         edge = stack.pop()
         name = edge.name
-        included.add(name)
         if edge in processed:
             continue
         processed.add(edge)
-        package = packages.get(name)
-        if package is None:
-            continue
-        for dependency in _dependency_edges_from_object(
-            package.get("dependencies"),
+        active_variants = _active_lock_package_variants(
+            packages,
+            name,
             source_name=source_name,
-            context=f"package {name} dependencies",
-        ):
-            if dependency not in processed:
-                stack.append(dependency)
-        for dependency in _optional_dependency_edges(
+        )
+        if not active_variants:
+            continue
+        included.add(name)
+        for package in active_variants:
+            for dependency in _dependency_edges_from_object(
+                package.get("dependencies"),
+                source_name=source_name,
+                context=f"package {name} dependencies",
+            ):
+                if dependency not in processed:
+                    stack.append(dependency)
+            for dependency in _optional_dependency_edges(
+                package,
+                extras=edge.extras,
+                source_name=source_name,
+                package_name=name,
+            ):
+                if dependency not in processed:
+                    stack.append(dependency)
+    return included
+
+
+def _active_lock_package_variants(
+    packages: Mapping[str, tuple[Mapping[str, Any], ...]],
+    name: str,
+    *,
+    source_name: str,
+) -> tuple[Mapping[str, Any], ...]:
+    active: list[Mapping[str, Any]] = []
+    for package in packages.get(name, ()):
+        if _marker_allows_current_environment(
             package,
-            extras=edge.extras,
             source_name=source_name,
             package_name=name,
         ):
-            if dependency not in processed:
-                stack.append(dependency)
-    return included
+            active.append(package)
+    return tuple(active)
 
 
 def _project_name(root: Path) -> str | None:
diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py
index df99dc9..aa54f89 100644
--- a/tests/ssp/adapters/test_dependency_sources.py
+++ b/tests/ssp/adapters/test_dependency_sources.py
@@ -464,6 +464,48 @@ def test_uv_lock_translation_includes_requested_extra_dependency_closure(tmp_pat
     assert generated.lines == ("fast-helper==2.0.0", "lib==1.0.0", "shared==1.0.0")
 
 
+def test_uv_lock_translation_keeps_resolution_variants_separate_for_closure(tmp_path: Path):
+    (tmp_path / "pyproject.toml").write_text(
+        """
+[project]
+name = "example-app"
+""".lstrip(),
+        encoding="utf-8",
+    )
+    (tmp_path / "uv.lock").write_text(
+        """
+[[package]]
+name = "example-app"
+version = "0.1.0"
+dependencies = [
+  { name = "lib", extra = ["speed"] },
+]
+
+[[package]]
+name = "lib"
+version = "1.0.0"
+resolution-markers = ["python_version == '3.*'"]
+
+[package.optional-dependencies]
+speed = [{ name = "fast-helper" }]
+
+[[package]]
+name = "lib"
+version = "9.0.0"
+resolution-markers = ["python_version == '999.*'"]
+
+[[package]]
+name = "fast-helper"
+version = "2.0.0"
+""".lstrip(),
+        encoding="utf-8",
+    )
+
+    generated = generated_requirements_for_source(discover_dependency_source(tmp_path))
+
+    assert generated.lines == ("fast-helper==2.0.0", "lib==1.0.0")
+
+
 def test_uv_lock_translation_processes_later_requested_extra_for_seen_package(tmp_path: Path):
     (tmp_path / "pyproject.toml").write_text(
         """

From ce1bd780f5e00e17c8fecb0be446424c08e42a45 Mon Sep 17 00:00:00 2001
From: yuu 
Date: Thu, 11 Jun 2026 14:18:40 +0900
Subject: [PATCH 17/18] fix(ssp): fail closed for pylock without locked support

---
 .../ssp/adapters/pip_audit.py                 | 10 +++++-
 tests/ssp/adapters/test_pip_audit.py          | 31 +++++++++++++++++++
 2 files changed, 40 insertions(+), 1 deletion(-)

diff --git a/src/semantic_ci_code/ssp/adapters/pip_audit.py b/src/semantic_ci_code/ssp/adapters/pip_audit.py
index 66c479a..e1184cd 100644
--- a/src/semantic_ci_code/ssp/adapters/pip_audit.py
+++ b/src/semantic_ci_code/ssp/adapters/pip_audit.py
@@ -207,7 +207,15 @@ def _command_for_source(
             raise DependencySourceError("requirements source missing path")
         command.extend(("--requirement", str(source.path)))
         return command, None
-    if source.kind in {"pylock", "fallback"}:
+    if source.kind == "pylock":
+        if not _supports_locked_project_scan(version):
+            raise DependencySourceError(
+                f"{source.path.name if source.path else 'pylock.toml'} requires "
+                "pip-audit >= 2.9 for --locked scans"
+            )
+        command.extend(("--locked", str(source.root)))
+        return command, None
+    if source.kind == "fallback":
         if _supports_locked_project_scan(version):
             command.extend(("--locked", str(source.root)))
         else:
diff --git a/tests/ssp/adapters/test_pip_audit.py b/tests/ssp/adapters/test_pip_audit.py
index db9214f..5b473ff 100644
--- a/tests/ssp/adapters/test_pip_audit.py
+++ b/tests/ssp/adapters/test_pip_audit.py
@@ -314,6 +314,37 @@ def fake_run(
     assert result.output.status == "complete"
 
 
+def test_pip_audit_pylock_source_requires_locked_support(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+):
+    calls: list[list[str]] = []
+    (tmp_path / "pylock.toml").write_text("[packages]\n", encoding="utf-8")
+
+    def fake_run(
+        command: list[str],
+        *,
+        cwd: Path,
+        capture_output: bool,
+        text: bool,
+        check: bool,
+    ) -> subprocess.CompletedProcess[str]:
+        del cwd, capture_output, text, check
+        calls.append(command)
+        if command == ["pip-audit", "--version"]:
+            return subprocess.CompletedProcess(command, 0, "pip-audit 2.8.0\n", "")
+        raise AssertionError("pylock source must fail closed instead of scanning fallback")
+
+    monkeypatch.setattr(subprocess, "run", fake_run)
+
+    result = PipAuditAdapter().scan(source=discover_dependency_source(tmp_path), repo_root=tmp_path)
+
+    assert calls == [["pip-audit", "--version"]]
+    assert result.output.status == "error"
+    assert "pylock.toml" in result.output.error_message
+    assert "pip-audit >= 2.9" in result.output.error_message
+
+
 def test_pip_audit_malformed_recognized_source_returns_error_without_silent_fallback(
     monkeypatch: pytest.MonkeyPatch,
     tmp_path: Path,

From 690def3f60269aa7a669f35b58d1fb86e70cd3f0 Mon Sep 17 00:00:00 2001
From: yuu 
Date: Thu, 11 Jun 2026 14:34:21 +0900
Subject: [PATCH 18/18] fix(ssp): skip local lock packages when pinning

---
 .../ssp/adapters/dependency_sources.py        | 29 ++++++++++-
 tests/ssp/adapters/test_dependency_sources.py | 48 +++++++++++++++++++
 2 files changed, 75 insertions(+), 2 deletions(-)

diff --git a/src/semantic_ci_code/ssp/adapters/dependency_sources.py b/src/semantic_ci_code/ssp/adapters/dependency_sources.py
index faf4905..40bb7c6 100644
--- a/src/semantic_ci_code/ssp/adapters/dependency_sources.py
+++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py
@@ -29,6 +29,19 @@
     ("pdm.lock", "pdm-lock"),
     ("poetry.lock", "poetry-lock"),
 )
+_LOCAL_SOURCE_KEYS = frozenset(
+    {
+        "directory",
+        "editable",
+        "file",
+        "git",
+        "path",
+        "url",
+        "virtual",
+        "workspace",
+    }
+)
+_LOCAL_SOURCE_TYPES = frozenset({"directory", "file", "git", "path", "url"})
 
 
 class DependencySourceError(ValueError):
@@ -176,6 +189,10 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]:
             raise DependencySourceError(f"{path.name}: package entry {index} is missing name")
         if not isinstance(version, str) or not version:
             raise DependencySourceError(f"{path.name}: package {name} is missing version")
+        if self_name is not None and _normalize_name(name) == _normalize_name(self_name):
+            continue
+        if _is_local_lock_package(package):
+            continue
         if _normalize_name(name) in excluded_names:
             continue
         if _is_optional_package(package):
@@ -190,8 +207,6 @@ def _pinned_lines_from_lock(path: Path, *, root: Path) -> tuple[str, ...]:
             package, source_name=path.name, package_name=name
         ):
             continue
-        if self_name is not None and _normalize_name(name) == _normalize_name(self_name):
-            continue
         pinned.add((name, version))
     return tuple(f"{name}=={version}" for name, version in sorted(pinned))
 
@@ -386,6 +401,16 @@ def _is_optional_package(package: Mapping[str, Any]) -> bool:
     return package.get("optional") is True
 
 
+def _is_local_lock_package(package: Mapping[str, Any]) -> bool:
+    source = package.get("source")
+    if isinstance(source, Mapping):
+        if any(key in source for key in _LOCAL_SOURCE_KEYS):
+            return True
+        source_type = source.get("type")
+        return isinstance(source_type, str) and source_type.strip().lower() in _LOCAL_SOURCE_TYPES
+    return any(package.get(key) for key in ("editable", "develop"))
+
+
 def _dependency_edges_from_object(
     value: object,
     *,
diff --git a/tests/ssp/adapters/test_dependency_sources.py b/tests/ssp/adapters/test_dependency_sources.py
index aa54f89..d46cc23 100644
--- a/tests/ssp/adapters/test_dependency_sources.py
+++ b/tests/ssp/adapters/test_dependency_sources.py
@@ -3,6 +3,8 @@
 import sys
 from pathlib import Path
 
+import pytest
+
 from semantic_ci_code.ssp.adapters.dependency_sources import (
     DependencySourceError,
     discover_dependency_source,
@@ -506,6 +508,52 @@ def test_uv_lock_translation_keeps_resolution_variants_separate_for_closure(tmp_
     assert generated.lines == ("fast-helper==2.0.0", "lib==1.0.0")
 
 
+@pytest.mark.parametrize(
+    "source_table",
+    [
+        'source = { path = "../internal-lib" }',
+        'source = { editable = "../internal-lib" }',
+        "source = { workspace = true }",
+    ],
+)
+def test_uv_lock_translation_skips_local_packages_but_keeps_their_dependencies(
+    tmp_path: Path,
+    source_table: str,
+):
+    (tmp_path / "pyproject.toml").write_text(
+        """
+[project]
+name = "example-app"
+""".lstrip(),
+        encoding="utf-8",
+    )
+    (tmp_path / "uv.lock").write_text(
+        f"""
+[[package]]
+name = "example-app"
+version = "0.1.0"
+dependencies = [
+  {{ name = "internal-lib" }},
+]
+
+[[package]]
+name = "internal-lib"
+version = "0.1.0"
+{source_table}
+dependencies = [{{ name = "requests" }}]
+
+[[package]]
+name = "requests"
+version = "2.32.0"
+""".lstrip(),
+        encoding="utf-8",
+    )
+
+    generated = generated_requirements_for_source(discover_dependency_source(tmp_path))
+
+    assert generated.lines == ("requests==2.32.0",)
+
+
 def test_uv_lock_translation_processes_later_requested_extra_for_seen_package(tmp_path: Path):
     (tmp_path / "pyproject.toml").write_text(
         """