Skip to content

Commit a2e83a7

Browse files
authored
Merge pull request #1680 from blackducksoftware/dev/dterry/IDETECT-4965-npm-cli-alias
add support for aliases in the npm cli detector
2 parents e937093 + 49a4c6d commit a2e83a7

5 files changed

Lines changed: 418 additions & 48 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.blackduck.integration.detectable.detectables.npm;
2+
3+
/**
4+
* Utility class for parsing npm package aliases.
5+
* Npm aliases allow packages to be installed under a different name using the format:
6+
* "alias-name": "npm:actual-package@version"
7+
*/
8+
public class NpmAliasParser {
9+
10+
private static final String NPM_ALIAS_PREFIX = "npm:";
11+
12+
/**
13+
* Parses an npm alias string to extract the actual package name.
14+
* Npm aliases have the format: "npm:package@version" or "npm:@scope/package@version"
15+
*
16+
* For scoped packages (starting with @), there are two @ symbols:
17+
* - First @ is part of the scope name
18+
* - Second @ (after the /) separates the package name from the version
19+
*
20+
* @param aliasValue The full alias string (e.g., "npm:package@^1.0.0" or "npm:@scope/package@^7.0.0")
21+
* @return Array with [0] = package name, [1] = version specifier, or null if not an alias
22+
*/
23+
public static String[] parseNpmAlias(String aliasValue) {
24+
if (aliasValue == null || !aliasValue.startsWith(NPM_ALIAS_PREFIX)) {
25+
return null;
26+
}
27+
28+
String normalizedPackage = aliasValue.substring(4); // Remove "npm:" prefix
29+
int versionAtIndex = -1;
30+
31+
// For scoped packages (e.g., @scope/package@^7.0.0), find the @ after the /
32+
// For non-scoped packages (e.g., package@^1.0.0), find the first @
33+
if (normalizedPackage.startsWith("@")) {
34+
int slashIndex = normalizedPackage.indexOf('/');
35+
if (slashIndex > 0) {
36+
versionAtIndex = normalizedPackage.indexOf('@', slashIndex + 1);
37+
}
38+
} else {
39+
versionAtIndex = normalizedPackage.indexOf('@');
40+
}
41+
42+
if (versionAtIndex > 0) {
43+
return new String[] {
44+
normalizedPackage.substring(0, versionAtIndex),
45+
normalizedPackage.substring(versionAtIndex + 1)
46+
};
47+
} else {
48+
// No version specified (e.g., "npm:package" or "npm:@scope/package")
49+
return new String[] { normalizedPackage, normalizedPackage };
50+
}
51+
}
52+
53+
/**
54+
* Checks if a dependency value string is an npm alias.
55+
*
56+
* @param value The dependency value from package.json
57+
* @return true if the value is an npm alias (starts with "npm:")
58+
*/
59+
public static boolean isNpmAlias(String value) {
60+
return value != null && value.startsWith(NPM_ALIAS_PREFIX);
61+
}
62+
63+
/**
64+
* Extracts just the package name from an npm alias.
65+
*
66+
* @param aliasValue The full alias string
67+
* @return The actual package name, or null if not an alias
68+
*/
69+
public static String extractPackageName(String aliasValue) {
70+
String[] parsed = parseNpmAlias(aliasValue);
71+
return parsed != null ? parsed[0] : null;
72+
}
73+
}

detectable/src/main/java/com/blackduck/integration/detectable/detectables/npm/cli/parse/NpmCliParser.java

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.blackduck.integration.detectable.detectables.npm.cli.parse;
22

3+
import java.util.HashMap;
4+
import java.util.Map;
35
import java.util.Map.Entry;
46
import java.util.Objects;
57
import java.util.Optional;
68
import java.util.Set;
79

10+
import org.apache.commons.collections4.MultiValuedMap;
811
import org.apache.commons.lang3.StringUtils;
912
import org.slf4j.Logger;
1013
import org.slf4j.LoggerFactory;
@@ -17,6 +20,7 @@
1720
import com.blackduck.integration.bdio.model.externalid.ExternalIdFactory;
1821
import com.blackduck.integration.detectable.detectable.codelocation.CodeLocation;
1922
import com.blackduck.integration.detectable.detectable.util.EnumListFilter;
23+
import com.blackduck.integration.detectable.detectables.npm.NpmAliasParser;
2024
import com.blackduck.integration.detectable.detectables.npm.NpmDependencyType;
2125
import com.blackduck.integration.detectable.detectables.npm.lockfile.result.NpmPackagerResult;
2226
import com.blackduck.integration.detectable.detectables.npm.packagejson.CombinedPackageJson;
@@ -65,7 +69,10 @@ public NpmPackagerResult convertNpmJsonFileToCodeLocation(String npmLsOutput, Co
6569
projectVersion = projectVersionElement.getAsString();
6670
}
6771

