diff --git a/src/upgrade/assessmentManager.ts b/src/upgrade/assessmentManager.ts index 6b0ba95e..1367b73a 100644 --- a/src/upgrade/assessmentManager.ts +++ b/src/upgrade/assessmentManager.ts @@ -1,7 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +import * as fs from 'fs'; import * as semver from 'semver'; +import * as glob from 'glob'; +import { promisify } from 'util'; + +const globAsync = promisify(glob); +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"; @@ -11,6 +17,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"]; @@ -143,50 +150,182 @@ async function getDependencyIssues(dependencies: PackageDescription[]): Promise< return issues; } -async function getProjectIssues(projectNode: INodeData): Promise { - 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 { + const issues: UpgradeIssue[] = []; + const dependenciesSet: Set = new Set(); + for (const { projectNode, dependencies } of projectDeps) { + issues.push(...getJavaIssues(projectNode)); + dependencies.forEach(dep => dependenciesSet.add(dep)); + } + issues.push(...await getCVEIssues(Array.from(dependenciesSet))); + issues.push(...await getDependencyIssues(Array.from(dependenciesSet))); return issues; +} +/** + * Find all pom.xml files in a directory using glob + */ +async function findAllPomFiles(dir: string): Promise { + try { + return await globAsync('**/pom.xml', { + cwd: dir, + absolute: true, + nodir: true, + ignore: ['**/node_modules/**', '**/target/**', '**/.git/**', '**/.idea/**', '**/.vscode/**'] + }); + } catch { + return []; + } } -async function getWorkspaceIssues(workspaceFolderUri: string): Promise { - 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 { + const directDeps = new Set(); + try { + const pomContent = fs.readFileSync(pomPath, 'utf-8'); + + // Extract dependencies from section (not inside ) + // First, remove dependencyManagement sections to avoid including managed deps + const withoutDepMgmt = pomContent.replace(/[\s\S]*?<\/dependencyManagement>/g, ''); - const workspaceIssues = projectsIssues.map(x => { - if (x.status === "fulfilled") { - return x.value; + // Match blocks and extract groupId and artifactId + const dependencyRegex = /\s*([^<]+)<\/groupId>\s*([^<]+)<\/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); } + } 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> { + const directDeps = new Set(); + + // 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 { + try { + return await globAsync('**/{build.gradle,build.gradle.kts}', { + cwd: dir, + absolute: true, + nodir: 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 { + const directDeps = new Set(); + 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; + 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); + } + } 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> { + const directDeps = new Set(); + + // 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 { +export async function getDirectDependencies(projectNode: INodeData): Promise { 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)); }) ); @@ -194,11 +333,52 @@ async function getAllDependencies(projectNode: INodeData): Promise 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) { + 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 | 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 + } + } + + if (!directDependencyIds) { + 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 + if (directDependencyIds && directDependencyIds.size > 0) { + dependencies = dependencies.filter(pkg => + directDependencyIds!.has(`${pkg.groupId}:${pkg.artifactId}`) + ); + } + + return dependencies; } async function getCVEIssues(dependencies: PackageDescription[]): Promise { diff --git a/src/upgrade/display/notificationManager.ts b/src/upgrade/display/notificationManager.ts index 3061e179..c3bb0429 100644 --- a/src/upgrade/display/notificationManager.ts +++ b/src/upgrade/display/notificationManager.ts @@ -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; @@ -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); @@ -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]; diff --git a/src/upgrade/upgradeManager.ts b/src/upgrade/upgradeManager.ts index c61c786b..73c3a1ab 100644 --- a/src/upgrade/upgradeManager.ts +++ b/src/upgrade/upgradeManager.ts @@ -10,10 +10,8 @@ import { instrumentOperation, instrumentOperationAsVsCodeCommand, sendInfo } fro import { Commands } from "../commands"; import notificationManager from "./display/notificationManager"; import { Settings } from "../settings"; -import assessmentManager from "./assessmentManager"; +import assessmentManager, { getDirectDependencies } from "./assessmentManager"; import { checkOrInstallAppModExtensionForUpgrade, checkOrPopupToInstallAppModExtensionForModernization } from "./utility"; -import { NodeKind } from "../../extension.bundle"; -import { ContainerPath } from "../views/containerNode"; const DEFAULT_UPGRADE_PROMPT = "Upgrade Java project dependency to latest version."; @@ -55,40 +53,42 @@ class UpgradeManager { } private static async runDependencyCheckup(folder: WorkspaceFolder) { - return (instrumentOperation("java.dependency.runDependencyCheckup", - async (_operationId: string) => { - if (!(await languageServerApiManager.ready())) { - sendInfo(_operationId, { "skipReason": "languageServerNotReady" }); - return; - } - const projectData = await Jdtls.getPackageData({ - kind: NodeKind.Project, - projectUri: folder.uri.toString(), - }); - const isMavenGradleProject = projectData.some( - (dep) => dep.kind === NodeKind.Container && - (dep.path?.startsWith(ContainerPath.Maven) || dep.path?.startsWith(ContainerPath.Gradle)) - ); - if (!isMavenGradleProject) { - sendInfo(_operationId, { "skipReason": "notMavenGradleProject" }); - return; - } - - const hasJavaError: boolean = await Jdtls.checkImportStatus(); - if (hasJavaError) { - sendInfo(_operationId, { "skipReason": "hasJavaError" }); - return; - } - - const uri = folder.uri.toString(); - const workspaceIssues = await assessmentManager.getWorkspaceIssues(uri); - - if (workspaceIssues.length > 0) { - // only show one issue in notifications - notificationManager.render(workspaceIssues); - } + return instrumentOperation("java.dependency.runDependencyCheckup", async (_operationId: string) => { + if (!(await languageServerApiManager.ready())) { + sendInfo(_operationId, { skipReason: "languageServerNotReady" }); + return; } - ))(); + + const hasJavaError: boolean = await Jdtls.checkImportStatus(); + if (hasJavaError) { + sendInfo(_operationId, { skipReason: "hasJavaError" }); + return; + } + + const projects = await Jdtls.getProjects(folder.uri.toString()); + const projectDirectDepsResults = await Promise.allSettled( + projects.map(async (projectNode) => ({ + projectNode, + dependencies: await getDirectDependencies(projectNode), + })) + ); + + const allProjectDirectDeps = projectDirectDepsResults + .filter((result): result is PromiseFulfilledResult<{ projectNode: typeof projects[0]; dependencies: Awaited> }> => + result.status === "fulfilled" + ) + .map((result) => result.value); + + if (allProjectDirectDeps.every((x) => x.dependencies.length === 0)) { + sendInfo(_operationId, { skipReason: "notMavenGradleProject" }); + return; + } + + const workspaceIssues = await assessmentManager.getWorkspaceIssues(allProjectDirectDeps); + if (workspaceIssues.length > 0) { + notificationManager.render(workspaceIssues); + } + })(); } }