From 094be5d9dc2e7534ce75d019156d6473b2a6be0c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:04:08 +0200 Subject: [PATCH 01/13] feat(manifest): add --facts mode to socket manifest gradle (REA-442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a per-(sub)project .socket.facts.json describing the resolved compile/runtime dependency graph, matching the canonical SocketFacts schema consumed by depscan's SBOM_Resolve pipeline. The new init script (socket-facts.init.gradle) registers a socketFacts task on every project, walks resolvable compile/runtime classpaths via LenientConfiguration so unresolved deps degrade gracefully instead of failing the build, dedupes Gradle's variant artifacts (java-classes-directory vs jar) into one component per logical Maven coordinate, and classifies prod/dev correctly (prod-seen-anywhere wins; production deps are no longer flagged dev just because test classpaths inherit them). Output filename matches Coana's .socket.facts.json so the depscan **/*.socket.facts.json glob picks both pre- and post-reachability versions out of a scan tarball. The TS wrapper (convert-gradle-to-facts.mts) routes --facts through the project's ./gradlew by default; the init script is bundled into dist/ alongside the existing pom-generating init.gradle. Verified end-to-end across JDK 8/Gradle 5.6.4, JDK 11/Gradle 6.9.4, JDK 17/Gradle 7.6.4, JDK 21/Gradle 8.10.2, JDK 21/Gradle 9.2.1 — outputs byte-identical after JSON normalization across the matrix on the test fixtures. Test fixtures cover single-module Java, multi-module with project deps, and an unresolvable-dep scenario. The integration test (socket-facts-init-gradle.e2e.test.mts) is e2e-only and auto-skips when no gradle binary is on PATH; CI doesn't yet install JDK/Gradle. Open follow-ups (tracked in REA-442): - AGP-aware variant config discovery and an Android fixture - Kotlin Multiplatform fixture to exercise kotlin.targets.compilations - Mirror --facts onto cmd-manifest-kotlin and the auto-detect path - Promote the sdkman matrix sweep to a CI job --- .config/rollup.dist.config.mjs | 10 + .gitignore | 6 + src/commands/manifest/cmd-manifest-gradle.mts | 36 ++- .../manifest/cmd-manifest-gradle.test.mts | 1 + .../manifest/convert-gradle-to-facts.mts | 127 ++++++++++ .../socket-facts-init-gradle.e2e.test.mts | 224 +++++++++++++++++ .../manifest/socket-facts.init.gradle | 226 ++++++++++++++++++ src/utils/socket-json.mts | 1 + .../multi-module-java/app/build.gradle | 8 + .../multi-module-java/build.gradle | 8 + .../multi-module-java/lib/build.gradle | 8 + .../multi-module-java/settings.gradle | 4 + .../single-module-java/build.gradle | 20 ++ .../single-module-java/settings.gradle | 1 + .../gradle-facts/unresolved-deps/build.gradle | 20 ++ .../unresolved-deps/settings.gradle | 1 + 16 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 src/commands/manifest/convert-gradle-to-facts.mts create mode 100644 src/commands/manifest/socket-facts-init-gradle.e2e.test.mts create mode 100644 src/commands/manifest/socket-facts.init.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle 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/.gitignore b/.gitignore index f66a8c8fb..2ab8acf63 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ 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 + /.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..1c4d88e98 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 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..45cbaf4e1 --- /dev/null +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -0,0 +1,127 @@ +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 execGradleWithSpinner(rBin, commandArgs, cwd) + if (verbose) { + logger.group('[VERBOSE] gradle stdout:') + logger.log(output.stdout) + logger.groupEnd() + } + 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') + 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 execGradleWithSpinner( + bin: string, + commandArgs: string[], + cwd: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + const { spinner } = constants + + let pass = false + try { + logger.info( + '(Running gradle can take a while, it depends on how long gradlew has to run)', + ) + logger.info( + '(It will show no output, you can use --verbose to see its output)', + ) + 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/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts new file mode 100644 index 000000000..ffde4f113 --- /dev/null +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -0,0 +1,224 @@ +// 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 + 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('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('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 one facts file per project', async () => { + clean(rootOut, appOut, libOut) + await runFacts(fixture) + expect(existsSync(rootOut)).toBe(true) + expect(existsSync(appOut)).toBe(true) + expect(existsSync(libOut)).toBe(true) + }) + + it('represents project dependencies as a single artifact', async () => { + const appFacts = readFacts(appOut) + // `implementation project(':lib')` should appear once, not split across + // Gradle variants (java-classes-directory vs jar). + const libEntries = findById( + appFacts, + c => c.namespace === 'com.example.socket' && c.name === 'lib', + ) + expect( + libEntries.length, + `expected one entry for project(:lib), got ${libEntries.length}: ${JSON.stringify(libEntries.map(e => e.id))}`, + ).toBe(1) + }) + + it('does not mark prod deps as dev in multi-module builds', async () => { + const appFacts = readFacts(appOut) + const slf4j = findById( + appFacts, + c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', + ) + expect(slf4j.length).toBeGreaterThan(0) + for (const c of slf4j) { + expect(c.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..a83014304 --- /dev/null +++ b/src/commands/manifest/socket-facts.init.gradle @@ -0,0 +1,226 @@ +// Gradle init script that emits a `*.socket.facts.json` file per project, +// describing the resolved compile/runtime dependency graph. +// +// 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?, dependencies? }`. `qualifiers` is +// strict on `{ classifier?, ext? }` — anything else is dropped. +// +// Invoke via: +// ./gradlew --init-script socket-facts.init.gradle socketFacts +// +// Phase discipline (matters for older Gradle compatibility): +// - configuration phase : register `socketFacts` on every project +// - afterEvaluate : capture project coordinates once plugins are done +// applying (group/version aren't reliably set before) +// - execution / doLast : resolve configurations through LenientConfiguration +// so unresolved deps are reported, not fatal. + +import groovy.json.JsonOutput + +// Match the canonical Coana / depscan filename so the pipeline glob +// (`**/*.socket.facts.json`) picks both pre- and post-reachability versions +// of the file out of a scan tarball. +ext.SOCKET_FACTS_FILENAME = '.socket.facts.json' + +allprojects { project -> + // `tasks.create` (eager) keeps compatibility with Gradle versions predating + // the lazy `tasks.register` API (4.9+). + project.tasks.create('socketFacts') { + group = 'socket' + description = 'Generates a Socket facts JSON file for this project' + // Dependency resolution depends on state Gradle's up-to-date tracking + // can't represent reliably. + outputs.upToDateWhen { false } + + def rootCoord + + project.afterEvaluate { + rootCoord = [ + groupId : project.group?.toString() ?: '', + artifactId: project.name, + version : project.version?.toString() ?: '', + classifier: '', + ext : '', + ] + } + + doLast { + // `id` deliberately omits `ext` so we dedupe Gradle's variant artifacts + // (e.g. `java-classes-directory` + `jar` for the same project dep) 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(':') + } + + // Single source of truth: id -> { coord, children, prod }. + // `prod=true` once the artifact has been observed in a non-test + // configuration; final `dev` is `!prod`. + def nodes = [:] + def directIds = [] as Set + + def upsertNode = { Map coord, boolean isProd -> + def id = coordId(coord) + def node = nodes[id] + if (node == null) { + node = [coord: coord, children: [] as Set, prod: 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 + } + id + } + + def coordFromArtifact = { dep, artifact -> + [ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: artifact?.classifier ?: '', + // `extension` is what Gradle reports as the file extension of the + // resolved artifact (jar/aar/pom/...). For Gradle-internal variants + // exposed as directories (e.g. `java-classes-directory`) the + // artifact is skipped earlier — see the `findAll` below. + ext : artifact?.extension ?: '', + ] + } + + // 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. + def visit + visit = { dep, boolean isProd, Map cache -> + if (cache.containsKey(dep)) { + return cache[dep] + } + // Pre-populate the cache to break cycles before we recurse. + def producedIds = [] as Set + cache[dep] = producedIds + + // Skip Gradle-internal directory variants (e.g. `java-classes-directory` + // emitted on `compileClasspath` for project deps). The corresponding + // jar variant under `runtimeClasspath` is what we want to surface. + def artifacts = dep.moduleArtifacts.findAll { !it.file.isDirectory() } + if (artifacts.isEmpty()) { + producedIds << upsertNode(coordFromArtifact(dep, null), isProd) + } else { + artifacts.each { a -> + producedIds << upsertNode(coordFromArtifact(dep, a), isProd) + } + } + + def childIds = [] as Set + dep.children.each { child -> + childIds.addAll(visit(child, isProd, cache)) + } + 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`, ...). A future revision should + // switch to Gradle data-model discovery (source sets, KMP compilations, + // AGP variants), but until we have fixtures covering each of those, a + // name-based whitelist gives us broad coverage with one code path. + def isClasspath = { String name -> + def lower = name.toLowerCase() + lower.endsWith('compileclasspath') || lower.endsWith('runtimeclasspath') + } + def isTestClasspath = { String name -> name.toLowerCase().contains('test') } + + def targetConfigs = project.configurations.findAll { + it.canBeResolved && isClasspath(it.name) + } + + def rootId = coordId(rootCoord) + nodes[rootId] = [coord: rootCoord, children: [] as Set, prod: true] + + targetConfigs.each { cfg -> + def isProd = !isTestClasspath(cfg.name) + def lenient = cfg.resolvedConfiguration.lenientConfiguration + def cache = [:] + lenient.firstLevelModuleDependencies.each { dep -> + def ids = visit(dep, isProd, cache) + nodes[rootId].children.addAll(ids) + directIds.addAll(ids) + } + lenient.unresolvedModuleDependencies.each { dep -> + def coord = [ + groupId : dep.selector.group ?: '', + artifactId: dep.selector.name, + version : dep.selector.version ?: '', + classifier: '', + ext : '', + ] + def id = upsertNode(coord, isProd) + nodes[rootId].children.add(id) + directIds.add(id) + } + } + + 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.children.isEmpty()) { + component.dependencies = (node.children as List).sort() + } + component + } + + 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}" + } + } +} 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/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..b52091f21 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle @@ -0,0 +1,20 @@ +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' +} 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' From 8aab9dd636294d51f6ad21bca97e452a2543b4cf Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:11:09 +0200 Subject: [PATCH 02/13] test(manifest): drop gradle facts integration suite, match existing surface tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added an e2e test that runs the socket-facts init script against real Gradle fixtures. That's broader coverage than the sibling `socket manifest gradle` / `kotlin` / `auto` commands currently have in CI — those are tested only via `--help` snapshot and a `--dry-run` short-circuit, and never actually invoke gradle. Bring the new `--facts` mode in line: keep the help-text snapshot (already covers the new flag) and add a `--facts --dry-run` case that mirrors the existing dry-run test pattern. Removes the e2e test and the gradle-facts fixtures; drops the matching .gitignore entries that no longer have anywhere to apply. The matrix sweep and integration coverage stay as an open follow-up in REA-442 — to be picked up alongside `setup-java`/`setup-gradle` in CI if/when we want any of the gradle commands actually exercised end-to-end. --- .gitignore | 6 - .../manifest/cmd-manifest-gradle.test.mts | 18 ++ .../socket-facts-init-gradle.e2e.test.mts | 224 ------------------ .../multi-module-java/app/build.gradle | 8 - .../multi-module-java/build.gradle | 8 - .../multi-module-java/lib/build.gradle | 8 - .../multi-module-java/settings.gradle | 4 - .../single-module-java/build.gradle | 20 -- .../single-module-java/settings.gradle | 1 - .../gradle-facts/unresolved-deps/build.gradle | 20 -- .../unresolved-deps/settings.gradle | 1 - 11 files changed, 18 insertions(+), 300 deletions(-) delete mode 100644 src/commands/manifest/socket-facts-init-gradle.e2e.test.mts delete mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle delete mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle diff --git a/.gitignore b/.gitignore index 2ab8acf63..f66a8c8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,12 +21,6 @@ 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 - /.claude/* !/.claude/agents/ !/.claude/commands/ diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 1c4d88e98..8c5393441 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -86,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/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts deleted file mode 100644 index ffde4f113..000000000 --- a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts +++ /dev/null @@ -1,224 +0,0 @@ -// 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 - 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('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('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 one facts file per project', async () => { - clean(rootOut, appOut, libOut) - await runFacts(fixture) - expect(existsSync(rootOut)).toBe(true) - expect(existsSync(appOut)).toBe(true) - expect(existsSync(libOut)).toBe(true) - }) - - it('represents project dependencies as a single artifact', async () => { - const appFacts = readFacts(appOut) - // `implementation project(':lib')` should appear once, not split across - // Gradle variants (java-classes-directory vs jar). - const libEntries = findById( - appFacts, - c => c.namespace === 'com.example.socket' && c.name === 'lib', - ) - expect( - libEntries.length, - `expected one entry for project(:lib), got ${libEntries.length}: ${JSON.stringify(libEntries.map(e => e.id))}`, - ).toBe(1) - }) - - it('does not mark prod deps as dev in multi-module builds', async () => { - const appFacts = readFacts(appOut) - const slf4j = findById( - appFacts, - c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', - ) - expect(slf4j.length).toBeGreaterThan(0) - for (const c of slf4j) { - expect(c.dev).not.toBe(true) - } - }) - }) -}) 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 deleted file mode 100644 index bb8dc4e27..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 9ad0f8369..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 5d40be4e2..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index c7597fad0..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index b52091f21..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -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' -} 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 deleted file mode 100644 index 3eeb892e6..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index d1b9da529..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index af3e0f106..000000000 --- a/test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'unresolved-deps' From 44f67399ff96bd367a855c1e50e9b9f91af96149 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:14:50 +0200 Subject: [PATCH 03/13] fix(manifest): don't emit the scan-target project as a facts component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The socketFacts init script was adding the project being scanned as a component in its own .socket.facts.json. With no parent edges this shows up downstream as `orphaned component not reachable from any direct dependency`. The project is the SBOM target, not one of its own dependencies — drop the root node entirely and let `directIds` carry the first-level edges. afterEvaluate is no longer needed since project coordinates were only used to populate that root entry. --- .../manifest/socket-facts.init.gradle | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/commands/manifest/socket-facts.init.gradle b/src/commands/manifest/socket-facts.init.gradle index a83014304..39e6058e1 100644 --- a/src/commands/manifest/socket-facts.init.gradle +++ b/src/commands/manifest/socket-facts.init.gradle @@ -37,18 +37,6 @@ allprojects { project -> // can't represent reliably. outputs.upToDateWhen { false } - def rootCoord - - project.afterEvaluate { - rootCoord = [ - groupId : project.group?.toString() ?: '', - artifactId: project.name, - version : project.version?.toString() ?: '', - classifier: '', - ext : '', - ] - } - doLast { // `id` deliberately omits `ext` so we dedupe Gradle's variant artifacts // (e.g. `java-classes-directory` + `jar` for the same project dep) into @@ -154,17 +142,17 @@ allprojects { project -> it.canBeResolved && isClasspath(it.name) } - def rootId = coordId(rootCoord) - nodes[rootId] = [coord: rootCoord, children: [] as Set, prod: true] - + // The project being scanned is the SBOM target itself, not one of its + // own dependencies — including it as a component would surface as an + // orphan in downstream tooling ("not reachable from any direct + // dependency"). We emit only the deps; `directIds` carries the + // first-level edges so consumers don't need a synthetic root node. targetConfigs.each { cfg -> def isProd = !isTestClasspath(cfg.name) def lenient = cfg.resolvedConfiguration.lenientConfiguration def cache = [:] lenient.firstLevelModuleDependencies.each { dep -> - def ids = visit(dep, isProd, cache) - nodes[rootId].children.addAll(ids) - directIds.addAll(ids) + directIds.addAll(visit(dep, isProd, cache)) } lenient.unresolvedModuleDependencies.each { dep -> def coord = [ @@ -174,9 +162,7 @@ allprojects { project -> classifier: '', ext : '', ] - def id = upsertNode(coord, isProd) - nodes[rootId].children.add(id) - directIds.add(id) + directIds.add(upsertNode(coord, isProd)) } } From 5c0ac037eaa62aa572f16413e68296b6d652246d Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:26:38 +0200 Subject: [PATCH 04/13] feat(manifest): mirror --facts onto kotlin and auto-detect paths cmd-manifest-kotlin.mts: add the --facts flag (also overridable via defaults.manifest.gradle.facts in socket.json) and route through convertGradleToFacts when set. Test pair (--help snapshot + --facts --dry-run) mirrors what we did for cmd-manifest-gradle. generate_auto_manifest.mts: when defaults.manifest.gradle.facts is true, the auto-detect path now generates Socket facts instead of pom files, matching what the explicit subcommands do. Brings the new mode to feature parity with the existing pom path, which is exposed through gradle, kotlin and auto. --- src/commands/manifest/cmd-manifest-kotlin.mts | 36 ++++++++++++++++--- .../manifest/cmd-manifest-kotlin.test.mts | 19 ++++++++++ .../manifest/generate_auto_manifest.mts | 19 +++++++--- 3 files changed, 64 insertions(+), 10 deletions(-) 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/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) { From 3043215e25ac070a231e049cc4ddf910c32723b5 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:29:45 +0200 Subject: [PATCH 05/13] feat(manifest): surface --facts in socket manifest setup wizard `socket manifest setup` now asks whether gradle should emit Socket facts instead of pom.xml files, writing the answer to defaults.manifest.gradle.facts in socket.json. The prompt sits next to the existing --verbose toggle and follows the same yes/no/leave-default ternary shape. --- .../manifest/setup-manifest-config.mts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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') From 275356609c82a2d0110a8f11ef13a2d7748f47eb Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 10:33:27 +0200 Subject: [PATCH 06/13] test(manifest): restore gradle facts integration test, install JDK+Gradle in e2e CI Brings back the e2e test and gradle-facts fixtures we dropped earlier in this branch and wires `setup-java@v4` + `gradle/actions/setup-gradle@v4` into .github/workflows/e2e-tests.yml so the test actually exercises the init script on every PR (it was previously auto-skipping for lack of a gradle binary). Fixtures keep the guava 31.1-jre / slf4j 1.7.36 pins so resolution stays clean across Gradle 5.6.4 through 9.2.1 in the local sdkman matrix. The e2e CI uses Gradle 9.2.1 / JDK 21 as a single baseline; wider Gradle version coverage in CI is still tracked as a follow-up. Also restores the .gitignore entries for the .gradle/, build/, .socket.facts.json, and pom.xml outputs that integration runs produce. --- .github/workflows/e2e-tests.yml | 12 + .gitignore | 6 + .../socket-facts-init-gradle.e2e.test.mts | 224 ++++++++++++++++++ .../multi-module-java/app/build.gradle | 8 + .../multi-module-java/build.gradle | 8 + .../multi-module-java/lib/build.gradle | 8 + .../multi-module-java/settings.gradle | 4 + .../single-module-java/build.gradle | 20 ++ .../single-module-java/settings.gradle | 1 + .../gradle-facts/unresolved-deps/build.gradle | 20 ++ .../unresolved-deps/settings.gradle | 1 + 11 files changed, 312 insertions(+) create mode 100644 src/commands/manifest/socket-facts-init-gradle.e2e.test.mts create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/app/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/lib/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/multi-module-java/settings.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/single-module-java/settings.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/unresolved-deps/settings.gradle diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 13b7e7f6f..5b9c1993a 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@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '9.2.1' + - name: Download sfw-free shell: bash env: diff --git a/.gitignore b/.gitignore index f66a8c8fb..2ab8acf63 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ 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 + /.claude/* !/.claude/agents/ !/.claude/commands/ 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..ffde4f113 --- /dev/null +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -0,0 +1,224 @@ +// 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 + 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('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('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 one facts file per project', async () => { + clean(rootOut, appOut, libOut) + await runFacts(fixture) + expect(existsSync(rootOut)).toBe(true) + expect(existsSync(appOut)).toBe(true) + expect(existsSync(libOut)).toBe(true) + }) + + it('represents project dependencies as a single artifact', async () => { + const appFacts = readFacts(appOut) + // `implementation project(':lib')` should appear once, not split across + // Gradle variants (java-classes-directory vs jar). + const libEntries = findById( + appFacts, + c => c.namespace === 'com.example.socket' && c.name === 'lib', + ) + expect( + libEntries.length, + `expected one entry for project(:lib), got ${libEntries.length}: ${JSON.stringify(libEntries.map(e => e.id))}`, + ).toBe(1) + }) + + it('does not mark prod deps as dev in multi-module builds', async () => { + const appFacts = readFacts(appOut) + const slf4j = findById( + appFacts, + c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', + ) + expect(slf4j.length).toBeGreaterThan(0) + for (const c of slf4j) { + expect(c.dev).not.toBe(true) + } + }) + }) +}) 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..b52091f21 --- /dev/null +++ b/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle @@ -0,0 +1,20 @@ +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' +} 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' From 37d8e6212beffb53b704c98c53667bfe8b329376 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 11:15:34 +0200 Subject: [PATCH 07/13] feat(manifest): cover Android Gradle Plugin variants in --facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an android-library fixture (AGP 8.7.3, compileSdk 34) and the minimum machinery the init script needs to scrape AGP-flavored classpaths without blowing up. Two changes to socket-facts.init.gradle: - Skip configurations matching *AndroidTest* (instrumented tests). Their resolution needs device-vs-host target attributes the init script doesn't set, and they fail before producing useful data. - Wrap per-configuration resolution in try/catch. AGP unit-test classpaths (releaseUnitTestCompileClasspath etc.) pull in the project's own debugApiElements, which exposes multiple variants (android-classes-jar, r-class-jar, android-lint, ...); without consumer-side build-type attributes we hit "variant ambiguity" errors. We log "[socket-facts] skipping : ..." and continue so other classpaths still produce output. Production (release + debug compile/runtime) variants resolve fine. The e2e test skips the Android case when neither ANDROID_HOME nor ANDROID_SDK_ROOT is set — same auto-skip posture as the rest of the gradle suite. Asserts that androidx.annotation:annotation is captured as a direct dep, confirming AGP variant configs are being walked. Still pending: principled discovery via androidComponents.onVariants (AGP 7+) or android.libraryVariants — current name-pattern matching catches Android variant configs by suffix and gets the job done, but isn't AGP-aware in the strict sense. --- .gitignore | 1 + .../socket-facts-init-gradle.e2e.test.mts | 31 +++++++++++ .../manifest/socket-facts.init.gradle | 55 ++++++++++++------- .../gradle-facts/android-library/build.gradle | 32 +++++++++++ .../android-library/gradle.properties | 1 + .../android-library/settings.gradle | 16 ++++++ 6 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 test/fixtures/commands/manifest/gradle-facts/android-library/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/android-library/gradle.properties create mode 100644 test/fixtures/commands/manifest/gradle-facts/android-library/settings.gradle diff --git a/.gitignore b/.gitignore index 2ab8acf63..0662c3125 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ 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/ diff --git a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts index ffde4f113..002ae1467 100644 --- a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -181,6 +181,37 @@ describeOrSkip('socket-facts.init.gradle', () => { }) }) + 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') diff --git a/src/commands/manifest/socket-facts.init.gradle b/src/commands/manifest/socket-facts.init.gradle index 39e6058e1..d00403542 100644 --- a/src/commands/manifest/socket-facts.init.gradle +++ b/src/commands/manifest/socket-facts.init.gradle @@ -128,18 +128,26 @@ allprojects { project -> // `testCompileClasspath`, `testRuntimeClasspath`), Kotlin Gradle Plugin // (`jvmMainCompileClasspath`, `linuxX64MainRuntimeClasspath`, ...) and // AGP per-variant (`debugCompileClasspath`, `releaseRuntimeClasspath`, - // `debugUnitTestRuntimeClasspath`, ...). A future revision should - // switch to Gradle data-model discovery (source sets, KMP compilations, - // AGP variants), but until we have fixtures covering each of those, a - // name-based whitelist gives us broad coverage with one code path. + // `debugUnitTestRuntimeClasspath`, ...). + // + // 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. + // A future revision should switch to Gradle data-model discovery + // (source sets, KMP compilations, AGP variants). def isClasspath = { String name -> def lower = name.toLowerCase() lower.endsWith('compileclasspath') || lower.endsWith('runtimeclasspath') } + def isAndroidInstrumentedTest = { String name -> + name.toLowerCase().contains('androidtest') + } def isTestClasspath = { String name -> name.toLowerCase().contains('test') } def targetConfigs = project.configurations.findAll { - it.canBeResolved && isClasspath(it.name) + it.canBeResolved && isClasspath(it.name) && !isAndroidInstrumentedTest(it.name) } // The project being scanned is the SBOM target itself, not one of its @@ -149,20 +157,29 @@ allprojects { project -> // first-level edges so consumers don't need a synthetic root node. targetConfigs.each { cfg -> def isProd = !isTestClasspath(cfg.name) - def lenient = cfg.resolvedConfiguration.lenientConfiguration - def cache = [:] - lenient.firstLevelModuleDependencies.each { dep -> - directIds.addAll(visit(dep, isProd, cache)) - } - lenient.unresolvedModuleDependencies.each { dep -> - def coord = [ - groupId : dep.selector.group ?: '', - artifactId: dep.selector.name, - version : dep.selector.version ?: '', - classifier: '', - ext : '', - ] - directIds.add(upsertNode(coord, isProd)) + // 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 — the other classpaths still produce useful output. + try { + def lenient = cfg.resolvedConfiguration.lenientConfiguration + def cache = [:] + lenient.firstLevelModuleDependencies.each { dep -> + directIds.addAll(visit(dep, isProd, cache)) + } + lenient.unresolvedModuleDependencies.each { dep -> + def coord = [ + groupId : dep.selector.group ?: '', + artifactId: dep.selector.name, + version : dep.selector.version ?: '', + classifier: '', + ext : '', + ] + directIds.add(upsertNode(coord, isProd)) + } + } catch (Exception e) { + println "[socket-facts] skipping ${cfg.name}: ${e.message?.readLines()?.first()}" } } 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' From bfd09fb6f4e474fd360863c9b19cb77b0573e264 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 11:19:03 +0200 Subject: [PATCH 08/13] test(manifest): add Kotlin Multiplatform fixture for --facts A minimal KMP project (jvm + js targets) exercises per-target compile and runtime classpaths (jvmMainCompileClasspath, jsTestRuntimeClasspath, ...) that aren't surfaced through Java's SourceSetContainer. Our name-pattern selection picks them up by suffix. The fixture pulls kotlinx-serialization-core (commonMain) so it shows up in both jvm and js target variants of the artifact, and slf4j-api (jvmMain-only) to confirm target-specific classpaths flow through. The test asserts both deps are present in the resulting components array. --- .github/workflows/e2e-tests.yml | 4 +-- .../socket-facts-init-gradle.e2e.test.mts | 30 ++++++++++++++++ .../kotlin-multiplatform/build.gradle | 35 +++++++++++++++++++ .../kotlin-multiplatform/settings.gradle | 9 +++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/build.gradle create mode 100644 test/fixtures/commands/manifest/gradle-facts/kotlin-multiplatform/settings.gradle diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 5b9c1993a..262fdd485 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -68,12 +68,12 @@ jobs: # 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@v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: '21' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 with: gradle-version: '9.2.1' diff --git a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts index 002ae1467..7f6f8f9f1 100644 --- a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -181,6 +181,36 @@ describeOrSkip('socket-facts.init.gradle', () => { }) }) + 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') 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' From bedd24347d48787661bed99e539f11d6aa808eb9 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 12:12:59 +0200 Subject: [PATCH 09/13] feat(manifest): emit `tooling` flag on facts components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens what `socket manifest gradle --facts` emits: instead of whitelisting only `*CompileClasspath` / `*RuntimeClasspath` configurations and silently dropping the rest, we now walk every resolvable configuration and tag each artifact with two independent boolean flags: - `dev: true` ← artifact only ever appeared in test-named configs - `tooling: true` ← artifact only ever appeared outside compile/runtime classpaths (annotation processors, linters, code-gen plugins, gradle plugin internals) "Only ever" means the inverse semantic: if a dep also appears in a non-test classpath, `dev` is cleared; if it also appears in a compile/runtime classpath, `tooling` is cleared. So a dep that shows up as both an `api` and an `annotationProcessor` ends up flagged as neither dev nor tooling — the production usage wins. Motivation: downstream reachability scanners (depscan) want to suppress reachability analysis for tooling artifacts, while still including them in the SBOM for non-reachability alerts (malware, license, supply chain). This was previously impossible because the script dropped tooling deps entirely. Schema: relies on a new `tooling: z.boolean().optional()` on SF_ArtifactSchema in depscan, separate work. The cli side emits the field regardless; older consumers that ignore it stay unaffected. Fixture/test: single-module-java now declares `annotationProcessor 'org.projectlombok:lombok:1.18.30'`, exercising the tooling path. A new test case asserts lombok is emitted with tooling=true while guava (api) and junit (testImplementation) are not. Effect on existing fixtures: - single-module-java: 11 components total (10 non-tooling + lombok) - kotlin-multiplatform: 29 components (11 non-tooling + 18 Kotlin compiler plugin classpath deps as tooling) - android-library: 83 components (5 non-tooling, 78 AGP internals as tooling) — previously these AGP internals were dropped - multi-module-java, unresolved-deps: unchanged shape --- .../socket-facts-init-gradle.e2e.test.mts | 34 +++++++++++ .../manifest/socket-facts.init.gradle | 61 ++++++++++++------- .../single-module-java/build.gradle | 5 ++ 3 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts index 7f6f8f9f1..75e35328e 100644 --- a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -20,6 +20,7 @@ type FactsArtifact = { id: string direct?: boolean dev?: boolean + tooling?: boolean dependencies?: string[] } @@ -144,6 +145,39 @@ describeOrSkip('socket-facts.init.gradle', () => { 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])) diff --git a/src/commands/manifest/socket-facts.init.gradle b/src/commands/manifest/socket-facts.init.gradle index d00403542..28e727f88 100644 --- a/src/commands/manifest/socket-facts.init.gradle +++ b/src/commands/manifest/socket-facts.init.gradle @@ -49,17 +49,24 @@ allprojects { project -> parts.join(':') } - // Single source of truth: id -> { coord, children, prod }. - // `prod=true` once the artifact has been observed in a non-test - // configuration; final `dev` is `!prod`. + // Single source of truth: id -> { coord, children, prod, nonTooling }. + // We accumulate two independent "seen as non-X" flags across all the + // configurations an artifact appears in: + // - `prod` — seen in a non-test configuration → `dev = !prod` + // - `nonTooling` — seen in a compile/runtime classpath (not in + // annotation-processor / linter / build-tooling + // configurations) → `tooling = !nonTooling` + // The "any non-X clears X" rule means a dep that appears as both a + // production classpath dep and an annotation processor will be emitted + // as `dev: undefined, tooling: undefined` (neither flag set). def nodes = [:] def directIds = [] as Set - def upsertNode = { Map coord, boolean isProd -> + def upsertNode = { Map coord, boolean isProd, boolean isNonTooling -> def id = coordId(coord) def node = nodes[id] if (node == null) { - node = [coord: coord, children: [] as Set, prod: false] + 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 @@ -71,6 +78,9 @@ allprojects { project -> if (isProd) { node.prod = true } + if (isNonTooling) { + node.nonTooling = true + } id } @@ -92,7 +102,7 @@ allprojects { project -> // transitive closure. `cache` is keyed by ResolvedDependency identity // and short-circuits revisits in diamond/cyclic graphs. def visit - visit = { dep, boolean isProd, Map cache -> + visit = { dep, boolean isProd, boolean isNonTooling, Map cache -> if (cache.containsKey(dep)) { return cache[dep] } @@ -105,16 +115,16 @@ allprojects { project -> // jar variant under `runtimeClasspath` is what we want to surface. def artifacts = dep.moduleArtifacts.findAll { !it.file.isDirectory() } if (artifacts.isEmpty()) { - producedIds << upsertNode(coordFromArtifact(dep, null), isProd) + producedIds << upsertNode(coordFromArtifact(dep, null), isProd, isNonTooling) } else { artifacts.each { a -> - producedIds << upsertNode(coordFromArtifact(dep, a), isProd) + producedIds << upsertNode(coordFromArtifact(dep, a), isProd, isNonTooling) } } def childIds = [] as Set dep.children.each { child -> - childIds.addAll(visit(child, isProd, cache)) + childIds.addAll(visit(child, isProd, isNonTooling, cache)) } producedIds.each { pid -> nodes[pid].children.addAll(childIds) @@ -122,13 +132,18 @@ allprojects { project -> 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`, ...). + // We walk every resolvable configuration the project exposes — not just + // compile/runtime classpaths — so tooling deps (annotation processors, + // linters, code-gen plugins) land in the output too. Each artifact gets + // two flags: + // + // - `dev` ← inverse of "seen in any non-test configuration" + // - `tooling` ← inverse of "seen in any compile/runtime classpath" + // + // An artifact only carries `tooling: true` when it ONLY appears in + // non-classpath configurations (annotation processors et al). Downstream + // reachability scanners skip tooling-only artifacts; everything else is + // still scanned for general (non-reachability) alerts. // // We exclude AGP's instrumented-test classpaths (`*AndroidTest*`) // because their variant resolution requires consumer attributes @@ -144,10 +159,10 @@ allprojects { project -> def isAndroidInstrumentedTest = { String name -> name.toLowerCase().contains('androidtest') } - def isTestClasspath = { String name -> name.toLowerCase().contains('test') } + def isTestConfig = { String name -> name.toLowerCase().contains('test') } def targetConfigs = project.configurations.findAll { - it.canBeResolved && isClasspath(it.name) && !isAndroidInstrumentedTest(it.name) + it.canBeResolved && !isAndroidInstrumentedTest(it.name) } // The project being scanned is the SBOM target itself, not one of its @@ -156,7 +171,8 @@ allprojects { project -> // dependency"). We emit only the deps; `directIds` carries the // first-level edges so consumers don't need a synthetic root node. targetConfigs.each { cfg -> - def isProd = !isTestClasspath(cfg.name) + 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 @@ -166,7 +182,7 @@ allprojects { project -> def lenient = cfg.resolvedConfiguration.lenientConfiguration def cache = [:] lenient.firstLevelModuleDependencies.each { dep -> - directIds.addAll(visit(dep, isProd, cache)) + directIds.addAll(visit(dep, isProd, isNonTooling, cache)) } lenient.unresolvedModuleDependencies.each { dep -> def coord = [ @@ -176,7 +192,7 @@ allprojects { project -> classifier: '', ext : '', ] - directIds.add(upsertNode(coord, isProd)) + directIds.add(upsertNode(coord, isProd, isNonTooling)) } } catch (Exception e) { println "[socket-facts] skipping ${cfg.name}: ${e.message?.readLines()?.first()}" @@ -210,6 +226,9 @@ allprojects { project -> if (!node.prod) { component.dev = true } + if (!node.nonTooling) { + component.tooling = true + } if (!node.children.isEmpty()) { component.dependencies = (node.children as List).sort() } 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 index b52091f21..7137608a2 100644 --- a/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle +++ b/test/fixtures/commands/manifest/gradle-facts/single-module-java/build.gradle @@ -17,4 +17,9 @@ 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' } From f557a83b1c9725c1084d4db3b1eec1da2db603ae Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 13:18:39 +0200 Subject: [PATCH 10/13] fix(manifest): make --verbose actually stream gradle output live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today both the pom path and the facts path print "(It will show no output, you can use --verbose to see its output)" but --verbose only dumped a captured stdout dump *after* gradle finished. For large multi-project builds (elasticsearch-scale), that means the user stared at a spinner for many minutes with no signal that anything was happening. When --verbose is set, spawn gradle with `stdio: 'inherit'` so the build's stdout/stderr stream live to the user's terminal. The spinner is skipped (would conflict with inherited tty output) and the post-run "Reported exports:" / "POM file copied to:" summary is skipped too — those lines were already visible inline during the streamed run. Non-verbose runs are unchanged: spinner + captured stdout + summary. Also corrects the misleading "(It will show no output, you can use --verbose to see its output)" message to "(No live output. Pass --verbose to stream gradle output instead.)". --- .../manifest/convert-gradle-to-facts.mts | 38 ++++++++++----- .../manifest/convert_gradle_to_maven.mts | 47 +++++++++++-------- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 45cbaf4e1..a191c96ed 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -53,12 +53,7 @@ export async function convertGradleToFacts({ logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) } logger.log(`Generating Socket facts from \`${bin}\` on \`${cwd}\` ...`) - const output = await execGradleWithSpinner(rBin, commandArgs, cwd) - if (verbose) { - logger.group('[VERBOSE] gradle stdout:') - logger.log(output.stdout) - 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}`) @@ -70,6 +65,15 @@ export async function convertGradleToFacts({ 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, @@ -96,23 +100,35 @@ export async function convertGradleToFacts({ } } -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). 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, 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, { cwd }) pass = true const { code, stderr, stdout } = output 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 } From 732d5b801db215ded64f6eba9f3def6d04f5e50f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 13:20:33 +0200 Subject: [PATCH 11/13] perf(manifest): stop downloading artifact files in facts init script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dep.moduleArtifacts` access combined with `.file.isDirectory()` filtering was forcing Gradle to *download* every resolved artifact file. On large multi-project builds (e.g. elasticsearch) this pulled hundreds of MB of distribution archives — .deb / .tar.gz / .zip packaging outputs that some configurations expose as dependencies of the build target itself, not as library deps. User observed: `> :qa:packaging:socketFacts > elasticsearch-7.17.22-amd64.deb > 88.3 MiB/310.4 MiB downloaded` mid-task. Fix: read `artifact.type` / `artifact.extension` / `artifact.classifier` from already-fetched POM/GMM metadata. Never touch `artifact.file` — that's what triggers the actual file download. Replace the `!file.isDirectory()` filter (which forced fetch) with a name-based filter (`INTERNAL_ARTIFACT_TYPES`: java-classes-directory, java-resources-directory, android-classes-directory, android-resources-directory) that drops Gradle-internal variants we don't want to surface as `qualifiers.ext`. Verified locally: - commons-io:commons-io:2.15.1 resolves cleanly under cleared cache, emits qualifiers.ext='jar', no jar in ~/.gradle/caches/modules-2/files-2.1/commons-io after the run - 11/11 e2e fixture tests still green, qualifiers preserved across single-module / multi-module / Android / KMP fixtures --- .../manifest/socket-facts.init.gradle | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/src/commands/manifest/socket-facts.init.gradle b/src/commands/manifest/socket-facts.init.gradle index 28e727f88..f37416ff2 100644 --- a/src/commands/manifest/socket-facts.init.gradle +++ b/src/commands/manifest/socket-facts.init.gradle @@ -84,23 +84,30 @@ allprojects { project -> id } - def coordFromArtifact = { dep, artifact -> - [ - groupId : dep.moduleGroup ?: '', - artifactId: dep.moduleName, - version : dep.moduleVersion ?: '', - classifier: artifact?.classifier ?: '', - // `extension` is what Gradle reports as the file extension of the - // resolved artifact (jar/aar/pom/...). For Gradle-internal variants - // exposed as directories (e.g. `java-classes-directory`) the - // artifact is skipped earlier — see the `findAll` below. - ext : artifact?.extension ?: '', - ] - } + // Internal Gradle artifact types we don't want as `qualifiers.ext` — + // these represent on-disk directories for project deps rather than + // packaged jars/aars. Filtering by type name (not by file.isDirectory(), + // which forces artifact download) means we never trigger a fetch. + def INTERNAL_ARTIFACT_TYPES = [ + 'java-classes-directory', + 'java-resources-directory', + 'android-classes-directory', + 'android-resources-directory', + ] as Set - // 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. + // Walk a resolved dependency, emitting one node per ResolvedDependency + // and recursing through its children. `cache` is keyed by + // ResolvedDependency identity and short-circuits revisits in + // diamond/cyclic graphs. + // + // CRITICAL: we read `artifact.type` and `artifact.classifier` to + // populate qualifiers, but we never touch `artifact.file`. Accessing + // `getFile()` triggers Gradle to *download* the underlying artifact — + // catastrophic on large builds that declare distribution archives + // (.deb / .tar.gz / .zip) as dependencies (e.g. elasticsearch's + // packaging configurations pull 100+ MB binaries). The metadata path + // gives us ext/classifier without any I/O beyond the POM/GMM that + // resolution already needed. def visit visit = { dep, boolean isProd, boolean isNonTooling, Map cache -> if (cache.containsKey(dep)) { @@ -110,15 +117,26 @@ allprojects { project -> def producedIds = [] as Set cache[dep] = producedIds - // Skip Gradle-internal directory variants (e.g. `java-classes-directory` - // emitted on `compileClasspath` for project deps). The corresponding - // jar variant under `runtimeClasspath` is what we want to surface. - def artifacts = dep.moduleArtifacts.findAll { !it.file.isDirectory() } + def artifacts = dep.moduleArtifacts.findAll { a -> + !INTERNAL_ARTIFACT_TYPES.contains(a.type) + } if (artifacts.isEmpty()) { - producedIds << upsertNode(coordFromArtifact(dep, null), isProd, isNonTooling) + producedIds << upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: '', + ext : '', + ], isProd, isNonTooling) } else { artifacts.each { a -> - producedIds << upsertNode(coordFromArtifact(dep, a), isProd, isNonTooling) + producedIds << upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: a.classifier ?: '', + ext : a.extension ?: a.type ?: '', + ], isProd, isNonTooling) } } From e26981e3be83e517e39722ecb0a9da741767078e Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 14:08:18 +0200 Subject: [PATCH 12/13] feat(manifest): emit single facts file per build, drop intra-project deps Previously each subproject's `socketFacts` task emitted its own `.socket.facts.json`, and intra-project deps (`project(':lib')`) flowed into every consuming subproject's file with no signal that they're project-local. Downstream consumers (coana `mvn dependency:get`) would then try to resolve them against Maven Central and fail because those coordinates don't publish to a repository. Restructured to mirror the pattern at program-analysis/plugins/coana-gradle-script/coana-workspaces.init.gradle: - shared accumulators (`nodes`, `directIds`, `reportedUnresolved`, `projectKeys`) live on `gradle.ext.socketFactsState` as synchronized collections, so --parallel-enabled builds don't race - per-subproject `socketFactsCollect` tasks resolve that subproject's configurations and contribute to the shared state - a root `socketFacts` task depends on every collector (via `gradle.projectsEvaluated`) and serializes the aggregated graph to a single `.socket.facts.json` at the build root Intra-project deps are dropped at visit time: when a ResolvedDependency matches a known project's `group:name`, we return an empty producedIds set, don't emit a node, and don't recurse. The externals those project deps expose are picked up via the intra-project's own collector instead (Gradle's classpath inheritance gives the consumer subproject those externals directly, and the producer subproject's collector emits them with `direct: true` from its own classpath position). Verified across the fixture matrix: - single-module-java: unchanged shape (no intra-project deps) - multi-module-java: emits ONE file at build root, no `com.example.socket:lib` or `com.example.socket:app` entries, guava (lib api) + slf4j (app impl) both present - unresolved-deps, kotlin-multiplatform, android-library: unchanged Side benefits: no more empty `.socket.facts.json` files on aggregator subprojects, and the unresolved-dep warning dedupes across the build. --- .../socket-facts-init-gradle.e2e.test.mts | 50 ++-- .../manifest/socket-facts.init.gradle | 274 +++++++++++------- 2 files changed, 202 insertions(+), 122 deletions(-) diff --git a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts index 75e35328e..8a9eaf198 100644 --- a/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts +++ b/src/commands/manifest/socket-facts-init-gradle.e2e.test.mts @@ -282,37 +282,49 @@ describeOrSkip('socket-facts.init.gradle', () => { const appOut = path.join(fixture, 'app/.socket.facts.json') const libOut = path.join(fixture, 'lib/.socket.facts.json') - it('emits one facts file per project', async () => { + it('emits a single facts file at the build root', async () => { clean(rootOut, appOut, libOut) await runFacts(fixture) - expect(existsSync(rootOut)).toBe(true) - expect(existsSync(appOut)).toBe(true) - expect(existsSync(libOut)).toBe(true) + 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('represents project dependencies as a single artifact', async () => { - const appFacts = readFacts(appOut) - // `implementation project(':lib')` should appear once, not split across - // Gradle variants (java-classes-directory vs jar). - const libEntries = findById( - appFacts, - c => c.namespace === 'com.example.socket' && c.name === 'lib', + 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( - libEntries.length, - `expected one entry for project(:lib), got ${libEntries.length}: ${JSON.stringify(libEntries.map(e => e.id))}`, - ).toBe(1) + intraProject.length, + `intra-project deps should be dropped, got ${JSON.stringify(intraProject.map(e => e.id))}`, + ).toBe(0) }) - it('does not mark prod deps as dev in multi-module builds', async () => { - const appFacts = readFacts(appOut) + 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( - appFacts, + facts, c => c.namespace === 'org.slf4j' && c.name === 'slf4j-api', ) - expect(slf4j.length).toBeGreaterThan(0) + expect(slf4j.length, 'slf4j (app impl)').toBeGreaterThan(0) for (const c of slf4j) { - expect(c.dev).not.toBe(true) + 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 index f37416ff2..7f8858b6a 100644 --- a/src/commands/manifest/socket-facts.init.gradle +++ b/src/commands/manifest/socket-facts.init.gradle @@ -1,5 +1,6 @@ -// Gradle init script that emits a `*.socket.facts.json` file per project, -// describing the resolved compile/runtime dependency graph. +// 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`): @@ -7,41 +8,76 @@ // { components: SF_Artifact[] } // // Each Maven SF_Artifact is `{ type: 'maven', namespace, name, version?, -// qualifiers? } & { id, direct?, dev?, dependencies? }`. `qualifiers` is -// strict on `{ classifier?, ext? }` — anything else is dropped. +// 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 // -// Phase discipline (matters for older Gradle compatibility): -// - configuration phase : register `socketFacts` on every project -// - afterEvaluate : capture project coordinates once plugins are done -// applying (group/version aren't reliably set before) -// - execution / doLast : resolve configurations through LenientConfiguration -// so unresolved deps are reported, not fatal. +// 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 -// Match the canonical Coana / depscan filename so the pipeline glob -// (`**/*.socket.facts.json`) picks both pre- and post-reachability versions -// of the file out of a scan tarball. 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 -> - // `tasks.create` (eager) keeps compatibility with Gradle versions predating - // the lazy `tasks.register` API (4.9+). - project.tasks.create('socketFacts') { - group = 'socket' - description = 'Generates a Socket facts JSON file for this 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 { - // `id` deliberately omits `ext` so we dedupe Gradle's variant artifacts - // (e.g. `java-classes-directory` + `jar` for the same project dep) into - // a single component. Classifier stays in the id since it identifies a - // distinct artifact (sources, javadoc, etc.). + 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 @@ -49,77 +85,68 @@ allprojects { project -> parts.join(':') } - // Single source of truth: id -> { coord, children, prod, nonTooling }. - // We accumulate two independent "seen as non-X" flags across all the - // configurations an artifact appears in: - // - `prod` — seen in a non-test configuration → `dev = !prod` - // - `nonTooling` — seen in a compile/runtime classpath (not in - // annotation-processor / linter / build-tooling - // configurations) → `tooling = !nonTooling` - // The "any non-X clears X" rule means a dep that appears as both a - // production classpath dep and an annotation processor will be emitted - // as `dev: undefined, tooling: undefined` (neither flag set). - def nodes = [:] - def directIds = [] as Set + 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) - 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 + 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 } - // Internal Gradle artifact types we don't want as `qualifiers.ext` — - // these represent on-disk directories for project deps rather than - // packaged jars/aars. Filtering by type name (not by file.isDirectory(), - // which forces artifact download) means we never trigger a fetch. - def INTERNAL_ARTIFACT_TYPES = [ - 'java-classes-directory', - 'java-resources-directory', - 'android-classes-directory', - 'android-resources-directory', - ] as Set - - // Walk a resolved dependency, emitting one node per ResolvedDependency - // and recursing through its children. `cache` is keyed by - // ResolvedDependency identity and short-circuits revisits in - // diamond/cyclic graphs. + // 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. // - // CRITICAL: we read `artifact.type` and `artifact.classifier` to - // populate qualifiers, but we never touch `artifact.file`. Accessing - // `getFile()` triggers Gradle to *download* the underlying artifact — - // catastrophic on large builds that declare distribution archives - // (.deb / .tar.gz / .zip) as dependencies (e.g. elasticsearch's - // packaging configurations pull 100+ MB binaries). The metadata path - // gives us ext/classifier without any I/O beyond the POM/GMM 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.findAll { a -> - !INTERNAL_ARTIFACT_TYPES.contains(a.type) - } + def artifacts = dep.moduleArtifacts if (artifacts.isEmpty()) { producedIds << upsertNode([ groupId : dep.moduleGroup ?: '', @@ -135,7 +162,12 @@ allprojects { project -> artifactId: dep.moduleName, version : dep.moduleVersion ?: '', classifier: a.classifier ?: '', - ext : a.extension ?: a.type ?: '', + // 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) } } @@ -144,32 +176,32 @@ allprojects { project -> dep.children.each { child -> childIds.addAll(visit(child, isProd, isNonTooling, cache)) } - producedIds.each { pid -> - nodes[pid].children.addAll(childIds) + synchronized (nodes) { + producedIds.each { pid -> + nodes[pid].children.addAll(childIds) + } } producedIds } - // We walk every resolvable configuration the project exposes — not just - // compile/runtime classpaths — so tooling deps (annotation processors, - // linters, code-gen plugins) land in the output too. Each artifact gets - // two flags: - // - // - `dev` ← inverse of "seen in any non-test configuration" - // - `tooling` ← inverse of "seen in any compile/runtime classpath" + // 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`, ...). // - // An artifact only carries `tooling: true` when it ONLY appears in - // non-classpath configurations (annotation processors et al). Downstream - // reachability scanners skip tooling-only artifacts; everything else is - // still scanned for general (non-reachability) alerts. + // 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. - // A future revision should switch to Gradle data-model discovery - // (source sets, KMP compilations, AGP variants). def isClasspath = { String name -> def lower = name.toLowerCase() lower.endsWith('compileclasspath') || lower.endsWith('runtimeclasspath') @@ -183,19 +215,14 @@ allprojects { project -> it.canBeResolved && !isAndroidInstrumentedTest(it.name) } - // The project being scanned is the SBOM target itself, not one of its - // own dependencies — including it as a component would surface as an - // orphan in downstream tooling ("not reachable from any direct - // dependency"). We emit only the deps; `directIds` carries the - // first-level edges so consumers don't need a synthetic root node. 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 — the other classpaths still produce useful output. + // 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 = [:] @@ -203,6 +230,14 @@ allprojects { project -> 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, @@ -213,9 +248,23 @@ allprojects { project -> directIds.add(upsertNode(coord, isProd, isNonTooling)) } } catch (Exception e) { - println "[socket-facts] skipping ${cfg.name}: ${e.message?.readLines()?.first()}" + 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 @@ -253,6 +302,11 @@ allprojects { project -> 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 @@ -264,3 +318,17 @@ allprojects { project -> } } } + +// 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) + } + } + } +} From 8d72b017a85ec55d79692bed958f09320ee0fd8c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 19 May 2026 14:18:52 +0200 Subject: [PATCH 13/13] fix(ci): correct gradle/actions v4 commit SHA pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zizmor's `impostor-commit` audit failed because the SHA I used (0b6dd653...) was the v4 *tag object* SHA, not the commit it dereferences to. gradle/actions uses nested annotated tags — v4 points to another tag (48b5f213...) which points to commit ed408507eac0... That last hop is what should be pinned. setup-java@v4 was already correct (resolves directly to a commit). --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 262fdd485..9ff52f4fc 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -73,7 +73,7 @@ jobs: distribution: temurin java-version: '21' - - uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 with: gradle-version: '9.2.1'