68-
populateChildren(graph, null, npmJson.getAsJsonObject(JSON_DEPENDENCIES), true, combinedPackageJson);
72+
// Build alias mapping once from package.json
73+
Map<String, String> aliasMapping = buildAliasMapping(combinedPackageJson);
74+
75+
populateChildren(graph, null, npmJson.getAsJsonObject(JSON_DEPENDENCIES), true, combinedPackageJson, aliasMapping);
6976

7077
ExternalId externalId = externalIdFactory.createNameVersionExternalId(Forge.NPMJS, projectName, projectVersion);
7178

@@ -75,7 +82,46 @@ public NpmPackagerResult convertNpmJsonFileToCodeLocation(String npmLsOutput, Co
7582

7683
}
7784

78-
private void populateChildren(DependencyGraph graph, Dependency parentDependency, JsonObject parentNodeChildren, boolean isRootDependency, CombinedPackageJson combinedPackageJson) {
85+
/**
86+
* Builds a mapping of alias names to actual package names from CombinedPackageJson.
87+
* Scans all dependency maps (dependencies, devDependencies, peerDependencies, optionalDependencies)
88+
* looking for entries with "npm:" prefix.
89+
*
90+
* @param combinedPackageJson The package.json data
91+
* @return Map of alias name -> actual package name
92+
*/
93+
private Map<String, String> buildAliasMapping(CombinedPackageJson combinedPackageJson) {
94+
Map<String, String> aliasMapping = new HashMap<>();
95+
96+
// Check all dependency types for aliases
97+
scanDependenciesForAliases(combinedPackageJson.getDependencies(), aliasMapping);
98+
scanDependenciesForAliases(combinedPackageJson.getDevDependencies(), aliasMapping);
99+
scanDependenciesForAliases(combinedPackageJson.getPeerDependencies(), aliasMapping);
100+
scanDependenciesForAliases(combinedPackageJson.getOptionalDependencies(), aliasMapping);
101+
102+
return aliasMapping;
103+
}
104+
105+
private void scanDependenciesForAliases(MultiValuedMap<String, String> dependencies, Map<String, String> aliasMapping) {
106+
if (dependencies == null) {
107+
return;
108+
}
109+
110+
for (Map.Entry<String, String> entry : dependencies.entries()) {
111+
String aliasName = entry.getKey();
112+
String versionSpec = entry.getValue();
113+
114+
if (NpmAliasParser.isNpmAlias(versionSpec)) {
115+
String actualPackageName = NpmAliasParser.extractPackageName(versionSpec);
116+
if (actualPackageName != null) {
117+
aliasMapping.put(aliasName, actualPackageName);
118+
logger.debug("Found npm alias: {} -> {}", aliasName, actualPackageName);
119+
}
120+
}
121+
}
122+
}
123+
124+
private void populateChildren(DependencyGraph graph, Dependency parentDependency, JsonObject parentNodeChildren, boolean isRootDependency, CombinedPackageJson combinedPackageJson, Map<String, String> aliasMapping) {
79125
if (parentNodeChildren == null) {
80126
return;
81127
}
@@ -97,28 +143,32 @@ private void populateChildren(DependencyGraph graph, Dependency parentDependency
97143
&& combinedPackageJson.getOptionalDependencies().containsKey(elementEntry.getKey()));
98144
return !excludingBecauseDev && !excludingBecausePeer && !excludingBecauseOptional;
99145
})
100-
.forEach(elementEntry -> processChild(elementEntry, graph, parentDependency, isRootDependency, combinedPackageJson));
146+
.forEach(elementEntry -> processChild(elementEntry, graph, parentDependency, isRootDependency, combinedPackageJson, aliasMapping));
101147
}
102148

