Skip to content

Commit cd6bcbe

Browse files
committed
Add global package detection support for npm
1 parent baa1ee7 commit cd6bcbe

File tree

5 files changed

+264
-93
lines changed

5 files changed

+264
-93
lines changed

docs/technical/architecture/adr/0006-multi-location-detection.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ We will implement **conditional output structure** for multi-location detection:
104104
## Implementation Notes
105105

106106
- **pip detector**: First implementation targeting venv + system detection
107+
- **npm detector**: Implemented for local + global package detection
107108
- **Test Infrastructure**: Updated to handle both output formats generically
108109
- **Documentation**: Updated to explain conditional structure behavior
109-
- **Future Detectors**: npm could implement similar pattern for global vs. local node_modules
110110

111111
This approach solves the multi-location problem while minimizing breaking changes and maintaining clear traceability of package origins.

docs/technical/detectors/npm_detector.md

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,37 @@
22

33
## Purpose & Scope
44

5-
The NPM detector identifies Node.js packages managed by `npm` in project environments. It provides structured package information with intelligent package manager detection to avoid conflicts with other Node.js package managers (yarn, pnpm, bun).
5+
The NPM detector identifies Node.js packages managed by `npm` in both project and system environments. It supports multi-location detection to capture project dependencies and globally installed packages simultaneously.
66

77
## Key Features
88

9+
### Multi-Location Detection
10+
11+
Detects npm packages in multiple locations:
12+
13+
- **Local dependencies**: Project-level packages via `npm list --json --depth=0`
14+
- **Global packages**: System-level packages via `npm list -g --json --depth=0`
15+
- **Mixed output**: Returns both when present in same environment
16+
917
### Smart Package Manager Detection
1018

11-
The detector avoids conflicts with other Node.js package managers by checking for lock files:
19+
For local projects, avoids conflicts with other Node.js package managers:
1220

1321
**Exclusion checks:**
1422

15-
- `yarn.lock` exists → Skip (defer to yarn)
16-
- `pnpm-lock.yaml` exists → Skip (defer to pnpm)
17-
- `bun.lockb` exists → Skip (defer to bun)
23+
- `yarn.lock` exists → Skip local detection (defer to yarn)
24+
- `pnpm-lock.yaml` exists → Skip local detection (defer to pnpm)
25+
- `bun.lockb` exists → Skip local detection (defer to bun)
1826

1927
**Inclusion checks:**
2028

21-
- `package.json` or `package-lock.json` exists
29+
- `package.json`, `node_modules`, or `package-lock.json` exists
2230
- No conflicting lock files present
2331

24-
### Project Context Detection
25-
26-
Determines scope based on project indicators:
27-
28-
- **Project scope**: When `package.json` or `node_modules` exists
29-
- **System scope**: When no project indicators found
32+
## Commands Used
3033

31-
## Command Used
32-
33-
- **Package listing**: `npm list --json --depth=0` for structured JSON output of direct dependencies only
34+
- **Local packages**: `npm list --json --depth=0`
35+
- **Global packages**: `npm list -g --json --depth=0`
3436

3537
## Hash Generation
3638

@@ -45,7 +47,7 @@ Individual package-level hashes are **not implemented** for npm dependencies. Th
4547

4648
### Directory Content Hashing
4749

48-
For project-scoped dependencies, generates location-based hashes by scanning the project directory while excluding:
50+
Generates location-based hashes for both project and system locations while excluding:
4951

5052
- npm cache directories (`node_modules/.cache`)
5153
- Log files (`*.log`)
@@ -54,32 +56,54 @@ For project-scoped dependencies, generates location-based hashes by scanning the
5456

5557
## Output Format
5658

57-
**Project Scope** (with location hash):
58-
59-
- Includes `scope: "project"`, project location, and content hash
60-
- Contains only direct dependencies from the project
61-
62-
**System Scope** (no location/hash):
63-
64-
- Includes `scope: "system"`
65-
- Contains system-wide npm packages
59+
**Single Location** (project or system):
60+
61+
```json
62+
{
63+
"scope": "project" | "system",
64+
"location": "/path/to/location",
65+
"hash": "abc123...",
66+
"dependencies": {...}
67+
}
68+
```
69+
70+
**Mixed Locations** (both project and global):
71+
72+
```json
73+
{
74+
"scope": "mixed",
75+
"locations": {
76+
"/path/to/project": {
77+
"scope": "project",
78+
"hash": "abc123...",
79+
"dependencies": {...}
80+
},
81+
"/usr/lib/node_modules": {
82+
"scope": "system",
83+
"hash": "def456...",
84+
"dependencies": {...}
85+
}
86+
}
87+
}
88+
```
6689

6790
## Benefits
6891

92+
- **Complete visibility**: Captures both project and global npm packages
6993
- **Conflict avoidance**: Prevents npm from running in yarn/pnpm/bun projects
70-
- **Project isolation**: Distinguishes project dependencies from system packages
94+
- **Multi-location support**: Follows pip_detector pattern for consistency
7195
- **Structured data**: JSON output provides reliable programmatic parsing
72-
- **Direct dependencies focus**: Captures only project's direct dependencies
96+
- **Direct dependencies focus**: Captures only direct dependencies (depth=0)
7397

