diff --git a/docs/dogfooding_findings_tracker.md b/docs/dogfooding_findings_tracker.md index b00e855..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 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` / `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 @@ -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..6ee10a5 100644 --- a/docs/dogfooding_scale_and_security.md +++ b/docs/dogfooding_scale_and_security.md @@ -197,6 +197,13 @@ 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, optional/non-default-group/marker-inactive packages are filtered, +and malformed recognized sources fail closed to SSP `unknown`. + ## Headline / conclusion **Scale & robustness.** 16 runs total (5 scale + 5 random + 3 litellm @@ -372,10 +379,11 @@ 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, 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 85eb6f0..aa468c3 100644 --- a/docs/ssp_usage_guide.md +++ b/docs/ssp_usage_guide.md @@ -50,10 +50,27 @@ 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` / `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. Lockfile translation skips optional packages and packages whose +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/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..40bb7c6 --- /dev/null +++ b/src/semantic_ci_code/ssp/adapters/dependency_sources.py @@ -0,0 +1,796 @@ +"""Dependency source discovery for pip-audit SSP scans.""" + +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 +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"), +) +_LOCAL_SOURCE_KEYS = frozenset( + { + "directory", + "editable", + "file", + "git", + "path", + "url", + "virtual", + "workspace", + } +) +_LOCAL_SOURCE_TYPES = frozenset({"directory", "file", "git", "path", "url"}) + + +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 + + +@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.""" + + resolved_root = root.resolve() + requirements = resolved_root / "requirements.txt" + if requirements.exists(): + return DependencySource(kind="requirements", root=resolved_root, path=requirements) + + 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: + 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 _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.""" + + 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) + 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): + 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 + if _is_local_lock_package(package): + continue + if _normalize_name(name) in excluded_names: + continue + 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 + ): + continue + pinned.add((name, version)) + 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() + + 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: + 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 + default_roots.update( + _dependency_edges_from_object( + package.get("dependencies"), + source_name=source_name, + context="dependencies", + ) + ) + non_default_roots.update( + _dependency_edges_from_object( + package.get("dev-dependencies"), + source_name=source_name, + context="dev-dependencies", + ) + ) + non_default_roots.update( + _dependency_edges_from_object( + package.get("dependency-groups"), + source_name=source_name, + context="dependency-groups", + ) + ) + non_default_roots.update( + _dependency_edges_from_object( + package.get("optional-dependencies"), + source_name=source_name, + context="optional-dependencies", + ) + ) + + default_closure = _dependency_closure(default_roots, packages_by_name, source_name=source_name) + if project_name is not None: + return frozenset( + packages_by_name.keys() - default_closure - {_normalize_name(project_name)} + ) + non_default_closure = _dependency_closure( + non_default_roots, + packages_by_name, + source_name=source_name, + ) + return frozenset(non_default_closure - default_closure) + + +def _lock_packages_by_name( + packages: Sequence[object], + *, + source_name: str, +) -> 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: + 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, tuple[Mapping[str, Any], ...]], + *, + source_name: str, +) -> set[str]: + included: set[str] = set() + processed: set[_DependencyEdge] = set() + stack = list(roots) + while stack: + edge = stack.pop() + name = edge.name + if edge in processed: + continue + processed.add(edge) + active_variants = _active_lock_package_variants( + packages, + name, + source_name=source_name, + ) + 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, + source_name=source_name, + package_name=name, + ): + active.append(package) + return tuple(active) + + +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() + + +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, + *, + source_name: str, + context: str, +) -> frozenset[_DependencyEdge]: + if value is None: + return frozenset() + if isinstance(value, Mapping): + 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") + edges.update( + _dependency_edges_from_object( + dependencies, + source_name=source_name, + context=f"{context}.{group_name}", + ) + ) + return frozenset(edges) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + edges = set() + for item in value: + edge = _dependency_edge_from_item( + item, + source_name=source_name, + context=context, + ) + if edge is not None: + edges.add(edge) + return frozenset(edges) + raise DependencySourceError(f"{source_name}: invalid {context}") + + +def _dependency_edge_from_item( + item: object, + *, + source_name: str, + context: 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): + raise DependencySourceError(f"{source_name}: invalid {context} dependency") + if not _marker_allows_current_environment( + item, + source_name=source_name, + package_name=raw, + ): + 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 _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: + 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], + *, + 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], + *, + 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 + 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 keys: + 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: + 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] + 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 _marker_values_equal(left, right) + if isinstance(op, ast.NotEq): + return not _marker_values_equal(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 _compare_marker_versions(left, right) < 0 + if isinstance(op, ast.LtE): + return _compare_marker_versions(left, right) <= 0 + if isinstance(op, ast.Gt): + return _compare_marker_versions(left, right) > 0 + if isinstance(op, ast.GtE): + return _compare_marker_versions(left, right) >= 0 + 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 _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:
+    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/src/semantic_ci_code/ssp/adapters/pip_audit.py b/src/semantic_ci_code/ssp/adapters/pip_audit.py
index 4f3ec64..e1184cd 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,54 @@ 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 == "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:
+            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..d46cc23
--- /dev/null
+++ b/tests/ssp/adapters/test_dependency_sources.py
@@ -0,0 +1,944 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+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_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"
+    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_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_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_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"
+dependencies = [{ name = "django" }]
+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"
+dependencies = [{ name = "django" }]
+
+[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_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_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_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 = "compatible", marker = "python_version ~= '3.0'" },
+  { name = "colorama", marker = "sys_platform == '__semantic_ci_never__'" },
+]
+
+[[package]]
+name = "django"
+version = "3.2.0"
+
+[[package]]
+name = "compatible"
+version = "1.0.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 == ("compatible==1.0.0", "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_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_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")
+
+
+@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(
+        """
+[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(
+        """
+[[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(
+        """
+[[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_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_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_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(
+        """
+[[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_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):
+    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}'"
+
+[[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_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(
+        """
+[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..5b473ff 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,215 @@ 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_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,
+):
+    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 +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 result.output.findings == ()
@@ -283,7 +493,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 +508,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 "")