103149
private void processChild(
104150
Entry<String, JsonElement> elementEntry,
105151
DependencyGraph graph,
106152
Dependency parentDependency,
107153
boolean isRootDependency,
108-
CombinedPackageJson combinedPackageJson
154+
CombinedPackageJson combinedPackageJson,
155+
Map<String, String> aliasMapping
109156
) {
110157
JsonObject element = elementEntry.getValue().getAsJsonObject();
111158
String name = elementEntry.getKey();
159+
160+
// Check if this is an alias and resolve to actual package name
161+
String actualName = aliasMapping.getOrDefault(name, name);
112162
String version = Optional.ofNullable(element.getAsJsonPrimitive(JSON_VERSION))
113163
.filter(JsonPrimitive::isString)
114164
.map(JsonPrimitive::getAsString)
115165
.orElse(null);
116166

117167
JsonObject children = element.getAsJsonObject(JSON_DEPENDENCIES);
118168

119-
if (name != null && version != null) {
120-
ExternalId externalId = externalIdFactory.createNameVersionExternalId(Forge.NPMJS, name, version);
121-
Dependency child = new Dependency(name, version, externalId);
169+
if (actualName != null && version != null) {
170+
ExternalId externalId = externalIdFactory.createNameVersionExternalId(Forge.NPMJS, actualName, version);
171+
Dependency child = new Dependency(actualName, version, externalId);
122172

123173
// Any workspace dependency is considered a direct dependency
124174
boolean directWorkspaceDependency = false;
@@ -137,15 +187,15 @@ private void processChild(
137187
combinedPackageJson.getRelativeWorkspaces().stream().anyMatch(workspace -> workspace.equals(convertedPossibleWorkspaceDependency));
138188
}
139189

140-
populateChildren(graph, child, children, directWorkspaceDependency, combinedPackageJson);
190+
populateChildren(graph, child, children, directWorkspaceDependency, combinedPackageJson, aliasMapping);
141191

142192
if (isRootDependency || directWorkspaceDependency) {
143193
graph.addChildToRoot(child);
144194
} else {
145195
graph.addParentWithChild(parentDependency, child);
146196
}
147197
} else {
148-
logger.trace(String.format("Excluding Json Element missing name or version: { name: %s, version: %s }", name, version));
198+
logger.trace(String.format("Excluding Json Element missing name or version: { name: %s, version: %s }", actualName, version));
149199
}
150200
}
151201
}

