Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "vscode-java-dependency",
"displayName": "Project Manager for Java",
"description": "%description%",
"version": "0.26.5",
"version": "0.27.0",
"publisher": "vscjava",
"preview": false,
"aiKey": "5c642b22-e845-4400-badb-3f8509a70777",
Expand Down
2 changes: 1 addition & 1 deletion src/syncHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class SyncHandler implements Disposable {

this.disposables.push(workspace.onDidChangeWorkspaceFolders(() => {
this.refresh();
upgradeManager.scan();
setImmediate(() => upgradeManager.scan()); // Deferred
}));

try {
Expand Down
231 changes: 206 additions & 25 deletions src/upgrade/assessmentManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

import * as fs from 'fs';
import * as semver from 'semver';
import { globby } from 'globby';

import { Uri } from 'vscode';
import { Jdtls } from "../java/jdtls";
import { NodeKind, type INodeData } from "../java/nodeData";
import { type DependencyCheckItem, type UpgradeIssue, type PackageDescription, UpgradeReason } from "./type";
Expand All @@ -11,6 +15,7 @@ import { buildPackageId } from './utility';
import metadataManager from './metadataManager';
import { sendInfo } from 'vscode-extension-telemetry-wrapper';
import { batchGetCVEIssues } from './cve';
import { ContainerPath } from '../views/containerNode';

function packageNodeToDescription(node: INodeData): PackageDescription | null {
const version = node.metaData?.["maven.version"];
Expand Down Expand Up @@ -143,62 +148,238 @@ async function getDependencyIssues(dependencies: PackageDescription[]): Promise<
return issues;
}

async function getProjectIssues(projectNode: INodeData): Promise<UpgradeIssue[]> {
const issues: UpgradeIssue[] = [];
const dependencies = await getAllDependencies(projectNode);
issues.push(...await getCVEIssues(dependencies));
issues.push(...getJavaIssues(projectNode));
issues.push(...await getDependencyIssues(dependencies));
async function getWorkspaceIssues(projectDeps: {projectNode: INodeData, dependencies: PackageDescription[]}[]): Promise<UpgradeIssue[]> {

const issues: UpgradeIssue[] = [];
const dependencyMap: Map<string, PackageDescription> = new Map();
for (const { projectNode, dependencies } of projectDeps) {
issues.push(...getJavaIssues(projectNode));
for (const dep of dependencies) {
const key = `${dep.groupId}:${dep.artifactId}:${dep.version ?? ""}`;
if (!dependencyMap.has(key)) {
dependencyMap.set(key, dep);
}
}
}
const uniqueDependencies = Array.from(dependencyMap.values());
issues.push(...await getCVEIssues(uniqueDependencies));
issues.push(...await getDependencyIssues(uniqueDependencies));
return issues;
}

/**
* Find all pom.xml files in a directory using glob
*/
async function findAllPomFiles(dir: string): Promise<string[]> {
try {
return await globby('**/pom.xml', {
cwd: dir,
absolute: true,
ignore: ['**/node_modules/**', '**/target/**', '**/.git/**', '**/.idea/**', '**/.vscode/**']
});
} catch {
return [];
}
}

async function getWorkspaceIssues(workspaceFolderUri: string): Promise<UpgradeIssue[]> {
const projects = await Jdtls.getProjects(workspaceFolderUri);
const projectsIssues = await Promise.allSettled(projects.map(async (projectNode) => {
const issues = await getProjectIssues(projectNode);
return issues;
}));
/**
* Parse dependencies from a single pom.xml file
*/
function parseDependenciesFromSinglePom(pomPath: string): Set<string> {
// TODO : Use a proper XML parser if needed
const directDeps = new Set<string>();
try {
const pomContent = fs.readFileSync(pomPath, 'utf-8');

// Extract dependencies from <dependencies> section (not inside <dependencyManagement>)
// First, remove dependencyManagement sections to avoid including managed deps
const withoutDepMgmt = pomContent.replace(/<dependencyManagement>[\s\S]*?<\/dependencyManagement>/g, '');
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern /<dependencyManagement>[\s\S]*?<\/dependencyManagement>/g uses a non-greedy match, but this can fail with nested structures. If the XML contains CDATA sections, comments with these tags, or multiple dependencyManagement blocks, the regex might remove content incorrectly. Also, the regex won't handle self-closing tags or handle namespace prefixes (e.g., <maven:dependencyManagement>). Consider using a proper XML parser instead of regex-based string manipulation.

Copilot uses AI. Check for mistakes.

const workspaceIssues = projectsIssues.map(x => {
if (x.status === "fulfilled") {
return x.value;
// Match <dependency> blocks and extract groupId and artifactId
const dependencyRegex = /<dependency>\s*<groupId>([^<]+)<\/groupId>\s*<artifactId>([^<]+)<\/artifactId>/g;
let match = dependencyRegex.exec(withoutDepMgmt);
while (match !== null) {
const groupId = match[1].trim();
const artifactId = match[2].trim();
// Skip property references like ${project.groupId}
if (!groupId.includes('${') && !artifactId.includes('${')) {
directDeps.add(`${groupId}:${artifactId}`);
}
match = dependencyRegex.exec(withoutDepMgmt);
Comment on lines +198 to +208
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Maven dependency parser doesn't filter out test-scoped dependencies. Test dependencies should typically be excluded from upgrade notifications since they're not part of the production runtime. Consider adding logic to parse and exclude dependencies with <scope>test</scope> or other non-runtime scopes (provided, system, etc.).

Suggested change
// Match <dependency> blocks and extract groupId and artifactId
const dependencyRegex = /<dependency>\s*<groupId>([^<]+)<\/groupId>\s*<artifactId>([^<]+)<\/artifactId>/g;
let match = dependencyRegex.exec(withoutDepMgmt);
while (match !== null) {
const groupId = match[1].trim();
const artifactId = match[2].trim();
// Skip property references like ${project.groupId}
if (!groupId.includes('${') && !artifactId.includes('${')) {
directDeps.add(`${groupId}:${artifactId}`);
}
match = dependencyRegex.exec(withoutDepMgmt);
// Match full <dependency> blocks so we can inspect scope as well as coordinates
const dependencyBlockRegex = /<dependency>([\s\S]*?)<\/dependency>/g;
let blockMatch = dependencyBlockRegex.exec(withoutDepMgmt);
while (blockMatch !== null) {
const dependencyBlock = blockMatch[1];
const groupIdMatch = /<groupId>\s*([^<]+?)\s*<\/groupId>/.exec(dependencyBlock);
const artifactIdMatch = /<artifactId>\s*([^<]+?)\s*<\/artifactId>/.exec(dependencyBlock);
if (groupIdMatch && artifactIdMatch) {
const groupId = groupIdMatch[1].trim();
const artifactId = artifactIdMatch[1].trim();
// Skip property references like ${project.groupId}
if (!groupId.includes('${') && !artifactId.includes('${')) {
// Extract scope if present; default (no scope) is treated as compile/runtime
const scopeMatch = /<scope>\s*([^<]+?)\s*<\/scope>/.exec(dependencyBlock);
const scope = scopeMatch ? scopeMatch[1].trim() : undefined;
const nonRuntimeScopes = new Set(['test', 'provided', 'system']);
if (!scope || !nonRuntimeScopes.has(scope)) {
directDeps.add(`${groupId}:${artifactId}`);
}
}
}
blockMatch = dependencyBlockRegex.exec(withoutDepMgmt);

Copilot uses AI. Check for mistakes.
}
Comment on lines +198 to 209
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern for parsing Maven dependencies has limitations:

  1. It requires groupId to come before artifactId, but XML allows any order
  2. It doesn't handle extra whitespace or newlines between elements robustly
  3. The pattern assumes no whitespace inside tags, but XML parsers would allow < groupId >

Consider using a proper XML parser (e.g., xml2js or fast-xml-parser) for more reliable parsing of pom.xml files.

Copilot uses AI. Check for mistakes.
} catch {
// If we can't read the pom, return empty set
}
return directDeps;
}

sendInfo("", {
operationName: "java.dependency.assessmentManager.getWorkspaceIssues",
/**
* Parse direct dependencies from all pom.xml files in the project.
* Finds all pom.xml files starting from the project root and parses them to collect dependencies.
*/
async function parseDirectDependenciesFromPom(projectPath: string): Promise<Set<string>> {
const directDeps = new Set<string>();

// Find all pom.xml files in the project starting from the project root
const allPomFiles = await findAllPomFiles(projectPath);

// Parse each pom.xml and collect dependencies
for (const pom of allPomFiles) {
const deps = parseDependenciesFromSinglePom(pom);
deps.forEach(dep => directDeps.add(dep));
}

return directDeps;
}

/**
* Find all Gradle build files in a directory using glob
*/
async function findAllGradleFiles(dir: string): Promise<string[]> {
try {
return await globby('**/{build.gradle,build.gradle.kts}', {
cwd: dir,
absolute: true,
ignore: ['**/node_modules/**', '**/build/**', '**/.git/**', '**/.idea/**', '**/.vscode/**', '**/.gradle/**']
});
} catch {
return [];
}).flat();
}
}

/**
* Parse dependencies from a single Gradle build file
*/
function parseDependenciesFromSingleGradle(gradlePath: string): Set<string> {
const directDeps = new Set<string>();
try {
const gradleContent = fs.readFileSync(gradlePath, 'utf-8');

// Match common dependency configurations:
// implementation 'group:artifact:version'
// implementation "group:artifact:version"
// api 'group:artifact:version'
// compileOnly, runtimeOnly, testImplementation, etc.
const shortFormRegex = /(?:implementation|api|compile|compileOnly|runtimeOnly|testImplementation|testCompileOnly|testRuntimeOnly)\s*\(?['"]([^:'"]+):([^:'"]+)(?::[^'"]*)?['"]\)?/g;
let match = shortFormRegex.exec(gradleContent);
while (match !== null) {
const groupId = match[1].trim();
const artifactId = match[2].trim();
if (!groupId.includes('$') && !artifactId.includes('$')) {
directDeps.add(`${groupId}:${artifactId}`);
}
match = shortFormRegex.exec(gradleContent);
}

// Match map notation: implementation group: 'x', name: 'y', version: 'z'
const mapFormRegex = /(?:implementation|api|compile|compileOnly|runtimeOnly|testImplementation|testCompileOnly|testRuntimeOnly)\s*\(?group:\s*['"]([^'"]+)['"]\s*,\s*name:\s*['"]([^'"]+)['"]/g;
Comment on lines +263 to +275
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Gradle dependency parser includes test-scoped dependencies (testImplementation, testCompileOnly, testRuntimeOnly) at lines 260 and 272. These test dependencies should typically be excluded from upgrade notifications as they are not part of the production runtime. Consider either filtering out test configurations or making this behavior configurable.

Copilot uses AI. Check for mistakes.
match = mapFormRegex.exec(gradleContent);
while (match !== null) {
const groupId = match[1].trim();
const artifactId = match[2].trim();
if (!groupId.includes('$') && !artifactId.includes('$')) {
directDeps.add(`${groupId}:${artifactId}`);
}
match = mapFormRegex.exec(gradleContent);
}
Comment on lines +263 to +284
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex patterns for parsing Gradle dependencies have several limitations:

  1. They don't handle multiline dependency declarations (e.g., when group, name, and version are on separate lines)
  2. The Kotlin DSL syntax differs from Groovy (uses parentheses and different string syntax)
  3. Complex scenarios like dependency constraints, platform dependencies, or dependencies with closures will be missed

Consider using Gradle's built-in dependency reporting (e.g., parsing output from gradle dependencies) or a more robust parser.

Copilot uses AI. Check for mistakes.
} catch {
// If we can't read the gradle file, return empty set
}
return directDeps;
}

/**
* Parse direct dependencies from all Gradle build files in the project.
* Finds all build.gradle and build.gradle.kts files and parses them to collect dependencies.
*/
async function parseDirectDependenciesFromGradle(projectPath: string): Promise<Set<string>> {
const directDeps = new Set<string>();

// Find all Gradle build files in the project
const allGradleFiles = await findAllGradleFiles(projectPath);

// Parse each gradle file and collect dependencies
for (const gradleFile of allGradleFiles) {
const deps = parseDependenciesFromSingleGradle(gradleFile);
deps.forEach(dep => directDeps.add(dep));
}

return workspaceIssues;
return directDeps;
}

async function getAllDependencies(projectNode: INodeData): Promise<PackageDescription[]> {
export async function getDirectDependencies(projectNode: INodeData): Promise<PackageDescription[]> {
const projectStructureData = await Jdtls.getPackageData({ kind: NodeKind.Project, projectUri: projectNode.uri });
const packageContainers = projectStructureData.filter(x => x.kind === NodeKind.Container);
// Only include Maven or Gradle containers (not JRE or other containers)
const dependencyContainers = projectStructureData.filter(x =>
x.kind === NodeKind.Container &&
(x.path?.startsWith(ContainerPath.Maven) || x.path?.startsWith(ContainerPath.Gradle))
);

if (dependencyContainers.length === 0) {
return [];
}

const allPackages = await Promise.allSettled(
packageContainers.map(async (packageContainer) => {
dependencyContainers.map(async (packageContainer) => {
const packageNodes = await Jdtls.getPackageData({
kind: NodeKind.Container,
projectUri: projectNode.uri,
path: packageContainer.path,
});
return packageNodes.map(packageNodeToDescription).filter((x): x is PackageDescription => Boolean(x));
return packageNodes
.map(packageNodeToDescription)
.filter((x): x is PackageDescription => Boolean(x));
})
);

const fulfilled = allPackages.filter((x): x is PromiseFulfilledResult<PackageDescription[]> => x.status === "fulfilled");
const failedPackageCount = allPackages.length - fulfilled.length;
if (failedPackageCount > 0) {
sendInfo("", {
operationName: "java.dependency.assessmentManager.getAllDependencies.rejected",
operationName: "java.dependency.assessmentManager.getDirectDependencies.rejected",
failedPackageCount: String(failedPackageCount),
});
}
return fulfilled.map(x => x.value).flat();

let dependencies = fulfilled.map(x => x.value).flat();

if (!dependencies || dependencies.length === 0) {
sendInfo("", {
operationName: "java.dependency.assessmentManager.getDirectDependencies.noDependencyInfo"
});
return [];
}

// Determine build type from dependency containers
const isMaven = dependencyContainers.some(x => x.path?.startsWith(ContainerPath.Maven));
// Get direct dependency identifiers from build files
let directDependencyIds: Set<string> | null = null;
if (projectNode.uri && dependencyContainers.length > 0) {
try {
const projectPath = Uri.parse(projectNode.uri).fsPath;
if (isMaven) {
directDependencyIds = await parseDirectDependenciesFromPom(projectPath);
} else {
directDependencyIds = await parseDirectDependenciesFromGradle(projectPath);
}
} catch {
// Ignore errors
Comment on lines +365 to +366
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch blocks at lines 362-364 silently ignore all errors when parsing build files. This makes debugging difficult when the parsing logic fails. Consider logging errors or sending telemetry with error details to help diagnose why direct dependency parsing might fail in production.

Suggested change
} catch {
// Ignore errors
} catch (error) {
// Log parsing errors for diagnostics while preserving fallback behavior
let errorName: string | undefined;
let errorMessage: string | undefined;
let errorStack: string | undefined;
if (error instanceof Error) {
errorName = error.name;
errorMessage = error.message;
errorStack = error.stack;
} else if (error !== undefined && error !== null) {
errorMessage = String(error);
}
sendInfo("", {
operationName: "java.dependency.assessmentManager.getDirectDependencies.parseDirectDependencies.error",
buildTool: isMaven ? "maven" : "gradle",
projectUri: projectNode.uri,
errorName,
errorMessage,
// Truncate stack to avoid oversized telemetry; rely on default batching limits.
errorStack,
});

Copilot uses AI. Check for mistakes.
}
}

if (!directDependencyIds || directDependencyIds.size === 0) {
sendInfo("", {
operationName: "java.dependency.assessmentManager.getDirectDependencies.noDirectDependencyInfo"
});
// TODO: fallback to return all dependencies if we cannot parse direct dependencies or just return empty?
return dependencies;
}
// Filter to only direct dependencies if we have build file info
dependencies = dependencies.filter(pkg =>
directDependencyIds!.has(`${pkg.groupId}:${pkg.artifactId}`)
);

return dependencies;
}

async function getCVEIssues(dependencies: PackageDescription[]): Promise<UpgradeIssue[]> {
Expand Down
21 changes: 13 additions & 8 deletions src/upgrade/display/notificationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,16 @@ class NotificationManager implements IUpgradeIssuesRenderer {
if (issues.length === 0) {
return;
}
const issue = issues[0];

// Filter to only CVE issues and cast to CveUpgradeIssue[]
const cveIssues = issues.filter(
(i): i is CveUpgradeIssue => i.reason === UpgradeReason.CVE
);
const nonCVEIssues = issues.filter(
(i) => i.reason !== UpgradeReason.CVE
);
const hasCVEIssue = cveIssues.length > 0;
const issue = hasCVEIssue ? cveIssues[0] : nonCVEIssues[0];

if (!this.shouldShow()) {
return;
Expand All @@ -56,12 +65,8 @@ class NotificationManager implements IUpgradeIssuesRenderer {
const prompt = buildFixPrompt(issue);

let notificationMessage = "";
let cveIssues: CveUpgradeIssue[] = [];
if (issue.reason === UpgradeReason.CVE) {
// Filter to only CVE issues and cast to CveUpgradeIssue[]
cveIssues = issues.filter(
(i): i is CveUpgradeIssue => i.reason === UpgradeReason.CVE
);

if (hasCVEIssue) {
notificationMessage = buildCVENotificationMessage(cveIssues, hasExtension);
} else {
notificationMessage = buildNotificationMessage(issue, hasExtension);
Expand All @@ -72,7 +77,7 @@ class NotificationManager implements IUpgradeIssuesRenderer {
operationName: "java.dependency.upgradeNotification.show",
});

const buttons = issue.reason === UpgradeReason.CVE
const buttons = hasCVEIssue
? [fixCVEButtonText, BUTTON_TEXT_NOT_NOW]
: [upgradeButtonText, BUTTON_TEXT_NOT_NOW];

Expand Down
Loading
Loading