From 2e1d6d4d9a0ebe78ebe5678e21745734bf0013c3 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 29 Oct 2026 08:02:35 +0100 Subject: [PATCH 1/4] fix(manifest): run Gradle and sbt on a JDK they support Gradle before 9.1 and sbt before 1.9 do not start on Java 25, and sbt before 1.3 does not start on Java 17. A machine whose default JDK is newer than the project's pinned build tool supports therefore failed to generate Socket facts, even with a suitable JDK installed. Facts generation now reads the Gradle wrapper or sbt version the project pins and, when the current JDK is outside the range that version runs on, runs the build on the newest installed JDK inside it. Installed JDKs are found under the usual JVM directories, SDKMAN, Gradle- and IDE-provisioned JDKs, macOS bundles, and the JAVA_HOME__ variables CI runners set. A javaHome configured in socket.json still wins, and Maven builds keep the current JDK. --- CHANGELOG.md | 1 + src/commands/manifest/scripts/java-home.mts | 304 ++++++++++++++++++ .../manifest/scripts/java-home.test.mts | 246 ++++++++++++++ src/commands/manifest/scripts/run.mts | 31 +- src/commands/manifest/scripts/run.test.mts | 66 ++++ 5 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 src/commands/manifest/scripts/java-home.mts create mode 100644 src/commands/manifest/scripts/java-home.test.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3f67cd9..c38520501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Updated the Coana CLI to v `15.13.1`. +- Generating Socket facts for a Gradle wrapper or sbt build now picks an installed JDK the pinned Gradle or sbt version runs on, when the JDK in `JAVA_HOME` or on `PATH` is one it cannot start on. A `javaHome` set in `socket.json` is used as is. ### Fixed - Reachability analysis of Gradle and sbt builds now sees the code of Kotlin Multiplatform and Android modules, and sbt sources placed directly in a project's directory. diff --git a/src/commands/manifest/scripts/java-home.mts b/src/commands/manifest/scripts/java-home.mts new file mode 100644 index 000000000..405ee94dc --- /dev/null +++ b/src/commands/manifest/scripts/java-home.mts @@ -0,0 +1,304 @@ +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { whichBin } from '@socketsecurity/registry/lib/bin' + +import { resolveBuildToolBin } from './build-tool.mts' + +import type { BuildTool } from './build-tool.mts' + +export type JavaInstallation = { home: string; feature: number } + +export type JavaRange = { min: number; max: number } + +export type BuildJavaHome = { + // Undefined when no usable JDK is set in JAVA_HOME or found on PATH. + current: JavaInstallation | undefined + picked: JavaInstallation + tool: BuildTool + toolVersion: string +} + +type Version = number[] + +// The first Gradle release that runs on each Java release, from +// https://docs.gradle.org/current/userguide/compatibility.html. +const GRADLE_RUNS_ON_JAVA_FROM: ReadonlyArray<[number, string]> = [ + [8, '2.0'], + [9, '4.3'], + [10, '4.7'], + [11, '5.0'], + [12, '5.4'], + [13, '6.0'], + [14, '6.3'], + [15, '6.7'], + [16, '7.0'], + [17, '7.3'], + [18, '7.5'], + [19, '7.6'], + [20, '8.3'], + [21, '8.5'], + [22, '8.8'], + [23, '8.10'], + [24, '8.14'], + [25, '9.1'], +] + +// The first sbt release that runs on each LTS Java release. The sbt version is +// the project's, which the launcher downloads and runs. +const SBT_RUNS_ON_JAVA_FROM: ReadonlyArray<[number, string]> = [ + [8, '0.13'], + [11, '0.13'], + [17, '1.3'], + [21, '1.9'], + [25, '1.9'], +] + +function parseVersion(text: string): Version | undefined { + const match = /^(\d+(?:\.\d+)*)/.exec(text.trim()) + return match ? match[1]!.split('.').map(Number) : undefined +} + +function compareVersions(a: Version, b: Version): number { + for (let i = 0, length = Math.max(a.length, b.length); i < length; i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff + } + } + return 0 +} + +// Past the table's newest entry the tool may support newer Java than we know +// of, so any Java counts as supported there. +function maxJavaFor( + table: ReadonlyArray<[number, string]>, + version: Version, +): number { + const newest = table[table.length - 1]! + if (compareVersions(version, parseVersion(newest[1])!) > 0) { + return Number.POSITIVE_INFINITY + } + let max = 0 + for (const [java, since] of table) { + if (compareVersions(version, parseVersion(since)!) >= 0) { + max = java + } + } + return max +} + +export function gradleJavaRange(version: string): JavaRange | undefined { + const parsed = parseVersion(version) + if (!parsed) { + return undefined + } + return { + min: parsed[0]! >= 9 ? 17 : 8, + max: maxJavaFor(GRADLE_RUNS_ON_JAVA_FROM, parsed), + } +} + +export function sbtJavaRange(version: string): JavaRange | undefined { + const parsed = parseVersion(version) + if (!parsed) { + return undefined + } + return { + min: parsed[0]! >= 2 ? 17 : 8, + max: maxJavaFor(SBT_RUNS_ON_JAVA_FROM, parsed), + } +} + +// `JAVA_VERSION` in a JDK's `release` file: "1.8.0_462", "17.0.16", "25". +export function parseJavaFeatureVersion(release: string): number | undefined { + const match = /^JAVA_VERSION="?(\d+)(?:\.(\d+))?/m.exec(release) + if (!match) { + return undefined + } + const first = Number(match[1]) + return first === 1 && match[2] ? Number(match[2]) : first +} + +export async function readJavaInstallation( + home: string, +): Promise { + if (!existsSync(path.join(home, 'bin', 'java'))) { + return undefined + } + let release: string + try { + release = await fs.readFile(path.join(home, 'release'), 'utf8') + } catch { + return undefined + } + const feature = parseJavaFeatureVersion(release) + return feature === undefined ? undefined : { home, feature } +} + +export function defaultJavaSearchDirs(): string[] { + const userHome = os.homedir() + return [ + '/usr/lib/jvm', + '/usr/lib64/jvm', + '/usr/java', + '/opt/java', + '/Library/Java/JavaVirtualMachines', + path.join(userHome, 'Library', 'Java', 'JavaVirtualMachines'), + path.join(userHome, '.sdkman', 'candidates', 'java'), + path.join(userHome, '.gradle', 'jdks'), + path.join(userHome, '.jdks'), + ] +} + +// Installed JDKs: the children of each search dir (macOS bundles keep theirs +// under Contents/Home), and the JAVA_HOME__ variables GitHub +// and Azure runners set. +export async function discoverJavaInstallations( + env: NodeJS.ProcessEnv, + searchDirs: readonly string[] = defaultJavaSearchDirs(), +): Promise { + const candidates: string[] = [] + for (const [name, value] of Object.entries(env)) { + if (value && /^JAVA_HOME_\d+_/i.test(name)) { + candidates.push(value) + } + } + for (const dir of searchDirs) { + let children: string[] + try { + children = await fs.readdir(dir) + } catch { + continue + } + for (const child of children.sort()) { + candidates.push(path.join(dir, child)) + candidates.push(path.join(dir, child, 'Contents', 'Home')) + } + } + const seen = new Set() + const found: JavaInstallation[] = [] + for (const candidate of candidates) { + const installation = await readJavaInstallation(candidate) + if (!installation) { + continue + } + const home = await fs.realpath(candidate) + if (!seen.has(home)) { + seen.add(home) + found.push({ ...installation, home }) + } + } + return found +} + +// The JDK a build tool would run on: JAVA_HOME, else the `java` on PATH. +export async function currentJavaInstallation( + env: NodeJS.ProcessEnv, +): Promise { + const javaHome = env['JAVA_HOME'] + if (javaHome) { + return await readJavaInstallation(javaHome) + } + const java = await whichBin('java', { nothrow: true, path: env['PATH'] }) + if (typeof java !== 'string') { + return undefined + } + const home = path.dirname(path.dirname(await fs.realpath(java))) + // A JDK 8 `java` on PATH can be the one under /jre/bin. + return ( + (await readJavaInstallation(home)) ?? + (await readJavaInstallation(path.dirname(home))) + ) +} + +async function readProperty( + file: string, + key: string, +): Promise { + let text: string + try { + text = await fs.readFile(file, 'utf8') + } catch { + return undefined + } + for (const line of text.split(/\r?\n/)) { + const match = /^\s*([^#!=:\s]+)\s*[=:]\s*(.*?)\s*$/.exec(line) + if (match?.[1] === key) { + return match[2] + } + } + return undefined +} + +// The version the project pins: the Gradle wrapper's distribution, or sbt's +// `sbt.version`. Undefined for a Gradle on PATH or a custom bin. +export async function projectBuildToolVersion( + tool: BuildTool, + projectDir: string, + bin?: string | undefined, +): Promise { + if (tool === 'gradle') { + if (resolveBuildToolBin('gradle', projectDir, bin) !== './gradlew') { + return undefined + } + const url = await readProperty( + path.join(projectDir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + 'distributionUrl', + ) + return url ? /gradle-(\d+(?:\.\d+)*)[^/]*\.zip$/.exec(url)?.[1] : undefined + } + if (tool === 'sbt') { + return await readProperty( + path.join(projectDir, 'project', 'build.properties'), + 'sbt.version', + ) + } + return undefined +} + +function javaRangeFor(tool: BuildTool, version: string): JavaRange | undefined { + switch (tool) { + case 'gradle': + return gradleJavaRange(version) + case 'sbt': + return sbtJavaRange(version) + case 'maven': + return undefined + } +} + +// A JDK to run the project's build tool on, when the current one is outside +// the range the tool's pinned version runs on. Undefined keeps the current JDK. +export async function selectBuildJavaHome( + tool: BuildTool, + projectDir: string, + opts: { + bin?: string | undefined + env: NodeJS.ProcessEnv + searchDirs?: readonly string[] | undefined + }, +): Promise { + const toolVersion = await projectBuildToolVersion(tool, projectDir, opts.bin) + const range = toolVersion ? javaRangeFor(tool, toolVersion) : undefined + if (!toolVersion || !range) { + return undefined + } + const fits = ({ feature }: JavaInstallation) => + feature >= range.min && feature <= range.max + const current = await currentJavaInstallation(opts.env) + if (current && fits(current)) { + return undefined + } + let picked: JavaInstallation | undefined + for (const installation of await discoverJavaInstallations( + opts.env, + opts.searchDirs, + )) { + if (fits(installation) && installation.feature > (picked?.feature ?? 0)) { + picked = installation + } + } + return picked ? { current, picked, tool, toolVersion } : undefined +} diff --git a/src/commands/manifest/scripts/java-home.test.mts b/src/commands/manifest/scripts/java-home.test.mts new file mode 100644 index 000000000..df3701e8d --- /dev/null +++ b/src/commands/manifest/scripts/java-home.test.mts @@ -0,0 +1,246 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + currentJavaInstallation, + discoverJavaInstallations, + gradleJavaRange, + parseJavaFeatureVersion, + projectBuildToolVersion, + sbtJavaRange, + selectBuildJavaHome, +} from './java-home.mts' + +let root: string + +beforeEach(async () => { + root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'socket-java-home-')), + ) +}) + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) +}) + +async function fakeJdk(home: string, javaVersion: string): Promise { + await fs.mkdir(path.join(home, 'bin'), { recursive: true }) + await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(home, 'release'), + `IMPLEMENTOR="Eclipse Adoptium"\nJAVA_VERSION="${javaVersion}"\n`, + ) + return home +} + +async function gradleProject(gradleVersion: string): Promise { + const dir = path.join(root, `gradle-${gradleVersion}`) + await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) + await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + `distributionBase=GRADLE_USER_HOME\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip\n`, + ) + return dir +} + +describe('parseJavaFeatureVersion', () => { + it.each([ + ['JAVA_VERSION="1.8.0_504"', 8], + ['JAVA_VERSION="11.0.32.1"', 11], + ['JAVA_VERSION="25.0.4.1"', 25], + ['JAVA_VERSION="26"', 26], + ['IMPLEMENTOR="x"\nJAVA_VERSION="21.0.12"\n', 21], + ])('reads %s as Java %i', (release, feature) => { + expect(parseJavaFeatureVersion(release)).toBe(feature) + }) + + it('reads nothing from a release file without JAVA_VERSION', () => { + expect(parseJavaFeatureVersion('IMPLEMENTOR="x"')).toBeUndefined() + }) +}) + +describe('gradleJavaRange', () => { + it.each([ + ['6.9.4', 8, 15], + ['7.6.4', 8, 19], + ['8.4', 8, 20], + ['8.5', 8, 21], + ['8.14.3', 8, 24], + ['9.0.0', 17, 24], + ['9.1.0', 17, 25], + ])('runs Gradle %s on Java %i to %i', (version, min, max) => { + expect(gradleJavaRange(version)).toEqual({ min, max }) + }) + + it('runs a Gradle newer than it knows of on any Java from 17', () => { + expect(gradleJavaRange('9.4.0')).toEqual({ + min: 17, + max: Number.POSITIVE_INFINITY, + }) + }) +}) + +describe('sbtJavaRange', () => { + it.each([ + ['0.13.18', 8, 11], + ['1.2.8', 8, 11], + ['1.5.8', 8, 17], + ['1.8.3', 8, 17], + ['1.9.0', 8, 25], + ])('runs sbt %s on Java %i to %i', (version, min, max) => { + expect(sbtJavaRange(version)).toEqual({ min, max }) + }) + + it('runs sbt 2 on any Java from 17', () => { + expect(sbtJavaRange('2.0.0')).toEqual({ + min: 17, + max: Number.POSITIVE_INFINITY, + }) + }) +}) + +describe('projectBuildToolVersion', () => { + it('reads the Gradle wrapper distribution version', async () => { + const dir = await gradleProject('8.10-rc-1') + expect(await projectBuildToolVersion('gradle', dir)).toBe('8.10') + }) + + it('knows no version for a Gradle run without the wrapper', async () => { + const dir = await gradleProject('8.14.3') + expect( + await projectBuildToolVersion('gradle', dir, '/opt/gradle/bin/gradle'), + ).toBeUndefined() + }) + + it("reads sbt's pinned version", async () => { + const dir = path.join(root, 'sbt') + await fs.mkdir(path.join(dir, 'project'), { recursive: true }) + await fs.writeFile( + path.join(dir, 'project', 'build.properties'), + '# pinned\nsbt.version = 1.5.8\n', + ) + expect(await projectBuildToolVersion('sbt', dir)).toBe('1.5.8') + }) +}) + +describe('discoverJavaInstallations', () => { + it('finds JDKs in search dirs, macOS bundles and runner variables', async () => { + const jvm = path.join(root, 'jvm') + const jdk17 = await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20') + const jdk21 = await fakeJdk( + path.join(jvm, 'temurin-21.jdk', 'Contents', 'Home'), + '21.0.12', + ) + const jdk8 = await fakeJdk(path.join(root, 'hosted', '8'), '1.8.0_504') + await fs.mkdir(path.join(jvm, 'not-a-jdk')) + await fs.symlink(jdk17, path.join(jvm, 'default')) + + const found = await discoverJavaInstallations({ JAVA_HOME_8_X64: jdk8 }, [ + jvm, + path.join(root, 'missing'), + ]) + + expect(found).toEqual([ + { home: jdk8, feature: 8 }, + { home: jdk17, feature: 17 }, + { home: jdk21, feature: 21 }, + ]) + }) +}) + +describe('currentJavaInstallation', () => { + it('takes JAVA_HOME over the java on PATH', async () => { + const jdk21 = await fakeJdk(path.join(root, 'jdk21'), '21.0.12') + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + expect( + await currentJavaInstallation({ + JAVA_HOME: jdk21, + PATH: path.join(jdk25, 'bin'), + }), + ).toEqual({ home: jdk21, feature: 21 }) + }) + + it('resolves the java on PATH through symlinks', async () => { + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + const binDir = path.join(root, 'usr-bin') + await fs.mkdir(binDir) + await fs.symlink(path.join(jdk25, 'bin', 'java'), path.join(binDir, 'java')) + expect(await currentJavaInstallation({ PATH: binDir })).toEqual({ + home: jdk25, + feature: 25, + }) + }) +}) + +describe('selectBuildJavaHome', () => { + async function installed(): Promise> { + const jvm = path.join(root, 'jvm') + return { + 17: await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20'), + 21: await fakeJdk(path.join(jvm, 'temurin-21'), '21.0.12'), + 25: await fakeJdk(path.join(jvm, 'temurin-25'), '25.0.4'), + } + } + + it('moves Gradle 8 off Java 25 onto the newest JDK it runs on', async () => { + const jdks = await installed() + const dir = await gradleProject('8.14.3') + const selected = await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdks[25] }, + searchDirs: [path.join(root, 'jvm')], + }) + expect(selected).toEqual({ + current: { home: jdks[25], feature: 25 }, + picked: { home: jdks[21], feature: 21 }, + tool: 'gradle', + toolVersion: '8.14.3', + }) + }) + + it('moves Gradle 9 off Java 8', async () => { + const jdks = await installed() + const jdk8 = await fakeJdk(path.join(root, 'jdk8'), '1.8.0_504') + const dir = await gradleProject('9.1.0') + const selected = await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdk8 }, + searchDirs: [path.join(root, 'jvm')], + }) + expect(selected?.picked).toEqual({ home: jdks[25], feature: 25 }) + }) + + it('keeps a JDK the build tool runs on', async () => { + const jdks = await installed() + const dir = await gradleProject('8.14.3') + expect( + await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdks[17] }, + searchDirs: [path.join(root, 'jvm')], + }), + ).toBeUndefined() + }) + + it('keeps the current JDK when no installed one fits', async () => { + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + const dir = await gradleProject('8.14.3') + expect( + await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdk25 }, + searchDirs: [], + }), + ).toBeUndefined() + }) + + it('leaves Maven on the current JDK', async () => { + const jdks = await installed() + expect( + await selectBuildJavaHome('maven', root, { + env: { JAVA_HOME: jdks[25] }, + searchDirs: [path.join(root, 'jvm')], + }), + ).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index e6c6e0db8..79a2a2804 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -1,11 +1,13 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' +import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' +import { selectBuildJavaHome } from './java-home.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' import { withTmpDir } from '../../../utils/fs.mts' @@ -179,12 +181,36 @@ function assertMavenExtensionBuilt(jarPath: string): void { ) } +// A JAVA_HOME the caller passes (a configured javaHome) is kept as is. +async function withBuildJavaHome( + tool: BuildTool, + opts: ManifestScriptOptions, +): Promise { + if (opts.env?.['JAVA_HOME']) { + return opts + } + const env = opts.env ?? process.env + const selected = await selectBuildJavaHome(tool, opts.projectDir, { + bin: opts.bin, + env, + }) + if (!selected) { + return opts + } + const { current, picked, toolVersion } = selected + logger.info( + `Running ${tool} ${toolVersion} on Java ${picked.feature} at ${picked.home}, since it does not run on ${current ? `Java ${current.feature} at ${current.home}` : 'the Java in JAVA_HOME or on PATH'}.`, + ) + return { ...opts, env: { ...env, JAVA_HOME: picked.home } } +} + // Runs the build-tool script (which emits a records file) and assembles it. // Writes no files; the caller persists facts or consumes artifactPaths. export async function runManifestScript( tool: BuildTool, - opts: ManifestScriptOptions, + scriptOpts: ManifestScriptOptions, ): Promise { + const opts = await withBuildJavaHome(tool, scriptOpts) switch (tool) { case 'gradle': return await runGradle(opts) @@ -199,8 +225,9 @@ export async function runManifestScript( // before committing to a full `runManifestScript` resolution cost. export async function enumerateWorkspaces( tool: BuildTool, - opts: ManifestScriptOptions, + scriptOpts: ManifestScriptOptions, ): Promise { + const opts = await withBuildJavaHome(tool, scriptOpts) const result = await (tool === 'gradle' ? enumerateGradleWorkspaces(opts) : tool === 'sbt' diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 9192a699f..b8575a828 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -99,3 +99,69 @@ describe('maven entry file', () => { }, ) }) + +async function fakeJdk(home: string, javaVersion: string): Promise { + await fs.mkdir(path.join(home, 'bin'), { recursive: true }) + await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(home, 'release'), + `JAVA_VERSION="${javaVersion}"\n`, + ) + return await fs.realpath(home) +} + +async function withGradle814( + fn: (dir: string, jdk21: string, jdk25: string) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gradle-814-')) + try { + await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) + await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + 'distributionUrl=https\\://services.gradle.org/distributions/gradle-8.14.3-bin.zip\n', + ) + await fn( + dir, + await fakeJdk(path.join(dir, 'jdk21'), '21.0.12'), + await fakeJdk(path.join(dir, 'jdk25'), '25.0.4'), + ) + } finally { + await fs.rm(dir, { force: true, recursive: true }) + } +} + +function spawnedJavaHome(): string | undefined { + return vi.mocked(spawn).mock.calls[0]?.[2]?.env?.['JAVA_HOME'] +} + +describe('build JDK', () => { + it('runs the Gradle wrapper on a JDK it supports', async () => { + await withGradle814(async (dir, jdk21, jdk25) => { + vi.mocked(spawn).mockClear() + vi.stubEnv('JAVA_HOME', jdk25) + vi.stubEnv('JAVA_HOME_21_X64', jdk21) + try { + await runManifestScript('gradle', { projectDir: dir, stdio: 'pipe' }) + } finally { + vi.unstubAllEnvs() + } + const javaHome = spawnedJavaHome() + expect(javaHome).not.toBe(jdk25) + const release = await fs.readFile(path.join(javaHome!, 'release'), 'utf8') + expect(Number(/JAVA_VERSION="(\d+)/.exec(release)?.[1])).toBeLessThan(25) + }) + }) + + it('keeps the JAVA_HOME a caller configures', async () => { + await withGradle814(async (dir, jdk21, jdk25) => { + vi.mocked(spawn).mockClear() + await runManifestScript('gradle', { + env: { JAVA_HOME: jdk25, JAVA_HOME_21_X64: jdk21 }, + projectDir: dir, + stdio: 'pipe', + }) + expect(spawnedJavaHome()).toBe(jdk25) + }) + }) +}) From 69ddd819443e00632c2606051e883288cff60ea6 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Thu, 29 Oct 2026 14:54:23 +0100 Subject: [PATCH 2/4] refactor(manifest): simplify the build JDK picker The Gradle and sbt Java ranges now come from one table and one function, compared with semver. JDK discovery reads its search directories and candidates in parallel, and the two test files share their fake JDK and Gradle wrapper fixtures. --- src/commands/manifest/scripts/java-home.mts | 220 ++++++++---------- .../manifest/scripts/java-home.test.mts | 55 ++--- src/commands/manifest/scripts/run.test.mts | 29 +-- .../manifest/scripts/test/java-fixtures.mts | 28 +++ 4 files changed, 152 insertions(+), 180 deletions(-) create mode 100644 src/commands/manifest/scripts/test/java-fixtures.mts diff --git a/src/commands/manifest/scripts/java-home.mts b/src/commands/manifest/scripts/java-home.mts index 405ee94dc..7a0a9fe16 100644 --- a/src/commands/manifest/scripts/java-home.mts +++ b/src/commands/manifest/scripts/java-home.mts @@ -2,6 +2,8 @@ import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' +import semver from 'semver' + import { whichBin } from '@socketsecurity/registry/lib/bin' import { resolveBuildToolBin } from './build-tool.mts' @@ -16,99 +18,78 @@ export type BuildJavaHome = { // Undefined when no usable JDK is set in JAVA_HOME or found on PATH. current: JavaInstallation | undefined picked: JavaInstallation - tool: BuildTool toolVersion: string } -type Version = number[] - -// The first Gradle release that runs on each Java release, from -// https://docs.gradle.org/current/userguide/compatibility.html. -const GRADLE_RUNS_ON_JAVA_FROM: ReadonlyArray<[number, string]> = [ - [8, '2.0'], - [9, '4.3'], - [10, '4.7'], - [11, '5.0'], - [12, '5.4'], - [13, '6.0'], - [14, '6.3'], - [15, '6.7'], - [16, '7.0'], - [17, '7.3'], - [18, '7.5'], - [19, '7.6'], - [20, '8.3'], - [21, '8.5'], - [22, '8.8'], - [23, '8.10'], - [24, '8.14'], - [25, '9.1'], -] - -// The first sbt release that runs on each LTS Java release. The sbt version is -// the project's, which the launcher downloads and runs. -const SBT_RUNS_ON_JAVA_FROM: ReadonlyArray<[number, string]> = [ - [8, '0.13'], - [11, '0.13'], - [17, '1.3'], - [21, '1.9'], - [25, '1.9'], -] - -function parseVersion(text: string): Version | undefined { - const match = /^(\d+(?:\.\d+)*)/.exec(text.trim()) - return match ? match[1]!.split('.').map(Number) : undefined +type JavaSupport = { + // The first release of the tool that runs on each Java release. + runsOnJavaFrom: ReadonlyArray<[number, string]> + // The first major release that needs Java 17. + java17FromMajor: number } -function compareVersions(a: Version, b: Version): number { - for (let i = 0, length = Math.max(a.length, b.length); i < length; i += 1) { - const diff = (a[i] ?? 0) - (b[i] ?? 0) - if (diff !== 0) { - return diff - } - } - return 0 +const JAVA_SUPPORT: Partial> = { + // https://docs.gradle.org/current/userguide/compatibility.html + gradle: { + runsOnJavaFrom: [ + [8, '2.0.0'], + [9, '4.3.0'], + [10, '4.7.0'], + [11, '5.0.0'], + [12, '5.4.0'], + [13, '6.0.0'], + [14, '6.3.0'], + [15, '6.7.0'], + [16, '7.0.0'], + [17, '7.3.0'], + [18, '7.5.0'], + [19, '7.6.0'], + [20, '8.3.0'], + [21, '8.5.0'], + [22, '8.8.0'], + [23, '8.10.0'], + [24, '8.14.0'], + [25, '9.1.0'], + ], + java17FromMajor: 9, + }, + // LTS Java releases only. The sbt version is the project's, which the + // launcher downloads and runs. + sbt: { + runsOnJavaFrom: [ + [8, '0.13.0'], + [11, '0.13.0'], + [17, '1.3.0'], + [21, '1.9.0'], + [25, '1.9.0'], + ], + java17FromMajor: 2, + }, } -// Past the table's newest entry the tool may support newer Java than we know -// of, so any Java counts as supported there. -function maxJavaFor( - table: ReadonlyArray<[number, string]>, - version: Version, -): number { - const newest = table[table.length - 1]! - if (compareVersions(version, parseVersion(newest[1])!) > 0) { - return Number.POSITIVE_INFINITY +export function javaRange( + tool: BuildTool, + version: string, +): JavaRange | undefined { + const support = JAVA_SUPPORT[tool] + const parsed = semver.coerce(version) + if (!support || !parsed) { + return undefined + } + const { java17FromMajor, runsOnJavaFrom } = support + const min = parsed.major >= java17FromMajor ? 17 : 8 + // Past the table's newest entry the tool may support newer Java than we know + // of, so any Java counts as supported there. + if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) { + return { min, max: Number.POSITIVE_INFINITY } } let max = 0 - for (const [java, since] of table) { - if (compareVersions(version, parseVersion(since)!) >= 0) { + for (const [java, since] of runsOnJavaFrom) { + if (semver.gte(parsed, since)) { max = java } } - return max -} - -export function gradleJavaRange(version: string): JavaRange | undefined { - const parsed = parseVersion(version) - if (!parsed) { - return undefined - } - return { - min: parsed[0]! >= 9 ? 17 : 8, - max: maxJavaFor(GRADLE_RUNS_ON_JAVA_FROM, parsed), - } -} - -export function sbtJavaRange(version: string): JavaRange | undefined { - const parsed = parseVersion(version) - if (!parsed) { - return undefined - } - return { - min: parsed[0]! >= 2 ? 17 : 8, - max: maxJavaFor(SBT_RUNS_ON_JAVA_FROM, parsed), - } + return { min, max } } // `JAVA_VERSION` in a JDK's `release` file: "1.8.0_462", "17.0.16", "25". @@ -121,7 +102,7 @@ export function parseJavaFeatureVersion(release: string): number | undefined { return first === 1 && match[2] ? Number(match[2]) : first } -export async function readJavaInstallation( +async function readJavaInstallation( home: string, ): Promise { if (!existsSync(path.join(home, 'bin', 'java'))) { @@ -137,7 +118,7 @@ export async function readJavaInstallation( return feature === undefined ? undefined : { home, feature } } -export function defaultJavaSearchDirs(): string[] { +function defaultJavaSearchDirs(): string[] { const userHome = os.homedir() return [ '/usr/lib/jvm', @@ -159,35 +140,39 @@ export async function discoverJavaInstallations( env: NodeJS.ProcessEnv, searchDirs: readonly string[] = defaultJavaSearchDirs(), ): Promise { - const candidates: string[] = [] - for (const [name, value] of Object.entries(env)) { - if (value && /^JAVA_HOME_\d+_/i.test(name)) { - candidates.push(value) - } - } - for (const dir of searchDirs) { - let children: string[] - try { - children = await fs.readdir(dir) - } catch { - continue - } - for (const child of children.sort()) { - candidates.push(path.join(dir, child)) - candidates.push(path.join(dir, child, 'Contents', 'Home')) - } - } + const runnerHomes = Object.entries(env).flatMap(([name, value]) => + value && /^JAVA_HOME_\d+_/i.test(name) ? [value] : [], + ) + const searchDirHomes = await Promise.all( + searchDirs.map(async dir => { + let children: string[] + try { + children = await fs.readdir(dir) + } catch { + return [] + } + return children + .sort() + .flatMap(child => [ + path.join(dir, child), + path.join(dir, child, 'Contents', 'Home'), + ]) + }), + ) + const installations = await Promise.all( + [...runnerHomes, ...searchDirHomes.flat()].map(async candidate => { + const installation = await readJavaInstallation(candidate) + return installation + ? { ...installation, home: await fs.realpath(candidate) } + : undefined + }), + ) const seen = new Set() const found: JavaInstallation[] = [] - for (const candidate of candidates) { - const installation = await readJavaInstallation(candidate) - if (!installation) { - continue - } - const home = await fs.realpath(candidate) - if (!seen.has(home)) { - seen.add(home) - found.push({ ...installation, home }) + for (const installation of installations) { + if (installation && !seen.has(installation.home)) { + seen.add(installation.home) + found.push(installation) } } return found @@ -258,17 +243,6 @@ export async function projectBuildToolVersion( return undefined } -function javaRangeFor(tool: BuildTool, version: string): JavaRange | undefined { - switch (tool) { - case 'gradle': - return gradleJavaRange(version) - case 'sbt': - return sbtJavaRange(version) - case 'maven': - return undefined - } -} - // A JDK to run the project's build tool on, when the current one is outside // the range the tool's pinned version runs on. Undefined keeps the current JDK. export async function selectBuildJavaHome( @@ -281,7 +255,7 @@ export async function selectBuildJavaHome( }, ): Promise { const toolVersion = await projectBuildToolVersion(tool, projectDir, opts.bin) - const range = toolVersion ? javaRangeFor(tool, toolVersion) : undefined + const range = toolVersion ? javaRange(tool, toolVersion) : undefined if (!toolVersion || !range) { return undefined } @@ -300,5 +274,5 @@ export async function selectBuildJavaHome( picked = installation } } - return picked ? { current, picked, tool, toolVersion } : undefined + return picked ? { current, picked, toolVersion } : undefined } diff --git a/src/commands/manifest/scripts/java-home.test.mts b/src/commands/manifest/scripts/java-home.test.mts index df3701e8d..65d7b4b18 100644 --- a/src/commands/manifest/scripts/java-home.test.mts +++ b/src/commands/manifest/scripts/java-home.test.mts @@ -7,12 +7,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { currentJavaInstallation, discoverJavaInstallations, - gradleJavaRange, + javaRange, parseJavaFeatureVersion, projectBuildToolVersion, - sbtJavaRange, selectBuildJavaHome, } from './java-home.mts' +import { fakeJdk, gradleWrapperProject } from './test/java-fixtures.mts' let root: string @@ -26,25 +26,11 @@ afterEach(async () => { await fs.rm(root, { recursive: true, force: true }) }) -async function fakeJdk(home: string, javaVersion: string): Promise { - await fs.mkdir(path.join(home, 'bin'), { recursive: true }) - await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) - await fs.writeFile( - path.join(home, 'release'), - `IMPLEMENTOR="Eclipse Adoptium"\nJAVA_VERSION="${javaVersion}"\n`, +function gradleProject(gradleVersion: string): Promise { + return gradleWrapperProject( + path.join(root, `gradle-${gradleVersion}`), + gradleVersion, ) - return home -} - -async function gradleProject(gradleVersion: string): Promise { - const dir = path.join(root, `gradle-${gradleVersion}`) - await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) - await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) - await fs.writeFile( - path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), - `distributionBase=GRADLE_USER_HOME\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip\n`, - ) - return dir } describe('parseJavaFeatureVersion', () => { @@ -63,7 +49,7 @@ describe('parseJavaFeatureVersion', () => { }) }) -describe('gradleJavaRange', () => { +describe('Gradle javaRange', () => { it.each([ ['6.9.4', 8, 15], ['7.6.4', 8, 19], @@ -73,18 +59,18 @@ describe('gradleJavaRange', () => { ['9.0.0', 17, 24], ['9.1.0', 17, 25], ])('runs Gradle %s on Java %i to %i', (version, min, max) => { - expect(gradleJavaRange(version)).toEqual({ min, max }) + expect(javaRange('gradle', version)).toEqual({ min, max }) }) it('runs a Gradle newer than it knows of on any Java from 17', () => { - expect(gradleJavaRange('9.4.0')).toEqual({ + expect(javaRange('gradle', '9.4.0')).toEqual({ min: 17, max: Number.POSITIVE_INFINITY, }) }) }) -describe('sbtJavaRange', () => { +describe('sbt javaRange', () => { it.each([ ['0.13.18', 8, 11], ['1.2.8', 8, 11], @@ -92,11 +78,11 @@ describe('sbtJavaRange', () => { ['1.8.3', 8, 17], ['1.9.0', 8, 25], ])('runs sbt %s on Java %i to %i', (version, min, max) => { - expect(sbtJavaRange(version)).toEqual({ min, max }) + expect(javaRange('sbt', version)).toEqual({ min, max }) }) it('runs sbt 2 on any Java from 17', () => { - expect(sbtJavaRange('2.0.0')).toEqual({ + expect(javaRange('sbt', '2.0.0')).toEqual({ min: 17, max: Number.POSITIVE_INFINITY, }) @@ -176,16 +162,16 @@ describe('currentJavaInstallation', () => { }) }) -describe('selectBuildJavaHome', () => { - async function installed(): Promise> { - const jvm = path.join(root, 'jvm') - return { - 17: await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20'), - 21: await fakeJdk(path.join(jvm, 'temurin-21'), '21.0.12'), - 25: await fakeJdk(path.join(jvm, 'temurin-25'), '25.0.4'), - } +async function installed(): Promise> { + const jvm = path.join(root, 'jvm') + return { + 17: await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20'), + 21: await fakeJdk(path.join(jvm, 'temurin-21'), '21.0.12'), + 25: await fakeJdk(path.join(jvm, 'temurin-25'), '25.0.4'), } +} +describe('selectBuildJavaHome', () => { it('moves Gradle 8 off Java 25 onto the newest JDK it runs on', async () => { const jdks = await installed() const dir = await gradleProject('8.14.3') @@ -196,7 +182,6 @@ describe('selectBuildJavaHome', () => { expect(selected).toEqual({ current: { home: jdks[25], feature: 25 }, picked: { home: jdks[21], feature: 21 }, - tool: 'gradle', toolVersion: '8.14.3', }) }) diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index b8575a828..34e81dd9e 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -6,7 +6,10 @@ import { describe, expect, it, vi } from 'vitest' import { spawn } from '@socketsecurity/registry/lib/spawn' +import { parseJavaFeatureVersion } from './java-home.mts' import { enumerateWorkspaces, runManifestScript } from './run.mts' +import { fakeJdk, gradleWrapperProject } from './test/java-fixtures.mts' +import { withTmpDir } from '../../../utils/fs.mts' vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })), @@ -100,35 +103,17 @@ describe('maven entry file', () => { ) }) -async function fakeJdk(home: string, javaVersion: string): Promise { - await fs.mkdir(path.join(home, 'bin'), { recursive: true }) - await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) - await fs.writeFile( - path.join(home, 'release'), - `JAVA_VERSION="${javaVersion}"\n`, - ) - return await fs.realpath(home) -} - async function withGradle814( fn: (dir: string, jdk21: string, jdk25: string) => Promise, ): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gradle-814-')) - try { - await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) - await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) - await fs.writeFile( - path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), - 'distributionUrl=https\\://services.gradle.org/distributions/gradle-8.14.3-bin.zip\n', - ) + await withTmpDir('gradle-814-', async dir => { + await gradleWrapperProject(dir, '8.14.3') await fn( dir, await fakeJdk(path.join(dir, 'jdk21'), '21.0.12'), await fakeJdk(path.join(dir, 'jdk25'), '25.0.4'), ) - } finally { - await fs.rm(dir, { force: true, recursive: true }) - } + }) } function spawnedJavaHome(): string | undefined { @@ -149,7 +134,7 @@ describe('build JDK', () => { const javaHome = spawnedJavaHome() expect(javaHome).not.toBe(jdk25) const release = await fs.readFile(path.join(javaHome!, 'release'), 'utf8') - expect(Number(/JAVA_VERSION="(\d+)/.exec(release)?.[1])).toBeLessThan(25) + expect(parseJavaFeatureVersion(release)).toBeLessThan(25) }) }) diff --git a/src/commands/manifest/scripts/test/java-fixtures.mts b/src/commands/manifest/scripts/test/java-fixtures.mts new file mode 100644 index 000000000..6d076f9cf --- /dev/null +++ b/src/commands/manifest/scripts/test/java-fixtures.mts @@ -0,0 +1,28 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +export async function fakeJdk( + home: string, + javaVersion: string, +): Promise { + await fs.mkdir(path.join(home, 'bin'), { recursive: true }) + await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(home, 'release'), + `IMPLEMENTOR="Eclipse Adoptium"\nJAVA_VERSION="${javaVersion}"\n`, + ) + return await fs.realpath(home) +} + +export async function gradleWrapperProject( + dir: string, + gradleVersion: string, +): Promise { + await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) + await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + `distributionBase=GRADLE_USER_HOME\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip\n`, + ) + return dir +} From 31c5a316a49bc7636a0715b42354b6990f50754d Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Fri, 30 Oct 2026 12:40:00 +0100 Subject: [PATCH 3/4] fix(manifest): find the Gradle wrapper behind a bin A Gradle wrapper set as the bin in socket.json, or passed as a path like ../gradlew, now gets a JDK it supports too. The wrapper version is read from the gradle/wrapper folder next to the script, wherever that script lives. Also reads files through safeReadFile, drops two sbt table rows the lookup never reached, and states the Java 17 threshold as a version like the rest of the table. --- src/commands/manifest/scripts/java-home.mts | 52 +++++++++---------- .../manifest/scripts/java-home.test.mts | 12 +++++ src/commands/manifest/scripts/run.mts | 8 +-- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/commands/manifest/scripts/java-home.mts b/src/commands/manifest/scripts/java-home.mts index 7a0a9fe16..717d07991 100644 --- a/src/commands/manifest/scripts/java-home.mts +++ b/src/commands/manifest/scripts/java-home.mts @@ -5,6 +5,7 @@ import path from 'node:path' import semver from 'semver' import { whichBin } from '@socketsecurity/registry/lib/bin' +import { safeReadFile } from '@socketsecurity/registry/lib/fs' import { resolveBuildToolBin } from './build-tool.mts' @@ -24,8 +25,8 @@ export type BuildJavaHome = { type JavaSupport = { // The first release of the tool that runs on each Java release. runsOnJavaFrom: ReadonlyArray<[number, string]> - // The first major release that needs Java 17. - java17FromMajor: number + // The first release that needs Java 17. + needsJava17From: string } const JAVA_SUPPORT: Partial> = { @@ -51,19 +52,17 @@ const JAVA_SUPPORT: Partial> = { [24, '8.14.0'], [25, '9.1.0'], ], - java17FromMajor: 9, + needsJava17From: '9.0.0', }, // LTS Java releases only. The sbt version is the project's, which the // launcher downloads and runs. sbt: { runsOnJavaFrom: [ - [8, '0.13.0'], [11, '0.13.0'], [17, '1.3.0'], - [21, '1.9.0'], [25, '1.9.0'], ], - java17FromMajor: 2, + needsJava17From: '2.0.0', }, } @@ -76,8 +75,8 @@ export function javaRange( if (!support || !parsed) { return undefined } - const { java17FromMajor, runsOnJavaFrom } = support - const min = parsed.major >= java17FromMajor ? 17 : 8 + const { needsJava17From, runsOnJavaFrom } = support + const min = semver.gte(parsed, needsJava17From) ? 17 : 8 // Past the table's newest entry the tool may support newer Java than we know // of, so any Java counts as supported there. if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) { @@ -108,13 +107,8 @@ async function readJavaInstallation( if (!existsSync(path.join(home, 'bin', 'java'))) { return undefined } - let release: string - try { - release = await fs.readFile(path.join(home, 'release'), 'utf8') - } catch { - return undefined - } - const feature = parseJavaFeatureVersion(release) + const release = await safeReadFile(path.join(home, 'release')) + const feature = release ? parseJavaFeatureVersion(release) : undefined return feature === undefined ? undefined : { home, feature } } @@ -202,13 +196,8 @@ async function readProperty( file: string, key: string, ): Promise { - let text: string - try { - text = await fs.readFile(file, 'utf8') - } catch { - return undefined - } - for (const line of text.split(/\r?\n/)) { + const text = await safeReadFile(file) + for (const line of text?.split(/\r?\n/) ?? []) { const match = /^\s*([^#!=:\s]+)\s*[=:]\s*(.*?)\s*$/.exec(line) if (match?.[1] === key) { return match[2] @@ -225,11 +214,20 @@ export async function projectBuildToolVersion( bin?: string | undefined, ): Promise { if (tool === 'gradle') { - if (resolveBuildToolBin('gradle', projectDir, bin) !== './gradlew') { + const gradleBin = path.resolve( + projectDir, + resolveBuildToolBin('gradle', projectDir, bin), + ) + if (path.basename(gradleBin) !== 'gradlew') { return undefined } const url = await readProperty( - path.join(projectDir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + path.join( + path.dirname(gradleBin), + 'gradle', + 'wrapper', + 'gradle-wrapper.properties', + ), 'distributionUrl', ) return url ? /gradle-(\d+(?:\.\d+)*)[^/]*\.zip$/.exec(url)?.[1] : undefined @@ -265,11 +263,9 @@ export async function selectBuildJavaHome( if (current && fits(current)) { return undefined } + const installed = await discoverJavaInstallations(opts.env, opts.searchDirs) let picked: JavaInstallation | undefined - for (const installation of await discoverJavaInstallations( - opts.env, - opts.searchDirs, - )) { + for (const installation of installed) { if (fits(installation) && installation.feature > (picked?.feature ?? 0)) { picked = installation } diff --git a/src/commands/manifest/scripts/java-home.test.mts b/src/commands/manifest/scripts/java-home.test.mts index 65d7b4b18..a94be4c2c 100644 --- a/src/commands/manifest/scripts/java-home.test.mts +++ b/src/commands/manifest/scripts/java-home.test.mts @@ -102,6 +102,18 @@ describe('projectBuildToolVersion', () => { ).toBeUndefined() }) + it('reads the version next to a wrapper given as the bin', async () => { + const dir = await gradleProject('8.14.3') + const subproject = path.join(dir, 'app') + await fs.mkdir(subproject) + expect( + await projectBuildToolVersion('gradle', subproject, '../gradlew'), + ).toBe('8.14.3') + expect( + await projectBuildToolVersion('gradle', root, path.join(dir, 'gradlew')), + ).toBe('8.14.3') + }) + it("reads sbt's pinned version", async () => { const dir = path.join(root, 'sbt') await fs.mkdir(path.join(dir, 'project'), { recursive: true }) diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 79a2a2804..dd1a20ed4 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -208,9 +208,9 @@ async function withBuildJavaHome( // Writes no files; the caller persists facts or consumes artifactPaths. export async function runManifestScript( tool: BuildTool, - scriptOpts: ManifestScriptOptions, + opts: ManifestScriptOptions, ): Promise { - const opts = await withBuildJavaHome(tool, scriptOpts) + opts = await withBuildJavaHome(tool, opts) switch (tool) { case 'gradle': return await runGradle(opts) @@ -225,9 +225,9 @@ export async function runManifestScript( // before committing to a full `runManifestScript` resolution cost. export async function enumerateWorkspaces( tool: BuildTool, - scriptOpts: ManifestScriptOptions, + opts: ManifestScriptOptions, ): Promise { - const opts = await withBuildJavaHome(tool, scriptOpts) + opts = await withBuildJavaHome(tool, opts) const result = await (tool === 'gradle' ? enumerateGradleWorkspaces(opts) : tool === 'sbt' From 8b2e827ec4344d16d2f34f9f624f29c52dea6a14 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Fri, 30 Oct 2026 12:56:18 +0100 Subject: [PATCH 4/4] refactor(manifest): share the Gradle wrapper name Version detection now asks build-tool.mts whether a bin is the Gradle wrapper, so the wrapper name lives in one place. Also stops exporting three types nothing imports and finds the newest supported Java with findLast. --- src/commands/manifest/scripts/build-tool.mts | 4 ++++ src/commands/manifest/scripts/java-home.mts | 18 +++++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 6416cc1fd..157af4394 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -74,6 +74,10 @@ export function resolveBuildToolBin( return DEFAULT_BUILD_TOOL_BIN[tool] } +export function isBuildToolWrapper(tool: BuildTool, bin: string): boolean { + return basename(bin) === BUILD_TOOL_WRAPPER[tool] +} + // Distinct for every build sharing a directory. export function socketFactsFileName( tool: BuildTool, diff --git a/src/commands/manifest/scripts/java-home.mts b/src/commands/manifest/scripts/java-home.mts index 717d07991..3d9e3e6b0 100644 --- a/src/commands/manifest/scripts/java-home.mts +++ b/src/commands/manifest/scripts/java-home.mts @@ -7,15 +7,15 @@ import semver from 'semver' import { whichBin } from '@socketsecurity/registry/lib/bin' import { safeReadFile } from '@socketsecurity/registry/lib/fs' -import { resolveBuildToolBin } from './build-tool.mts' +import { isBuildToolWrapper, resolveBuildToolBin } from './build-tool.mts' import type { BuildTool } from './build-tool.mts' -export type JavaInstallation = { home: string; feature: number } +type JavaInstallation = { home: string; feature: number } -export type JavaRange = { min: number; max: number } +type JavaRange = { min: number; max: number } -export type BuildJavaHome = { +type BuildJavaHome = { // Undefined when no usable JDK is set in JAVA_HOME or found on PATH. current: JavaInstallation | undefined picked: JavaInstallation @@ -82,12 +82,8 @@ export function javaRange( if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) { return { min, max: Number.POSITIVE_INFINITY } } - let max = 0 - for (const [java, since] of runsOnJavaFrom) { - if (semver.gte(parsed, since)) { - max = java - } - } + const max = + runsOnJavaFrom.findLast(([, since]) => semver.gte(parsed, since))?.[0] ?? 0 return { min, max } } @@ -218,7 +214,7 @@ export async function projectBuildToolVersion( projectDir, resolveBuildToolBin('gradle', projectDir, bin), ) - if (path.basename(gradleBin) !== 'gradlew') { + if (!isBuildToolWrapper('gradle', gradleBin)) { return undefined } const url = await readProperty(