From b16227ea69915b9624536b36110435e416b460cb Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 09:51:36 +0200 Subject: [PATCH 1/7] feat(manifest): --facts emits Socket facts JSON for sbt/Scala projects (REA-474) `socket manifest scala --facts` emits a single .socket.facts.json describing an sbt build's resolved dependency graph (mirroring `manifest gradle --facts`), for use as pregenerated SBOM input to `scan create` Tier 1 reachability instead of generating pom.xml files. Delivered as a source-only sbt AutoPlugin dropped into an isolated -Dsbt.global.base plugins dir: no plugin install, no project changes, never touches the user's ~/.sbt. Compiled by the sbt meta-build so a single Ivy-based path spans sbt 0.13 through 1.x. The dependency graph is read from Ivy resolution metadata only (setDownload(false)) so no artifact jars are downloaded, and resolution is per-project so divergent per-module versions are both reported. Also exposed in the `manifest setup` wizard and honored by `scan create --auto-manifest`. --- .config/rollup.dist.config.mjs | 10 + CHANGELOG.md | 1 + src/commands/manifest/cmd-manifest-scala.mts | 44 +- .../manifest/cmd-manifest-scala.test.mts | 25 ++ .../manifest/convert-sbt-to-facts.mts | 217 ++++++++++ .../manifest/generate_auto_manifest.mts | 19 +- .../manifest/setup-manifest-config.mts | 41 +- .../manifest/socket-facts.plugin.scala | 403 ++++++++++++++++++ src/utils/socket-json.mts | 1 + 9 files changed, 736 insertions(+), 25 deletions(-) create mode 100644 src/commands/manifest/convert-sbt-to-facts.mts create mode 100644 src/commands/manifest/socket-facts.plugin.scala diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index fc7a15fa5..7a60c7f34 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -88,6 +88,15 @@ async function copySocketFactsInitGradle() { await fs.copyFile(filepath, destPath) } +async function copySocketFactsSbtPlugin() { + const filepath = path.join( + constants.srcPath, + 'commands/manifest/socket-facts.plugin.scala', + ) + const destPath = path.join(constants.distPath, 'socket-facts.plugin.scala') + await fs.copyFile(filepath, destPath) +} + async function copyBashCompletion() { const filepath = path.join( constants.srcPath, @@ -468,6 +477,7 @@ export default async () => { await Promise.all([ copyInitGradle(), copySocketFactsInitGradle(), + copySocketFactsSbtPlugin(), copyBashCompletion(), updatePackageJson(), // Remove dist/vendor.js.map file. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5993e3634..575cbe38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`socket manifest bazel [beta]`** — Generate Bazel JVM SBOM manifests by running `bazel query` against discovered Maven repos in a Bazel workspace. Closes the inline-Maven-declaration gap that lockfile-only parsing misses for repos like envoy, ray, tensorflow, tink-java, and or-tools. Auto-detects Bzlmod and legacy `WORKSPACE`. - **`socket scan create --auto-manifest`** now covers Bazel workspaces in addition to Gradle/Scala/Kotlin/Conda. Repos with `MODULE.bazel`, `WORKSPACE`, or `WORKSPACE.bazel` are detected automatically and their Maven dependencies extracted as part of the standard scan-create flow. - **Bazel PyPI extraction** — `socket manifest bazel --ecosystem pypi` now generates `requirements.txt` for Python Bazel workspaces. Discovers custom `rules_python` pip hub names with Bazel command output first, queries `py_library` / `py_binary` / `py_test` dependencies, resolves canonical pinned versions from `requirements_lock.txt`, and emits PEP 503-normalized `name==version` lines. Supports both Bzlmod (`pip.parse`) and legacy `WORKSPACE` (`pip_parse` / `pip_install`) configurations. PyPI remains explicit opt-in for `socket scan create --auto-manifest` until real-world no-lockfile recovery is validated. +- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph straight from an sbt build (no `pom.xml` round-trip), consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Reads dependency metadata only (no artifact downloads) and works across a wide range of sbt versions (0.13 through 1.x) with no plugin install or project changes. Toggle also exposed via the `socket manifest setup` wizard and honored by `socket scan create --auto-manifest`. ### Changed - **Bazel diagnostics** — `socket manifest bazel --verbose` now emits bounded subprocess traces with argv, cwd, duration, exit status, output sizes, and failure stderr tails to make customer log-only triage safer and faster. diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 5a971519b..5a098cb6e 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.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 { convertSbtToFacts } from './convert-sbt-to-facts.mts' import { convertSbtToMaven } from './convert_sbt_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 sbt binary to use', }, + facts: { + type: 'boolean', + description: + 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files', + }, out: { type: 'string', description: @@ -75,6 +81,11 @@ const config: CliCommandConfig = { You can specify --bin to override the path to the \`sbt\` binary to invoke. + Pass --facts to instead emit a single \`.socket.facts.json\` describing the + resolved dependency graph of the whole build (no \`pom.xml\` files). It reads + dependency metadata only and never downloads artifacts; an unresolved + dependency is a fatal error. + Support is beta. Please report issues or give us feedback on what's missing. This is only for SBT. If your Scala setup uses gradle, please see the help @@ -83,6 +94,7 @@ const config: CliCommandConfig = { Examples $ ${command} + $ ${command} --facts . $ ${command} ./proj --bin=/usr/bin/sbt --file=boot.sbt `, } @@ -125,7 +137,7 @@ async function run( sockJson?.defaults?.manifest?.sbt, ) - let { bin, out, sbtOpts, stdout, verbose } = cli.flags + let { bin, facts, out, sbtOpts, stdout, verbose } = cli.flags // Set defaults for any flag/arg that is not given. Check socket.json first. if (!bin) { @@ -136,6 +148,14 @@ async function run( bin = 'sbt' } } + if (facts === undefined) { + if (sockJson.defaults?.manifest?.sbt?.facts !== undefined) { + facts = sockJson.defaults?.manifest?.sbt?.facts + logger.info(`Using default --facts from ${SOCKET_JSON}:`, facts) + } else { + facts = false + } + } if ( stdout === undefined && sockJson.defaults?.manifest?.sbt?.stdout !== undefined @@ -206,14 +226,26 @@ async function run( return } + const parsedSbtOpts = String(sbtOpts || '') + .split(' ') + .map(s => s.trim()) + .filter(Boolean) + + if (facts) { + await convertSbtToFacts({ + bin: String(bin), + cwd, + sbtOpts: parsedSbtOpts, + verbose: Boolean(verbose), + }) + return + } + await convertSbtToMaven({ bin: String(bin), - cwd: cwd, + cwd, out: String(out), - sbtOpts: String(sbtOpts) - .split(' ') - .map(s => s.trim()) - .filter(Boolean), + sbtOpts: parsedSbtOpts, verbose: Boolean(verbose), }) } diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 40e59e382..eca9b4d64 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -24,6 +24,7 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use + --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph instead of generating \`pom.xml\` files --out Path of output file; where to store the resulting manifest, see also --stdout --sbt-opts Additional options to pass on to sbt, as per \`sbt --help\` --stdout Print resulting pom.xml to stdout (supersedes --out) @@ -51,6 +52,11 @@ describe('socket manifest scala', async () => { You can specify --bin to override the path to the \`sbt\` binary to invoke. + Pass --facts to instead emit a single \`.socket.facts.json\` describing the + resolved dependency graph of the whole build (no \`pom.xml\` files). It reads + dependency metadata only and never downloads artifacts; an unresolved + dependency is a fatal error. + Support is beta. Please report issues or give us feedback on what's missing. This is only for SBT. If your Scala setup uses gradle, please see the help @@ -59,6 +65,7 @@ describe('socket manifest scala', async () => { Examples $ socket manifest scala + $ socket manifest scala --facts . $ socket manifest scala ./proj --bin=/usr/bin/sbt --file=boot.sbt" `, ) @@ -94,4 +101,22 @@ describe('socket manifest scala', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + ['manifest', 'scala', '--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 scala\`, cwd: " + `) + + expect(code, '--facts --dry-run should exit with code 0').toBe(0) + }, + ) }) diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts new file mode 100644 index 000000000..b1508e112 --- /dev/null +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -0,0 +1,217 @@ +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' + +// Shown when the sbt launcher dies on a modern JDK. sbt 0.13 (and some early +// 1.x) install a SecurityManager, which JDK 18+ removed, so the launcher +// throws before our plugin runs. We don't pick a JDK for the user — they own +// their toolchain — but we point them at the fix. +const JDK_HINT = + 'Hint: old sbt (0.13.x and early 1.x) cannot run on modern JDKs because the Java Security Manager was removed in JDK 18+. Run with a compatible JDK by setting JAVA_HOME (e.g. Java 11) or passing `--sbt-opts "--java-home "`.' + +// The socket-owned global base sbt compiles our plugin into. Living under the +// app data dir (not the user's `~/.sbt`) means we never mutate their sbt +// config, while persisting the compiled plugin between runs. sbt namespaces +// the compiled output by Scala/sbt version (`target/scala-2.10/sbt-0.13`, +// `target/scala-2.12/sbt-1.0`, ...), so a single base safely serves every sbt +// version with no version detection needed. +function resolveGlobalBase(): string { + const { socketAppDataPath } = constants + return socketAppDataPath + ? path.join(path.dirname(socketAppDataPath), 'sbt-facts') + : path.join(os.tmpdir(), 'socket-sbt-facts') +} + +// Drop the shipped plugin source into `/plugins/`, rewriting only +// when its content changed so sbt's incremental compiler can reuse the cache. +async function ensurePluginSource( + pluginSrcPath: string, + pluginsDir: string, +): Promise { + const source = await fs.readFile(pluginSrcPath, 'utf8') + const destPath = path.join(pluginsDir, 'SocketFactsPlugin.scala') + let current: string | undefined + if (existsSync(destPath)) { + current = await fs.readFile(destPath, 'utf8') + } + if (current !== source) { + await fs.mkdir(pluginsDir, { recursive: true }) + await fs.writeFile(destPath, source, 'utf8') + } +} + +export async function convertSbtToFacts({ + bin, + cwd, + sbtOpts, + verbose, +}: { + bin: string + cwd: string + sbtOpts: string[] + verbose: boolean +}): Promise { + logger.group('sbt2facts:') + logger.info(`- executing: \`${bin}\``) + logger.info(`- src dir: \`${cwd}\``) + if (!existsSync(cwd)) { + logger.warn( + 'Warning: It appears the src dir could not be found. An error might be printed later because of that.', + ) + } + logger.groupEnd() + + try { + const pluginSrcPath = path.join( + constants.distPath, + 'socket-facts.plugin.scala', + ) + const globalBase = resolveGlobalBase() + await ensurePluginSource(pluginSrcPath, path.join(globalBase, 'plugins')) + + // `-Dsbt.global.base` points sbt at our isolated plugins dir, so the + // source-only plugin activates without touching the user's `~/.sbt`. + const commandArgs = [ + `-Dsbt.global.base=${globalBase}`, + ...sbtOpts, + '--batch', + 'socketFacts', + ] + if (verbose) { + logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) + } + logger.log(`Generating Socket facts from \`${bin}\` on \`${cwd}\` ...`) + + const output = await execSbt(bin, commandArgs, cwd, verbose) + if (output.code) { + process.exitCode = 1 + logger.fail(`sbt exited with exit code ${output.code}`) + if (!verbose) { + const errorLines = extractErrorLines(output.stdout, output.stderr) + if (errorLines) { + logger.group('sbt output:') + logger.error(errorLines) + logger.groupEnd() + } + } + if (/security ?manager/i.test(output.stdout + output.stderr)) { + logger.warn(JDK_HINT) + } + return + } + logger.success('Executed sbt successfully') + if (verbose) { + // Output already streamed inline; nothing to re-summarize. + logger.log('') + logger.log( + 'Next step is to generate a Scan by running the `socket scan create` command on the same directory.', + ) + return + } + // `spawn` already strips ANSI from captured output, and the plugin prints + // these lines bare (via println, no sbt `[info]` prefix), so plain line + // matching is stable. + const exports: string[] = [] + for (const m of output.stdout.matchAll( + /Socket facts file written to: (.+)/g, + )) { + const reported = m[1]?.trim() + if (reported) { + exports.push(reported) + } + } + if (exports.length) { + logger.log('Reported exports:') + for (const fn of exports) { + logger.log('- ', fn) + } + } else { + // The plugin skips emission when the build has no resolvable deps. + const skipMatch = output.stdout.match( + /\[socket-facts\] no resolvable dependencies.*/, + ) + if (skipMatch) { + logger.warn(skipMatch[0]) + } + } + 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 + // A missing sbt launcher is the most common setup failure; surface it + // clearly instead of the generic message. + if (e instanceof Error && (e as NodeJS.ErrnoException).code === 'ENOENT') { + logger.fail( + `Could not run \`${bin}\`. Make sure sbt is installed and on your PATH, or pass --bin with the path to your sbt launcher.`, + ) + } else { + 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() + } + } +} + +// Pull the actionable lines out of a noisy sbt run so a failure surfaces the +// plugin's own message (and sbt's `[error]` lines) without dumping the whole +// resolution log. +function extractErrorLines(stdout: string, stderr: string): string { + return `${stdout}\n${stderr}` + .split('\n') + .filter(line => + /\[error]|Socket facts|could not resolve|unresolved/i.test(line), + ) + .join('\n') + .trim() +} + +async function execSbt( + bin: string, + commandArgs: string[], + cwd: string, + verbose: boolean, +): Promise<{ code: number; stdout: string; stderr: string }> { + // When verbose, stream sbt output straight to the terminal so the user can + // watch resolution progress; otherwise show a spinner and capture output for + // the post-run summary. + if (verbose) { + logger.info('(Running sbt 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 sbt can take a while, depending on the size of the project)', + ) + logger.info( + '(No live output. Pass --verbose to stream sbt output instead.)', + ) + spinner.start('Running sbt...') + 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 sbt execution.') + } else { + spinner.failAndStop('There was an error while trying to run sbt.') + } + } +} diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 66c82227d..9a9d44e95 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -4,6 +4,7 @@ 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 { convertSbtToFacts } from './convert-sbt-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' @@ -36,19 +37,27 @@ export async function generateAutoManifest({ } if (!sockJson?.defaults?.manifest?.sbt?.disabled && detected.sbt) { - logger.log('Detected a Scala sbt build, generating pom files with sbt...') - await convertSbtToMaven({ - // Note: `sbt` is more likely to be resolved against PATH env + const sbtArgs = { + // Note: `sbt` is more likely to be resolved against PATH env. bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', cwd, - out: sockJson.defaults?.manifest?.sbt?.outfile ?? './pom.xml', sbtOpts: sockJson.defaults?.manifest?.sbt?.sbtOpts ?.split(' ') .map(s => s.trim()) .filter(Boolean) ?? [], verbose: Boolean(sockJson.defaults?.manifest?.sbt?.verbose), - }) + } + if (sockJson.defaults?.manifest?.sbt?.facts) { + logger.log('Detected a Scala sbt build, generating Socket facts...') + await convertSbtToFacts(sbtArgs) + } else { + logger.log('Detected a Scala sbt build, generating pom files with sbt...') + await convertSbtToMaven({ + ...sbtArgs, + out: sockJson.defaults?.manifest?.sbt?.outfile ?? './pom.xml', + }) + } } if (!sockJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) { diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index a7284b2e7..409a17399 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -332,29 +332,42 @@ async function setupSbt( delete config.sbtOpts } - const stdout = await askForStdout(config.stdout) - if (stdout === undefined) { + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { return canceledByUser() - } else if (stdout === 'yes') { - config.stdout = true - } else if (stdout === 'no') { - config.stdout = false + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' } else { - delete config.stdout + delete config.facts } - if (config.stdout !== true) { - const out = await askForOutputFile(config.outfile || 'sbt.pom.xml') - if (out === undefined) { + // --facts emits a .socket.facts.json instead of pom.xml files, so the pom + // output questions (stdout/outfile) don't apply when it is enabled. + if (config.facts !== true) { + const stdout = await askForStdout(config.stdout) + if (stdout === undefined) { return canceledByUser() - } else if (out === '-') { + } else if (stdout === 'yes') { config.stdout = true + } else if (stdout === 'no') { + config.stdout = false } else { delete config.stdout - if (out) { - config.outfile = out + } + + if (config.stdout !== true) { + const out = await askForOutputFile(config.outfile || 'sbt.pom.xml') + if (out === undefined) { + return canceledByUser() + } else if (out === '-') { + config.stdout = true } else { - delete config.outfile + delete config.stdout + if (out) { + config.outfile = out + } else { + delete config.outfile + } } } } diff --git a/src/commands/manifest/socket-facts.plugin.scala b/src/commands/manifest/socket-facts.plugin.scala new file mode 100644 index 000000000..eb846b7fb --- /dev/null +++ b/src/commands/manifest/socket-facts.plugin.scala @@ -0,0 +1,403 @@ +package socket + +import sbt._ +import sbt.Keys._ + +import org.apache.ivy.Ivy +import org.apache.ivy.core.cache.DefaultRepositoryCacheManager +import org.apache.ivy.core.module.descriptor.{ Artifact, ModuleDescriptor } +import org.apache.ivy.core.module.id.ModuleRevisionId +import org.apache.ivy.core.report.ResolveReport +import org.apache.ivy.core.resolve.{ IvyNode, ResolveOptions } + +import scala.collection.mutable + +/** + * Socket facts plugin for sbt. + * + * Emits a single `.socket.facts.json` at the build root describing the + * resolved dependency graph of every project in the build, in the canonical + * SocketFacts schema (mirrors socket-facts.init.gradle on the gradle side): + * + * { "components": SF_Artifact[] } + * + * Each Maven component is + * { type: 'maven', namespace, name, version?, qualifiers? } & + * { id, direct?, dev?, dependencies? } + * + * The graph is read from Ivy resolution metadata only: `setDownload(false)` + * means no artifact jars are fetched, just the POM/ivy.xml needed to compute + * the transitive closure. Resolution failures are fatal: any unresolved + * dependency aborts with a non-zero exit rather than being silently dropped + * (the env is the user's own, set up to resolve their deps). + * + * The project's dependency configurations are resolved into one component per + * module (org:name:version); a module's alternate artifacts (sources/javadoc + * classifier jars) are the same package, so they collapse into that single + * component rather than adding duplicates. The Scala compiler/scaladoc + * toolchain and sbt plugins are skipped by default: they're inherent to the + * chosen sbt/Scala version (absent from the pom-path manifest too) and + * dominate resolution cost for little actionable alert signal. Pass + * `-Dsocket.includeToolchain=true` to resolve them as well, tagged `tooling` + * so reachability skips them. (Any dependency reached only via a non-classpath + * config is likewise tagged `tooling`.) + * + * Intra-build project dependencies are omitted: sbt's `dependsOn` is a + * classpath dependency, not an Ivy one, so siblings never appear in a + * project's resolve, and a sibling referenced as an explicit library + * dependency is filtered out by coordinate. Each project's own external deps + * are aggregated, so a subproject's transitives still land in the output via + * that subproject's own resolve. + * + * Delivery: shipped as source and dropped into an isolated `-Dsbt.global.base` + * plugins dir, so it activates on any project without installation. It is + * compiled by the sbt meta-build, whose Scala is 2.10 for sbt 0.13 and 2.12 + * for sbt 1.x — so this file must compile on both. Reaching into Ivy (stable + * across those sbt versions) keeps the code free of the version-specific sbt + * APIs that would otherwise need reflection. + */ +object SocketFactsPlugin extends AutoPlugin { + override def trigger = allRequirements + + object autoImport { + val socketFacts = + taskKey[Unit]("Emit a Socket facts JSON for the whole build") + } + import autoImport._ + + // Configurations that put a dependency on the application's own classpath — + // what reachability analysis consumes. A dependency reached by any of these + // (or by a test config; see isTestConf) is a real dependency. EVERYTHING + // else — the Scala compiler/scaladoc toolchain, sbt plugins, and any other + // non-classpath config — is tagged `tooling`: still emitted (so artifact and + // vulnerability alerts see every package), but skipped by reachability. + // + // The point of `tooling` is operational: reachability needn't, and often + // can't, resolve internal build tools, and would error trying. Defining + // tooling as "not a known app classpath" (rather than allowlisting known + // tool configs) means even an unanticipated build-tool config is skipped + // rather than blowing up reachability. The cost is that a non-standard + // classpath config (e.g. sbt's IntegrationTest `it`) is also treated as + // tooling — acceptable, since it's still emitted for alerts and such deps + // are reasonable to leave out of production reachability. `scala-library` is + // on `compile`, so it stays non-tooling. + private val ClasspathConfs = Set( + "compile", + "compile-internal", + "default", + "optional", + "provided", + "runtime", + "runtime-internal", + "test", + "test-internal" + ) + + // Configs skipped by default. The Scala toolchain (scala-tool/scala-doc-tool) + // and sbt plugins are inherent to the chosen sbt/Scala version, not the + // project's declared deps (the pom-path manifest omits them too), and their + // metadata — chiefly the scaladoc tree — is most of the resolution cost for + // little actionable alert signal. docs/pom/sources only re-request alternate + // artifacts of modules already resolved. `-Dsocket.includeToolchain=true` + // resolves everything instead (kept so the perf impact can be A/B'd). + private val SkippedConfs = + Set("docs", "plugin", "pom", "scala-doc-tool", "scala-tool", "sources") + + // Must stay in sync with `DOT_SOCKET_DOT_FACTS_JSON` in src/constants.mts + // (TS side). Scala can't import the TS constant, so the two strings are + // intentionally duplicated; change them together. + private val SocketFactsFilename = ".socket.facts.json" + + override def projectSettings: Seq[Setting[_]] = Seq( + // Run once for the whole build; the task itself gathers every project via + // ScopeFilter, so we don't want sbt to also fan it out to aggregates. + // Note: `in` (not the newer `key / scope` slash syntax) is intentional — + // slash syntax doesn't exist in sbt 0.13, which we still support. The + // 1.5+ deprecation warning it triggers is harmless and only surfaces on a + // cold compile. Same goes for `baseDirectory in ThisBuild` below. + aggregate in socketFacts := false, + socketFacts := { + val log = streams.value.log + val modules = ivyModule.all(ScopeFilter(inAnyProject)).value + val buildRoot = (baseDirectory in ThisBuild).value + + // First pass: every project's own coordinate (org:name), so intra-build + // deps are omitted even when referenced as explicit library deps. + val projectCoords = mutable.HashSet.empty[String] + modules.foreach { module => + module.withModule(log) { (_, md, _) => + val mrid = md.getModuleRevisionId + projectCoords += mrid.getOrganisation + ":" + mrid.getName + } + } + + val nodes = mutable.LinkedHashMap.empty[String, Node] + val unresolved = mutable.LinkedHashSet.empty[String] + + // Second pass: resolve each project metadata-only and fold its graph in. + modules.foreach { module => + module.withModule(log) { (ivy, md, _) => + collectResolved(ivy, md, projectCoords, nodes, unresolved) + } + } + + if (unresolved.nonEmpty) { + log.error("Socket facts: could not resolve these dependencies:") + unresolved.toList.sorted.foreach(u => log.error(" - " + u)) + sys.error( + "Socket facts aborted: " + unresolved.size + + " unresolved dependency(ies). Fix resolution (repositories, " + + "credentials, offline cache) and retry." + ) + } + + if (nodes.isEmpty) { + // println (not log.info) so the line reaches stdout without sbt's + // `[info]` prefix, matching what the CLI parses for. + println("[socket-facts] no resolvable dependencies in build, skipping") + } else { + val outDir = sys.props.get("socket.outputDirectory") match { + case Some(d) if d.nonEmpty => new File(d) + case _ => buildRoot + } + outDir.mkdirs() + val outName = sys.props.get("socket.outputFile") match { + case Some(f) if f.nonEmpty => f + case _ => SocketFactsFilename + } + val outFile = new File(outDir, outName) + IO.write(outFile, renderJson(nodes)) + // println (not log.info) so the line reaches stdout without sbt's + // `[info]` prefix, matching what the CLI parses for. + println("Socket facts file written to: " + outFile.getAbsolutePath) + } + } + ) + + // Resolve one project's module metadata-only and fold its graph into the + // shared, build-wide node map. Takes the stable Ivy types (not sbt's + // IvySbt#Module, which moved packages between 0.13 and 1.x). + private def collectResolved( + ivy: Ivy, + md: ModuleDescriptor, + projectCoords: scala.collection.Set[String], + nodes: mutable.LinkedHashMap[String, Node], + unresolved: mutable.LinkedHashSet[String] + ): Unit = { + val rootMrid = md.getModuleRevisionId + // Resolve the project's dependency configs; skip the toolchain/no-op + // configs (SkippedConfs) by default so we don't pay for the scaladoc tree + // etc. Custom dependency configs (e.g. IntegrationTest `it`) are still + // resolved. `-Dsocket.includeToolchain=true` resolves everything. + val includeToolchain = java.lang.Boolean.parseBoolean( + sys.props.getOrElse("socket.includeToolchain", "false") + ) + val allConfs = md.getConfigurationsNames + val confs = + if (includeToolchain) allConfs + else allConfs.filterNot(SkippedConfs.contains) + if (confs.nonEmpty) { + // Don't revalidate cached metadata over the network: with release + // coordinates the cached POM/ivy.xml never changes, so HEAD/GET-ing each + // cached module per resolve is pure overhead (~30% of warm-cache time). + // Missing metadata is still fetched (this is not cache-only), so cold + // caches still work — we just never re-check what we already have. + ivy.getSettings.getDefaultRepositoryCacheManager match { + case drcm: DefaultRepositoryCacheManager => + drcm.setCheckmodified(false) + drcm.setUseOrigin(true) + drcm.setDefaultTTL(Long.MaxValue) + case _ => + } + val options = new ResolveOptions() + options.setDownload(false) + options.setTransitive(true) + options.setConfs(confs) + // Skip Ivy's report rendering — it re-walks the graph and we don't use it. + options.setOutputReport(false) + val report: ResolveReport = ivy.resolve(md, options) + + report.getUnresolvedDependencies.foreach { node => + unresolved += node.getId.toString + } + + // Pass 1: emit one node per resolved module, and remember which + // component id each Ivy module maps to (for wiring caller edges). + val mridToId = mutable.HashMap.empty[String, String] + val pass1 = report.getDependencies.iterator() + while (pass1.hasNext) { + val ivyNode = pass1.next().asInstanceOf[IvyNode] + if (isEmittable(ivyNode, projectCoords)) { + val mrid = ivyNode.getResolvedId + val rootConfs = ivyNode.getRootModuleConfigurations + // prod = reached by any non-test config. nonTooling = reached by a + // real app-classpath config (test configs count as classpath too); + // anything reachable only via non-classpath configs is build tooling. + val prod = rootConfs.exists(c => !isTestConf(c)) + val nonTooling = + rootConfs.exists(c => ClasspathConfs.contains(c) || isTestConf(c)) + val coord = coordFor(ivyNode, mrid) + val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) + if (prod) { + node.prod = true + } + if (nonTooling) { + node.nonTooling = true + } + mridToId(mrid.toString) = coord.id + } + } + + // Pass 2: wire caller edges. A caller that is the project root marks the + // node `direct`; any other caller becomes its parent. + val pass2 = report.getDependencies.iterator() + while (pass2.hasNext) { + val ivyNode = pass2.next().asInstanceOf[IvyNode] + if (isEmittable(ivyNode, projectCoords)) { + mridToId.get(ivyNode.getResolvedId.toString).foreach { childId => + ivyNode.getAllCallers.foreach { caller => + val callerMrid = caller.getModuleRevisionId + if (callerMrid == rootMrid) { + nodes(childId).direct = true + } else { + mridToId + .get(callerMrid.toString) + .foreach(parentId => nodes(parentId).children += childId) + } + } + } + } + } + } + } + + // A configuration whose name mentions "test" (test, test-internal, + // IntegrationTest, ...) contributes dev dependencies. Name-based, mirroring + // the gradle script, so it also catches custom test-like configs. + private def isTestConf(name: String): Boolean = + name.toLowerCase.contains("test") + + // A dependency node is emittable when it actually resolved to a real module + // that isn't a project in this build and isn't a conflict loser. Failed or + // unloaded nodes are skipped here (reading their metadata throws); they're + // reported separately via the resolve report's unresolved list, which aborts + // the run. + private def isEmittable( + node: IvyNode, + projectCoords: scala.collection.Set[String] + ): Boolean = { + val mrid = node.getResolvedId + mrid != null && + node.getModuleRevision != null && + !node.hasProblem && + !projectCoords.contains(mrid.getOrganisation + ":" + mrid.getName) && + !node.isCompletelyEvicted + } + + private def coordFor(node: IvyNode, mrid: ModuleRevisionId): Coord = + Coord(mrid.getOrganisation, mrid.getName, mrid.getRevision, primaryExt(node)) + + // The packaging extension of the module's main (classifier-less) artifact. + // Reading artifact metadata never triggers a download. Defaults to jar, + // which is correct for the overwhelming majority of Maven dependencies. + private def primaryExt(node: IvyNode): String = { + val artifacts = node.getAllArtifacts + if (artifacts == null) { + "jar" + } else { + artifacts.find(a => classifierOf(a).isEmpty).map(extOf).getOrElse("jar") + } + } + + private def extOf(a: Artifact): String = { + val e = a.getExt + if (e == null || e.isEmpty) "jar" else e + } + + private def classifierOf(a: Artifact): String = { + val extra = a.getExtraAttributes + val raw = + if (extra.get("classifier") != null) extra.get("classifier") + else extra.get("m:classifier") + if (raw == null) "" else raw.toString + } + + private def renderJson(nodes: mutable.LinkedHashMap[String, Node]): String = { + val sorted = nodes.values.toList.sortBy(_.coord.id) + val sb = new StringBuilder + sb.append("{\n \"components\": [\n") + sorted.zipWithIndex.foreach { + case (node, idx) => + appendComponent(sb, node) + if (idx < sorted.size - 1) { + sb.append(",") + } + sb.append("\n") + } + sb.append(" ]\n}\n") + sb.toString + } + + private def appendComponent(sb: StringBuilder, node: Node): Unit = { + val c = node.coord + val fields = mutable.ListBuffer.empty[String] + fields += "\"type\": \"maven\"" + fields += "\"namespace\": " + jsonString(c.org) + fields += "\"name\": " + jsonString(c.name) + if (c.version.nonEmpty) { + fields += "\"version\": " + jsonString(c.version) + } + if (c.ext.nonEmpty) { + fields += "\"qualifiers\": { \"ext\": " + jsonString(c.ext) + " }" + } + fields += "\"id\": " + jsonString(c.id) + if (node.direct) { + fields += "\"direct\": true" + } + if (!node.prod) { + fields += "\"dev\": true" + } + if (!node.nonTooling) { + fields += "\"tooling\": true" + } + if (node.children.nonEmpty) { + val depLines = node.children.toList.map(d => " " + jsonString(d)) + fields += "\"dependencies\": [\n" + depLines.mkString(",\n") + "\n ]" + } + sb.append(" {\n ") + sb.append(fields.mkString(",\n ")) + sb.append("\n }") + } + + private def jsonString(s: String): String = { + val sb = new StringBuilder("\"") + s.foreach { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case ch if ch < 0x20 => sb.append("\\u%04x".format(ch.toInt)) + case ch => sb.append(ch) + } + sb.append("\"") + sb.toString + } + + // A resolved Maven coordinate. + private final case class Coord( + org: String, + name: String, + version: String, + ext: String + ) { + val id: String = org + ":" + name + ":" + version + } + + private final class Node(val coord: Coord) { + val children = mutable.TreeSet.empty[String] + var prod = false + var nonTooling = false + var direct = false + } +} diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 401cc71da..7fd8e2a94 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -70,6 +70,7 @@ export interface SocketJson { infile?: string | undefined stdin?: boolean | undefined bin?: string | undefined + facts?: boolean | undefined outfile?: string | undefined sbtOpts?: string | undefined stdout?: boolean | undefined From 160208a16e68494fa557aff9b1cb19ed13d284e5 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 10:51:57 +0200 Subject: [PATCH 2/7] feat(manifest): scope sbt facts to dependency configs + add --configs/--ignore-unresolved (REA-474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve only the project's real dependency configurations by default (compile/runtime/test/provided/optional) instead of every configuration. On large multi-module builds (e.g. sbt-native-packager projects) this avoids resolving packager/toolchain/internal configs entirely — a major speedup with no jar downloads, and cleaner output. Add `--configs` to choose configurations and `--ignore-unresolved` to skip unresolvable dependencies instead of failing the run (default: fail). Drop the temporary toolchain-tag mechanism, which the default config set makes unnecessary, and skip Ivy metadata revalidation for faster warm-cache resolution. --- src/commands/manifest/cmd-manifest-scala.mts | 38 +++- .../manifest/cmd-manifest-scala.test.mts | 6 +- .../manifest/convert-sbt-to-facts.mts | 15 +- .../manifest/generate_auto_manifest.mts | 4 + .../manifest/socket-facts.plugin.scala | 165 ++++++++---------- src/utils/socket-json.mts | 2 + 6 files changed, 132 insertions(+), 98 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 5a098cb6e..b7cd96ca7 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -34,6 +34,16 @@ const config: CliCommandConfig = { description: 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files', }, + configs: { + type: 'string', + description: + 'With --facts: comma-separated sbt configurations to resolve (default: compile,runtime,test,provided,optional)', + }, + ignoreUnresolved: { + type: 'boolean', + description: + 'With --facts: skip dependencies that fail to resolve instead of failing the run', + }, out: { type: 'string', description: @@ -84,7 +94,9 @@ const config: CliCommandConfig = { Pass --facts to instead emit a single \`.socket.facts.json\` describing the resolved dependency graph of the whole build (no \`pom.xml\` files). It reads dependency metadata only and never downloads artifacts; an unresolved - dependency is a fatal error. + dependency is a fatal error. With --facts you can pass + --configs=compile,test to choose which sbt configurations to resolve, and + --ignore-unresolved to skip dependencies that fail to resolve. Support is beta. Please report issues or give us feedback on what's missing. @@ -137,7 +149,8 @@ async function run( sockJson?.defaults?.manifest?.sbt, ) - let { bin, facts, out, sbtOpts, stdout, verbose } = cli.flags + let { bin, configs, facts, ignoreUnresolved, out, sbtOpts, stdout, verbose } = + cli.flags // Set defaults for any flag/arg that is not given. Check socket.json first. if (!bin) { @@ -156,6 +169,25 @@ async function run( facts = false } } + if (configs === undefined) { + if (sockJson.defaults?.manifest?.sbt?.configs !== undefined) { + configs = sockJson.defaults?.manifest?.sbt?.configs + logger.info(`Using default --configs from ${SOCKET_JSON}:`, configs) + } else { + configs = '' + } + } + if (ignoreUnresolved === undefined) { + if (sockJson.defaults?.manifest?.sbt?.ignoreUnresolved !== undefined) { + ignoreUnresolved = sockJson.defaults?.manifest?.sbt?.ignoreUnresolved + logger.info( + `Using default --ignore-unresolved from ${SOCKET_JSON}:`, + ignoreUnresolved, + ) + } else { + ignoreUnresolved = false + } + } if ( stdout === undefined && sockJson.defaults?.manifest?.sbt?.stdout !== undefined @@ -234,7 +266,9 @@ async function run( if (facts) { await convertSbtToFacts({ bin: String(bin), + configs: String(configs || ''), cwd, + ignoreUnresolved: Boolean(ignoreUnresolved), sbtOpts: parsedSbtOpts, verbose: Boolean(verbose), }) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index eca9b4d64..ea82391c4 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -24,7 +24,9 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use + --configs With --facts: comma-separated sbt configurations to resolve (default: compile,runtime,test,provided,optional) --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph instead of generating \`pom.xml\` files + --ignore-unresolved With --facts: skip dependencies that fail to resolve instead of failing the run --out Path of output file; where to store the resulting manifest, see also --stdout --sbt-opts Additional options to pass on to sbt, as per \`sbt --help\` --stdout Print resulting pom.xml to stdout (supersedes --out) @@ -55,7 +57,9 @@ describe('socket manifest scala', async () => { Pass --facts to instead emit a single \`.socket.facts.json\` describing the resolved dependency graph of the whole build (no \`pom.xml\` files). It reads dependency metadata only and never downloads artifacts; an unresolved - dependency is a fatal error. + dependency is a fatal error. With --facts you can pass + --configs=compile,test to choose which sbt configurations to resolve, and + --ignore-unresolved to skip dependencies that fail to resolve. Support is beta. Please report issues or give us feedback on what's missing. diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index b1508e112..d8dabe01b 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -47,12 +47,16 @@ async function ensurePluginSource( export async function convertSbtToFacts({ bin, + configs, cwd, + ignoreUnresolved, sbtOpts, verbose, }: { bin: string + configs: string cwd: string + ignoreUnresolved: boolean sbtOpts: string[] verbose: boolean }): Promise { @@ -75,9 +79,18 @@ export async function convertSbtToFacts({ await ensurePluginSource(pluginSrcPath, path.join(globalBase, 'plugins')) // `-Dsbt.global.base` points sbt at our isolated plugins dir, so the - // source-only plugin activates without touching the user's `~/.sbt`. + // source-only plugin activates without touching the user's `~/.sbt`. The + // resolution options are passed as JVM system properties the plugin reads. + const socketProps: string[] = [] + if (ignoreUnresolved) { + socketProps.push('-Dsocket.ignoreUnresolved=true') + } + if (configs) { + socketProps.push(`-Dsocket.configs=${configs}`) + } const commandArgs = [ `-Dsbt.global.base=${globalBase}`, + ...socketProps, ...sbtOpts, '--batch', 'socketFacts', diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 9a9d44e95..8a9498fe0 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -40,7 +40,11 @@ export async function generateAutoManifest({ const sbtArgs = { // Note: `sbt` is more likely to be resolved against PATH env. bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', + configs: sockJson.defaults?.manifest?.sbt?.configs ?? '', cwd, + ignoreUnresolved: Boolean( + sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, + ), sbtOpts: sockJson.defaults?.manifest?.sbt?.sbtOpts ?.split(' ') diff --git a/src/commands/manifest/socket-facts.plugin.scala b/src/commands/manifest/socket-facts.plugin.scala index eb846b7fb..f2ca0be09 100644 --- a/src/commands/manifest/socket-facts.plugin.scala +++ b/src/commands/manifest/socket-facts.plugin.scala @@ -27,87 +27,62 @@ import scala.collection.mutable * * The graph is read from Ivy resolution metadata only: `setDownload(false)` * means no artifact jars are fetched, just the POM/ivy.xml needed to compute - * the transitive closure. Resolution failures are fatal: any unresolved - * dependency aborts with a non-zero exit rather than being silently dropped - * (the env is the user's own, set up to resolve their deps). + * the transitive closure — we never consume bandwidth pulling jars. * - * The project's dependency configurations are resolved into one component per - * module (org:name:version); a module's alternate artifacts (sources/javadoc - * classifier jars) are the same package, so they collapse into that single - * component rather than adding duplicates. The Scala compiler/scaladoc - * toolchain and sbt plugins are skipped by default: they're inherent to the - * chosen sbt/Scala version (absent from the pom-path manifest too) and - * dominate resolution cost for little actionable alert signal. Pass - * `-Dsocket.includeToolchain=true` to resolve them as well, tagged `tooling` - * so reachability skips them. (Any dependency reached only via a non-classpath - * config is likewise tagged `tooling`.) + * By default only the project's real dependency configurations are resolved + * (`compile`, `runtime`, `test`, `provided`, `optional`); the Scala + * compiler/scaladoc toolchain, sbt-native-packager configs (debian, docker, + * universal, ...), `-internal` duplicates and the sources/docs/pom artifact + * configs are skipped. They aren't the project's declared dependencies (the + * pom-path manifest omits them too) and resolving them dominates cost on large + * builds. Override the set with `-Dsocket.configs=comma,separated` (e.g. + * `compile,test`, or add a custom config). One component is emitted per + * resolved module (org:name:version); a module's alternate artifacts + * (sources/javadoc classifier jars) are the same package, so they collapse + * into that single component rather than adding duplicates. `test`-scoped + * configs are tagged `dev`. + * + * Unresolved dependencies are fatal by default (non-zero exit) rather than + * silently dropped — the env is the user's own, set up to resolve their deps. + * `-Dsocket.ignoreUnresolved=true` downgrades that to a warning and emits the + * resolvable deps anyway. * * Intra-build project dependencies are omitted: sbt's `dependsOn` is a - * classpath dependency, not an Ivy one, so siblings never appear in a + * classpath dependency, not an Ivy one, so siblings rarely appear in a * project's resolve, and a sibling referenced as an explicit library * dependency is filtered out by coordinate. Each project's own external deps - * are aggregated, so a subproject's transitives still land in the output via - * that subproject's own resolve. + * are aggregated, and resolution is per-project, so divergent per-module + * versions are all reported. * * Delivery: shipped as source and dropped into an isolated `-Dsbt.global.base` * plugins dir, so it activates on any project without installation. It is * compiled by the sbt meta-build, whose Scala is 2.10 for sbt 0.13 and 2.12 * for sbt 1.x — so this file must compile on both. Reaching into Ivy (stable * across those sbt versions) keeps the code free of the version-specific sbt - * APIs that would otherwise need reflection. + * APIs that would otherwise need reflection, and lets us scope resolution to + * the configs we want (which sbt's own `update`/`updateFull` can't). */ object SocketFactsPlugin extends AutoPlugin { override def trigger = allRequirements - object autoImport { - val socketFacts = - taskKey[Unit]("Emit a Socket facts JSON for the whole build") - } - import autoImport._ - - // Configurations that put a dependency on the application's own classpath — - // what reachability analysis consumes. A dependency reached by any of these - // (or by a test config; see isTestConf) is a real dependency. EVERYTHING - // else — the Scala compiler/scaladoc toolchain, sbt plugins, and any other - // non-classpath config — is tagged `tooling`: still emitted (so artifact and - // vulnerability alerts see every package), but skipped by reachability. - // - // The point of `tooling` is operational: reachability needn't, and often - // can't, resolve internal build tools, and would error trying. Defining - // tooling as "not a known app classpath" (rather than allowlisting known - // tool configs) means even an unanticipated build-tool config is skipped - // rather than blowing up reachability. The cost is that a non-standard - // classpath config (e.g. sbt's IntegrationTest `it`) is also treated as - // tooling — acceptable, since it's still emitted for alerts and such deps - // are reasonable to leave out of production reachability. `scala-library` is - // on `compile`, so it stays non-tooling. - private val ClasspathConfs = Set( - "compile", - "compile-internal", - "default", - "optional", - "provided", - "runtime", - "runtime-internal", - "test", - "test-internal" - ) - - // Configs skipped by default. The Scala toolchain (scala-tool/scala-doc-tool) - // and sbt plugins are inherent to the chosen sbt/Scala version, not the - // project's declared deps (the pom-path manifest omits them too), and their - // metadata — chiefly the scaladoc tree — is most of the resolution cost for - // little actionable alert signal. docs/pom/sources only re-request alternate - // artifacts of modules already resolved. `-Dsocket.includeToolchain=true` - // resolves everything instead (kept so the perf impact can be A/B'd). - private val SkippedConfs = - Set("docs", "plugin", "pom", "scala-doc-tool", "scala-tool", "sources") + // The configurations resolved by default: the project's real dependency + // scopes. Override via `-Dsocket.configs`. Everything else (toolchain, + // packager, `-internal`, sources/docs/pom) is skipped — not declared deps, + // and costly to resolve. + private val DefaultConfs = + Set("compile", "optional", "provided", "runtime", "test") // Must stay in sync with `DOT_SOCKET_DOT_FACTS_JSON` in src/constants.mts // (TS side). Scala can't import the TS constant, so the two strings are // intentionally duplicated; change them together. private val SocketFactsFilename = ".socket.facts.json" + object autoImport { + val socketFacts = + taskKey[Unit]("Emit a Socket facts JSON for the whole build") + } + import autoImport._ + override def projectSettings: Seq[Setting[_]] = Seq( // Run once for the whole build; the task itself gathers every project via // ScopeFilter, so we don't want sbt to also fan it out to aggregates. @@ -142,13 +117,23 @@ object SocketFactsPlugin extends AutoPlugin { } if (unresolved.nonEmpty) { - log.error("Socket facts: could not resolve these dependencies:") - unresolved.toList.sorted.foreach(u => log.error(" - " + u)) - sys.error( - "Socket facts aborted: " + unresolved.size + - " unresolved dependency(ies). Fix resolution (repositories, " + - "credentials, offline cache) and retry." - ) + val ignore = boolProp("socket.ignoreUnresolved") + if (ignore) { + log.warn( + "Socket facts: skipping " + unresolved.size + + " unresolved dependency(ies) (ignore-unresolved):" + ) + unresolved.toList.sorted.foreach(u => log.warn(" - " + u)) + } else { + log.error("Socket facts: could not resolve these dependencies:") + unresolved.toList.sorted.foreach(u => log.error(" - " + u)) + sys.error( + "Socket facts aborted: " + unresolved.size + + " unresolved dependency(ies). Pass --ignore-unresolved to skip " + + "them, or fix resolution (repositories, credentials, offline " + + "cache) and retry." + ) + } } if (nodes.isEmpty) { @@ -174,6 +159,18 @@ object SocketFactsPlugin extends AutoPlugin { } ) + // The configurations to resolve: `-Dsocket.configs=a,b,c` if set, else the + // real dependency scopes. + private def requestedConfs: Set[String] = + sys.props.get("socket.configs") match { + case Some(s) if s.trim.nonEmpty => + s.split(",").map(_.trim).filter(_.nonEmpty).toSet + case _ => DefaultConfs + } + + private def boolProp(name: String): Boolean = + java.lang.Boolean.parseBoolean(sys.props.getOrElse(name, "false")) + // Resolve one project's module metadata-only and fold its graph into the // shared, build-wide node map. Takes the stable Ivy types (not sbt's // IvySbt#Module, which moved packages between 0.13 and 1.x). @@ -185,17 +182,8 @@ object SocketFactsPlugin extends AutoPlugin { unresolved: mutable.LinkedHashSet[String] ): Unit = { val rootMrid = md.getModuleRevisionId - // Resolve the project's dependency configs; skip the toolchain/no-op - // configs (SkippedConfs) by default so we don't pay for the scaladoc tree - // etc. Custom dependency configs (e.g. IntegrationTest `it`) are still - // resolved. `-Dsocket.includeToolchain=true` resolves everything. - val includeToolchain = java.lang.Boolean.parseBoolean( - sys.props.getOrElse("socket.includeToolchain", "false") - ) - val allConfs = md.getConfigurationsNames - val confs = - if (includeToolchain) allConfs - else allConfs.filterNot(SkippedConfs.contains) + val wanted = requestedConfs + val confs = md.getConfigurationsNames.filter(wanted.contains) if (confs.nonEmpty) { // Don't revalidate cached metadata over the network: with release // coordinates the cached POM/ivy.xml never changes, so HEAD/GET-ing each @@ -229,21 +217,15 @@ object SocketFactsPlugin extends AutoPlugin { val ivyNode = pass1.next().asInstanceOf[IvyNode] if (isEmittable(ivyNode, projectCoords)) { val mrid = ivyNode.getResolvedId - val rootConfs = ivyNode.getRootModuleConfigurations - // prod = reached by any non-test config. nonTooling = reached by a - // real app-classpath config (test configs count as classpath too); - // anything reachable only via non-classpath configs is build tooling. - val prod = rootConfs.exists(c => !isTestConf(c)) - val nonTooling = - rootConfs.exists(c => ClasspathConfs.contains(c) || isTestConf(c)) + // prod = reached by any non-test config; a dep reached only via test + // configs is dev. + val prod = + ivyNode.getRootModuleConfigurations.exists(c => !isTestConf(c)) val coord = coordFor(ivyNode, mrid) val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) if (prod) { node.prod = true } - if (nonTooling) { - node.nonTooling = true - } mridToId(mrid.toString) = coord.id } } @@ -280,8 +262,7 @@ object SocketFactsPlugin extends AutoPlugin { // A dependency node is emittable when it actually resolved to a real module // that isn't a project in this build and isn't a conflict loser. Failed or // unloaded nodes are skipped here (reading their metadata throws); they're - // reported separately via the resolve report's unresolved list, which aborts - // the run. + // reported separately via the resolve report's unresolved list. private def isEmittable( node: IvyNode, projectCoords: scala.collection.Set[String] @@ -357,9 +338,6 @@ object SocketFactsPlugin extends AutoPlugin { if (!node.prod) { fields += "\"dev\": true" } - if (!node.nonTooling) { - fields += "\"tooling\": true" - } if (node.children.nonEmpty) { val depLines = node.children.toList.map(d => " " + jsonString(d)) fields += "\"dependencies\": [\n" + depLines.mkString(",\n") + "\n ]" @@ -397,7 +375,6 @@ object SocketFactsPlugin extends AutoPlugin { private final class Node(val coord: Coord) { val children = mutable.TreeSet.empty[String] var prod = false - var nonTooling = false var direct = false } } diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 7fd8e2a94..e6dd5abde 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -70,7 +70,9 @@ export interface SocketJson { infile?: string | undefined stdin?: boolean | undefined bin?: string | undefined + configs?: string | undefined facts?: boolean | undefined + ignoreUnresolved?: boolean | undefined outfile?: string | undefined sbtOpts?: string | undefined stdout?: boolean | undefined From fb27b44a179b78df5f9408eeeb745ea2b7704f0d Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 11:06:53 +0200 Subject: [PATCH 3/7] fix(manifest): warn when --configs/--ignore-unresolved are used without --facts (REA-474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those options only affect --facts; the pom path (`sbt makePom`) has no equivalent. Warn on an explicitly-passed flag in pom mode rather than silently ignoring it. socket.json defaults don't trigger the warning — only a flag actually present on the command line. --- src/commands/manifest/cmd-manifest-scala.mts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index b7cd96ca7..fee3f9f70 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -223,6 +223,20 @@ async function run( verbose = false } + // `--configs` and `--ignore-unresolved` only affect --facts; the pom path + // (`sbt makePom`) has no equivalent knobs. Warn rather than silently ignore + // an explicitly-passed flag. (socket.json defaults don't trip this — only a + // flag actually present on the command line does.) + if ( + !facts && + (cli.flags['configs'] !== undefined || + cli.flags['ignoreUnresolved'] !== undefined) + ) { + logger.warn( + 'The `--configs` and `--ignore-unresolved` options only apply with `--facts`; ignoring them.', + ) + } + if (verbose) { logger.group('- ', parentName, config.commandName, ':') logger.group('- flags:', cli.flags) From 75e0e2b79d5b43a38cf6f6b095a6ac7b4c777eed Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 11:17:17 +0200 Subject: [PATCH 4/7] chore(release): cut 1.1.105 with sbt manifest --facts (REA-474) Bump version to 1.1.105 and move the `socket manifest scala --facts` entry out of [Unreleased] into a dated 1.1.105 changelog section. --- CHANGELOG.md | 6 +++++- package.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 403ee7677..db6aa1972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`socket manifest bazel [beta]`** — Generate Bazel JVM SBOM manifests by running `bazel query` against discovered Maven repos in a Bazel workspace. Closes the inline-Maven-declaration gap that lockfile-only parsing misses for repos like envoy, ray, tensorflow, tink-java, and or-tools. Auto-detects Bzlmod and legacy `WORKSPACE`. - **`socket scan create --auto-manifest`** now covers Bazel workspaces in addition to Gradle/Scala/Kotlin/Conda. Repos with `MODULE.bazel`, `WORKSPACE`, or `WORKSPACE.bazel` are detected automatically and their Maven dependencies extracted as part of the standard scan-create flow. - **Bazel PyPI extraction** — `socket manifest bazel --ecosystem pypi` now generates `requirements.txt` for Python Bazel workspaces. Discovers custom `rules_python` pip hub names with Bazel command output first, queries `py_library` / `py_binary` / `py_test` dependencies, resolves canonical pinned versions from `requirements_lock.txt`, and emits PEP 503-normalized `name==version` lines. Supports both Bzlmod (`pip.parse`) and legacy `WORKSPACE` (`pip_parse` / `pip_install`) configurations. PyPI remains explicit opt-in for `socket scan create --auto-manifest` until real-world no-lockfile recovery is validated. -- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph straight from an sbt build (no `pom.xml` round-trip), consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Reads dependency metadata only (no artifact downloads) and works across a wide range of sbt versions (0.13 through 1.x) with no plugin install or project changes. Toggle also exposed via the `socket manifest setup` wizard and honored by `socket scan create --auto-manifest`. ### Changed - **Bazel diagnostics** — `socket manifest bazel --verbose` now emits bounded subprocess traces with argv, cwd, duration, exit status, output sizes, and failure stderr tails to make customer log-only triage safer and faster. +## [1.1.105](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.105) - 2026-05-27 + +### Added +- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph straight from an sbt build (no `pom.xml` round-trip), consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Reads dependency metadata only (no artifact downloads) and works across a wide range of sbt versions (0.13 through 1.x) with no plugin install or project changes. Toggle also exposed via the `socket manifest setup` wizard and honored by `socket scan create --auto-manifest`. + ## [1.1.104](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.104) - 2026-05-26 ### Fixed diff --git a/package.json b/package.json index eb82d5031..e52db28a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.104", + "version": "1.1.105", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT AND OFL-1.1", From 7a889b81ab934aaa6b592c08824c917e8cd1382b Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 11:18:39 +0200 Subject: [PATCH 5/7] docs(changelog): tighten 1.1.105 sbt --facts entry to match gradle --facts Drop the sbt-specific implementation asides (pom round-trip, metadata-only, version range) so the entry mirrors the existing gradle --facts wording. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db6aa1972..bab51fa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [1.1.105](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.105) - 2026-05-27 ### Added -- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph straight from an sbt build (no `pom.xml` round-trip), consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Reads dependency metadata only (no artifact downloads) and works across a wide range of sbt versions (0.13 through 1.x) with no plugin install or project changes. Toggle also exposed via the `socket manifest setup` wizard and honored by `socket scan create --auto-manifest`. +- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph from an sbt build, consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Toggle also exposed via the `socket manifest setup` wizard for use with `--auto-manifest`. ## [1.1.104](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.104) - 2026-05-26 From f3a8fe766869e793bfb4d651ee2818c398e71c8c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 11:20:37 +0200 Subject: [PATCH 6/7] docs(changelog): correct --facts wording for gradle and sbt entries The `.socket.facts.json` SBOM is consumed by any `socket scan create`, not just `--reach` / Tier 1 reachability scans. Reword both the gradle (1.1.98) and sbt (1.1.105) entries to drop the reachability-specific framing. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bab51fa7d..5cb348709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [1.1.105](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.105) - 2026-05-27 ### Added -- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph from an sbt build, consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Toggle also exposed via the `socket manifest setup` wizard for use with `--auto-manifest`. +- **`socket manifest scala --facts [beta]`** — Emit a `.socket.facts.json` dependency graph from an sbt build for `socket scan create` to consume as a pregenerated SBOM. Toggle also exposed via the `socket manifest setup` wizard for use with `--auto-manifest`. ## [1.1.104](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.104) - 2026-05-26 @@ -30,7 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [1.1.98](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.98) - 2026-05-22 ### Added -- **`socket manifest gradle --facts [beta]`** (and its `socket manifest kotlin --facts` alias) — Emit a `.socket.facts.json` dependency graph from a Gradle build, consumable by `socket scan create --reach` as pregenerated SBOM input for Tier 1 reachability. Toggle also exposed via the `socket manifest setup` wizard for use with `--auto-manifest`. +- **`socket manifest gradle --facts [beta]`** (and its `socket manifest kotlin --facts` alias) — Emit a `.socket.facts.json` dependency graph from a Gradle build for `socket scan create` to consume as a pregenerated SBOM. Toggle also exposed via the `socket manifest setup` wizard for use with `--auto-manifest`. ### Changed - Updated the Coana CLI to v `15.3.8`. From 367127ff089c27ed4ba79ec3712f284cf8711cc1 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 27 May 2026 16:24:40 +0200 Subject: [PATCH 7/7] fix(manifest): address sbt --facts review feedback (REA-474) - generate_auto_manifest: keep the shared sbt arg bag to fields both handlers accept; add facts-only (configs/ignoreUnresolved) and pom-only (out) per branch so convertSbtToMaven is no longer spread properties it doesn't take. - cmd-manifest-scala: warn when --out/--stdout are passed with --facts (the facts file is always written to the build root), mirroring the existing --configs/--ignore-unresolved guard. - socket-facts.plugin: key intra-build project filtering on the full GAV (org:name:version) via a shared gavKey helper, so an external dep that only shares an org:name with a build project is still emitted. - cmd-manifest-scala: sort the --configs default list in the help text to match DefaultConfs ordering in the plugin. --- src/commands/manifest/cmd-manifest-scala.mts | 16 +++++++++++++++- .../manifest/cmd-manifest-scala.test.mts | 2 +- .../manifest/generate_auto_manifest.mts | 15 ++++++++++----- .../manifest/socket-facts.plugin.scala | 18 +++++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index fee3f9f70..155bfe775 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -37,7 +37,7 @@ const config: CliCommandConfig = { configs: { type: 'string', description: - 'With --facts: comma-separated sbt configurations to resolve (default: compile,runtime,test,provided,optional)', + 'With --facts: comma-separated sbt configurations to resolve (default: compile,optional,provided,runtime,test)', }, ignoreUnresolved: { type: 'boolean', @@ -237,6 +237,20 @@ async function run( ) } + // Conversely, --out / --stdout only affect the pom path; with --facts the + // plugin always writes `.socket.facts.json` to the build root (its + // socket.outputDirectory/outputFile JVM props aren't exposed by the CLI), so + // warn rather than let `--facts --out custom.json` silently write nothing + // there. + if ( + facts && + (cli.flags['out'] !== undefined || cli.flags['stdout'] !== undefined) + ) { + logger.warn( + 'The `--out` and `--stdout` options do not apply with `--facts`; the facts file is always written to the build root.', + ) + } + if (verbose) { logger.group('- ', parentName, config.commandName, ':') logger.group('- flags:', cli.flags) diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index ea82391c4..e7067f250 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -24,7 +24,7 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use - --configs With --facts: comma-separated sbt configurations to resolve (default: compile,runtime,test,provided,optional) + --configs With --facts: comma-separated sbt configurations to resolve (default: compile,optional,provided,runtime,test) --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph instead of generating \`pom.xml\` files --ignore-unresolved With --facts: skip dependencies that fail to resolve instead of failing the run --out Path of output file; where to store the resulting manifest, see also --stdout diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 8a9498fe0..55c5fee24 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -37,14 +37,13 @@ export async function generateAutoManifest({ } if (!sockJson?.defaults?.manifest?.sbt?.disabled && detected.sbt) { + // Args shared by both paths. The facts-only knobs (`configs`, + // `ignoreUnresolved`) and the pom-only `out` are added per branch so + // neither handler is spread properties it doesn't accept. const sbtArgs = { // Note: `sbt` is more likely to be resolved against PATH env. bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', - configs: sockJson.defaults?.manifest?.sbt?.configs ?? '', cwd, - ignoreUnresolved: Boolean( - sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, - ), sbtOpts: sockJson.defaults?.manifest?.sbt?.sbtOpts ?.split(' ') @@ -54,7 +53,13 @@ export async function generateAutoManifest({ } if (sockJson.defaults?.manifest?.sbt?.facts) { logger.log('Detected a Scala sbt build, generating Socket facts...') - await convertSbtToFacts(sbtArgs) + await convertSbtToFacts({ + ...sbtArgs, + configs: sockJson.defaults?.manifest?.sbt?.configs ?? '', + ignoreUnresolved: Boolean( + sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, + ), + }) } else { logger.log('Detected a Scala sbt build, generating pom files with sbt...') await convertSbtToMaven({ diff --git a/src/commands/manifest/socket-facts.plugin.scala b/src/commands/manifest/socket-facts.plugin.scala index f2ca0be09..2349ac508 100644 --- a/src/commands/manifest/socket-facts.plugin.scala +++ b/src/commands/manifest/socket-facts.plugin.scala @@ -96,13 +96,14 @@ object SocketFactsPlugin extends AutoPlugin { val modules = ivyModule.all(ScopeFilter(inAnyProject)).value val buildRoot = (baseDirectory in ThisBuild).value - // First pass: every project's own coordinate (org:name), so intra-build - // deps are omitted even when referenced as explicit library deps. + // First pass: every project's own coordinate (org:name:version), so + // intra-build deps are omitted even when referenced as explicit library + // deps. Keying on the full GAV (not just org:name) means an external dep + // that merely shares an org:name with a build project is still emitted. val projectCoords = mutable.HashSet.empty[String] modules.foreach { module => module.withModule(log) { (_, md, _) => - val mrid = md.getModuleRevisionId - projectCoords += mrid.getOrganisation + ":" + mrid.getName + projectCoords += gavKey(md.getModuleRevisionId) } } @@ -271,10 +272,17 @@ object SocketFactsPlugin extends AutoPlugin { mrid != null && node.getModuleRevision != null && !node.hasProblem && - !projectCoords.contains(mrid.getOrganisation + ":" + mrid.getName) && + !projectCoords.contains(gavKey(mrid)) && !node.isCompletelyEvicted } + // Build-wide coordinate key (org:name:version) identifying a project in this + // build, so its own modules are omitted when they surface in another + // project's resolve. Full GAV (not just org:name) so a same-named external + // dependency at a different version is still emitted. + private def gavKey(mrid: ModuleRevisionId): String = + mrid.getOrganisation + ":" + mrid.getName + ":" + mrid.getRevision + private def coordFor(node: IvyNode, mrid: ModuleRevisionId): Coord = Coord(mrid.getOrganisation, mrid.getName, mrid.getRevision, primaryExt(node))