diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 7857466ad..fc7a15fa5 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -79,6 +79,15 @@ async function copyInitGradle() { await fs.copyFile(filepath, destPath) } +async function copySocketFactsInitGradle() { + const filepath = path.join( + constants.srcPath, + 'commands/manifest/socket-facts.init.gradle', + ) + const destPath = path.join(constants.distPath, 'socket-facts.init.gradle') + await fs.copyFile(filepath, destPath) +} + async function copyBashCompletion() { const filepath = path.join( constants.srcPath, @@ -458,6 +467,7 @@ export default async () => { async writeBundle() { await Promise.all([ copyInitGradle(), + copySocketFactsInitGradle(), copyBashCompletion(), updatePackageJson(), // Remove dist/vendor.js.map file. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 13b7e7f6f..9ff52f4fc 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -65,6 +65,18 @@ jobs: with: node-version: ${{ matrix.node-version }} + # Required by socket-facts-init-gradle.e2e.test.mts — exercises the + # `socket manifest gradle --facts` init script against real Gradle. + # Without these the test auto-skips. + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + with: + gradle-version: '9.2.1' + - name: Download sfw-free shell: bash env: diff --git a/.gitignore b/.gitignore index f66a8c8fb..0662c3125 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,13 @@ Thumbs.db test/fixtures/commands/fix/e2e-test-js-temp-* test/fixtures/commands/fix/e2e-test-py-temp-* +# Generated by `socket manifest gradle --facts` integration runs. +test/fixtures/commands/manifest/gradle-facts/**/.gradle/ +test/fixtures/commands/manifest/gradle-facts/**/build/ +test/fixtures/commands/manifest/gradle-facts/**/.socket.facts.json +test/fixtures/commands/manifest/gradle-facts/**/pom.xml +test/fixtures/commands/manifest/gradle-facts/**/local.properties + /.claude/* !/.claude/agents/ !/.claude/commands/ diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 59c105122..134c15d2e 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' +import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' @@ -28,6 +29,11 @@ const config: CliCommandConfig = { type: 'string', description: 'Location of gradlew binary to use, default: CWD/gradlew', }, + facts: { + type: 'boolean', + description: + 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files', + }, gradleOpts: { type: 'string', description: @@ -110,7 +116,7 @@ async function run( sockJson?.defaults?.manifest?.gradle, ) - let { bin, gradleOpts, verbose } = cli.flags + let { bin, facts, gradleOpts, verbose } = cli.flags // Set defaults for any flag/arg that is not given. Check socket.json first. if (!bin) { @@ -140,6 +146,14 @@ async function run( verbose = false } } + if (facts === undefined) { + if (sockJson.defaults?.manifest?.gradle?.facts !== undefined) { + facts = sockJson.defaults?.manifest?.gradle?.facts + logger.info(`Using default --facts from ${SOCKET_JSON}:`, facts) + } else { + facts = false + } + } if (verbose) { logger.group('- ', parentName, config.commandName, ':') @@ -175,13 +189,25 @@ async function run( return } + const parsedGradleOpts = String(gradleOpts || '') + .split(' ') + .map(s => s.trim()) + .filter(Boolean) + + if (facts) { + await convertGradleToFacts({ + bin: String(bin), + cwd, + gradleOpts: parsedGradleOpts, + verbose: Boolean(verbose), + }) + return + } + await convertGradleToMaven({ bin: String(bin), cwd, - gradleOpts: String(gradleOpts || '') - .split(' ') - .map(s => s.trim()) - .filter(Boolean), + gradleOpts: parsedGradleOpts, verbose: Boolean(verbose), }) } diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 4f2b30b6b..8c5393441 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -24,6 +24,7 @@ describe('socket manifest gradle', async () => { Options --bin Location of gradlew binary to use, default: CWD/gradlew + --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph instead of generating \`pom.xml\` files --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --verbose Print debug messages @@ -85,4 +86,22 @@ describe('socket manifest gradle', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + ['manifest', 'gradle', '--facts', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should accept --facts with dry-run', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: " + `) + + expect(code, '--facts --dry-run should exit with code 0').toBe(0) + }, + ) }) diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 68d57b9a8..ffc3f227e 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' +import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' @@ -33,6 +34,11 @@ const config: CliCommandConfig = { type: 'string', description: 'Location of gradlew binary to use, default: CWD/gradlew', }, + facts: { + type: 'boolean', + description: + 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files', + }, gradleOpts: { type: 'string', description: @@ -115,7 +121,7 @@ async function run( sockJson?.defaults?.manifest?.gradle, ) - let { bin, gradleOpts, verbose } = cli.flags + let { bin, facts, gradleOpts, verbose } = cli.flags // Set defaults for any flag/arg that is not given. Check socket.json first. if (!bin) { @@ -145,6 +151,14 @@ async function run( verbose = false } } + if (facts === undefined) { + if (sockJson.defaults?.manifest?.gradle?.facts !== undefined) { + facts = sockJson.defaults?.manifest?.gradle?.facts + logger.info(`Using default --facts from ${SOCKET_JSON}:`, facts) + } else { + facts = false + } + } if (verbose) { logger.group('- ', parentName, config.commandName, ':') @@ -180,13 +194,25 @@ async function run( return } + const parsedGradleOpts = String(gradleOpts || '') + .split(' ') + .map(s => s.trim()) + .filter(Boolean) + + if (facts) { + await convertGradleToFacts({ + bin: String(bin), + cwd, + gradleOpts: parsedGradleOpts, + verbose: Boolean(verbose), + }) + return + } + await convertGradleToMaven({ bin: String(bin), cwd, - gradleOpts: String(gradleOpts || '') - .split(' ') - .map(s => s.trim()) - .filter(Boolean), + gradleOpts: parsedGradleOpts, verbose: Boolean(verbose), }) } diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index d946f122f..ebb5d8510 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -24,6 +24,7 @@ describe('socket manifest kotlin', async () => { Options --bin Location of gradlew binary to use, default: CWD/gradlew + --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph instead of generating \`pom.xml\` files --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --verbose Print debug messages @@ -85,4 +86,22 @@ describe('socket manifest kotlin', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + ['manifest', 'kotlin', '--facts', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should accept --facts with dry-run', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: " + `) + + expect(code, '--facts --dry-run should exit with code 0').toBe(0) + }, + ) }) diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts new file mode 100644 index 000000000..a191c96ed --- /dev/null +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -0,0 +1,143 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' + +export async function convertGradleToFacts({ + bin, + cwd, + gradleOpts, + verbose, +}: { + bin: string + cwd: string + gradleOpts: string[] + verbose: boolean +}): Promise { + const rBin = path.resolve(cwd, bin) + const binExists = fs.existsSync(rBin) + const cwdExists = fs.existsSync(cwd) + + logger.group('gradle2facts:') + logger.info(`- executing: \`${rBin}\``) + if (!binExists) { + logger.warn( + `Warning: It appears the executable could not be found. An error might be printed later because of that.`, + ) + } + logger.info(`- src dir: \`${cwd}\``) + if (!cwdExists) { + logger.warn( + `Warning: It appears the src dir could not be found. An error might be printed later because of that.`, + ) + } + logger.groupEnd() + + try { + // The init script is bundled alongside the existing pom-generating one. + // See .config/rollup.dist.config.mjs:copySocketFactsInitGradle. + const initLocation = path.join( + constants.distPath, + 'socket-facts.init.gradle', + ) + const commandArgs = [ + '--init-script', + initLocation, + ...gradleOpts, + 'socketFacts', + ] + if (verbose) { + logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) + } + logger.log(`Generating Socket facts from \`${bin}\` on \`${cwd}\` ...`) + const output = await execGradle(rBin, commandArgs, cwd, verbose) + if (output.code) { + process.exitCode = 1 + logger.fail(`Gradle exited with exit code ${output.code}`) + if (!verbose) { + logger.group('stderr:') + logger.error(output.stderr) + logger.groupEnd() + } + return + } + logger.success('Executed gradle successfully') + if (verbose) { + // Output already streamed; the "Reported exports:" summary lines were + // visible inline. No need to repeat them from a captured stdout. + logger.log('') + logger.log( + 'Next step is to generate a Scan by running the `socket scan create` command on the same directory.', + ) + return + } + logger.log('Reported exports:') + output.stdout.replace( + /^Socket facts file written to: (.*)/gm, + (_all: string, fn: string) => { + logger.log('- ', fn) + return fn + }, + ) + logger.log('') + logger.log( + 'Next step is to generate a Scan by running the `socket scan create` command on the same directory.', + ) + } catch (e) { + process.exitCode = 1 + logger.fail( + 'There was an unexpected error while generating Socket facts' + + (verbose ? '' : ' (use --verbose for details)'), + ) + if (verbose) { + logger.group('[VERBOSE] error:') + logger.log(e) + logger.groupEnd() + } + } +} + +async function execGradle( + bin: string, + commandArgs: string[], + cwd: string, + verbose: boolean, +): Promise<{ code: number; stdout: string; stderr: string }> { + // When verbose, stream gradle stdout/stderr directly to the user's + // terminal — no spinner, no capture. The trade-off is that the post-run + // "Reported exports:" summary is skipped (the lines were already visible + // inline). For huge builds where the user wants to see progress, this is + // the right default. Non-verbose runs still get the spinner + summary. + if (verbose) { + logger.info( + '(Running gradle with output streaming. This can take a while.)', + ) + const output = await spawn(bin, commandArgs, { cwd, stdio: 'inherit' }) + return { code: output.code, stdout: '', stderr: '' } + } + + const { spinner } = constants + let pass = false + try { + logger.info( + '(Running gradle can take a while, depending on the size of the project)', + ) + logger.info( + '(No live output. Pass --verbose to stream gradle output instead.)', + ) + spinner.start(`Running gradlew...`) + const output = await spawn(bin, commandArgs, { cwd }) + pass = true + const { code, stderr, stdout } = output + return { code, stdout, stderr } + } finally { + if (pass) { + spinner.successAndStop('Gracefully completed gradlew execution.') + } else { + spinner.failAndStop('There was an error while trying to run gradlew.') + } + } +} diff --git a/src/commands/manifest/convert_gradle_to_maven.mts b/src/commands/manifest/convert_gradle_to_maven.mts index 265a9c8d3..74953a28d 100644 --- a/src/commands/manifest/convert_gradle_to_maven.mts +++ b/src/commands/manifest/convert_gradle_to_maven.mts @@ -53,16 +53,10 @@ export async function convertGradleToMaven({ logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) } logger.log(`Converting gradle to maven from \`${bin}\` on \`${cwd}\` ...`) - const output = await execGradleWithSpinner(rBin, commandArgs, cwd) - if (verbose) { - logger.group('[VERBOSE] gradle stdout:') - logger.log(output) - logger.groupEnd() - } + const output = await execGradle(rBin, commandArgs, cwd, verbose) if (output.code) { process.exitCode = 1 logger.fail(`Gradle exited with exit code ${output.code}`) - // (In verbose mode, stderr was printed above, no need to repeat it) if (!verbose) { logger.group('stderr:') logger.error(output.stderr) @@ -71,6 +65,15 @@ export async function convertGradleToMaven({ return } logger.success('Executed gradle successfully') + if (verbose) { + // Output already streamed; "POM file copied to:" lines were visible + // inline. Skip the captured-stdout summary. + logger.log('') + logger.log( + 'Next step is to generate a Scan by running the `socket scan create` command on the same directory', + ) + return + } logger.log('Reported exports:') output.stdout.replace( /^POM file copied to: (.*)/gm, @@ -97,31 +100,35 @@ export async function convertGradleToMaven({ } } -async function execGradleWithSpinner( +async function execGradle( bin: string, commandArgs: string[], cwd: string, + verbose: boolean, ): Promise<{ code: number; stdout: string; stderr: string }> { - const { spinner } = constants + // When verbose, stream gradle stdout/stderr directly to the user's + // terminal — no spinner, no capture. The trade-off is that the post-run + // "Reported exports:" summary is skipped (the lines were already visible + // inline). Non-verbose runs still get the spinner + summary. + if (verbose) { + logger.info( + '(Running gradle with output streaming. This can take a while.)', + ) + const output = await spawn(bin, commandArgs, { cwd, stdio: 'inherit' }) + return { code: output.code, stdout: '', stderr: '' } + } + const { spinner } = constants let pass = false try { logger.info( - '(Running gradle can take a while, it depends on how long gradlew has to run)', + '(Running gradle can take a while, depending on the size of the project)', ) logger.info( - '(It will show no output, you can use --verbose to see its output)', + '(No live output. Pass --verbose to stream gradle output instead.)', ) spinner.start(`Running gradlew...`) - - const output = await spawn(bin, commandArgs, { - // We can pipe the output through to have the user see the result - // of running gradlew, but then we can't (easily) gather the output - // to discover the generated files... probably a flag we should allow? - // stdio: isDebug() ? 'inherit' : undefined, - cwd, - }) - + const output = await spawn(bin, commandArgs, { cwd }) pass = true const { code, stderr, stdout } = output return { code, stdout, stderr } diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 63df846bf..d5bea252a 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { extractBazelToMaven } from './bazel/extract_bazel_to_maven.mts' +import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { convertSbtToMaven } from './convert_sbt_to_maven.mts' import { handleManifestConda } from './handle-manifest-conda.mts' @@ -51,10 +52,7 @@ export async function generateAutoManifest({ } if (!sockJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) { - logger.log( - 'Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...', - ) - await convertGradleToMaven({ + const gradleArgs = { // Note: `gradlew` is more likely to be resolved against cwd. // Note: .resolve() won't butcher an absolute path. // TODO: `gradlew` (or anything else given) may want to resolve against PATH. @@ -68,7 +66,18 @@ export async function generateAutoManifest({ ?.split(' ') .map(s => s.trim()) .filter(Boolean) ?? [], - }) + } + if (sockJson.defaults?.manifest?.gradle?.facts) { + logger.log( + 'Detected a gradle build (Gradle, Kotlin, Scala), generating Socket facts...', + ) + await convertGradleToFacts(gradleArgs) + } else { + logger.log( + 'Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...', + ) + await convertGradleToMaven(gradleArgs) + } } if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) { diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 19076c5d5..a7284b2e7 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -283,6 +283,15 @@ async function setupGradle( delete config.gradleOpts } + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { + return canceledByUser() + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' + } else { + delete config.facts + } + const verbose = await askForVerboseFlag(config.verbose) if (verbose === undefined) { return canceledByUser() @@ -480,6 +489,34 @@ async function askForVerboseFlag( }) } +async function askForFactsFlag( + current: boolean | undefined, +): Promise { + return await select({ + message: + '(--facts) Emit a Socket facts JSON file instead of generating pom.xml?', + choices: [ + { + name: 'no', + value: 'no', + description: 'Generate pom.xml files (default behavior)', + }, + { + name: 'yes', + value: 'yes', + description: + 'Generate a .socket.facts.json file describing the resolved dependency graph', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: current === true ? 'yes' : current === false ? 'no' : '', + }) +} + function canceledByUser(): CResult<{ canceled: boolean }> { logger.log('') logger.info('User canceled') diff --git a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts new file mode 100644 index 000000000..8a9eaf198 --- /dev/null +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -0,0 +1,331 @@ +// Integration tests for the socket-facts.init.gradle init script. +// Skipped when no `gradle` binary is on PATH; otherwise resolves real +// Maven dependencies against Maven Central, so they require network on +// first run and are slower than the rest of the unit suite (~2-10s each). +import { existsSync, readFileSync, rmSync } from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { testPath } from '../../../test/utils.mts' + +type FactsArtifact = { + type: string + namespace: string + name: string + version?: string + qualifiers?: { classifier?: string; ext?: string } + id: string + direct?: boolean + dev?: boolean + tooling?: boolean + dependencies?: string[] +} + +type SocketFacts = { + components: FactsArtifact[] +} + +const initScriptPath = path.join( + testPath, + '..', + 'src', + 'commands', + 'manifest', + 'socket-facts.init.gradle', +) +const fixturesRoot = path.join( + testPath, + 'fixtures/commands/manifest/gradle-facts', +) + +async function gradleAvailable(): Promise { + try { + const out = await spawn('gradle', ['--version']) + return out.code === 0 + } catch { + return false + } +} + +async function runFacts(cwd: string): Promise { + const out = await spawn( + 'gradle', + ['--init-script', initScriptPath, '--quiet', 'socketFacts'], + { cwd }, + ) + if (out.code !== 0) { + throw new Error( + `gradle socketFacts exited with ${out.code} in ${cwd}\nstderr:\n${out.stderr}\nstdout:\n${out.stdout}`, + ) + } +} + +function readFacts(file: string): SocketFacts { + return JSON.parse(readFileSync(file, 'utf8')) as SocketFacts +} + +function clean(...files: string[]): void { + for (const f of files) { + if (existsSync(f)) { + rmSync(f, { force: true }) + } + } +} + +function findById( + facts: SocketFacts, + predicate: (a: FactsArtifact) => boolean, +): FactsArtifact[] { + return facts.components.filter(predicate) +} + +const hasGradle = await gradleAvailable() +const describeOrSkip = hasGradle ? describe : describe.skip + +describeOrSkip('socket-facts.init.gradle', () => { + describe('single-module-java fixture', () => { + const fixture = path.join(fixturesRoot, 'single-module-java') + const output = path.join(fixture, '.socket.facts.json') + + it('produces a facts file with the expected shape', async () => { + clean(output) + await runFacts(fixture) + expect(existsSync(output)).toBe(true) + const facts = readFacts(output) + expect(facts.components.length).toBeGreaterThan(0) + for (const c of facts.components) { + expect(c.type).toBe('maven') + expect(typeof c.namespace).toBe('string') + expect(typeof c.name).toBe('string') + expect(typeof c.id).toBe('string') + if (c.qualifiers !== undefined) { + // MavenQualifiersSchema is .strict() — only classifier and ext. + const keys = Object.keys(c.qualifiers).sort() + expect( + keys.every(k => k === 'classifier' || k === 'ext'), + `unexpected qualifier keys: ${keys.join(',')}`, + ).toBe(true) + } + } + }) + + it('marks first-level dependencies as direct', async () => { + const facts = readFacts(output) + const guava = findById( + facts, + c => c.namespace === 'com.google.guava' && c.name === 'guava', + ) + expect(guava.length, 'guava artifact present').toBeGreaterThan(0) + expect(guava.some(c => c.direct === true)).toBe(true) + }) + + it('does not mark production deps as dev', async () => { + const facts = readFacts(output) + // slf4j-api is `implementation` (production). It must not be dev:true. + const slf4j = findById( + facts, + c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', + ) + expect(slf4j.length).toBeGreaterThan(0) + for (const c of slf4j) { + expect( + c.dev, + `slf4j-api should not be dev: ${JSON.stringify(c)}`, + ).not.toBe(true) + } + // junit is testImplementation — must be dev:true. + const junit = findById( + facts, + c => c.namespace === 'junit' && c.name === 'junit', + ) + expect(junit.length).toBeGreaterThan(0) + expect(junit.some(c => c.dev === true)).toBe(true) + }) + + it('flags annotation-processor deps as tooling, prod deps not as tooling', async () => { + const facts = readFacts(output) + // Lombok is declared on `annotationProcessor` only — must carry + // tooling: true. + const lombok = findById( + facts, + c => c.namespace === 'org.projectlombok' && c.name === 'lombok', + ) + expect(lombok.length, 'lombok artifact present').toBeGreaterThan(0) + expect(lombok.some(c => c.tooling === true)).toBe(true) + // Guava is `api` (production) — must NOT carry tooling. + const guava = findById( + facts, + c => c.namespace === 'com.google.guava' && c.name === 'guava', + ) + expect(guava.length).toBeGreaterThan(0) + for (const c of guava) { + expect( + c.tooling, + `guava should not be tooling: ${JSON.stringify(c)}`, + ).not.toBe(true) + } + // Junit is `testImplementation` — must be dev:true but not tooling. + const junit = findById( + facts, + c => c.namespace === 'junit' && c.name === 'junit', + ) + expect(junit.length).toBeGreaterThan(0) + for (const c of junit) { + expect(c.tooling).not.toBe(true) + } + }) + + it('records dependency edges by artifact id', async () => { + const facts = readFacts(output) + const byId = new Map(facts.components.map(c => [c.id, c])) + for (const c of facts.components) { + for (const childId of c.dependencies ?? []) { + expect( + byId.has(childId), + `dangling dependency id from ${c.id}: ${childId}`, + ).toBe(true) + } + } + }) + }) + + describe('unresolved-deps fixture', () => { + const fixture = path.join(fixturesRoot, 'unresolved-deps') + const output = path.join(fixture, '.socket.facts.json') + + it('records unresolvable dependencies without failing the build', async () => { + clean(output) + await runFacts(fixture) + expect(existsSync(output)).toBe(true) + const facts = readFacts(output) + const fake = findById( + facts, + c => + c.namespace === 'com.example.does-not-exist' && + c.name === 'fake-artifact', + ) + expect(fake.length, 'unresolved dep should appear exactly once').toBe(1) + // Never-resolved coordinates carry no artifact, so we expect no `ext`. + expect(fake[0].qualifiers?.ext).toBeUndefined() + // It's a top-level dep, so it should be marked direct. + expect(fake[0].direct).toBe(true) + }) + }) + + describe('kotlin-multiplatform fixture', () => { + const fixture = path.join(fixturesRoot, 'kotlin-multiplatform') + const output = path.join(fixture, '.socket.facts.json') + + it('captures per-target classpaths from kotlin.targets', async () => { + clean(output) + await runFacts(fixture) + expect(existsSync(output)).toBe(true) + const facts = readFacts(output) + // commonMain dep — should resolve into both jvm and js target variants. + const serializationCore = findById( + facts, + c => + c.namespace === 'org.jetbrains.kotlinx' && + c.name.startsWith('kotlinx-serialization-core'), + ) + expect( + serializationCore.length, + `expected kotlinx-serialization-core in at least one target variant: ${facts.components.map(c => c.id).join(', ')}`, + ).toBeGreaterThan(0) + // jvmMain-only dep — should be present, exercising the per-target + // classpath name pattern (`jvmMainRuntimeClasspath` and friends). + const slf4j = findById( + facts, + c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', + ) + expect(slf4j.length, 'jvmMain slf4j-api present').toBeGreaterThan(0) + }) + }) + + describe('android-library fixture', () => { + const fixture = path.join(fixturesRoot, 'android-library') + const output = path.join(fixture, '.socket.facts.json') + + const androidSdk = + process.env['ANDROID_HOME'] || process.env['ANDROID_SDK_ROOT'] + const androidDescribeOrSkip = androidSdk ? describe : describe.skip + + androidDescribeOrSkip('with ANDROID_HOME set', () => { + it('resolves Android variant classpaths (debug + release)', async () => { + clean(output) + await runFacts(fixture) + expect(existsSync(output)).toBe(true) + const facts = readFacts(output) + // The androidx.annotation dep is declared as `implementation` and + // should appear via debug/release runtime classpaths. Its + // qualifiers.ext should be 'jar' or 'aar' (annotation 1.7.1 ships + // both — Android uses the aar via variant resolution). + const annotation = findById( + facts, + c => c.namespace === 'androidx.annotation' && c.name === 'annotation', + ) + expect( + annotation.length, + `androidx.annotation:annotation present (got ${facts.components.length} components total)`, + ).toBeGreaterThan(0) + expect(annotation.some(c => c.direct === true)).toBe(true) + }) + }) + }) + + describe('multi-module-java fixture', () => { + const fixture = path.join(fixturesRoot, 'multi-module-java') + const rootOut = path.join(fixture, '.socket.facts.json') + const appOut = path.join(fixture, 'app/.socket.facts.json') + const libOut = path.join(fixture, 'lib/.socket.facts.json') + + it('emits a single facts file at the build root', async () => { + clean(rootOut, appOut, libOut) + await runFacts(fixture) + expect(existsSync(rootOut), 'root facts file exists').toBe(true) + // Per-subproject files are deliberately NOT emitted — the single + // root file aggregates everything from every subproject's + // collector. + expect(existsSync(appOut), 'app/ subproject file NOT emitted').toBe(false) + expect(existsSync(libOut), 'lib/ subproject file NOT emitted').toBe(false) + }) + + it('drops intra-project dependencies', async () => { + const facts = readFacts(rootOut) + // `implementation project(':lib')` and the `:app` / `:lib` projects + // themselves should not appear in components. Their externals are + // picked up via each subproject's own collector instead. + const intraProject = findById( + facts, + c => c.namespace === 'com.example.socket', + ) + expect( + intraProject.length, + `intra-project deps should be dropped, got ${JSON.stringify(intraProject.map(e => e.id))}`, + ).toBe(0) + }) + + it('aggregates externals from every subproject', async () => { + const facts = readFacts(rootOut) + // guava is api-declared on :lib — surfaces via :lib's collector. + const guava = findById( + facts, + c => c.namespace === 'com.google.guava' && c.name === 'guava', + ) + expect(guava.length, 'guava (lib api)').toBeGreaterThan(0) + // slf4j is implementation-declared on :app — surfaces via :app's + // collector. + const slf4j = findById( + facts, + c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', + ) + expect(slf4j.length, 'slf4j (app impl)').toBeGreaterThan(0) + for (const c of slf4j) { + expect(c.dev, 'slf4j is prod, not dev').not.toBe(true) + } + }) + }) +}) diff --git a/src/commands/manifest/socket-facts.init.gradle b/src/commands/manifest/socket-facts.init.gradle new file mode 100644 index 000000000..7f8858b6a --- /dev/null +++ b/src/commands/manifest/socket-facts.init.gradle @@ -0,0 +1,334 @@ +// Gradle init script that emits a single `.socket.facts.json` file at the +// build root describing the resolved compile/runtime dependency graph of +// every subproject combined. +// +// Schema matches the canonical SocketFacts shape consumed by depscan +// (`workspaces/lib/src/socket-facts/socket-facts-schema.ts`): +// +// { components: SF_Artifact[] } +// +// Each Maven SF_Artifact is `{ type: 'maven', namespace, name, version?, +// qualifiers? } & { id, direct?, dev?, tooling?, dependencies? }`. +// `qualifiers` is strict on `{ classifier?, ext? }` — anything else is +// dropped. +// +// Invoke via: +// ./gradlew --init-script socket-facts.init.gradle socketFacts +// +// Structure: +// - per-subproject `socketFactsCollect` tasks resolve that subproject's +// configurations and contribute to shared accumulators on gradle.ext +// - the root `socketFacts` task depends on every collector, then +// serializes the accumulated graph to a single JSON file at the build +// root +// +// Intra-project dependencies (i.e. `project(':lib')` style edges between +// subprojects in the same build) are dropped from the output entirely. +// Their reasoning: each subproject contributes its own external deps to +// the shared facts; the inter-project edges would just be noise that +// downstream consumers (coana mvn dependency:get) would try to resolve +// against Maven Central and fail. The externals each intra-project dep +// brings in are picked up via that subproject's own collector. + +import java.util.Collections +import groovy.json.JsonOutput + +ext.SOCKET_FACTS_FILENAME = '.socket.facts.json' + +// Shared accumulators across all subprojects' contributions. Synchronized +// collections so --parallel-enabled builds don't race. The accumulator +// lives on `gradle.ext` so every subproject's collector and the root +// aggregator share the same instance. +gradle.ext.socketFactsState = [ + // id -> [coord, children, prod, nonTooling] + nodes : Collections.synchronizedMap([:]), + // first-level dep ids + directIds : Collections.synchronizedSet([] as Set), + // selectors we've already logged as unresolved (deduped across configs) + reportedUnresolved : Collections.synchronizedSet([] as Set), + // "group:name" of every project in this build — used to filter + // intra-project deps. Populated once all projects are evaluated. + projectKeys : Collections.synchronizedSet([] as Set), +] + +// Capture every project's (group:name) once all projects are configured so +// per-subproject collectors can filter intra-project deps without an +// ordering dependency on other subprojects. +gradle.projectsEvaluated { g -> + g.rootProject.allprojects.each { p -> + g.socketFactsState.projectKeys.add("${p.group ?: ''}:${p.name}") + } +} + +allprojects { project -> + def collectTask = project.tasks.create('socketFactsCollect') { + description = "Resolves ${project.path}'s configurations into the build-wide Socket facts accumulator" + // Dependency resolution depends on state Gradle's up-to-date tracking + // can't represent reliably. + outputs.upToDateWhen { false } + + doLast { + def state = gradle.socketFactsState + def nodes = state.nodes + def directIds = state.directIds + def reportedUnresolved = state.reportedUnresolved + def projectKeys = state.projectKeys + + // `id` omits ext so Gradle's variant artifacts (e.g. + // `java-classes-directory` and `jar` for the same project dep) + // dedupe into a single component. Classifier stays in the id since + // it identifies a distinct artifact (sources, javadoc, etc.). + def coordId = { coord -> + def parts = [coord.groupId, coord.artifactId] + if (coord.classifier) parts << coord.classifier + parts << coord.version + parts.join(':') + } + + def isIntraProject = { String group, String name -> + projectKeys.contains("${group ?: ''}:${name}") + } + + // Atomic upsert: bracket the read-modify-write under the nodes map's + // monitor so concurrent contributions don't lose flag updates. + def upsertNode = { Map coord, boolean isProd, boolean isNonTooling -> + def id = coordId(coord) + synchronized (nodes) { + def node = nodes[id] + if (node == null) { + node = [coord: coord, children: [] as Set, prod: false, nonTooling: false] + nodes[id] = node + } else if (!node.coord.ext && coord.ext) { + // Upgrade to the variant whose Gradle artifact has a real + // packaging extension. Compile classpath visits often arrive + // with no ext (a project dep exposes only its classes-directory + // variant there); the runtime classpath visit then fills in + // the canonical jar/aar. + node.coord = coord + } + if (isProd) { + node.prod = true + } + if (isNonTooling) { + node.nonTooling = true + } + } + id + } + + // Walk a resolved dependency, emitting nodes for itself and its + // transitive closure. `cache` is keyed by ResolvedDependency identity + // and short-circuits revisits in diamond/cyclic graphs. + // + // We never touch `artifact.file` — that forces Gradle to *download* + // the underlying file (catastrophic on large builds that declare + // distribution archives as dependencies). `artifact.extension` and + // `artifact.classifier` read from metadata that resolution already + // needed. + // + // Intra-project deps (project(':lib') and friends) are dropped at + // visit time: we return an empty produced-id set, don't emit a node, + // and don't recurse into the dep's children. The transitives those + // intra-project deps expose are picked up via the consumer + // subproject's classpath directly (Gradle merges them) and via the + // intra-project's own collector. + def visit + visit = { dep, boolean isProd, boolean isNonTooling, Map cache -> + if (cache.containsKey(dep)) { + return cache[dep] + } + if (isIntraProject(dep.moduleGroup, dep.moduleName)) { + def empty = [] as Set + cache[dep] = empty + return empty + } + // Pre-populate the cache to break cycles before we recurse. + def producedIds = [] as Set + cache[dep] = producedIds + + def artifacts = dep.moduleArtifacts + if (artifacts.isEmpty()) { + producedIds << upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: '', + ext : '', + ], isProd, isNonTooling) + } else { + artifacts.each { a -> + producedIds << upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: a.classifier ?: '', + // Use the file extension Gradle reports. For Gradle-internal + // directory variants (java-classes-directory etc.) the + // extension is empty — we let that through and emit no ext + // qualifier. Never fall back to artifact.type, which is + // Gradle's variant attribute, not Maven packaging. + ext : a.extension ?: '', + ], isProd, isNonTooling) + } + } + + def childIds = [] as Set + dep.children.each { child -> + childIds.addAll(visit(child, isProd, isNonTooling, cache)) + } + synchronized (nodes) { + producedIds.each { pid -> + nodes[pid].children.addAll(childIds) + } + } + producedIds + } + + // Configuration selection by name pattern. We match the conventional + // suffixes used across Gradle plugins for resolvable classpath configs: + // Java (`compileClasspath`, `runtimeClasspath`, + // `testCompileClasspath`, `testRuntimeClasspath`), Kotlin Gradle Plugin + // (`jvmMainCompileClasspath`, `linuxX64MainRuntimeClasspath`, ...) and + // AGP per-variant (`debugCompileClasspath`, `releaseRuntimeClasspath`, + // `debugUnitTestRuntimeClasspath`, ...). + // + // Beyond classpaths we also walk other resolvable configurations + // (annotation processors, linter classpaths, etc.) so build-tooling + // deps land in the output too — tagged `tooling: true` so downstream + // reachability scanners can skip them. + // + // We exclude AGP's instrumented-test classpaths (`*AndroidTest*`) + // because their variant resolution requires consumer attributes + // (target SDK, device/host runtime) that an init-script-driven + // resolution doesn't set, and they produce ambiguity errors at + // resolution time. Unit-test classpaths (`*UnitTest*`) resolve fine. + def isClasspath = { String name -> + def lower = name.toLowerCase() + lower.endsWith('compileclasspath') || lower.endsWith('runtimeclasspath') + } + def isAndroidInstrumentedTest = { String name -> + name.toLowerCase().contains('androidtest') + } + def isTestConfig = { String name -> name.toLowerCase().contains('test') } + + def targetConfigs = project.configurations.findAll { + it.canBeResolved && !isAndroidInstrumentedTest(it.name) + } + + targetConfigs.each { cfg -> + def isProd = !isTestConfig(cfg.name) + def isNonTooling = isClasspath(cfg.name) + // Per-configuration try/catch: AGP-style configurations can fail + // with "variant ambiguity" when resolved from an init-script + // context that doesn't carry the consumer attributes AGP sets + // internally. We log and continue so a single ambiguous config + // doesn't sink the whole facts file. + try { + def lenient = cfg.resolvedConfiguration.lenientConfiguration + def cache = [:] + lenient.firstLevelModuleDependencies.each { dep -> + directIds.addAll(visit(dep, isProd, isNonTooling, cache)) + } + lenient.unresolvedModuleDependencies.each { dep -> + if (isIntraProject(dep.selector.group, dep.selector.name)) { + return + } + def selectorKey = dep.selector.toString() + if (reportedUnresolved.add(selectorKey)) { + def reason = dep.problem?.message?.readLines()?.first() ?: 'unknown reason' + println "[socket-facts] unresolved: ${selectorKey} in ${project.path}: ${reason}" + } + def coord = [ + groupId : dep.selector.group ?: '', + artifactId: dep.selector.name, + version : dep.selector.version ?: '', + classifier: '', + ext : '', + ] + directIds.add(upsertNode(coord, isProd, isNonTooling)) + } + } catch (Exception e) { + println "[socket-facts] skipping ${project.path}:${cfg.name}: ${e.message?.readLines()?.first()}" + } + } + } + } +} + +rootProject { + tasks.create('socketFacts') { + group = 'socket' + description = 'Aggregates a single Socket facts JSON for the entire build' + outputs.upToDateWhen { false } + + doLast { + def state = gradle.socketFactsState + def nodes = state.nodes + def directIds = state.directIds + + def components = nodes.collect { id, node -> + def coord = node.coord + def component = [ + type : 'maven', + namespace: coord.groupId, + name : coord.artifactId, + ] + if (coord.version) { + component.version = coord.version + } + def qualifiers = [:] + if (coord.classifier) { + qualifiers.classifier = coord.classifier + } + if (coord.ext) { + qualifiers.ext = coord.ext + } + if (!qualifiers.isEmpty()) { + component.qualifiers = qualifiers + } + component.id = id + if (directIds.contains(id)) { + component.direct = true + } + if (!node.prod) { + component.dev = true + } + if (!node.nonTooling) { + component.tooling = true + } + if (!node.children.isEmpty()) { + component.dependencies = (node.children as List).sort() + } + component + } + + if (components.isEmpty()) { + println "[socket-facts] no resolvable dependencies in build, skipping" + return + } + + def outputDir = project.findProperty('socket.outputDirectory') + ? new File(project.findProperty('socket.outputDirectory').toString()) + : project.projectDir + outputDir.mkdirs() + def fileName = project.findProperty('socket.outputFile') ?: SOCKET_FACTS_FILENAME + def outFile = new File(outputDir, fileName.toString()) + outFile.text = JsonOutput.prettyPrint(JsonOutput.toJson([components: components])) + println "Socket facts file written to: ${outFile.absolutePath}" + } + } +} + +// Wire every subproject's collector as a dependency of the root aggregator +// so the aggregator runs after all contributions have been made. +gradle.projectsEvaluated { g -> + def aggregator = g.rootProject.tasks.findByName('socketFacts') + if (aggregator) { + g.rootProject.allprojects.each { p -> + def collector = p.tasks.findByName('socketFactsCollect') + if (collector) { + aggregator.dependsOn(collector) + } + } + } +} diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 331c0be05..401cc71da 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -61,6 +61,7 @@ export interface SocketJson { gradle?: { disabled?: boolean | undefined bin?: string | undefined + facts?: boolean | undefined gradleOpts?: string | undefined verbose?: boolean | undefined } diff --git a/test/fixtures/commands/manifest/gradle-facts/android-library/build.gradle b/test/fixtures/commands/manifest/gradle-facts/android-library/build.gradle new file mode 100644 index 000000000..f555aae3d --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/android-library/build.gradle @@ -0,0 +1,32 @@ +// Minimal AGP fixture for socket-facts.init.gradle. Exercises Android's +// per-variant compile/runtime classpath configurations (debugCompileClasspath, +// releaseRuntimeClasspath, debugUnitTestRuntimeClasspath, ...) that the Java +// SourceSetContainer doesn't surface. +// +// Requires: +// - JDK 17+ +// - Gradle 8.7+ (matched to the AGP version below) +// - An Android SDK reachable via ANDROID_HOME / ANDROID_SDK_ROOT or +// local.properties (gitignored). +plugins { + id 'com.android.library' version '8.7.3' +} + +android { + namespace 'com.example.socket.androidlib' + compileSdk 34 + + defaultConfig { + minSdk 24 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.7.1' + testImplementation 'junit:junit:4.13.2' +} diff --git a/test/fixtures/commands/manifest/gradle-facts/android-library/gradle.properties b/test/fixtures/commands/manifest/gradle-facts/android-library/gradle.properties new file mode 100644 index 000000000..5bac8ac50 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/android-library/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/test/fixtures/commands/manifest/gradle-facts/android-library/settings.gradle b/test/fixtures/commands/manifest/gradle-facts/android-library/settings.gradle new file mode 100644 index 000000000..520ef3021 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/android-library/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'android-library' diff --git a/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/build.gradle b/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/build.gradle new file mode 100644 index 000000000..43e67efd2 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/build.gradle @@ -0,0 +1,35 @@ +// Kotlin Multiplatform fixture for socket-facts.init.gradle. Exercises +// per-target compile/runtime configurations (jvmMainCompileClasspath, +// jsTestRuntimeClasspath, etc.) that the Java SourceSetContainer doesn't +// surface, but which our name-pattern selection (`*CompileClasspath` / +// `*RuntimeClasspath`) still picks up. +plugins { + id 'org.jetbrains.kotlin.multiplatform' version '1.9.25' +} + +group = 'com.example.socket.kmp' +version = '1.0.0' + +repositories { + mavenCentral() +} + +kotlin { + jvm() + js { + nodejs() + } + + sourceSets { + commonMain { + dependencies { + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2' + } + } + jvmMain { + dependencies { + implementation 'org.slf4j:slf4j-api:1.7.36' + } + } + } +} diff --git a/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/settings.gradle b/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/settings.gradle new file mode 100644 index 000000000..a06570ebc --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +rootProject.name = 'kotlin-multiplatform' diff --git a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle new file mode 100644 index 000000000..bb8dc4e27 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle @@ -0,0 +1,8 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':lib') + implementation 'org.slf4j:slf4j-api:1.7.36' +} diff --git a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle new file mode 100644 index 000000000..9ad0f8369 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle @@ -0,0 +1,8 @@ +allprojects { + group = 'com.example.socket' + version = '1.0.0' + + repositories { + mavenCentral() + } +} diff --git a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle new file mode 100644 index 000000000..5d40be4e2 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle @@ -0,0 +1,8 @@ +plugins { + id 'java-library' +} + +dependencies { + api 'com.google.guava:guava:31.1-jre' + testImplementation 'junit:junit:4.13.2' +} diff --git a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle new file mode 100644 index 000000000..c7597fad0 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle @@ -0,0 +1,4 @@ +rootProject.name = 'multi-module-java' + +include 'lib' +include 'app' diff --git a/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle b/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle new file mode 100644 index 000000000..7137608a2 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java-library' +} + +group = 'com.example.socket' +version = '1.0.0' + +repositories { + mavenCentral() +} + +// Versions chosen to resolve cleanly across Gradle 5.6.4 through 9.2.1. +// guava 32+ publishes Gradle Module Metadata that Gradle 6.9.4 can't fully +// parse (its transitives go missing); guava 31.1-jre uses POM-only metadata +// the older resolver understands. +dependencies { + api 'com.google.guava:guava:31.1-jre' + implementation 'org.slf4j:slf4j-api:1.7.36' + testImplementation 'junit:junit:4.13.2' + // Exercises the `tooling: true` flag. Lombok lives on the + // `annotationProcessor` configuration, not on any compile/runtime + // classpath — so it should be emitted with tooling=true and never have + // production deps spuriously flagged as tooling. + annotationProcessor 'org.projectlombok:lombok:1.18.30' +} diff --git a/test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle b/test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle new file mode 100644 index 000000000..3eeb892e6 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'single-module-java' diff --git a/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle b/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle new file mode 100644 index 000000000..d1b9da529 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle @@ -0,0 +1,20 @@ +// Fixture for the "unresolvable dep" path. Pairs a real dep with one that +// will never resolve from Maven Central so we exercise both branches of the +// init script: +// - first-level resolved deps via `lenient.firstLevelModuleDependencies` +// - unresolved deps via `lenient.unresolvedModuleDependencies` +plugins { + id 'java-library' +} + +group = 'com.example.socket' +version = '1.0.0' + +repositories { + mavenCentral() +} + +dependencies { + api 'org.slf4j:slf4j-api:1.7.36' + implementation 'com.example.does-not-exist:fake-artifact:9.9.9' +} diff --git a/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle b/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle new file mode 100644 index 000000000..af3e0f106 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'unresolved-deps'