detectable/src/main/java/com/blackduck/integration/detectable/detectables/npm/packagejson/PackageJsonExtractor.java

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.blackduck.integration.detectable.detectable.codelocation.CodeLocation;
2222
import com.blackduck.integration.detectable.detectable.util.EnumListFilter;
2323
import com.blackduck.integration.detectable.detectable.util.SemVerComparator;
24+
import com.blackduck.integration.detectable.detectables.npm.NpmAliasParser;
2425
import com.blackduck.integration.detectable.detectables.npm.NpmDependencyType;
2526
import com.blackduck.integration.detectable.extraction.Extraction;
2627
import com.google.gson.Gson;
@@ -82,8 +83,8 @@ private List<Dependency> transformDependencies(MultiValuedMap<String, String> de
8283

8384
private Dependency entryToDependency(String key, String value) {
8485
// Handle npm aliases: "alias-name": "npm:actual-package@version"
85-
if (value.startsWith("npm:")) {
86-
String[] parsed = parseNpmAlias(value);
86+
String[] parsed = NpmAliasParser.parseNpmAlias(value);
87+
if (parsed != null) {
8788
key = parsed[0];
8889
value = parsed[1];
8990
}
@@ -93,43 +94,6 @@ private Dependency entryToDependency(String key, String value) {
9394
return new Dependency(externalId);
9495
}
9596

96-
/**
97-
* Parses an npm alias string to extract the actual package name and version specifier.
98-
*
99-
* Npm aliases have the format: "npm:package@version" or "npm:@scope/package@version"
100-
* For scoped packages (starting with @), there are two @ symbols:
101-
* - First @ is part of the scope name
102-
* - Second @ (after the /) separates the package name from the version
103-
*
104-
* @param aliasValue The full alias string (e.g., "npm:package@^1.0.0" or "npm:@scope/package@^7.0.0")
105-
* @return Array with [0] = package name, [1] = version specifier
106-
*/
107-
private String[] parseNpmAlias(String aliasValue) {
108-
String normalizedPackage = aliasValue.substring(4); // Remove "npm:" prefix
109-
int versionAtIndex = -1;
110-
111-
// For scoped packages (e.g., @scope/package@^7.0.0), find the @ after the /
112-
// For non-scoped packages (e.g., package@^1.0.0), find the first @
113-
if (normalizedPackage.startsWith("@")) {
114-
int slashIndex = normalizedPackage.indexOf('/');
115-
if (slashIndex > 0) {
116-
versionAtIndex = normalizedPackage.indexOf('@', slashIndex + 1);
117-
}
118-
} else {
119-
versionAtIndex = normalizedPackage.indexOf('@');
120-
}
121-
122-
if (versionAtIndex > 0) {
123-
return new String[] {
124-
normalizedPackage.substring(0, versionAtIndex),
125-
normalizedPackage.substring(versionAtIndex + 1)
126-
};
127-
} else {
128-
// No version specified (e.g., "npm:package" or "npm:@scope/package")
129-
return new String[] { normalizedPackage, normalizedPackage };
130-
}
131-
}
132-
13397
public String extractLowestVersion(String value) {
13498
SemVerComparator semVerComparator = new SemVerComparator();
13599

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.blackduck.integration.detectable.detectables.npm;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertNull;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
class NpmAliasParserTest {
12+
13+
@Test
14+
void testParseNonScopedAlias() {
15+
String aliasValue = "npm:actualpackage@^1.0.0";
16+
String[] result = NpmAliasParser.parseNpmAlias(aliasValue);
17+
18+
assertArrayEquals(new String[] { "actualpackage", "^1.0.0" }, result);
19+
}
20+
21+
@Test
22+
void testParseScopedAlias() {
23+
String aliasValue = "npm:@scope/package@^2.0.0";
24+
String[] result = NpmAliasParser.parseNpmAlias(aliasValue);
25+
26+
assertArrayEquals(new String[] { "@scope/package", "^2.0.0" }, result);
27+
}
28+
29+
@Test
30+
void testParseAliasWithoutVersion() {
31+
String aliasValue = "npm:package";
32+
String[] result = NpmAliasParser.parseNpmAlias(aliasValue);
33+
34+
assertArrayEquals(new String[] { "package", "package" }, result);
35+
}
36+
37+
@Test
38+
void testParseScopedAliasWithoutVersion() {
39+
String aliasValue = "npm:@scope/package";
40+
String[] result = NpmAliasParser.parseNpmAlias(aliasValue);
41+
42+
assertArrayEquals(new String[] { "@scope/package", "@scope/package" }, result);
43+
}
44+
45+
@Test
46+
void testParseNonAlias() {
47+
String regularValue = "^1.0.0";
48+
String[] result = NpmAliasParser.parseNpmAlias(regularValue);
49+
50+
assertNull(result);
51+
}
52+
53+
@Test
54+
void testParseNullValue() {
55+
String[] result = NpmAliasParser.parseNpmAlias(null);
56+
57+
assertNull(result);
58+
}
59+
60+
@Test
61+
void testIsNpmAlias() {
62+
assertTrue(NpmAliasParser.isNpmAlias("npm:package@^1.0.0"));
63+
assertTrue(NpmAliasParser.isNpmAlias("npm:@scope/package@^2.0.0"));
64+
assertTrue(NpmAliasParser.isNpmAlias("npm:package"));
65+
}
66+
67+
@Test
68+
void testIsNotNpmAlias() {
69+
assertFalse(NpmAliasParser.isNpmAlias("^1.0.0"));
70+
assertFalse(NpmAliasParser.isNpmAlias("package"));
71+
assertFalse(NpmAliasParser.isNpmAlias(null));
72+
assertFalse(NpmAliasParser.isNpmAlias(""));
73+
}
74+
75+
@Test
76+
void testExtractPackageName() {
77+
assertEquals("actualpackage", NpmAliasParser.extractPackageName("npm:actualpackage@^1.0.0"));
78+
assertEquals("@scope/package", NpmAliasParser.extractPackageName("npm:@scope/package@^2.0.0"));
79+
assertEquals("package", NpmAliasParser.extractPackageName("npm:package"));
80+
}
81+
82+
@Test
83+
void testExtractPackageNameFromNonAlias() {
84+
assertNull(NpmAliasParser.extractPackageName("^1.0.0"));
85+
assertNull(NpmAliasParser.extractPackageName(null));
86+
}
87+
88+
@Test
89+
void testParseComplexVersionSpecifiers() {
90+
// Test with various version specifiers
91+
String[] result1 = NpmAliasParser.parseNpmAlias("npm:package@~1.2.3");
92+
assertArrayEquals(new String[] { "package", "~1.2.3" }, result1);
93+
94+
String[] result2 = NpmAliasParser.parseNpmAlias("npm:package@>=1.0.0");
95+
assertArrayEquals(new String[] { "package", ">=1.0.0" }, result2);
96+
97+
String[] result3 = NpmAliasParser.parseNpmAlias("npm:@scope/package@1.2.3-beta.1");
98+
assertArrayEquals(new String[] { "@scope/package", "1.2.3-beta.1" }, result3);
99+
}
100+
}

0 commit comments

Comments
 (0)