Skip to content

Commit 1f820a6

Browse files
committed
Change to new output format separating packages by project/system scope instead of package managers
1 parent ed3d114 commit 1f820a6

29 files changed

+961
-570
lines changed

SPECIFICATION.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,34 @@ For complete architecture documentation, see [docs/technical/architecture/overvi
6060

6161
### JSON Output Schema
6262

63-
The tool outputs structured JSON with detected package managers and their dependencies:
63+
The tool outputs structured JSON with packages aggregated by scope (project/system):
6464

6565
```json
6666
{
6767
"_container-info": { "name": "nginx-container", "image": "nginx:latest", "hash": "sha256:..." },
68-
"dpkg": { "scope": "system", "dependencies": { "curl": { "version": "7.81.0-1ubuntu1.18 amd64" } } },
69-
"pip": { "scope": "project", "location": "/app/venv/lib/python3.12/site-packages", "dependencies": { "numpy": { "version": "1.3.3" } } }
68+
"project": {
69+
"packages": [
70+
{ "name": "numpy", "version": "1.3.3", "type": "pip" }
71+
],
72+
"pip": { "location": "/app/venv/lib/python3.12/site-packages", "hash": "sha256:..." }
73+
},
74+
"system": {
75+
"packages": [
76+
{ "name": "curl", "version": "7.81.0-1ubuntu1.18 amd64", "type": "dpkg", "hash": "abc123..." }
77+
]
78+
}
7079
}
7180
```
7281

7382
For complete JSON schema documentation, field definitions, and examples, see [docs/usage/output-format.md](docs/usage/output-format.md).
7483

7584
### Key Schema Concepts
7685

77-
- **Scope**: `"system"` (system-wide packages) or `"project"` (project-specific packages)
78-
- **Location**: Path to project-local installations (project scope only)
79-
- **Hash**: Dependency integrity verification when available
80-
- **Container Info**: Docker metadata included as `_container-info`
86+
- **Project Packages**: All project-scoped packages aggregated in `project.packages[]` array
87+
- **System Packages**: All system-scoped packages aggregated in `system.packages[]` array
88+
- **Package Type**: Each package includes `type` field (`pip`, `npm`, `dpkg`, `apk`, `maven`)
89+
- **Metadata**: Package manager metadata stored in `project.{manager}` sections (location, hash)
90+
- **Container Info**: Docker metadata included as `_container-info` when applicable
8191

8292
## Implementation Constraints
8393

dependency_resolver/core/interfaces.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@ def is_usable(self, executor: EnvironmentExecutor, working_dir: Optional[str] =
3131
raise NotImplementedError
3232

3333
@abstractmethod
34-
def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> dict[str, Any]:
35-
"""Extract dependencies with versions and hashes."""
34+
def get_dependencies(
35+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None
36+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
37+
"""Extract dependencies with versions and hashes.
38+
39+
Returns:
40+
tuple: (packages, metadata)
41+
- packages: List of package dicts with name, version, type, and optional hash
42+
- metadata: Dict with location and hash for project scope, empty for system scope
43+
"""
3644
raise NotImplementedError
3745

3846
@abstractmethod

dependency_resolver/core/orchestrator.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ def resolve_dependencies(
3838
if working_dir is not None and not executor.path_exists(working_dir):
3939
raise ValueError(f"Working directory does not exist: {working_dir}")
4040

41-
result = {}
41+
result: dict[str, Any] = {}
42+
project_packages: list[dict[str, Any]] = []
43+
project_metadata: dict[str, dict[str, Any]] = {}
44+
system_packages: list[dict[str, Any]] = []
45+
system_metadata: dict[str, dict[str, Any]] = {}
4246

4347
if only_container_info:
4448
# Only run docker-info detector when only container info is requested
@@ -63,22 +67,31 @@ def resolve_dependencies(
6367
if self.debug:
6468
print(f"{detector_name} is usable, extracting dependencies...")
6569

66-
dependencies = detector.get_dependencies(executor, working_dir)
67-
68-
# Special handling for docker-info detector (simplified format)
70+
# Special handling for docker-info detector
6971
if detector_name == "docker-info":
70-
if dependencies: # Only include if we got container info
71-
result["_container-info"] = dependencies
72+
packages, metadata = detector.get_dependencies(executor, working_dir)
73+
if metadata: # Only include if we got container info
74+
result["_container-info"] = metadata
7275
if self.debug:
7376
print(f"Found container info for {detector_name}")
7477
else:
75-
# Standard handling for other detectors
76-
if dependencies.get("dependencies") or self.debug:
77-
result[detector_name] = dependencies
78-
79-
if self.debug:
80-
dep_count = len(dependencies.get("dependencies", {}))
81-
print(f"Found {dep_count} dependencies for {detector_name}")
78+
# Standard handling for package detectors
79+
packages, metadata = detector.get_dependencies(executor, working_dir)
80+
81+
if detector.has_system_scope(executor, working_dir):
82+
# System scope packages
83+
system_packages.extend(packages)
84+
if metadata:
85+
system_metadata[detector_name] = metadata
86+
if self.debug:
87+
print(f"Found {len(packages)} system packages for {detector_name}")
88+
else:
89+
# Project scope packages
90+
project_packages.extend(packages)
91+
if metadata:
92+
project_metadata[detector_name] = metadata
93+
if self.debug:
94+
print(f"Found {len(packages)} project packages for {detector_name}")
8295
else:
8396
if self.debug:
8497
print(f"{detector_name} is not available")
@@ -88,4 +101,15 @@ def resolve_dependencies(
88101
print(f"Error checking {detector_name}: {str(e)}")
89102
continue
90103

104+
# Build final result structure matching proposal
105+
if project_packages or project_metadata:
106+
project_section: dict[str, Any] = {"packages": project_packages}
107+
project_section.update(project_metadata)
108+
result["project"] = project_section
109+
110+
if system_packages or system_metadata:
111+
system_section: dict[str, Any] = {"packages": system_packages}
112+
system_section.update(system_metadata)
113+
result["system"] = system_section
114+
91115
return result

dependency_resolver/core/output_formatter.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,39 @@ def format_json(self, dependencies: dict[str, Any], pretty_print: bool = True) -
2222
else:
2323
return json.dumps(dependencies)
2424

25-
def create_excerpt(self, dependencies: dict[str, Any], max_deps_per_manager: int = 3) -> dict[str, Any]:
25+
def create_excerpt(self, dependencies: dict[str, Any], max_deps_per_section: int = 3) -> dict[str, Any]:
2626
"""Create an excerpt of dependencies for debug mode."""
2727
excerpt: dict[str, Any] = {}
2828

29-
for manager_name, manager_data in dependencies.items():
30-
excerpt[manager_name] = {}
31-
32-
for key, value in manager_data.items():
33-
if key != "dependencies":
34-
excerpt[manager_name][key] = value
35-
36-
if "dependencies" in manager_data:
37-
deps = manager_data["dependencies"]
38-
total_deps = len(deps)
39-
40-
if total_deps <= max_deps_per_manager:
41-
excerpt[manager_name]["dependencies"] = deps
42-
else:
43-
limited_deps = dict(list(deps.items())[:max_deps_per_manager])
44-
excerpt[manager_name]["dependencies"] = limited_deps
45-
excerpt[manager_name]["_excerpt_info"] = {
46-
"total_dependencies": total_deps,
47-
"shown": max_deps_per_manager,
48-
"note": f"Showing {max_deps_per_manager} of {total_deps} dependencies (debug mode excerpt)",
49-
}
29+
for section_name, section_data in dependencies.items():
30+
if section_name.startswith("_"):
31+
# Copy container info and other metadata as-is
32+
excerpt[section_name] = section_data
33+
elif section_name in ("project", "system"):
34+
# Handle new unified structure with packages array
35+
excerpt[section_name] = {}
36+
37+
for key, value in section_data.items():
38+
if key != "packages":
39+
# Copy metadata (location, hash for project managers)
40+
excerpt[section_name][key] = value
41+
42+
if "packages" in section_data:
43+
packages = section_data["packages"]
44+
total_packages = len(packages)
45+
46+
if total_packages <= max_deps_per_section:
47+
excerpt[section_name]["packages"] = packages
48+
else:
49+
limited_packages = packages[:max_deps_per_section]
50+
excerpt[section_name]["packages"] = limited_packages
51+
excerpt[section_name]["_excerpt_info"] = {
52+
"total_packages": total_packages,
53+
"shown": max_deps_per_section,
54+
"note": f"Showing {max_deps_per_section} of {total_packages} packages (debug mode excerpt)",
55+
}
56+
else:
57+
# Fallback for any legacy structure
58+
excerpt[section_name] = section_data
5059

5160
return excerpt

dependency_resolver/detectors/apk_detector.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from typing import Optional, Any
22
from ..core.interfaces import EnvironmentExecutor, PackageManagerDetector
33

4+
# Package type constant
5+
PACKAGE_TYPE_APK = "apk"
6+
47

58
class ApkDetector(PackageManagerDetector):
69
"""Detector for system packages managed by apk (Alpine Linux)."""
@@ -21,19 +24,26 @@ def is_usable(self, executor: EnvironmentExecutor, working_dir: Optional[str] =
2124
_, _, apk_exit_code = executor.execute_command("apk --version")
2225
return apk_exit_code == 0
2326

24-
def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> dict[str, Any]:
27+
def get_dependencies(
28+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None
29+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
2530
"""Extract system packages with versions and architecture using apk list.
2631
2732
Uses 'apk list --installed' for comprehensive package information including architecture.
2833
See docs/technical/detectors/apk_detector.md
34+
35+
Returns:
36+
tuple: (packages, metadata)
37+
- packages: List of package dicts with name, version, type
38+
- metadata: Empty dict (system scope has no metadata)
2939
"""
3040
command = "apk list --installed"
3141
stdout, _, exit_code = executor.execute_command(command, working_dir)
3242

3343
if exit_code != 0:
34-
return {"scope": "system", "dependencies": {}}
44+
return [], {}
3545

36-
dependencies = {}
46+
packages = []
3747
for line in stdout.strip().split("\n"):
3848
line = line.strip()
3949

@@ -56,13 +66,9 @@ def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[
5666

5767
full_version = f"{version} {architecture}" if architecture else version
5868

59-
package_data = {
60-
"version": full_version,
61-
}
62-
63-
dependencies[package_name] = package_data
69+
packages.append({"name": package_name, "version": full_version, "type": PACKAGE_TYPE_APK})
6470

65-
return {"scope": "system", "dependencies": dependencies}
71+
return packages, {}
6672

6773
def has_system_scope(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> bool:
6874
"""APK always has system scope (system packages)."""

dependency_resolver/detectors/docker_info_detector.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ def is_usable(self, executor: EnvironmentExecutor, working_dir: Optional[str] =
1212
"""Check if this is a Docker environment."""
1313
return isinstance(executor, DockerExecutor)
1414

15-
def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> dict[str, Any]:
16-
"""Extract Docker container metadata."""
15+
def get_dependencies(
16+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None
17+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
18+
"""Extract Docker container metadata.
19+
20+
Note: This detector returns metadata rather than packages, but conforms to the interface.
21+
The orchestrator handles this specially.
22+
"""
1723
if not isinstance(executor, DockerExecutor):
18-
return {}
24+
return [], {}
1925

2026
container_info = executor.get_container_info()
2127

22-
# Return simplified container info structure
28+
# Return simplified container info structure as metadata
29+
# The orchestrator will handle this specially for _container-info section
2330
result = {
2431
"name": container_info["name"],
2532
"image": container_info["image"],
@@ -30,7 +37,8 @@ def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[
3037
if "error" in container_info:
3138
result["error"] = container_info["error"]
3239

33-
return result
40+
# Return empty packages list and the container info as metadata
41+
return [], result
3442

3543
def has_system_scope(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> bool:
3644
"""Docker container info has container scope, not system scope."""

dependency_resolver/detectors/dpkg_detector.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
from ..core.interfaces import EnvironmentExecutor, PackageManagerDetector
55

6+
# Package type constant
7+
PACKAGE_TYPE_DPKG = "dpkg"
8+
69

710
class DpkgDetector(PackageManagerDetector):
811
"""Detector for system packages managed by dpkg (Debian/Ubuntu)."""
@@ -27,22 +30,29 @@ def is_usable(self, executor: EnvironmentExecutor, working_dir: Optional[str] =
2730
_, _, dpkg_exit_code = executor.execute_command("dpkg-query --version")
2831
return dpkg_exit_code == 0
2932

30-
def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> dict[str, Any]:
33+
def get_dependencies(
34+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None
35+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
3136
"""Extract system packages with versions using dpkg-query.
3237
3338
Uses dpkg-query -W -f for reliable package information extraction.
3439
See docs/technical/detectors/dpkg_detector.md
40+
41+
Returns:
42+
tuple: (packages, metadata)
43+
- packages: List of package dicts with name, version, type, and hash
44+
- metadata: Empty dict (system scope has no metadata)
3545
"""
3646
command = "dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n'"
3747
stdout, _, exit_code = executor.execute_command(command, working_dir)
3848

3949
if exit_code != 0:
40-
return {"scope": "system", "dependencies": {}}
50+
return [], {}
4151

4252
# Collect all package hashes in a single batch operation
4353
batch_hashes = self._collect_all_package_hashes(executor)
4454

45-
dependencies = {}
55+
packages = []
4656
for line in stdout.strip().split("\n"):
4757
if line and "\t" in line:
4858
parts = line.split("\t")
@@ -53,9 +63,7 @@ def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[
5363

5464
full_version = f"{version} {architecture}" if architecture else version
5565

56-
package_data = {
57-
"version": full_version,
58-
}
66+
package_data = {"name": package_name, "version": full_version, "type": PACKAGE_TYPE_DPKG}
5967

6068
# Use batch-collected hash or fallback to individual lookup
6169
package_hash = batch_hashes.get(package_name)
@@ -65,9 +73,9 @@ def get_dependencies(self, executor: EnvironmentExecutor, working_dir: Optional[
6573
if package_hash:
6674
package_data["hash"] = package_hash
6775

68-
dependencies[package_name] = package_data
76+
packages.append(package_data)
6977

70-
return {"scope": "system", "dependencies": dependencies}
78+
return packages, {}
7179

7280
def _get_package_hash(self, executor: EnvironmentExecutor, package_name: str, architecture: str = "") -> str | None:
7381
"""Get package hash from dpkg md5sums file if available.

0 commit comments

Comments
 (0)