From 28ca7d02ad4eed86395e48838752e2a73859bbb6 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 30 Apr 2026 16:47:52 +0100 Subject: [PATCH 1/5] feat(workspace): add Gradle multi-project workspace discovery --- src/index.js | 13 +- src/workspace.js | 158 +++++++++++++++++- .../gradle_mixed_variants/app/build.gradle | 1 + .../gradle_mixed_variants/build.gradle.kts | 1 + .../lib/build.gradle.kts | 1 + .../gradle_mixed_variants/settings.gradle.kts | 1 + .../gradle_multi_project/app/build.gradle | 1 + .../gradle/gradle_multi_project/build.gradle | 1 + .../gradle_multi_project/lib/build.gradle | 1 + .../gradle_multi_project/settings.gradle | 1 + .../gradle_nested_subprojects/build.gradle | 1 + .../libs/core/build.gradle | 1 + .../libs/util/build.gradle | 1 + .../gradle_nested_subprojects/settings.gradle | 1 + .../gradle/gradle_no_subprojects/build.gradle | 1 + .../gradle_no_subprojects/settings.gradle | 1 + test/providers/workspace.test.js | 146 +++++++++++++++- 17 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts create mode 100644 test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts create mode 100644 test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts create mode 100644 test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle create mode 100644 test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle diff --git a/src/index.js b/src/index.js index b3b915c8..ca722884 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' import { discoverMavenModules } from './providers/java_maven.js' import { + discoverGradleSubprojects, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -25,6 +26,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom } export { discoverMavenModules, + discoverGradleSubprojects, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -321,7 +323,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) { * * @param {string} root - Resolved workspace root * @param {Options} opts - * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'unknown', manifestPaths: string[] }>} + * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'unknown', manifestPaths: string[] }>} * @private */ async function detectWorkspaceManifests(root, opts) { @@ -341,6 +343,15 @@ async function detectWorkspaceManifests(root, opts) { } } + const hasGradleSettings = fs.existsSync(path.join(root, 'settings.gradle')) + || fs.existsSync(path.join(root, 'settings.gradle.kts')) + if (hasGradleSettings) { + const manifestPaths = await discoverGradleSubprojects(root, opts) + if (manifestPaths.length > 0) { + return { ecosystem: 'gradle', manifestPaths } + } + } + const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) || fs.existsSync(path.join(root, 'yarn.lock')) || fs.existsSync(path.join(root, 'package-lock.json')) diff --git a/src/workspace.js b/src/workspace.js index f71d5e18..39cc2724 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -1,16 +1,19 @@ import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import fg from 'fast-glob' import { load as yamlLoad } from 'js-yaml' import micromatch from 'micromatch' -import { getCustom, getCustomPath, invokeCommand } from './tools.js' +import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js' /** Default paths skipped during JS workspace discovery (merged with user patterns). */ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [ '**/node_modules/**', '**/.git/**', + '**/build/**', + '**/.gradle/**', ] /** @@ -224,6 +227,159 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } +/** + * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. + * + * @param {string} startDir - Absolute directory to start from + * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`) + * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) + * @returns {string | undefined} + */ +function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { + const currentDir = path.resolve(startDir) + repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root + const wrapperPath = path.join(currentDir, wrapperName) + + try { + fs.accessSync(wrapperPath, fs.constants.X_OK) + return wrapperPath + } catch (err) { + if (err.code === 'ENOENT') { + const rootDir = path.parse(currentDir).root + if (currentDir === repoRoot || currentDir === rootDir) { + return undefined + } + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir || parentDir === rootDir) { + return undefined + } + return traverseForWrapper(parentDir, wrapperName, repoRoot) + } + throw new Error(`failure searching for ${wrapperName}`, { cause: err }) + } +} + +/** Gradle init script that emits structured project listing. */ +const GRADLE_INIT_SCRIPT = `allprojects { + task daListProjects { + doLast { + println "::DA_PROJECT::\${project.path}::\${project.projectDir}" + } + } +} +` + +/** + * Resolve the Gradle binary, respecting wrapper preference. + * + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('./index.js').Options} [opts={}] + * @returns {string} Path to the Gradle binary + */ +function resolveGradleBinary(startDir, opts = {}) { + const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') + const useWrapper = getWrapperPreference('gradle', opts) + if (useWrapper) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath('gradle', opts) +} + +/** + * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. + * Uses a custom init script to get structured project listing. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts]) + * @param {import('./index.js').Options} [opts={}] + * @returns {Promise} Paths to build.gradle[.kts] files (absolute) + */ +export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const hasSettings = fs.existsSync(path.join(root, 'settings.gradle')) + || fs.existsSync(path.join(root, 'settings.gradle.kts')) + + if (!hasSettings) { + return [] + } + + const gradleBin = resolveGradleBinary(root, opts) + const manifestPaths = [] + + const rootBuildKts = path.join(root, 'build.gradle.kts') + const rootBuild = path.join(root, 'build.gradle') + if (fs.existsSync(rootBuildKts)) { + manifestPaths.push(rootBuildKts) + } else if (fs.existsSync(rootBuild)) { + manifestPaths.push(rootBuild) + } + + const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`) + try { + fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT) + let output + try { + output = invokeCommand(gradleBin, [ + '-q', '--no-daemon', + '--init-script', initScriptPath, + 'daListProjects', + ], { cwd: root }) + } catch { + const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) + } + + const projects = parseGradleInitScriptOutput(output.toString()) + for (const proj of projects) { + if (proj.path === ':') { + continue + } + const projDir = path.resolve(proj.dir) + const buildKts = path.join(projDir, 'build.gradle.kts') + const buildGroovy = path.join(projDir, 'build.gradle') + if (fs.existsSync(buildKts)) { + manifestPaths.push(buildKts) + } else if (fs.existsSync(buildGroovy)) { + manifestPaths.push(buildGroovy) + } + } + } finally { + try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ } + } + + const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * Parse the structured output from the Gradle init script. + * + * @param {string} raw - Raw stdout from gradle + * @returns {{ path: string, dir: string }[]} + */ +function parseGradleInitScriptOutput(raw) { + const projects = [] + for (const line of raw.split('\n')) { + if (!line.startsWith('::DA_PROJECT::')) { + continue + } + const prefix = '::DA_PROJECT::' + const remainder = line.substring(prefix.length) + const lastSep = remainder.lastIndexOf('::') + if (lastSep < 0) { + continue + } + const projPath = remainder.substring(0, lastSep) + const dir = remainder.substring(lastSep + 2) + if (projPath && dir) { + projects.push({ path: projPath, dir }) + } + } + return projects +} + /** * Discover all Cargo.toml manifest paths in a Cargo workspace. * Uses `cargo metadata` to get workspace members. diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle b/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle new file mode 100644 index 00000000..ad38b260 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle @@ -0,0 +1 @@ +// app build groovy diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts new file mode 100644 index 00000000..d78283f2 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts @@ -0,0 +1 @@ +// root build kts diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts new file mode 100644 index 00000000..342a08ea --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts @@ -0,0 +1 @@ +// lib build kts diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts new file mode 100644 index 00000000..3cbe2493 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts @@ -0,0 +1 @@ +include ':app', ':lib' diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle new file mode 100644 index 00000000..c6325b8f --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle @@ -0,0 +1 @@ +// app build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle new file mode 100644 index 00000000..eac0e240 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle @@ -0,0 +1 @@ +// lib build diff --git a/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle b/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle new file mode 100644 index 00000000..3cbe2493 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle @@ -0,0 +1 @@ +include ':app', ':lib' diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle new file mode 100644 index 00000000..3cc4bb73 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle @@ -0,0 +1 @@ +// core build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle new file mode 100644 index 00000000..693cd196 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle @@ -0,0 +1 @@ +// util build diff --git a/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle new file mode 100644 index 00000000..ded9291c --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle @@ -0,0 +1 @@ +include ':libs:core', ':libs:util' diff --git a/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle b/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle new file mode 100644 index 00000000..82766848 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle @@ -0,0 +1 @@ +// root build diff --git a/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle b/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle new file mode 100644 index 00000000..07b8ff07 --- /dev/null +++ b/test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle @@ -0,0 +1 @@ +// no includes diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index b2b7a38c..3b0e1886 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -6,6 +6,7 @@ import esmock from 'esmock' import { discoverMavenModules } from '../../src/providers/java_maven.js' import { + discoverGradleSubprojects, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -189,13 +190,156 @@ suite('discoverWorkspaceCrates', () => { }) }) + +suite('discoverGradleSubprojects', () => { + test('returns empty when no settings.gradle at root', async () => { + const result = await discoverGradleSubprojects('test/providers/tst_manifests/npm') + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(0) + }) + + test('discovers multi-project build', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const initScriptOutput = [ + `::DA_PROJECT:::${path.resolve(root)}`, + `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, + `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, + ].join('\n') + + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from(initScriptOutput), + }, + }) + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.true + }) + + test('discovers nested subprojects', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_nested_subprojects') + const initScriptOutput = [ + `::DA_PROJECT:::${path.resolve(root)}`, + `::DA_PROJECT:::libs:core::${path.resolve(root, 'libs/core')}`, + `::DA_PROJECT:::libs:util::${path.resolve(root, 'libs/util')}`, + ].join('\n') + + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from(initScriptOutput), + }, + }) + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + expect(result.some(p => p.includes(path.join('libs', 'core', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('libs', 'util', 'build.gradle')))).to.be.true + }) + + test('handles mixed Groovy and Kotlin build files', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_mixed_variants') + const initScriptOutput = [ + `::DA_PROJECT:::${path.resolve(root)}`, + `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, + `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, + ].join('\n') + + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from(initScriptOutput), + }, + }) + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'build.gradle.kts')) + expect(result.some(p => p.endsWith(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.endsWith(path.join('lib', 'build.gradle.kts')))).to.be.true + }) + + test('returns root only when no subprojects', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_no_subprojects') + const initScriptOutput = `::DA_PROJECT:::${path.resolve(root)}\n` + + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from(initScriptOutput), + }, + }) + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + }) + + test('returns root build file when gradle command fails', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => { throw new Error('gradle not found') }, + }, + }) + const result = await discoverGradleSubprojects(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'build.gradle')) + }) + + test('excludes paths matching workspaceDiscoveryIgnore', async () => { + const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') + const initScriptOutput = [ + `::DA_PROJECT:::${path.resolve(root)}`, + `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, + `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, + ].join('\n') + + const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'gradle', + getGitRootDir: () => null, + getWrapperPreference: () => false, + invokeCommand: () => Buffer.from(initScriptOutput), + }, + }) + const result = await discoverGradleSubprojects(root, { + workspaceDiscoveryIgnore: ['**/lib/**'], + }) + expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true + expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.false + }) +}) + suite('discoverMavenModules', () => { test('returns empty when no pom.xml at root', async () => { const result = await discoverMavenModules('test/providers/tst_manifests/npm') expect(result).to.be.an('array') expect(result).to.have.lengthOf(0) }) - test('returns root pom only when mvn reports no modules', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') const result = await discoverMavenModules(root) From 297ed655889213df3f52345b202c2450406bc8fc Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 13:04:24 +0100 Subject: [PATCH 2/5] refactor: move Gradle discovery logic from workspace.js to java_gradle.js Address review feedback: provider-specific discovery functions don't belong in workspace.js. Extract traverseForWrapper to tools.js as shared utility, update base_java.js to delegate to it, and move all Gradle workspace discovery functions (discoverGradleSubprojects, resolveGradleBinary, parseGradleInitScriptOutput, GRADLE_INIT_SCRIPT) to their provider file. Co-Authored-By: Claude Opus 4.6 --- src/index.js | 2 +- src/providers/java_gradle.js | 131 +++++++++++++++++++++++++ src/workspace.js | 158 +------------------------------ test/providers/workspace.test.js | 14 +-- 4 files changed, 140 insertions(+), 165 deletions(-) diff --git a/src/index.js b/src/index.js index ca722884..24d2ed26 100644 --- a/src/index.js +++ b/src/index.js @@ -8,8 +8,8 @@ import fs from 'node:fs' import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' import { discoverMavenModules } from './providers/java_maven.js' +import { discoverGradleSubprojects } from './providers/java_gradle.js' import { - discoverGradleSubprojects, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index f7095dac..641df8bb 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -1,4 +1,6 @@ +import crypto from 'node:crypto' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { EOL } from 'os' @@ -6,6 +8,8 @@ import TOML from 'fast-toml' import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' +import { getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from '../tools.js' +import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_gradle } from "./base_java.js"; @@ -466,3 +470,130 @@ export default class Java_gradle extends Base_java { return undefined } } + +const DEFAULT_GRADLE_DISCOVERY_IGNORE = [ + '**/build/**', + '**/.gradle/**', +] + +/** + * Resolve the Gradle binary, respecting wrapper preference. + * + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('../index.js').Options} [opts={}] + * @returns {string} Path to the Gradle binary + */ +function resolveGradleBinary(startDir, opts = {}) { + const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') + const useWrapper = getWrapperPreference('gradle', opts) + if (useWrapper) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath('gradle', opts) +} + +/** Gradle init script that emits structured project listing. */ +const GRADLE_INIT_SCRIPT = `allprojects { + task daListProjects { + doLast { + println "::DA_PROJECT::\${project.path}::\${project.projectDir}" + } + } +} +` + +/** + * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. + * Uses a custom init script to get structured project listing. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts]) + * @param {import('../index.js').Options} [opts={}] + * @returns {Promise} Paths to build.gradle[.kts] files (absolute) + */ +export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const hasSettings = fs.existsSync(path.join(root, 'settings.gradle')) + || fs.existsSync(path.join(root, 'settings.gradle.kts')) + + if (!hasSettings) { + return [] + } + + const gradleBin = resolveGradleBinary(root, opts) + const manifestPaths = [] + + const rootBuildKts = path.join(root, 'build.gradle.kts') + const rootBuild = path.join(root, 'build.gradle') + if (fs.existsSync(rootBuildKts)) { + manifestPaths.push(rootBuildKts) + } else if (fs.existsSync(rootBuild)) { + manifestPaths.push(rootBuild) + } + + const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${crypto.randomUUID()}.gradle`) + try { + fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT) + let output + try { + output = invokeCommand(gradleBin, [ + '-q', '--no-daemon', + '--init-script', initScriptPath, + 'daListProjects', + ], { cwd: root }) + } catch { + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) + } + + const projects = parseGradleInitScriptOutput(output.toString()) + for (const proj of projects) { + if (proj.path === ':') { + continue + } + const projDir = path.resolve(proj.dir) + const buildKts = path.join(projDir, 'build.gradle.kts') + const buildGroovy = path.join(projDir, 'build.gradle') + if (fs.existsSync(buildKts)) { + manifestPaths.push(buildKts) + } else if (fs.existsSync(buildGroovy)) { + manifestPaths.push(buildGroovy) + } + } + } finally { + try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ } + } + + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * Parse the structured output from the Gradle init script. + * + * @param {string} raw - Raw stdout from gradle + * @returns {{ path: string, dir: string }[]} + */ +export function parseGradleInitScriptOutput(raw) { + const projects = [] + for (const rawLine of raw.split('\n')) { + const line = rawLine.trimEnd() + if (!line.startsWith('::DA_PROJECT::')) { + continue + } + const prefix = '::DA_PROJECT::' + const remainder = line.substring(prefix.length) + const lastSep = remainder.lastIndexOf('::') + if (lastSep < 0) { + continue + } + const projPath = remainder.substring(0, lastSep) + const dir = remainder.substring(lastSep + 2) + if (projPath && dir) { + projects.push({ path: projPath, dir }) + } + } + return projects +} diff --git a/src/workspace.js b/src/workspace.js index 39cc2724..f71d5e18 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -1,19 +1,16 @@ import fs from 'node:fs' -import os from 'node:os' import path from 'node:path' import fg from 'fast-glob' import { load as yamlLoad } from 'js-yaml' import micromatch from 'micromatch' -import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js' +import { getCustom, getCustomPath, invokeCommand } from './tools.js' /** Default paths skipped during JS workspace discovery (merged with user patterns). */ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [ '**/node_modules/**', '**/.git/**', - '**/build/**', - '**/.gradle/**', ] /** @@ -227,159 +224,6 @@ async function discoverFromPackageJsonWorkspaces(root, packageJsonPath, globOpts return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } -/** - * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. - * - * @param {string} startDir - Absolute directory to start from - * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`) - * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) - * @returns {string | undefined} - */ -function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { - const currentDir = path.resolve(startDir) - repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root - const wrapperPath = path.join(currentDir, wrapperName) - - try { - fs.accessSync(wrapperPath, fs.constants.X_OK) - return wrapperPath - } catch (err) { - if (err.code === 'ENOENT') { - const rootDir = path.parse(currentDir).root - if (currentDir === repoRoot || currentDir === rootDir) { - return undefined - } - const parentDir = path.dirname(currentDir) - if (parentDir === currentDir || parentDir === rootDir) { - return undefined - } - return traverseForWrapper(parentDir, wrapperName, repoRoot) - } - throw new Error(`failure searching for ${wrapperName}`, { cause: err }) - } -} - -/** Gradle init script that emits structured project listing. */ -const GRADLE_INIT_SCRIPT = `allprojects { - task daListProjects { - doLast { - println "::DA_PROJECT::\${project.path}::\${project.projectDir}" - } - } -} -` - -/** - * Resolve the Gradle binary, respecting wrapper preference. - * - * @param {string} startDir - Directory from which to start the wrapper search - * @param {import('./index.js').Options} [opts={}] - * @returns {string} Path to the Gradle binary - */ -function resolveGradleBinary(startDir, opts = {}) { - const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') - const useWrapper = getWrapperPreference('gradle', opts) - if (useWrapper) { - const wrapper = traverseForWrapper(startDir, localWrapper) - if (wrapper !== undefined) { - return wrapper - } - } - return getCustomPath('gradle', opts) -} - -/** - * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. - * Uses a custom init script to get structured project listing. - * - * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain settings.gradle[.kts]) - * @param {import('./index.js').Options} [opts={}] - * @returns {Promise} Paths to build.gradle[.kts] files (absolute) - */ -export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { - const root = path.resolve(workspaceRoot) - const hasSettings = fs.existsSync(path.join(root, 'settings.gradle')) - || fs.existsSync(path.join(root, 'settings.gradle.kts')) - - if (!hasSettings) { - return [] - } - - const gradleBin = resolveGradleBinary(root, opts) - const manifestPaths = [] - - const rootBuildKts = path.join(root, 'build.gradle.kts') - const rootBuild = path.join(root, 'build.gradle') - if (fs.existsSync(rootBuildKts)) { - manifestPaths.push(rootBuildKts) - } else if (fs.existsSync(rootBuild)) { - manifestPaths.push(rootBuild) - } - - const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`) - try { - fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT) - let output - try { - output = invokeCommand(gradleBin, [ - '-q', '--no-daemon', - '--init-script', initScriptPath, - 'daListProjects', - ], { cwd: root }) - } catch { - const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) - return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) - } - - const projects = parseGradleInitScriptOutput(output.toString()) - for (const proj of projects) { - if (proj.path === ':') { - continue - } - const projDir = path.resolve(proj.dir) - const buildKts = path.join(projDir, 'build.gradle.kts') - const buildGroovy = path.join(projDir, 'build.gradle') - if (fs.existsSync(buildKts)) { - manifestPaths.push(buildKts) - } else if (fs.existsSync(buildGroovy)) { - manifestPaths.push(buildGroovy) - } - } - } finally { - try { fs.unlinkSync(initScriptPath) } catch { /* ignore */ } - } - - const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) - return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) -} - -/** - * Parse the structured output from the Gradle init script. - * - * @param {string} raw - Raw stdout from gradle - * @returns {{ path: string, dir: string }[]} - */ -function parseGradleInitScriptOutput(raw) { - const projects = [] - for (const line of raw.split('\n')) { - if (!line.startsWith('::DA_PROJECT::')) { - continue - } - const prefix = '::DA_PROJECT::' - const remainder = line.substring(prefix.length) - const lastSep = remainder.lastIndexOf('::') - if (lastSep < 0) { - continue - } - const projPath = remainder.substring(0, lastSep) - const dir = remainder.substring(lastSep + 2) - if (projPath && dir) { - projects.push({ path: projPath, dir }) - } - } - return projects -} - /** * Discover all Cargo.toml manifest paths in a Cargo workspace. * Uses `cargo metadata` to get workspace members. diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 3b0e1886..e9b2ee1c 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -4,9 +4,9 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock' +import { discoverGradleSubprojects } from '../../src/providers/java_gradle.js' import { discoverMavenModules } from '../../src/providers/java_maven.js' import { - discoverGradleSubprojects, discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, @@ -206,7 +206,7 @@ suite('discoverGradleSubprojects', () => { `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, ].join('\n') - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', @@ -231,7 +231,7 @@ suite('discoverGradleSubprojects', () => { `::DA_PROJECT:::libs:util::${path.resolve(root, 'libs/util')}`, ].join('\n') - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', @@ -256,7 +256,7 @@ suite('discoverGradleSubprojects', () => { `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, ].join('\n') - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', @@ -277,7 +277,7 @@ suite('discoverGradleSubprojects', () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_no_subprojects') const initScriptOutput = `::DA_PROJECT:::${path.resolve(root)}\n` - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', @@ -294,7 +294,7 @@ suite('discoverGradleSubprojects', () => { test('returns root build file when gradle command fails', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', @@ -317,7 +317,7 @@ suite('discoverGradleSubprojects', () => { `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, ].join('\n') - const { discoverGradleSubprojects } = await esmock('../../src/workspace.js', { + const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { getCustom: () => null, getCustomPath: () => 'gradle', From 7e07f94443dc8d01c6f494300fa21298735af736 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 11:50:02 +0100 Subject: [PATCH 3/5] refactor: extract normalizePath and resolveBinary to shared tools.js Move normalizePath from Base_java class and resolveGradleBinary from java_gradle.js into tools.js as shared standalone functions. Update traverseForWrapper to normalize paths consistently on Windows. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_gradle.js | 24 +++--------------------- src/tools.js | 19 +++++++++++++++++++ test/providers/workspace.test.js | 30 ++++++------------------------ 3 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index 641df8bb..619d0634 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -8,7 +8,7 @@ import TOML from 'fast-toml' import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from '../tools.js' +import { invokeCommand, resolveBinary } from '../tools.js' import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_gradle } from "./base_java.js"; @@ -476,25 +476,6 @@ const DEFAULT_GRADLE_DISCOVERY_IGNORE = [ '**/.gradle/**', ] -/** - * Resolve the Gradle binary, respecting wrapper preference. - * - * @param {string} startDir - Directory from which to start the wrapper search - * @param {import('../index.js').Options} [opts={}] - * @returns {string} Path to the Gradle binary - */ -function resolveGradleBinary(startDir, opts = {}) { - const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') - const useWrapper = getWrapperPreference('gradle', opts) - if (useWrapper) { - const wrapper = traverseForWrapper(startDir, localWrapper) - if (wrapper !== undefined) { - return wrapper - } - } - return getCustomPath('gradle', opts) -} - /** Gradle init script that emits structured project listing. */ const GRADLE_INIT_SCRIPT = `allprojects { task daListProjects { @@ -522,7 +503,8 @@ export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { return [] } - const gradleBin = resolveGradleBinary(root, opts) + const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') + const gradleBin = resolveBinary('gradle', localWrapper, root, opts) const manifestPaths = [] const rootBuildKts = path.join(root, 'build.gradle.kts') diff --git a/src/tools.js b/src/tools.js index c4d5801d..941c2103 100644 --- a/src/tools.js +++ b/src/tools.js @@ -178,6 +178,25 @@ export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) } } +/** + * Resolve a build-tool binary, preferring a wrapper when configured. + * + * @param {string} globalBinary - Global binary name (e.g. `mvn`, `gradle`) + * @param {string} localWrapper - Wrapper filename (e.g. `mvnw`, `gradlew.bat`) + * @param {string} startDir - Directory from which to start the wrapper search + * @param {import('./index.js').Options} [opts={}] + * @returns {string} Path to the resolved binary + */ +export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) { + if (getWrapperPreference(globalBinary, opts)) { + const wrapper = traverseForWrapper(startDir, localWrapper) + if (wrapper !== undefined) { + return wrapper + } + } + return getCustomPath(globalBinary, opts) +} + /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index e9b2ee1c..11c45b78 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -208,10 +208,7 @@ suite('discoverGradleSubprojects', () => { const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => Buffer.from(initScriptOutput), }, }) @@ -233,10 +230,7 @@ suite('discoverGradleSubprojects', () => { const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => Buffer.from(initScriptOutput), }, }) @@ -258,10 +252,7 @@ suite('discoverGradleSubprojects', () => { const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => Buffer.from(initScriptOutput), }, }) @@ -279,10 +270,7 @@ suite('discoverGradleSubprojects', () => { const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => Buffer.from(initScriptOutput), }, }) @@ -296,10 +284,7 @@ suite('discoverGradleSubprojects', () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => { throw new Error('gradle not found') }, }, }) @@ -319,10 +304,7 @@ suite('discoverGradleSubprojects', () => { const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - getCustom: () => null, - getCustomPath: () => 'gradle', - getGitRootDir: () => null, - getWrapperPreference: () => false, + resolveBinary: () => 'gradle', invokeCommand: () => Buffer.from(initScriptOutput), }, }) From b794d9e8031f906ec425c96edb4cf5878beebdfd Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 15:30:11 +0100 Subject: [PATCH 4/5] refactor: use selectToolBinary for Gradle discovery and remove mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace resolveBinary with selectToolBinary (via Base_java) in discoverGradleSubprojects, matching the Maven discovery pattern and gaining normalizePath via traverseForWrapper. Remove esmock invokeCommand mocks from happy-path Gradle workspace tests — they now invoke a real gradle binary. Keep esmock only for the error path (gradle not available). Fix settings.gradle.kts fixture to use valid Kotlin DSL syntax. Co-Authored-By: Claude Opus 4.6 --- src/providers/java_gradle.js | 19 ++++-- .../gradle_mixed_variants/settings.gradle.kts | 2 +- test/providers/workspace.test.js | 67 ++----------------- 3 files changed, 19 insertions(+), 69 deletions(-) diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index 619d0634..88b1505b 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -8,7 +8,7 @@ import TOML from 'fast-toml' import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { invokeCommand, resolveBinary } from '../tools.js' +import { invokeCommand } from '../tools.js' import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_gradle } from "./base_java.js"; @@ -503,16 +503,21 @@ export async function discoverGradleSubprojects(workspaceRoot, opts = {}) { return [] } - const localWrapper = 'gradlew' + (process.platform === 'win32' ? '.bat' : '') - const gradleBin = resolveBinary('gradle', localWrapper, root, opts) const manifestPaths = [] const rootBuildKts = path.join(root, 'build.gradle.kts') const rootBuild = path.join(root, 'build.gradle') - if (fs.existsSync(rootBuildKts)) { - manifestPaths.push(rootBuildKts) - } else if (fs.existsSync(rootBuild)) { - manifestPaths.push(rootBuild) + const rootManifest = fs.existsSync(rootBuildKts) ? rootBuildKts : fs.existsSync(rootBuild) ? rootBuild : null + if (rootManifest) { + manifestPaths.push(rootManifest) + } + + let gradleBin + try { + gradleBin = new Java_gradle().selectToolBinary(rootManifest || rootBuild, opts) + } catch { + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_GRADLE_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${crypto.randomUUID()}.gradle`) diff --git a/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts index 3cbe2493..d7baa552 100644 --- a/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts +++ b/test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts @@ -1 +1 @@ -include ':app', ':lib' +include(":app", ":lib") diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 11c45b78..3b160bb8 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -200,18 +200,6 @@ suite('discoverGradleSubprojects', () => { test('discovers multi-project build', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') - const initScriptOutput = [ - `::DA_PROJECT:::${path.resolve(root)}`, - `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, - `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, - ].join('\n') - - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { - '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => Buffer.from(initScriptOutput), - }, - }) const result = await discoverGradleSubprojects(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(3) @@ -222,18 +210,6 @@ suite('discoverGradleSubprojects', () => { test('discovers nested subprojects', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_nested_subprojects') - const initScriptOutput = [ - `::DA_PROJECT:::${path.resolve(root)}`, - `::DA_PROJECT:::libs:core::${path.resolve(root, 'libs/core')}`, - `::DA_PROJECT:::libs:util::${path.resolve(root, 'libs/util')}`, - ].join('\n') - - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { - '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => Buffer.from(initScriptOutput), - }, - }) const result = await discoverGradleSubprojects(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(3) @@ -244,18 +220,6 @@ suite('discoverGradleSubprojects', () => { test('handles mixed Groovy and Kotlin build files', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_mixed_variants') - const initScriptOutput = [ - `::DA_PROJECT:::${path.resolve(root)}`, - `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, - `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, - ].join('\n') - - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { - '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => Buffer.from(initScriptOutput), - }, - }) const result = await discoverGradleSubprojects(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(3) @@ -266,29 +230,22 @@ suite('discoverGradleSubprojects', () => { test('returns root only when no subprojects', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_no_subprojects') - const initScriptOutput = `::DA_PROJECT:::${path.resolve(root)}\n` - - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { - '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => Buffer.from(initScriptOutput), - }, - }) const result = await discoverGradleSubprojects(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(1) expect(result[0]).to.equal(path.join(root, 'build.gradle')) }) - test('returns root build file when gradle command fails', async () => { + test('returns root build file when gradle is not available', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { + const { discoverGradleSubprojects: discoverMocked } = await esmock('../../src/providers/java_gradle.js', { '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => { throw new Error('gradle not found') }, + getCustomPath: () => '/nonexistent/gradle', + getWrapperPreference: () => false, + invokeCommand: () => { throw Object.assign(new Error('gradle not found'), { code: 'ENOENT' }) }, }, }) - const result = await discoverGradleSubprojects(root) + const result = await discoverMocked(root) expect(result).to.be.an('array') expect(result).to.have.lengthOf(1) expect(result[0]).to.equal(path.join(root, 'build.gradle')) @@ -296,18 +253,6 @@ suite('discoverGradleSubprojects', () => { test('excludes paths matching workspaceDiscoveryIgnore', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') - const initScriptOutput = [ - `::DA_PROJECT:::${path.resolve(root)}`, - `::DA_PROJECT:::app::${path.resolve(root, 'app')}`, - `::DA_PROJECT:::lib::${path.resolve(root, 'lib')}`, - ].join('\n') - - const { discoverGradleSubprojects } = await esmock('../../src/providers/java_gradle.js', { - '../../src/tools.js': { - resolveBinary: () => 'gradle', - invokeCommand: () => Buffer.from(initScriptOutput), - }, - }) const result = await discoverGradleSubprojects(root, { workspaceDiscoveryIgnore: ['**/lib/**'], }) From 725a8535770c1c1e8bbeb0a303a533e1c87892f7 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 6 May 2026 15:32:40 +0100 Subject: [PATCH 5/5] test: add timeouts to Gradle workspace tests that invoke real gradle Co-Authored-By: Claude Opus 4.6 --- test/providers/workspace.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 3b160bb8..a75b20e5 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -206,7 +206,7 @@ suite('discoverGradleSubprojects', () => { expect(result[0]).to.equal(path.join(root, 'build.gradle')) expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.true - }) + }).timeout(40000) test('discovers nested subprojects', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_nested_subprojects') @@ -216,7 +216,7 @@ suite('discoverGradleSubprojects', () => { expect(result[0]).to.equal(path.join(root, 'build.gradle')) expect(result.some(p => p.includes(path.join('libs', 'core', 'build.gradle')))).to.be.true expect(result.some(p => p.includes(path.join('libs', 'util', 'build.gradle')))).to.be.true - }) + }).timeout(40000) test('handles mixed Groovy and Kotlin build files', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_mixed_variants') @@ -226,7 +226,7 @@ suite('discoverGradleSubprojects', () => { expect(result[0]).to.equal(path.join(root, 'build.gradle.kts')) expect(result.some(p => p.endsWith(path.join('app', 'build.gradle')))).to.be.true expect(result.some(p => p.endsWith(path.join('lib', 'build.gradle.kts')))).to.be.true - }) + }).timeout(40000) test('returns root only when no subprojects', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_no_subprojects') @@ -234,7 +234,7 @@ suite('discoverGradleSubprojects', () => { expect(result).to.be.an('array') expect(result).to.have.lengthOf(1) expect(result[0]).to.equal(path.join(root, 'build.gradle')) - }) + }).timeout(40000) test('returns root build file when gradle is not available', async () => { const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project') @@ -258,7 +258,7 @@ suite('discoverGradleSubprojects', () => { }) expect(result.some(p => p.includes(path.join('app', 'build.gradle')))).to.be.true expect(result.some(p => p.includes(path.join('lib', 'build.gradle')))).to.be.false - }) + }).timeout(40000) }) suite('discoverMavenModules', () => {