7498
## Limitations
7599

76-
- **npm-specific**: Limited to npm package manager only
77100
- **Direct dependencies only**: Excludes transitive dependencies
78101
- **npm dependency**: Requires npm to be installed and functional
102+
- **Global detection**: Only detects packages via `npm list -g` (not manual installations)
79103

80104
## Use Cases
81105

82-
- Node.js project dependency analysis
106+
- Node.js project dependency analysis in containers with global tools
83107
- Package manager conflict avoidance in multi-tool environments
84-
- Direct dependency tracking and version monitoring
108+
- Direct dependency tracking across project and system scopes
85109
- Build reproducibility through location hashing

docs/usage/output-format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Project-specific package managers (pip, npm) output:
8787

8888
### Mixed-Scope Packages (Multi-Location Detection)
8989

90-
When a detector finds packages in multiple locations (currently only pip supports this):
90+
When a detector finds packages in multiple locations (e.g. pip and npm support this):
9191

9292
```json
9393
{

energy_dependency_inspector/detectors/npm_detector.py

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,48 +14,115 @@ def __init__(self, debug: bool = False):
1414
self.debug = debug
1515

1616
def is_usable(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> bool:
17-
"""Check if npm is usable and the project uses npm (not yarn/pnpm)."""
17+
"""Check if npm is usable in the environment."""
1818
_, _, exit_code = executor.execute_command("npm --version", working_dir)
19-
if exit_code != 0:
20-
return False
19+
return exit_code == 0
20+
21+
def get_dependencies(
22+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None, skip_hash_collection: bool = False
23+
) -> dict[str, Any]:
24+
"""Extract npm dependencies with versions from multiple locations.
2125
22-
# Check if project uses npm by looking for npm-specific files in the working directory
26+
Uses 'npm list --json --depth=0' for local packages and
27+
'npm list -g --json --depth=0' for global packages.
28+
Returns single location structure or nested structure for mixed locations.
29+
See docs/technical/detectors/npm_detector.md
30+
"""
31+
# Collect dependencies from both local and global locations
32+
local_result = self._get_local_dependencies(executor, working_dir, skip_hash_collection)
33+
global_result = self._get_global_dependencies(executor, working_dir, skip_hash_collection)
34+
35+
# Determine output structure based on what was found
36+
if local_result and global_result:
37+
# Mixed case - use nested structure
38+
locations = {}
39+
40+
# Add local location
41+
local_location_data = {"scope": "project", "dependencies": local_result["dependencies"]}
42+
if "hash" in local_result:
43+
local_location_data["hash"] = local_result["hash"]
44+
locations[local_result["location"]] = local_location_data
45+
46+
# Add global location
47+
global_location_data = {"scope": "system", "dependencies": global_result["dependencies"]}
48+
if "hash" in global_result:
49+
global_location_data["hash"] = global_result["hash"]
50+
locations[global_result["location"]] = global_location_data
51+
52+
return {"scope": "mixed", "locations": locations}
53+
elif local_result:
54+
# Only local - use single location structure
55+
return local_result
56+
elif global_result:
57+
# Only global - use single location structure
58+
return global_result
59+
else:
60+
# No packages found - return empty result
61+
if working_dir:
62+
location = self._resolve_absolute_path(executor, working_dir)
63+
return {"scope": "project", "location": location, "dependencies": {}}
64+
else:
65+
return {"scope": "system", "location": "/usr/lib/node_modules", "dependencies": {}}
66+
67+
def _get_local_dependencies(
68+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None, skip_hash_collection: bool = False
69+
) -> dict[str, Any] | None:
70+
"""Get dependencies from local npm project if it exists."""
2371
search_dir = working_dir or "."
2472

25-
# Check package.json first (most common case)
26-
if executor.path_exists(f"{search_dir}/package.json"):
27-
# Only check exclusions if package.json exists
73+
# Check if this is a local npm project (not yarn/pnpm/bun)
74+
has_package_json = executor.path_exists(f"{search_dir}/package.json")
75+
has_node_modules = executor.path_exists(f"{search_dir}/node_modules")
76+
has_package_lock = executor.path_exists(f"{search_dir}/package-lock.json")
77+
78+
# Exclude if using other package managers
79+
if has_package_json:
2880
exclusions = ["yarn.lock", "pnpm-lock.yaml", "bun.lockb"]
2981
for exclusion in exclusions:
3082
if executor.path_exists(f"{search_dir}/{exclusion}"):
31-
return False
32-
return True
83+
return None
3384

34-
# Fallback to package-lock.json
35-
return executor.path_exists(f"{search_dir}/package-lock.json")
85+
# Only proceed if we have signs of a local npm project
86+
if not (has_package_json or has_node_modules or has_package_lock):
87+
return None
3688

37-
def get_dependencies(
38-
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None, skip_hash_collection: bool = False
39-
) -> dict[str, Any]:
40-
"""Extract npm dependencies with versions.
41-
42-
Uses 'npm list --json --depth=0' for structured package information.
43-
See docs/technical/detectors/npm_detector.md
44-
"""
4589
stdout, _, exit_code = executor.execute_command("npm list --json --depth=0", working_dir)
90+
if exit_code != 0:
91+
return None
92+
93+
dependencies = {}
94+
try:
95+
npm_data = json.loads(stdout)
96+
npm_dependencies = npm_data.get("dependencies", {})
97+
98+
for package_name, package_info in npm_dependencies.items():
99+
version = package_info.get("version", "unknown")
100+
dependencies[package_name] = {"version": version}
46101

47-
location = self._get_npm_location(executor, working_dir)
48-
scope = "system" if self._is_system_location(location) else "project"
49-
dependencies: dict[str, dict[str, str]] = {}
102+
except (json.JSONDecodeError, AttributeError):
103+
pass
104+
105+
if not dependencies:
106+
return None
50107

51-
# Build result with desired field order: scope, location, hash, dependencies
52-
result: dict[str, Any] = {"scope": scope}
53-
result["location"] = location
108+
# Get local project location
109+
location = self._get_local_npm_location(executor, working_dir)
110+
111+
result: dict[str, Any] = {"scope": "project", "location": location}
112+
if not skip_hash_collection:
113+
result["hash"] = self._generate_location_hash(executor, location)
114+
result["dependencies"] = dependencies
115+
return result
54116

117+
def _get_global_dependencies(
118+
self, executor: EnvironmentExecutor, working_dir: Optional[str] = None, skip_hash_collection: bool = False
119+
) -> dict[str, Any] | None:
120+
"""Get dependencies from global npm installation."""
121+
stdout, _, exit_code = executor.execute_command("npm list -g --json --depth=0", working_dir)
55122
if exit_code != 0:
56-
result["dependencies"] = dependencies
57-
return result
123+
return None
58124

125+
dependencies = {}
59126
try:
60127
npm_data = json.loads(stdout)
61128
npm_dependencies = npm_data.get("dependencies", {})
@@ -67,24 +134,24 @@ def get_dependencies(
67134
except (json.JSONDecodeError, AttributeError):
68135
pass
69136

70-
# Generate location-based hash if appropriate
71-
if dependencies and not skip_hash_collection:
72-
result["hash"] = self._generate_location_hash(executor, location)
137+
if not dependencies:
138+
return None
73139

74-
result["dependencies"] = dependencies
140+
# Get global npm location
141+
location = self._get_global_npm_location(executor)
75142

143+
result: dict[str, Any] = {"scope": "system", "location": location}
144+
if not skip_hash_collection:
145+
result["hash"] = self._generate_location_hash(executor, location)
146+
result["dependencies"] = dependencies
76147
return result
77148

78149
def _is_system_location(self, location: str) -> bool:
79-
"""Check if a location represents a system-wide npm installation.
150+
"""Check if a location represents a system-wide npm installation."""
151+
return location.startswith(("/usr/lib/node_modules", "/usr/local/lib/node_modules"))
80152

81-
For npm, system scope means no local package.json or node_modules found.
82-
This is determined by the special 'system' marker returned by _get_npm_location.
83-
"""
84-
return location == "system"
85-
86-
def _get_npm_location(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> str:
87-
"""Get the actual location path of the npm project or global installation."""
153+
def _get_local_npm_location(self, executor: EnvironmentExecutor, working_dir: Optional[str] = None) -> str:
154+
"""Get the actual location path of the local npm project."""
88155
search_dir = working_dir or "."
89156

90157
package_json_path = f"{search_dir}/package.json"
@@ -95,13 +162,19 @@ def _get_npm_location(self, executor: EnvironmentExecutor, working_dir: Optional
95162
if executor.path_exists(node_modules_path):
96163
return self._resolve_absolute_path(executor, search_dir)
97164

98-
# Fallback: get npm global location when no local project found
165+
# Fallback to current directory
166+
return self._resolve_absolute_path(executor, search_dir)
167+
168+
def _get_global_npm_location(self, executor: EnvironmentExecutor) -> str:
169+
"""Get the actual location path of the global npm installation."""
170+
# Get npm prefix (e.g., /usr)
99171
stdout, _, exit_code = executor.execute_command("npm config get prefix")
100172
if exit_code == 0 and stdout.strip():
101-
return stdout.strip()
173+
prefix = stdout.strip()
174+
return f"{prefix}/lib/node_modules"
102175

103-
# Final fallback: return "system" marker for system scope identification
104-
return "system"
176+
# Fallback: return default global location
177+
return "/usr/lib/node_modules"
105178

106179
def _resolve_absolute_path(self, executor: EnvironmentExecutor, path: str) -> str:
107180
"""Resolve absolute path within the executor's context."""

0 commit comments

Comments
 (0)