From 6ccc47764f77067b2c266a75ddde32525d29e98c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 22 Oct 2026 12:27:25 +0200 Subject: [PATCH 1/5] feat(manifest): name failing JVM dependencies and remedies the command accepts When dependency resolution fails, the report now names the dependency that failed, the subproject and scope that needed it, and only remedies the invoking command actually accepts: - Single-build manifest commands suggest their flags; `socket manifest dynamic-sbom-inference` gains --ignore-unresolved; scan/fix/auto-manifest point at the failing build's own socket.json and its setup wizard. - Maven no longer reports a dependency-graph failure as a `graph` configuration against the module itself: it names the failing dependency and the scope it has in the graph, so excluding that scope clears it. - Unresolved dependencies are reported and left out of the facts for Maven, Gradle and sbt alike. sbt retries leniently instead of excluding modules, so an unresolved dependency no longer empties the whole project. - socket.json `verbose` is honored on the dynamic-sbom-inference path, a build tool that cannot start says why, and scan create/reach warn about build flags they read from socket.json instead. --- CHANGELOG.md | 13 + .../cmd-manifest-dynamic-sbom-inference.mts | 8 + ...d-manifest-dynamic-sbom-inference.test.mts | 1 + .../manifest/convert-gradle-to-facts.mts | 4 + .../manifest/convert-maven-to-facts.mts | 4 + .../manifest/convert-sbt-to-facts.mts | 4 + .../manifest/enumerate-workspaces.mts | 5 +- .../manifest/generate-recursive-manifests.mts | 43 ++- .../generate-recursive-manifests.test.mts | 111 +++++++- .../manifest/generate_auto_manifest.mts | 11 + ...handle-manifest-dynamic-sbom-inference.mts | 4 + src/commands/manifest/run-manifest-facts.mts | 19 +- src/commands/manifest/scripts/assemble.mts | 4 +- .../socket/SocketFactsRecordsEngine.java | 121 +++++++-- src/commands/manifest/scripts/records.mts | 17 +- .../scripts/resolution-report-maven.mts | 6 +- .../scripts/resolution-report-render.mts | 249 ++++++++++++++---- .../scripts/resolution-report-render.test.mts | 188 ++++++++++++- .../manifest/scripts/resolution-report.mts | 12 +- .../manifest/scripts/socket-facts.init.gradle | 7 +- .../scripts/socket-facts.plugin.scala | 119 +++++---- .../scripts/test/maven-compat/.gitignore | 2 + .../test/maven-compat/assert-fail-closed.py | 10 +- .../assert-no-invented-failures.py | 15 +- .../test/maven-compat/graph-failure/pom.xml | 38 +++ .../smoke-test-duplicate-failure.sh | 11 +- .../maven-compat/smoke-test-graph-failure.sh | 112 ++++++++ .../manifest/scripts/test/run-compat.sh | 2 + .../scripts/test/sbt-compat/.gitignore | 5 + .../scripts/test/sbt-compat/failure/build.sbt | 19 ++ .../test/sbt-compat/smoke-test-failure.sh | 79 ++++++ src/commands/scan/cmd-scan-create.mts | 3 + src/commands/scan/cmd-scan-reach.mts | 3 + .../scan/warn-socket-json-only-flags.mts | 27 ++ 34 files changed, 1095 insertions(+), 181 deletions(-) create mode 100644 src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml create mode 100755 src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh create mode 100644 src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt create mode 100755 src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh create mode 100644 src/commands/scan/warn-socket-json-only-flags.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0416356b35..2509455ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added +- `socket manifest dynamic-sbom-inference --ignore-unresolved` keeps going past unresolved JVM dependencies in every discovered build. + +### Fixed +- `--dynamic-sbom-inference` honors each build's `verbose` setting in `socket.json`. +- JVM resolution failures name the dependency that failed, the subproject and scope that needed it, and suggest only options the command you ran accepts, pointing at the failing build's own `socket.json`. +- Unresolved JVM dependencies are reported and left out of the Socket facts for Maven, Gradle and sbt alike, as far as each build tool identifies them; a Maven dependency in an excluded scope no longer blocks the run. +- An sbt project with an unresolved dependency keeps the rest of its dependencies instead of silently losing all of them. +- `socket scan create` and `socket scan reach` warn about `--ignore-unresolved`, `--exclude-configs`, `--include-configs` and `--verbose`, which they read from `socket.json` instead. +- When a build tool cannot be started, the error says why, for example a missing `mvn`. + ## [1.9.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.9.1) - 2026-10-21 ### Changed diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts index e8d06a0af3..9385ab41f0 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -27,6 +27,11 @@ const config: CliCommandConfig = { ...commonFlags, ...outputFlags, ...excludePathsFlag, + ignoreUnresolved: { + type: 'boolean', + description: + "Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts files); overrides each build's socket.json setting", + }, verbose: { type: 'boolean', default: false, @@ -76,11 +81,13 @@ async function run( const { dryRun, + ignoreUnresolved, json, markdown, verbose: verboseFlag, } = cli.flags as { dryRun: boolean + ignoreUnresolved: boolean | undefined json: boolean markdown: boolean verbose: boolean | undefined @@ -134,6 +141,7 @@ async function run( await handleManifestDynamicSbomInference({ cwd, excludePaths, + ignoreUnresolved, outputKind, verbose, }) diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 53ac8fe5f9..ed47e2b979 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -34,6 +34,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { Options --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts files); overrides each build's socket.json setting --json Output as JSON --markdown Output as Markdown --verbose Print debug messages diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 1c5dccc6cf..f9ab8a13d4 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -1,5 +1,6 @@ import { runManifestFacts } from './run-manifest-facts.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' // Generates `gradle.socket.facts.json` for a Gradle project via the bundled @@ -10,6 +11,7 @@ export async function convertGradleToFacts({ excludeConfigs, excludePaths, gradleOpts, + hints, ignoreUnresolved, includeConfigs, javaHome, @@ -22,6 +24,7 @@ export async function convertGradleToFacts({ excludeConfigs: string excludePaths?: string[] | undefined gradleOpts: string[] + hints?: BuildOptionHints | undefined ignoreUnresolved: boolean includeConfigs: string javaHome?: string | undefined @@ -36,6 +39,7 @@ export async function convertGradleToFacts({ ecosystem: 'gradle', excludeConfigs, excludePaths, + hints, ignoreUnresolved, includeConfigs, javaHome, diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index d325814622..bb50bd4177 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -1,5 +1,6 @@ import { runManifestFacts } from './run-manifest-facts.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' // Generates `pom.xml.socket.facts.json` (named after the POM Maven runs on) @@ -9,6 +10,7 @@ export async function convertMavenToFacts({ cwd, excludeConfigs, excludePaths, + hints, ignoreUnresolved, includeConfigs, javaHome, @@ -21,6 +23,7 @@ export async function convertMavenToFacts({ cwd: string excludeConfigs: string excludePaths?: string[] | undefined + hints?: BuildOptionHints | undefined ignoreUnresolved: boolean includeConfigs: string javaHome?: string | undefined @@ -36,6 +39,7 @@ export async function convertMavenToFacts({ ecosystem: 'maven', excludeConfigs, excludePaths, + hints, ignoreUnresolved, includeConfigs, javaHome, diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index 07ee34eb17..999a4ccb54 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -1,5 +1,6 @@ import { runManifestFacts } from './run-manifest-facts.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' // Generates `sbt.socket.facts.json` for an sbt project via the bundled sbt plugin. @@ -10,6 +11,7 @@ export async function convertSbtToFacts({ cwd, excludeConfigs, excludePaths, + hints, ignoreUnresolved, includeConfigs, javaHome, @@ -23,6 +25,7 @@ export async function convertSbtToFacts({ cwd: string excludeConfigs: string excludePaths?: string[] | undefined + hints?: BuildOptionHints | undefined ignoreUnresolved: boolean includeConfigs: string javaHome?: string | undefined @@ -40,6 +43,7 @@ export async function convertSbtToFacts({ ecosystem: 'sbt', excludeConfigs, excludePaths, + hints, ignoreUnresolved, includeConfigs, javaHome, diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index f5361e770c..6108798cdd 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -67,10 +67,7 @@ export async function enumerateWorkspaces({ } catch (e) { process.exitCode = 1 logger.fail( - `Could not run the ${ecosystem} build tool` + - (verbose - ? `: ${getErrorMessageOr(e, String(e))}` - : ' (run with --verbose for details).'), + `Could not run the ${ecosystem} build tool: ${getErrorMessageOr(e, String(e))}`, ) return } diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 91562239b9..c126fcfa81 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -12,6 +12,8 @@ import { import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { runManifestFacts } from './run-manifest-facts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { DSI_SETUP_COMMAND } from './scripts/resolution-report-render.mts' +import { SOCKET_JSON } from '../../constants.mts' import { withTmpDir } from '../../utils/fs.mts' import { readOrDefaultSocketJson, @@ -20,6 +22,7 @@ import { import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' +import type { BuildOption } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' import type { SocketJson } from '../../utils/socket-json.mts' @@ -47,6 +50,7 @@ export type EcosystemBuildConfig = { javaHome: string | undefined // Set when this build root should be skipped entirely (never invoked). skipReason: string | undefined + verbose: boolean } // A cascaded (not just root-level) disabled additionally skips this one build @@ -54,14 +58,15 @@ export type EcosystemBuildConfig = { // auto/gradle/etc. facts:false has no pom-mode equivalent here (facts-only), // so it skips too. function getSkipReason( + ecosystem: BuildTool, disabled: boolean | undefined, facts?: boolean | undefined, ): string | undefined { if (disabled) { - return 'defaults.manifest..disabled is true' + return `defaults.manifest.${ecosystem}.disabled is true` } if (facts === false) { - return 'defaults.manifest..facts is false (pom mode)' + return `defaults.manifest.${ecosystem}.facts is false (pom mode)` } return undefined } @@ -90,6 +95,8 @@ function nearestDisabledRoot( async function runEcosystemCandidates({ candidatesByTool, excludePaths, + ignoreUnresolved: ignoreUnresolvedOverride, + optionFlags, realCwd, rootSockJson, sbtTmpDir, @@ -99,6 +106,8 @@ async function runEcosystemCandidates({ }: { candidatesByTool: Map excludePaths: string[] | undefined + ignoreUnresolved: boolean | undefined + optionFlags: ReadonlySet realCwd: string rootSockJson: SocketJson // sbt only: a shared global base reused across every sbt root in this run, @@ -133,6 +142,7 @@ async function runEcosystemCandidates({ includeConfigs, javaHome, skipReason, + verbose: configVerbose, } = resolveEcosystemConfig(ecosystem, dir, sockJson) if (skipReason) { @@ -162,19 +172,24 @@ async function runEcosystemCandidates({ ecosystem, excludeConfigs, excludePaths: excludePathsForRoot, - ignoreUnresolved, + hints: { + flags: optionFlags, + setupCommand: DSI_SETUP_COMMAND, + socketJson: path.join(path.relative(process.cwd(), dir), SOCKET_JSON), + }, + ignoreUnresolved: ignoreUnresolvedOverride ?? ignoreUnresolved, includeConfigs, javaHome, sidecarAcc, tmpDir: ecosystem === 'sbt' ? sbtTmpDir : undefined, - verbose, + verbose: verbose || configVerbose, withFiles, }) if (result === null) { outcomes.push({ dir, ecosystem, status: 'failed' }) logger.warn( - `Aborting ${ecosystem} discovery: ${dir}'s workspace layout could not be determined, so its remaining build roots cannot be safely classified as covered or independent.`, + `Aborting ${ecosystem} discovery: ${dir} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`, ) for (const abortedDir of dirs.slice(dirIndex + 1)) { outcomes.push({ dir: abortedDir, ecosystem, status: 'aborted' }) @@ -209,6 +224,8 @@ async function runEcosystemCandidates({ export async function generateRecursiveManifests({ cwd, excludePaths, + ignoreUnresolved, + optionFlags = new Set(), sbtTmpDir, sidecarAcc, verbose, @@ -216,6 +233,11 @@ export async function generateRecursiveManifests({ }: { cwd: string excludePaths?: string[] | undefined + // When set, overrides every build root's own socket.json setting. + ignoreUnresolved?: boolean | undefined + // Options the invoking command takes as flags; hints send the rest to each + // build root's socket.json. + optionFlags?: ReadonlySet | undefined // Reachability path only: caller-supplied directory to use as sbt's shared // global base across every sbt root in this run. sbt provisions the Scala // toolchain under `/boot`, which withFiles' artifactPaths point into, @@ -250,6 +272,8 @@ export async function generateRecursiveManifests({ runEcosystemCandidates({ candidatesByTool, excludePaths, + ignoreUnresolved, + optionFlags, realCwd, rootSockJson, sbtTmpDir: resolvedSbtTmpDir, @@ -296,7 +320,8 @@ export function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled, config?.facts), + skipReason: getSkipReason(ecosystem, config?.disabled, config?.facts), + verbose: Boolean(config?.verbose), } } if (ecosystem === 'gradle') { @@ -309,7 +334,8 @@ export function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled, config?.facts), + skipReason: getSkipReason(ecosystem, config?.disabled, config?.facts), + verbose: Boolean(config?.verbose), } } const config = sockJson.defaults?.manifest?.maven @@ -321,6 +347,7 @@ export function resolveEcosystemConfig( ignoreUnresolved: Boolean(config?.ignoreUnresolved), includeConfigs: config?.includeConfigs ?? '', javaHome: config?.javaHome ?? undefined, - skipReason: getSkipReason(config?.disabled), + skipReason: getSkipReason(ecosystem, config?.disabled), + verbose: Boolean(config?.verbose), } } diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 71eae70171..22bc94b41c 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -503,7 +503,10 @@ describe('generateRecursiveManifests', () => { projects: [], })) - await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) const javaHomeByCall = new Map( vi @@ -534,7 +537,10 @@ describe('generateRecursiveManifests', () => { projects: [], })) - await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) const call = vi .mocked(runManifestFacts) @@ -545,6 +551,97 @@ describe('generateRecursiveManifests', () => { expect(call?.[0].javaHome).toBeUndefined() }) + it("honors a build root's own socket.json verbose and points hints at it", async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { maven: { verbose: true } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + + const calls = vi.mocked(runManifestFacts).mock.calls.map(([opts]) => opts) + const verboseByCall = new Map( + calls.map(opts => [`${opts.ecosystem}:${relOf(opts.cwd)}`, opts.verbose]), + ) + expect(verboseByCall.get('maven:dual-marker-dir')).toBe(true) + expect(verboseByCall.get('gradle:dual-marker-dir')).toBe(false) + const hints = calls.find( + opts => + `${opts.ecosystem}:${relOf(opts.cwd)}` === 'maven:dual-marker-dir', + )?.hints + expect(hints?.flags.size).toBe(0) + expect(hints?.socketJson).toBe( + path.join(path.relative(process.cwd(), dualMarkerDir), 'socket.json'), + ) + }) + + it.each([true, false])( + 'lets an explicit ignoreUnresolved (%s) override every build root socket.json', + async ignoreUnresolved => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { + defaults: { + manifest: { maven: { ignoreUnresolved: !ignoreUnresolved } }, + }, + } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ + cwd: monorepo, + ignoreUnresolved, + verbose: false, + }) + + const calls = vi.mocked(runManifestFacts).mock.calls.map(([opts]) => opts) + expect(calls.length).toBeGreaterThan(1) + expect( + calls.every(opts => opts.ignoreUnresolved === ignoreUnresolved), + ).toBe(true) + }, + ) + + it("falls back to each build root's socket.json ignoreUnresolved when not overridden", async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { maven: { ignoreUnresolved: true } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const ignoreByCall = new Map( + vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => [ + `${opts.ecosystem}:${relOf(opts.cwd)}`, + opts.ignoreUnresolved, + ]), + ) + expect(ignoreByCall.get('maven:dual-marker-dir')).toBe(true) + expect(ignoreByCall.get('maven:reactor')).toBe(false) + }) + it('skips (with a warning) a resolved config that sets facts: false, never invoking the build tool', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => @@ -832,7 +929,10 @@ describe('generateRecursiveManifests', () => { }, ) - await generateRecursiveManifests({ cwd: outer, verbose: false }) + await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) expect(tmpDirsSeen['sbt']).toHaveLength(2) expect(tmpDirsSeen['sbt']![0]).toBeDefined() @@ -853,7 +953,10 @@ describe('generateRecursiveManifests', () => { projects: [], })) - await generateRecursiveManifests({ cwd: outer, verbose: false }) + await generateRecursiveManifests({ + cwd: outer, + verbose: false, + }) expect( vi.mocked(runManifestFacts).mock.calls[0]?.[0].tmpDir, diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 2c4325bfff..5803a065f0 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -21,12 +21,20 @@ import { InputError } from '../../utils/errors.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import type { GeneratableManifests } from './detect-manifest-actions.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { ResolvedPathsSidecar, SidecarAccumulator, } from './scripts/sidecar.mts' import type { OutputKind } from '../../types.mts' +// Auto-manifest reads every build option from the root socket.json. +const AUTO_MANIFEST_HINTS: BuildOptionHints = { + flags: new Set(), + setupCommand: 'socket manifest setup', + socketJson: SOCKET_JSON, +} + export type GenerateAutoManifestResult = { generatedFiles: string[] // Reachability path only: resolved on-disk paths from the build-tool run. @@ -111,6 +119,7 @@ export async function generateAutoManifest({ ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined, + hints: AUTO_MANIFEST_HINTS, sidecarAcc, tmpDir, withFiles: computeArtifactsSidecar, @@ -157,6 +166,7 @@ export async function generateAutoManifest({ includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined, + hints: AUTO_MANIFEST_HINTS, sidecarAcc, withFiles: computeArtifactsSidecar, }) @@ -190,6 +200,7 @@ export async function generateAutoManifest({ mavenOpts: parseBuildToolOpts( sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined, ), + hints: AUTO_MANIFEST_HINTS, sidecarAcc, verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose), withFiles: computeArtifactsSidecar, diff --git a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts index 9f601a304a..81cedbebc4 100644 --- a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts +++ b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts @@ -7,17 +7,21 @@ import type { CResult, OutputKind } from '../../types.mts' export async function handleManifestDynamicSbomInference({ cwd, excludePaths, + ignoreUnresolved, outputKind, verbose, }: { cwd: string excludePaths: string[] + ignoreUnresolved?: boolean | undefined outputKind: OutputKind verbose: boolean }): Promise { const outcomes = await generateRecursiveManifests({ cwd, excludePaths, + ignoreUnresolved, + optionFlags: new Set(['ignoreUnresolved', 'verbose']), verbose, }) diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index a6d496806c..b9598a44d2 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -8,7 +8,11 @@ import { formatMissingEnvVarRefs, } from './expand-env-var-refs.mts' import { looksLikeSbtBuild } from './scripts/build-tool.mts' -import { renderResolutionErrorReport } from './scripts/resolution-report-render.mts' +import { + FLAG_HINTS, + renderResolutionErrorReport, + verboseHint, +} from './scripts/resolution-report-render.mts' import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' import constants from '../../constants.mts' @@ -17,6 +21,7 @@ import { realpathOrResolved } from '../../utils/fs.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { ManifestRunResult } from './scripts/run.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' @@ -57,6 +62,7 @@ export async function runManifestFacts({ ecosystem, excludeConfigs, excludePaths, + hints = FLAG_HINTS, ignoreUnresolved, includeConfigs, javaHome, @@ -71,6 +77,8 @@ export async function runManifestFacts({ ecosystem: BuildTool excludeConfigs: string excludePaths?: string[] | undefined + // Where the user sets build options, for remedy hints. + hints?: BuildOptionHints | undefined ignoreUnresolved: boolean includeConfigs: string javaHome?: string | undefined @@ -132,7 +140,7 @@ export async function runManifestFacts({ result = await runManifestScript(ecosystem, scriptOpts) } else { logger.info( - `(No live output; pass --verbose to stream the ${ecosystem} build output.)`, + `(No live output; to stream the ${ecosystem} build output, ${verboseHint(hints, ecosystem)}.)`, ) spinner.start(`Resolving ${ecosystem} dependencies ...`) result = await runManifestScript(ecosystem, scriptOpts) @@ -152,10 +160,7 @@ export async function runManifestFacts({ } process.exitCode = 1 logger.fail( - `Could not run the ${ecosystem} build tool` + - (verbose - ? `: ${getErrorMessageOr(e, String(e))}` - : ' (run with --verbose for details).'), + `Could not run the ${ecosystem} build tool: ${getErrorMessageOr(e, String(e))}`, ) return null } @@ -174,7 +179,7 @@ export async function runManifestFacts({ report.failures, report.scannedConfigs, ecosystem, - { ignoreUnresolved, unscannable: report.unscannable }, + { hints, ignoreUnresolved, unscannable: report.unscannable }, ) if (rendered.hasBlockingFailures) { diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 60daa21df6..cb53d3d9e9 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -432,7 +432,7 @@ function buildArtifactPaths( function buildReport(parsed: ParsedRecords): ResolutionReport { const seen = new Set() const failures = parsed.failures.filter(f => { - const key = `${f.coord}|${f.detail}|${f.config}` + const key = `${f.coord}|${f.detail}|${f.config}|${f.project}` if (seen.has(key)) { return false } @@ -441,7 +441,7 @@ function buildReport(parsed: ParsedRecords): ResolutionReport { }) const seenUnscannable = new Set() const unscannable = parsed.unscannable.filter(u => { - const key = `${u.config}|${u.detail}` + const key = `${u.config}|${u.detail}|${u.project}` if (seenUnscannable.has(key)) { return false } diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java index 4ac60988c5..b343020e88 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java @@ -12,6 +12,8 @@ import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.graph.DependencyFilter; import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.resolution.ArtifactDescriptorException; +import org.eclipse.aether.resolution.VersionRangeResolutionException; import org.slf4j.Logger; import java.io.File; @@ -22,8 +24,11 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -117,6 +122,7 @@ public void run(MavenSession session, List reactor, File rootDir, for (String scope : passingScopes) rec(lines, "scanned", scope); Set failures = new LinkedHashSet<>(); + Set unscannable = new LinkedHashSet<>(); int rootIdx = 0; for (MavenProject module : reactor) { if (module.getBasedir() == null) continue; @@ -127,11 +133,12 @@ public void run(MavenSession session, List reactor, File rootDir, Set directIds = new HashSet<>(); // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots). Set directProdIds = new HashSet<>(); - collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures); + collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures, unscannable); rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds, directProdIds); } - for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config); + for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config, f.project); + for (Unscannable u : unscannable) rec(lines, "unscannable", "", u.detail, u.project); write(opts.recordsFile, lines); } @@ -154,7 +161,8 @@ private void collectModule( Map nodes, Set directIds, Set directProdIds, - Set failures) { + Set failures, + Set unscannable) { String moduleCoord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); DependencyResolutionResult result; DependencyResolutionException thrown = null; @@ -169,16 +177,31 @@ private void collectModule( thrown = e; result = e.getResult(); if (result == null) { - failures.add(new Failure(moduleCoord, rootMessage(e), "graph")); + unscannable.add(new Unscannable(rootMessage(e), moduleCoord)); log.warn("[socket-facts] could not resolve dependencies for " + moduleCoord + ": " + rootMessage(e)); return; } } - // Tracked per call, not by `failures.size()`: the set is shared across modules and Failure has - // value equality, so a sibling module failing on the same dependency absorbs this module's add. boolean reported = false; + DependencyNode graph = result.getDependencyGraph(); + // Dependencies whose own resolution failed are reported, not emitted, as Gradle and sbt do. + Set unresolved = new HashSet<>(); + Map> scopesByGav = null; for (Exception e : result.getCollectionErrors()) { - failures.add(new Failure(moduleCoord, rootMessage(e), "graph")); + String failed = failedArtifact(e); + if (failed == null) { + unscannable.add(new Unscannable(rootMessage(e), moduleCoord)); + } else { + // Maven keeps a dependency whose POM failed in the graph, minus its children (Maven 3.2 drops it), so + // the failure belongs to the scopes it has there. One absent from the graph has none. + if (scopesByGav == null) scopesByGav = scopesByGav(graph); + Set scopes = scopesByGav.getOrDefault(failed, Collections.emptySet()); + if (scopes.isEmpty()) failures.add(new Failure(failed, rootMessage(e), "", moduleCoord)); + for (String scope : scopes) { + if (passingScopes.contains(scope)) failures.add(new Failure(failed, rootMessage(e), scope, moduleCoord)); + } + unresolved.add(failed); + } log.warn("[socket-facts] could not build dependency graph for " + moduleCoord + ": " + rootMessage(e)); reported = true; } @@ -187,21 +210,20 @@ private void collectModule( if (artifact == null) continue; List errors = result.getResolutionErrors(dep); failures.add(new Failure( - gav(artifact), rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep))); + gav(artifact), rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep), moduleCoord)); log.debug("[socket-facts] could not materialize " + artifact + " (" + scopeOf(dep) + ")"); reported = true; } // Fail closed: a throw whose result named nothing must still surface, or an unresolved dependency // would silently leave the reachability analysis blind to whatever that artifact contains. if (thrown != null && !reported) { - failures.add(new Failure(moduleCoord, rootMessage(thrown), "graph")); + unscannable.add(new Unscannable(rootMessage(thrown), moduleCoord)); log.warn("[socket-facts] could not resolve dependencies for " + moduleCoord + ": " + rootMessage(thrown)); } - DependencyNode root = result.getDependencyGraph(); - if (root == null) return; + if (graph == null) return; Set visited = new HashSet<>(); - for (DependencyNode child : root.getChildren()) { - String id = visit(child, passingScopes, reactorGavs, opts, nodes, visited); + for (DependencyNode child : graph.getChildren()) { + String id = visit(child, passingScopes, reactorGavs, unresolved, opts, nodes, visited); if (id != null) { directIds.add(id); // A direct dependency's effective scope is its declared scope (Aether's scope selector keeps @@ -257,6 +279,7 @@ private String visit( DependencyNode dn, Set passingScopes, Set reactorGavs, + Set unresolved, Options opts, Map nodes, Set visited) { @@ -270,6 +293,7 @@ private String visit( if (!passingScopes.contains(scope)) return null; String gav = gav(artifact); + if (unresolved.contains(gav)) return null; boolean internal = reactorGavs.contains(gav); // Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the // assembler and the Gradle/SBT scripts already emit. @@ -299,7 +323,7 @@ private String visit( } for (DependencyNode child : dn.getChildren()) { - String childId = visit(child, passingScopes, reactorGavs, opts, nodes, visited); + String childId = visit(child, passingScopes, reactorGavs, unresolved, opts, nodes, visited); if (childId != null) node.children.add(childId); } return id; @@ -500,15 +524,53 @@ private static String rootMessage(Throwable t) { return (msg != null ? msg : "unknown resolution failure").trim(); } + private static Map> scopesByGav(DependencyNode root) { + Map> scopes = new HashMap<>(); + if (root == null) return scopes; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque todo = new ArrayDeque<>(root.getChildren()); + while (!todo.isEmpty()) { + DependencyNode dn = todo.pop(); + if (!seen.add(dn) || dn.getData().get(NODE_DATA_CONFLICT_WINNER) != null) continue; + if (dn.getArtifact() != null) { + scopes.computeIfAbsent(gav(dn.getArtifact()), k -> new TreeSet<>()).add(scopeOf(dn.getDependency())); + } + todo.addAll(dn.getChildren()); + } + return scopes; + } + + // The artifact a collection error is about, when the resolver names one. + private static String failedArtifact(Throwable t) { + Throwable cur = t; + int guard = 0; + while (cur != null && guard++ < 12) { + Artifact artifact = null; + if (cur instanceof ArtifactDescriptorException) { + ArtifactDescriptorException e = (ArtifactDescriptorException) cur; + if (e.getResult() != null) artifact = e.getResult().getRequest().getArtifact(); + } else if (cur instanceof VersionRangeResolutionException) { + VersionRangeResolutionException e = (VersionRangeResolutionException) cur; + if (e.getResult() != null) artifact = e.getResult().getRequest().getArtifact(); + } + if (artifact != null) return gav(artifact); + cur = cur.getCause(); + } + return null; + } + private static final class Failure { final String coord; final String detail; + // Empty when the failure belongs to no scope. final String config; + final String project; - Failure(String coord, String detail, String config) { + Failure(String coord, String detail, String config, String project) { this.coord = coord; this.detail = detail; this.config = config; + this.project = project; } @Override @@ -516,12 +578,37 @@ public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Failure)) return false; Failure f = (Failure) o; - return coord.equals(f.coord) && detail.equals(f.detail) && config.equals(f.config); + return coord.equals(f.coord) && detail.equals(f.detail) && config.equals(f.config) + && project.equals(f.project); + } + + @Override + public int hashCode() { + return (coord + "|" + detail + "|" + config + "|" + project).hashCode(); + } + } + + // A module whose dependencies could not be resolved at all, with no failing artifact to name. + private static final class Unscannable { + final String detail; + final String project; + + Unscannable(String detail, String project) { + this.detail = detail; + this.project = project; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Unscannable)) return false; + Unscannable u = (Unscannable) o; + return detail.equals(u.detail) && project.equals(u.project); } @Override public int hashCode() { - return (coord + "|" + detail + "|" + config).hashCode(); + return (detail + "|" + project).hashCode(); } } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 5d43f14c12..428e614e33 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -1,7 +1,4 @@ -import type { - ResolutionFailure, - UnscannableConfig, -} from './resolution-report.mts' +import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // Line-protocol the build-tool scripts emit to a records file (NOT stdout — sbt // prints unsilenceable resolution noise there). One record per line, fields @@ -21,8 +18,8 @@ import type { // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) // scanned config -// failure coord detail config -// unscannable config detail +// failure coord detail config projectKey (config empty: no configuration) +// unscannable config detail projectKey (config empty: whole project) // // `projectKey` is the tool's unique project identity (Maven: GAV; Gradle: // project path; sbt: project id), emitted as the project id; a `node` whose @@ -84,7 +81,7 @@ export type ParsedRecords = { roots: Map scannedConfigs: string[] failures: ResolutionFailure[] - unscannable: UnscannableConfig[] + unscannable: Unscannable[] } export function unescapeField(s: string): string { @@ -247,14 +244,16 @@ export function parseRecords(text: string): ParsedRecords { coord: f[1], detail: f[2] ?? '', config: f[3] ?? '', + project: f[4] ?? '', }) } break case 'unscannable': - if (f[1]) { + if (f[1] || f[3]) { result.unscannable.push({ - config: f[1], + config: f[1] ?? '', detail: f[2] ?? '', + project: f[3] ?? '', }) } break diff --git a/src/commands/manifest/scripts/resolution-report-maven.mts b/src/commands/manifest/scripts/resolution-report-maven.mts index 5ab942f2a9..37cd7bceb1 100644 --- a/src/commands/manifest/scripts/resolution-report-maven.mts +++ b/src/commands/manifest/scripts/resolution-report-maven.mts @@ -3,9 +3,9 @@ import type { ResolutionDialect, } from './resolution-report-render.mts' -// Maven's resolver (Aether/maven-resolver): no attribute-based variants. Two -// failure shapes (artifact-resolution miss with config = scope, dependency-graph -// build failure with config = "graph") both classify off the root-cause message. +// Maven's resolver (Aether/maven-resolver): no attribute-based variants. Both +// failure shapes (artifact-resolution miss, dependency-graph build failure) +// classify off the root-cause message. export function classifyMavenFailure(detail: string): FailureCategory { const t = (detail || '').toLowerCase() if ( diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 58b973d302..6e2efe3e9c 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -1,12 +1,11 @@ import { GRADLE_DIALECT } from './resolution-report-gradle.mts' import { SBT_DIALECT } from './resolution-report-ivy.mts' import { MAVEN_DIALECT } from './resolution-report-maven.mts' +import constants from '../../../constants.mts' +import { camelToKebab } from '../../../utils/strings.mts' import type { BuildTool } from './build-tool.mts' -import type { - ResolutionFailure, - UnscannableConfig, -} from './resolution-report.mts' +import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // Recognized from the build tool's message; drives wording AND whether the kind // is blocking. An unrecognized message degrades to 'other' (blocking) — safe. @@ -39,6 +38,32 @@ export type ResolutionDialect = { categories: FailureCategorySpec[] } +export type BuildOption = 'excludeConfigs' | 'ignoreUnresolved' | 'verbose' + +// Where the invoking command lets the user set each build option, so a remedy +// names something they can actually set. +export type BuildOptionHints = { + // Taken as flags; every other option is set in `socketJson`. + flags: ReadonlySet + // Wizard that writes `socketJson`. + setupCommand: string + socketJson: string +} + +export const DSI_SETUP_COMMAND = + 'socket manifest setup --dynamic-sbom-inference' + +// The single-build manifest commands take every option as a flag. +export const FLAG_HINTS: BuildOptionHints = { + flags: new Set([ + 'excludeConfigs', + 'ignoreUnresolved', + 'verbose', + ]), + setupCommand: 'socket manifest setup', + socketJson: constants.SOCKET_JSON, +} + export type RenderedResolutionReport = { // Failure report for blocking kinds; empty when nothing blocks. summary: string @@ -69,11 +94,6 @@ function dedupCoords(coords: Iterable): string[] { .sort() } -function fmtList(list: string[], limit: number): string { - const shown = list.slice(0, limit).join(', ') - return list.length > limit ? `${shown} (+${list.length - limit} more)` : shown -} - function firstLine(s: string): string { return ( (s || '') @@ -83,32 +103,101 @@ function firstLine(s: string): string { ) } +function fmtList(list: string[], limit: number): string { + const shown = list.slice(0, limit).join(', ') + return list.length > limit ? `${shown} (+${list.length - limit} more)` : shown +} + +function flagText(option: BuildOption, value: string | true): string { + const flag = `--${camelToKebab(option)}` + return value === true ? flag : `${flag} '${value}'` +} + +// Where a failure happened, for builds with several subprojects. +function inProjects(projects: string | Iterable): string { + const list = [...(typeof projects === 'string' ? [projects] : projects)] + .filter(Boolean) + .sort() + .map(p => `\`${p}\``) + return list.length ? ` (in ${fmtList(list, 3)})` : '' +} + +// `excludableConfigs` is empty when excluding configurations cannot clear +// every blocking failure. +function proceedHint( + hints: BuildOptionHints, + tool: BuildTool, + excludableConfigs: string[], +): string[] { + const options: Array<[BuildOption, string | true]> = [ + ['ignoreUnresolved', true], + ] + if (excludableConfigs.length) { + options.push(['excludeConfigs', excludableConfigs.join(',')]) + } + const asFlags = options.filter(o => hints.flags.has(o[0])) + const inSocketJson = options.filter(o => !hints.flags.has(o[0])) + const setup = `\`${hints.setupCommand}\`` + if (!inSocketJson.length) { + return asFlags.length === 1 + ? [`To proceed, re-run with ${flagText(...asFlags[0]!)}.`] + : [ + 'To proceed, re-run with either:', + ...asFlags.map(o => ` ${flagText(...o)}`), + ] + } + if (!asFlags.length) { + return [ + `To proceed, set ${options.length > 1 ? 'either of these' : 'this'} under defaults.manifest.${tool} in ${hints.socketJson} (${setup} walks you through it):`, + ...options.map( + ([option, value]) => ` "${option}": ${JSON.stringify(value)}`, + ), + ] + } + return [ + 'To proceed, either:', + ...asFlags.map(o => ` re-run with ${flagText(...o)}`), + ...inSocketJson.map( + ([option, value]) => + ` set defaults.manifest.${tool}.${option} to ${JSON.stringify(value)} in ${hints.socketJson} (${setup})`, + ), + ] +} + // Severity is per-kind; the exit-code decision lives in the caller. We do NOT // cross-reference what resolved elsewhere: the failed selector carries no // classifier/type, so relating a failed and a succeeded dep is unsound. export function renderResolutionReport( failures: ResolutionFailure[], scannedConfigs: string[], - dialect: ResolutionDialect, + tool: BuildTool, opts: { + hints?: BuildOptionHints | undefined ignoreUnresolved?: boolean | undefined - unscannable?: UnscannableConfig[] | undefined + unscannable?: Unscannable[] | undefined } = {}, ): RenderedResolutionReport { + const dialect = dialectFor(tool) const name = dialect.label + const hints = opts.hints ?? FLAG_HINTS const unscannable = opts.unscannable ?? [] - const unscannableConfigs = new Set(unscannable.map(u => u.config)) + const unscannableConfigs = new Set( + unscannable.filter(u => u.config).map(u => u.config), + ) const specOf = new Map(dialect.categories.map(c => [c.key, c])) const isBlocking = (cat: FailureCategory): boolean => specOf.get(cat)?.blocking ?? true // Aggregate by (coord, category): one module can fail with different causes - // across configs. Keep first-seen detail, union the configs. + // across configs. Keep first-seen detail, union the configs and projects. type CoordInfo = { coord: string category: FailureCategory detail: string configs: Set + projects: Set + // Some failure of this coord belongs to no configuration. + configless: boolean } const byKey = new Map() const keyOf = (coord: string, category: FailureCategory): string => @@ -118,11 +207,23 @@ export function renderResolutionReport( const key = keyOf(f.coord, category) let info = byKey.get(key) if (!info) { - info = { coord: f.coord, category, detail: f.detail, configs: new Set() } + info = { + coord: f.coord, + category, + detail: f.detail, + configs: new Set(), + projects: new Set(), + configless: false, + } byKey.set(key, info) } if (f.config) { info.configs.add(f.config) + } else { + info.configless = true + } + if (f.project) { + info.projects.add(f.project) } } const allInfos = [...byKey.values()] @@ -135,6 +236,8 @@ export function renderResolutionReport( }) const blockingUnscannable = unscannableInfos.filter(u => u.blocking) const nonBlockingUnscannable = unscannableInfos.filter(u => !u.blocking) + const blockingUnscannableConfigs = blockingUnscannable.filter(u => u.config) + const blockingUnscannableProjects = blockingUnscannable.filter(u => !u.config) const perDepBlockingConfigs = new Set() for (const info of allInfos) { @@ -146,13 +249,20 @@ export function renderResolutionReport( } const blockingConfigs = new Set([ ...perDepBlockingConfigs, - ...blockingUnscannable.map(u => u.config), + ...blockingUnscannableConfigs.map(u => u.config), ]) const blockingFailed = [...blockingConfigs].sort() + // A blocking failure outside any configuration leaves every configuration of + // its project incomplete, so none can be reported as succeeded. + const hasBlockingConfiglessFailure = + blockingUnscannableProjects.length > 0 || + allInfos.some(i => i.configless && isBlocking(i.category)) // An un-scannable config was attempted but resolved nothing, so it didn't succeed. - const succeeded = scannedConfigs - .filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c)) - .sort() + const succeeded = hasBlockingConfiglessFailure + ? [] + : scannedConfigs + .filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c)) + .sort() const groups = dialect.categories .map(spec => ({ @@ -172,10 +282,13 @@ export function renderResolutionReport( const out: string[] = [] if (hasBlockingFailures) { if (blockingCount > 0) { + const inConfigs = perDepBlockingConfigs.size + ? ` in ${perDepBlockingConfigs.size} configuration(s)` + : '' out.push( opts.ignoreUnresolved - ? `Ignored ${blockingCount} unresolved dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):` - : `Could not resolve ${blockingCount} dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):`, + ? `Ignored ${blockingCount} unresolved dependency(ies)${inConfigs}:` + : `Could not resolve ${blockingCount} dependency(ies)${inConfigs}:`, ) for (const { infos, spec } of blockingGroups) { out.push('') @@ -183,7 +296,9 @@ export function renderResolutionReport( for (const info of infos.slice(0, RESOLUTION_REPORT_ARTIFACT_LIMIT)) { const fl = firstLine(info.detail) const reasonSuffix = spec.showReason && fl ? ` [${fl}]` : '' - out.push(` - ${info.coord}${reasonSuffix}`) + out.push( + ` - ${info.coord}${inProjects(info.projects)}${reasonSuffix}`, + ) } if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) { out.push( @@ -192,31 +307,47 @@ export function renderResolutionReport( } } } - if (blockingUnscannable.length) { - // Separate from the per-dep block above, but only if there is one — otherwise - // the summary would lead with a blank line (a dangling ✗ under logger.fail). + const pushUnscannable = ( + items: typeof blockingUnscannable, + header: string, + label: (u: (typeof blockingUnscannable)[number]) => string, + ): void => { + if (!items.length) { + return + } + // Separate from the block above, but only if there is one — otherwise the + // summary would lead with a blank line (a dangling ✗ under logger.fail). if (out.length) { out.push('') } - out.push( - opts.ignoreUnresolved - ? `Ignored ${blockingUnscannable.length} configuration(s) that could not be scanned:` - : `Could not scan ${blockingUnscannable.length} configuration(s) (reason from ${name}):`, - ) - for (const u of blockingUnscannable.slice( - 0, - RESOLUTION_REPORT_CONFIG_LIMIT, - )) { + out.push(header) + for (const u of items.slice(0, RESOLUTION_REPORT_CONFIG_LIMIT)) { const fl = firstLine(u.detail) - out.push(` - ${u.config}${fl ? ` [${fl}]` : ''}`) + out.push(` - ${label(u)}${fl ? ` [${fl}]` : ''}`) } - if (blockingUnscannable.length > RESOLUTION_REPORT_CONFIG_LIMIT) { + if (items.length > RESOLUTION_REPORT_CONFIG_LIMIT) { out.push( - ` … and ${blockingUnscannable.length - RESOLUTION_REPORT_CONFIG_LIMIT} more`, + ` … and ${items.length - RESOLUTION_REPORT_CONFIG_LIMIT} more`, ) } } - out.push('') + pushUnscannable( + blockingUnscannableConfigs, + opts.ignoreUnresolved + ? `Ignored ${blockingUnscannableConfigs.length} configuration(s) that could not be scanned:` + : `Could not scan ${blockingUnscannableConfigs.length} configuration(s) (reason from ${name}):`, + u => `${u.config}${inProjects(u.project)}`, + ) + pushUnscannable( + blockingUnscannableProjects, + opts.ignoreUnresolved + ? `Ignored ${blockingUnscannableProjects.length} project(s) whose dependencies could not be resolved:` + : `Could not resolve the dependencies of ${blockingUnscannableProjects.length} project(s) (reason from ${name}):`, + u => `\`${u.project}\``, + ) + if (succeeded.length || blockingFailed.length) { + out.push('') + } if (succeeded.length) { out.push( `Resolution succeeded in: ${fmtList(succeeded, RESOLUTION_REPORT_CONFIG_LIMIT)}`, @@ -229,14 +360,20 @@ export function renderResolutionReport( } if (willFail) { out.push('') - out.push(`To proceed, re-run with either:`) - out.push(` --ignore-unresolved`) - if (blockingFailed.length) { - out.push(` --exclude-configs '${blockingFailed.join(',')}'`) - } + out.push( + ...proceedHint( + hints, + tool, + hasBlockingConfiglessFailure ? [] : blockingFailed, + ), + ) } out.push('') - out.push(`Re-run with --verbose for ${name}'s full messages.`) + out.push( + hints.flags.has('verbose') + ? `Re-run with --verbose for ${name}'s full messages.` + : `For ${name}'s full messages, ${verboseHint(hints, tool)}.`, + ) } const notices: string[] = [] @@ -252,7 +389,9 @@ export function renderResolutionReport( if (nonBlockingUnscannable.length) { const n = new Set(nonBlockingUnscannable.map(u => u.config)).size notices.push( - `Could not scan ${n} configuration(s) — re-run with --verbose for ${name}'s messages.`, + hints.flags.has('verbose') + ? `Could not scan ${n} configuration(s) — re-run with --verbose for ${name}'s messages.` + : `Could not scan ${n} configuration(s); for ${name}'s messages, ${verboseHint(hints, tool)}.`, ) } @@ -266,10 +405,12 @@ export function renderResolutionReport( } if (unscannable.length) { detailLines.push('') - detailLines.push(`${name} configurations that could not be scanned:`) + detailLines.push(`${name} resolutions that could not be scanned:`) for (const u of unscannable) { detailLines.push('') - detailLines.push(` ${u.config}:`) + detailLines.push( + ` ${u.config || 'all dependencies'}${inProjects(u.project)}:`, + ) for (const line of (u.detail || '(no message)').split('\n')) { detailLines.push(` ${line}`) } @@ -300,14 +441,16 @@ export function renderResolutionErrorReport( scannedConfigs: string[] = [], tool: BuildTool = 'gradle', opts: { + hints?: BuildOptionHints | undefined ignoreUnresolved?: boolean | undefined - unscannable?: UnscannableConfig[] | undefined + unscannable?: Unscannable[] | undefined } = {}, ): RenderedResolutionReport { - return renderResolutionReport( - failures, - scannedConfigs, - dialectFor(tool), - opts, - ) + return renderResolutionReport(failures, scannedConfigs, tool, opts) +} + +export function verboseHint(hints: BuildOptionHints, tool: BuildTool): string { + return hints.flags.has('verbose') + ? 'pass --verbose' + : `set defaults.manifest.${tool}.verbose to true in ${hints.socketJson}` } diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index ebc86ecb51..6dd05e42ac 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -1,17 +1,23 @@ import { describe, expect, it } from 'vitest' -import { renderResolutionErrorReport } from './resolution-report-render.mts' +import { + DSI_SETUP_COMMAND, + renderResolutionErrorReport, +} from './resolution-report-render.mts' +import type { BuildOptionHints } from './resolution-report-render.mts' import type { ResolutionFailure } from './resolution-report.mts' const f = ( coord: string, detail: string, config = 'runtimeClasspath', + project = ':', ): ResolutionFailure => ({ coord, detail, config, + project, }) describe('resolution failure classification', () => { @@ -155,6 +161,7 @@ describe('resolution failure classification', () => { config: 'debugAndroidTestCompileClasspath', detail: 'Cannot choose between the following variants of com.example:foo:1.0', + project: ':', }, ], }, @@ -172,6 +179,7 @@ describe('resolution failure classification', () => { config: 'runtimeClasspath', detail: 'Could not GET https://repo.example/foo. Received status code 401', + project: ':', }, ], }) @@ -184,3 +192,181 @@ describe('resolution failure classification', () => { expect(r.summary.startsWith('\n')).toBe(false) }) }) + +describe('resolution failure remedies', () => { + const dsiCommand: BuildOptionHints = { + flags: new Set(['ignoreUnresolved', 'verbose']), + setupCommand: DSI_SETUP_COMMAND, + socketJson: 'socket.json', + } + const socketJsonOnly: BuildOptionHints = { + flags: new Set(), + setupCommand: DSI_SETUP_COMMAND, + socketJson: 'socket.json', + } + const mavenMiss = f( + 'com.example:missing:jar:1.0', + 'Could not find artifact com.example:missing:jar:1.0', + 'compile', + ) + // A dependency that failed while collecting the graph and is absent from + // it, so no scope holds it. + const mavenGraphFailure = f( + 'com.example:lib:1.0', + 'Could not transfer artifact com.example:lib-parent:pom:1.0 from/to internal: status code: 401', + '', + 'com.example:app:1.0', + ) + + it('suggests the flags for a single-build manifest command', () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven') + expect(r.summary).toContain('To proceed, re-run with either:') + expect(r.summary).toContain(' --ignore-unresolved') + expect(r.summary).toContain(" --exclude-configs 'compile'") + expect(r.summary).toContain( + "Re-run with --verbose for Maven's full messages.", + ) + }) + + it('reports a failure outside any configuration by its artifact and project', () => { + const r = renderResolutionErrorReport( + [mavenGraphFailure], + ['compile', 'test'], + 'maven', + ) + expect(r.summary).toContain('Could not resolve 1 dependency(ies):') + expect(r.summary).toContain( + ' - com.example:lib:1.0 (in `com.example:app:1.0`)', + ) + // Its project's graph is incomplete, so no scope resolved fully. + expect(r.summary).not.toContain('Resolution succeeded in') + expect(r.summary).not.toContain('Resolution failed in') + expect(r.summary).toContain('To proceed, re-run with --ignore-unresolved.') + expect(r.summary).not.toContain('--exclude-configs') + }) + + it('offers no configuration exclusion when a failure outside any configuration also blocks', () => { + const r = renderResolutionErrorReport( + [mavenMiss, mavenGraphFailure], + ['compile', 'test'], + 'maven', + ) + expect(r.summary).toContain('Resolution failed in: compile') + expect(r.summary).toContain('To proceed, re-run with --ignore-unresolved.') + expect(r.summary).not.toContain('--exclude-configs') + }) + + it('reports a project whose dependencies could not be resolved at all', () => { + const r = renderResolutionErrorReport([], ['compile'], 'maven', { + unscannable: [ + { + config: '', + detail: + 'Could not resolve dependencies for project com.example:app:1.0', + project: 'com.example:app:1.0', + }, + ], + }) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain( + 'Could not resolve the dependencies of 1 project(s) (reason from Maven):', + ) + expect(r.summary).toContain(' - `com.example:app:1.0`') + expect(r.summary).not.toContain('Resolution succeeded in') + expect(r.summary).toContain('To proceed, re-run with --ignore-unresolved.') + }) + + it('names the subproject of a per-configuration failure', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:missing:1.0', + 'Could not find com.example:missing:1.0.', + 'runtimeClasspath', + ':app', + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.summary).toContain(' - com.example:missing:1.0 (in `:app`)') + expect(r.summary).toContain('Resolution failed in: runtimeClasspath') + }) + + it('points dynamic-sbom-inference at its flag and socket.json excludeConfigs', () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { + hints: dsiCommand, + }) + expect(r.summary).toContain(' re-run with --ignore-unresolved') + expect(r.summary).toContain( + 'set defaults.manifest.maven.excludeConfigs to "compile" in socket.json (`socket manifest setup --dynamic-sbom-inference`)', + ) + expect(r.summary).not.toContain('--exclude-configs') + expect(r.summary).toContain( + "Re-run with --verbose for Maven's full messages.", + ) + }) + + it('points commands without these flags at socket.json and the dedicated command', () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { + hints: socketJsonOnly, + }) + expect(r.summary).toContain( + 'To proceed, set either of these under defaults.manifest.maven in socket.json (`socket manifest setup --dynamic-sbom-inference` walks you through it):', + ) + expect(r.summary).toContain(' "ignoreUnresolved": true') + expect(r.summary).toContain(' "excludeConfigs": "compile"') + expect(r.summary).toContain( + "For Maven's full messages, set defaults.manifest.maven.verbose to true in socket.json.", + ) + expect(r.summary).not.toMatch(/--ignore-unresolved|--exclude-configs/) + }) + + it("names the failing build root's own socket.json", () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { + hints: { ...socketJsonOnly, socketJson: 'tools/codegen/socket.json' }, + }) + expect(r.summary).toContain( + 'under defaults.manifest.maven in tools/codegen/socket.json', + ) + expect(r.summary).toContain( + 'set defaults.manifest.maven.verbose to true in tools/codegen/socket.json', + ) + }) + + it('uses the singular socket.json remedy when only ignoreUnresolved applies', () => { + const r = renderResolutionErrorReport( + [mavenGraphFailure], + ['compile'], + 'maven', + { hints: socketJsonOnly }, + ) + expect(r.summary).toContain( + 'To proceed, set this under defaults.manifest.maven', + ) + expect(r.summary).not.toContain('excludeConfigs') + }) + + it('points the non-blocking notice at socket.json when there is no --verbose flag', () => { + const r = renderResolutionErrorReport( + [], + ['debugCompileClasspath'], + 'gradle', + { + hints: socketJsonOnly, + unscannable: [ + { + config: 'debugCompileClasspath', + detail: + 'Cannot choose between the following variants of com.example:foo:1.0', + project: ':', + }, + ], + }, + ) + expect(r.nonBlockingNotice).toContain( + 'set defaults.manifest.gradle.verbose to true in socket.json', + ) + expect(r.nonBlockingNotice).not.toContain('re-run with --verbose') + }) +}) diff --git a/src/commands/manifest/scripts/resolution-report.mts b/src/commands/manifest/scripts/resolution-report.mts index eb6f645e6b..3a280195ae 100644 --- a/src/commands/manifest/scripts/resolution-report.mts +++ b/src/commands/manifest/scripts/resolution-report.mts @@ -3,17 +3,23 @@ export type ResolutionFailure = { // Build tool's own failure message (deepest cause; may be multi-line). // Classified on; first line shown by default, whole thing at --verbose. detail: string + // Empty when the failed module is absent from the tool's resolved graph, so + // no configuration holds it. config: string + // projectKey of the subproject being resolved. + project: string } -// A whole config whose resolution threw, vs a single unresolved dep (`ResolutionFailure`). -export type UnscannableConfig = { +// A resolution that threw without naming a dependency: one configuration, or +// with an empty `config` the whole project. +export type Unscannable = { config: string detail: string + project: string } export type ResolutionReport = { failures: ResolutionFailure[] scannedConfigs: string[] - unscannable: UnscannableConfig[] + unscannable: Unscannable[] } diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index bf3621a347..e7b64340d4 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -464,13 +464,14 @@ allprojects { project -> coord : "${sel.group}:${sel.name}${sel.version ? ':' + sel.version : ''}".toString(), detail: rawDetail(dep.problem), config: cfg.name, + project: project.path, ] } } catch (Exception e) { // A config-LEVEL resolution failure (a resolutionStrategy/beforeResolve rule that throws, // an artifact-transform failure, a broken repository, ...) rather than a per-dep failure. // Record it instead of swallowing; the build still exits cleanly and the TS layer surfaces it. - state.unscannable << [config: cfg.name, detail: rawDetail(e)] + state.unscannable << [config: cfg.name, detail: rawDetail(e), project: project.path] } // Stash this resolution root's tree; the key only has to be unique per root. Skip empty // configs — they'd contribute nothing. @@ -549,10 +550,10 @@ rootProject { rp -> scanned.each { name -> rec(['scanned', name]) } def failures synchronized (state.failures) { failures = new ArrayList(state.failures) } - failures.each { f -> rec(['failure', f.coord, f.detail, f.config]) } + failures.each { f -> rec(['failure', f.coord, f.detail, f.config, f.project]) } def unscannable synchronized (state.unscannable) { unscannable = new ArrayList(state.unscannable) } - unscannable.each { u -> rec(['unscannable', u.config, u.detail]) } + unscannable.each { u -> rec(['unscannable', u.config, u.detail, u.project]) } def outFile = new File(recordsFileOverride ?: defaultRecordsFile) outFile.parentFile?.mkdirs() diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index cbd4502f29..f605cea87c 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -4,7 +4,6 @@ import sbt._ import sbt.Keys._ import scala.collection.mutable -import scala.reflect.ClassTag /** * Emits a flat line-protocol RECORDS file at the build root (NOT the final @@ -16,7 +15,7 @@ import scala.reflect.ClassTag * * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x (compiled by the sbt * meta-build), hence string-named TaskKeys and reflection for version-specific - * ExclusionRule / ResolveException / ConfigRef shapes. + * UpdateConfiguration / ResolveException / ConfigRef shapes. */ object SocketFactsPlugin extends AutoPlugin { override def trigger = allRequirements @@ -57,6 +56,7 @@ object SocketFactsPlugin extends AutoPlugin { // One tree per resolution root (project, config); the TS assembler merges them path-sensitively. val perSub = mutable.LinkedHashMap.empty[String, RootTree] val failures = mutable.LinkedHashSet.empty[Failure] + val unscannable = mutable.LinkedHashSet.empty[Unscannable] val scannedConfigs = mutable.LinkedHashSet.empty[String] val matcher = buildConfigMatcher() @@ -65,8 +65,8 @@ object SocketFactsPlugin extends AutoPlugin { allRefs.foreach { ref => if (!isExcludedRef(ref)) { - runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => - foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + runUpdateResilient(updateTaskName, ref, extracted, st, matcher, failures, unscannable).foreach { case (report, unresolved) => + foldReport(report, unresolved, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -140,7 +140,8 @@ object SocketFactsPlugin extends AutoPlugin { // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering). scannedConfigs.toList.sorted.foreach(c => rec("scanned", c)) - failures.foreach(f => rec("failure", f.coord, f.detail, f.config)) + failures.foreach(f => rec("failure", f.coord, f.detail, f.config, f.project)) + unscannable.foreach(u => rec("unscannable", "", u.detail, u.project)) val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match { case Some(p) => new File(p) @@ -193,38 +194,80 @@ object SocketFactsPlugin extends AutoPlugin { }.toMap } - // On a hard resolve failure, record the failed modules and retry once with them excluded. Never throws. + // When a failure names modules, a lenient (missingOk) retry keeps everything else and, where the + // resolver still places a failed module, its configurations. Failed modules are reported, not emitted. private def runUpdateResilient( taskName: String, ref: ProjectRef, extracted: Extracted, state: State, - failures: mutable.LinkedHashSet[Failure] - ): Option[UpdateReport] = { + matcher: String => Boolean, + failures: mutable.LinkedHashSet[Failure], + unscannable: mutable.LinkedHashSet[Unscannable] + ): Option[(UpdateReport, Set[String])] = { val key = TaskKey[UpdateReport](taskName) - def runOn(st: State): Either[Seq[ModuleID], UpdateReport] = + def runOn(st: State): Either[(Seq[ModuleID], String), UpdateReport] = Project.runTask(key.in(ref).scopedKey, st) match { case Some((_, sbt.Value(rep))) => Right(rep) - case Some((_, sbt.Inc(inc))) => Left(extractFailedModules(inc)) - case _ => Left(Nil) + case Some((_, sbt.Inc(inc))) => Left((extractFailedModules(inc), incompleteMessage(inc))) + case _ => Left((Nil, s"$taskName did not run")) } runOn(state) match { - case Right(rep) => Some(rep) - case Left(failed) => - failed.foreach { m => - failures += Failure(coordOf(m), "unresolved dependency", taskName) + case Right(rep) => Some((rep, Set.empty[String])) + case Left((failed, detail)) if failed.isEmpty => + unscannable += Unscannable(detail, ref.project) + None + case Left((failed, detail)) => + val lenient = extracted.append(Seq(updateConfiguration.in(ref) ~= (c => withMissingOk(c))), state) + runOn(lenient) match { + case Right(rep) => + val confsByGav = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] + rep.configurations.foreach { cr => + cr.modules.foreach { r => + if (!r.evicted) confsByGav.getOrElseUpdate(gavKey(r.module), mutable.LinkedHashSet.empty[String]) += confName(cr) + } + } + failed.foreach { m => + // Absent from the graph: no configuration. Present only in excluded ones: not reported. + confsByGav.get(gavKey(m)) match { + case None => failures += Failure(coordOf(m), detail, "", ref.project) + case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detail, c, ref.project)) + } + } + Some((rep, failed.map(gavKey).toSet)) + case Left(_) => + // Even a lenient update failed: the whole project is unresolved, but the modules sbt + // named are still reported. + failed.foreach(m => failures += Failure(coordOf(m), detail, "", ref.project)) + unscannable += Unscannable(detail, ref.project) + None } - if (failed.isEmpty) None - else - runOn(extracted.append(exclusionSettings(extracted, failed), state)) match { - case Right(rep) => Some(rep) - case Left(_) => None - } } } + // UpdateConfiguration has `withMissingOk` on sbt 1.x; on 0.13 its only Boolean field is missingOk. + private def withMissingOk[T](c: T): T = { + val cls = c.getClass + try cls.getMethod("withMissingOk", classOf[Boolean]).invoke(c, java.lang.Boolean.TRUE).asInstanceOf[T] + catch { + case _: NoSuchMethodException => + val copy = cls.getMethods.find(_.getName == "copy").get + val args = copy.getParameterTypes.zipWithIndex.map { case (t, i) => + if (t == classOf[Boolean]) java.lang.Boolean.TRUE + else cls.getMethod("copy$default$" + (i + 1)).invoke(c) + } + copy.invoke(c, args: _*).asInstanceOf[T] + } + } + + private def incompleteMessage(inc: sbt.Incomplete): String = { + def first(i: sbt.Incomplete): Option[String] = + i.directCause.flatMap(ex => Option(ex.getMessage)).orElse(i.causes.view.flatMap(first).headOption) + first(inc).map(_.trim).filter(_.nonEmpty).getOrElse("unresolved dependency") + } + // Reflection: ResolveException's package and the `failed` accessor differ between sbt 0.13 and 1.x. private def extractFailedModules(inc: sbt.Incomplete): Seq[ModuleID] = { val out = mutable.ListBuffer.empty[ModuleID] @@ -244,35 +287,10 @@ object SocketFactsPlugin extends AutoPlugin { out.toList.distinct } - // ExclusionRule constructor shape differs between sbt 0.13 (`sbt.ExclusionRule`) and 1.x (`sbt.SbtExclusionRule`). - private def exclusionSettings(extracted: Extracted, failed: Seq[ModuleID]): Seq[Setting[_]] = { - val cls: Class[_] = - try Class.forName("sbt.SbtExclusionRule") - catch { case _: ClassNotFoundException => classOf[sbt.ExclusionRule] } - - def rule(m: ModuleID): Any = - if (cls.getName.contains("SbtExclusionRule")) { - cls.getConstructors.find(_.getParameterCount == 5) match { - case Some(ctor) => - ctor.newInstance(m.organization, m.name, "*", Seq(), sbt.CrossVersion.Disabled) - case None => - throw new IllegalStateException("No suitable SbtExclusionRule constructor") - } - } else ExclusionRule(organization = m.organization, name = m.name) - - def castSeq[T](xs: Seq[Any])(implicit ct: ClassTag[T]): Seq[T] = xs.map(_.asInstanceOf[T]) - - extracted.structure.allProjectRefs.map { ref => - excludeDependencies.in(ref) := { - val original = excludeDependencies.in(ref).value - castSeq(original ++ failed.map(rule))(ClassTag(cls)) - } - } - } - // Fold one project's UpdateReport into one tree per configuration (rootKey -> tree), keyed by coordinate. private def foldReport( report: UpdateReport, + unresolved: Set[String], ref: ProjectRef, extracted: Extracted, matcher: String => Boolean, @@ -287,7 +305,7 @@ object SocketFactsPlugin extends AutoPlugin { // Build modules come from the build structure instead: lm-coursier lists every inter-project // dependency in every configuration, and cross-versioning can merge distinct ones. def emittable(m: ModuleReport): Boolean = - !m.evicted && !moduleExts.contains(gavKey(m.module)) + !m.evicted && !moduleExts.contains(gavKey(m.module)) && !unresolved.contains(gavKey(m.module)) def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil) val buildDeps = extracted.get(buildDependencies) @@ -568,7 +586,10 @@ object SocketFactsPlugin extends AutoPlugin { else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r") } - private final case class Failure(coord: String, detail: String, config: String) + // Empty `config`: the failure belongs to no configuration. + private final case class Failure(coord: String, detail: String, config: String, project: String) + + private final case class Unscannable(detail: String, project: String) private final case class Coord( org: String, diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index e229e05c8b..2923ea4293 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -11,3 +11,5 @@ repo-inheritance/.mirror-settings.xml duplicate-failure/records.tsv duplicate-failure/target/ duplicate-failure/*/target/ +graph-failure/records.tsv +graph-failure/target/ diff --git a/src/commands/manifest/scripts/test/maven-compat/assert-fail-closed.py b/src/commands/manifest/scripts/test/maven-compat/assert-fail-closed.py index 738de9a1ac..f32220ef52 100644 --- a/src/commands/manifest/scripts/test/maven-compat/assert-fail-closed.py +++ b/src/commands/manifest/scripts/test/maven-compat/assert-fail-closed.py @@ -5,15 +5,15 @@ # # Both fixture modules reach demo.scoped:widget, so both fail on it, each naming the repository it # attempted — so the count of records is not pinned here. What is pinned: the failure is attributed -# to the artifact's own scope, never to a module-level `graph` failure, which would claim the -# dependency graph could not be built when it collected fine. +# to the artifact's own scope, never reported as a scope-less or whole-module failure, which would +# claim the dependency graph could not be built when it collected fine. # smoke-test-duplicate-failure.sh covers the identical-message case, where those records collapse. import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] failures = [r[1:] for r in rows if r[0] == 'failure'] widget = [f for f in failures if f[0].startswith('demo.scoped:widget:')] -graph = [f for f in failures if f[2] == 'graph'] +graph = [f for f in failures if not f[2]] + [r[1:] for r in rows if r[0] == 'unscannable'] errors = [] if not widget: @@ -21,11 +21,11 @@ elif any(f[2] != 'compile' for f in widget): errors.append(f"demo.scoped:widget should fail in config 'compile', got {[f[2] for f in widget]}") if graph: - errors.append(f"graph collected fine, so no module-level 'graph' failure should exist: {graph}") + errors.append(f"graph collected fine, so no graph-level failure should exist: {graph}") if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print(f"PASS (fail closed): unresolvable demo.scoped:widget reported in config 'compile' ({len(widget)} record(s)); no module-level 'graph' failure") +print(f"PASS (fail closed): unresolvable demo.scoped:widget reported in config 'compile' ({len(widget)} record(s)); no graph-level failure") diff --git a/src/commands/manifest/scripts/test/maven-compat/assert-no-invented-failures.py b/src/commands/manifest/scripts/test/maven-compat/assert-no-invented-failures.py index 80577d4fc8..976aeccd44 100644 --- a/src/commands/manifest/scripts/test/maven-compat/assert-no-invented-failures.py +++ b/src/commands/manifest/scripts/test/maven-compat/assert-no-invented-failures.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -# Assert the extension reported the unresolvable dependency, and only that: no module-level `graph` -# failure may be invented for a reactor whose graph collected fine. Counting the artifact's own -# failures is deliberately loose — identical messages collapse in the shared accumulator, so the -# count depends on how each Maven version words them, while the `graph` invariant does not. +# Assert the extension reported the unresolvable dependency, and only that: no scope-less or +# whole-module failure may be invented for a reactor whose graph collected fine. Counting the +# artifact's own failures is deliberately loose: it depends on how each Maven version words them, +# while the no-invented-failure invariant does not. import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] failures = [r[1:] for r in rows if r[0] == 'failure'] ghost = [f for f in failures if f[0].startswith('demo.missing:ghost:')] -graph = [f for f in failures if f[2] == 'graph'] +# A failure with no scope, or an unscannable module, would claim graph collection failed. +graph = [f for f in failures if not f[2]] + [r[1:] for r in rows if r[0] == 'unscannable'] errors = [] if not ghost: @@ -16,11 +17,11 @@ elif any(f[2] != 'compile' for f in ghost): errors.append(f"demo.missing:ghost should fail in config 'compile', got {[f[2] for f in ghost]}") if graph: - errors.append(f"every module's graph collected fine, so no 'graph' failure should exist: {graph}") + errors.append(f"every module's graph collected fine, so no graph-level failure should exist: {graph}") if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print("PASS (no invented failures): demo.missing:ghost reported in config 'compile'; no module-level 'graph' failure") +print("PASS (no invented failures): demo.missing:ghost reported in config 'compile'; no graph-level failure") diff --git a/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml b/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml new file mode 100644 index 0000000000..aab459112e --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + demo + graph-failure + 1.0 + + 8 + 8 + UTF-8 + + + + + demo.graphfail + ok + 1.0 + + + demo.graphfail + lib + 1.0 + + + demo.graphfail + testlib + 1.0 + test + + + demo.graphfail + ranged + [1.0,2.0) + + + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh index cf5a4ecf5c..365c1411ab 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh @@ -1,13 +1,12 @@ #!/usr/bin/env bash # Regression guard: an unresolvable dependency shared by several reactor modules must be reported as -# what it is — a per-artifact resolution failure — and must never be reported as a module-level -# `graph` failure, which would claim the dependency graph itself could not be built. +# what it is — a per-artifact resolution failure — and must never be reported as a whole-module +# failure, which would claim the dependency graph itself could not be built. # # Every module inherits the same missing dependency and the same repository list, so every module's -# failure carries an identical message. The accumulator is a value-equality set shared across the -# whole reactor, so those identical failures collapse into one entry — and a fail-closed check that -# asks "did this module add anything?" by comparing set sizes concludes, wrongly, that the module -# reported nothing and invents a `graph` failure for it. +# failure carries an identical message. A fail-closed check that asks "did this module add +# anything?" by comparing the size of a reactor-wide value-equality set would conclude, wrongly, +# that the module reported nothing and invent a whole-module failure for it. # # Runs offline: the missing artifact is never looked for in a remote repository, so this needs no # stub repo and never touches the network. diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh new file mode 100755 index 0000000000..f100536a8d --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Regression guard: a dependency that fails while Maven collects the graph must be reported as that +# dependency, in the module it was collected for, and never emitted as a node. Maven keeps a +# dependency whose POM failed in the graph (Maven 3.2's resolver leaves it out instead), so it fails +# in its own scope, and an excluded scope drops it; a dependency the graph lacks (an unsatisfiable +# range) has no scope. The module itself must never be reported as the failure. +# +# Runs offline: the POMs are seeded straight into the local repository, so their missing parent and +# the absent versions of ranged can only fail to resolve. +# +# Usage: smoke-test-graph-failure.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-graph-failure.sh }" +JAR="${2:?usage: smoke-test-graph-failure.sh }" +PROJECT="$HERE/graph-failure" +RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +M2="$SOCKET_COMPAT_CACHE/m2" + +rm -rf "$M2/demo/graphfail" +for lib in lib testlib; do + mkdir -p "$M2/demo/graphfail/$lib/1.0" + cat > "$M2/demo/graphfail/$lib/1.0/$lib-1.0.pom" < + 4.0.0 + + demo.graphfail + lib-parent + 1.0 + + $lib + +POM +done +mkdir -p "$M2/demo/graphfail/ok/1.0" +cat > "$M2/demo/graphfail/ok/1.0/ok-1.0.pom" <<'POM' + + 4.0.0 + demo.graphfail + ok + 1.0 + +POM + +run() { + rm -f "$RECORDS" + ( cd "$PROJECT" && "$MVN" --batch-mode -q -o \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-facts \ + "-Dmaven.repo.local=$M2" \ + "-Dsocket.recordsFile=$RECORDS" \ + "$@" \ + validate ) +} + +# $1: expected scope per coord (null: excluded) when the failed dependency stays in the graph. +check() { + python3 - "$RECORDS" "$1" "$KEEPS_FAILED" <<'PY' +import json, sys +def read(path): + return [l.rstrip('\n').split('\t') for l in open(path) if l.strip()] +rows = read(sys.argv[1]) +expected = json.loads(sys.argv[2]) +keeps_failed = sys.argv[3] == 'true' +emitted = {f"{r[3]}:{r[4]}:{r[5]}" for r in rows if r[0] == 'node'} +errors = [] +if 'demo.graphfail:ok:1.0' not in emitted: + errors.append(f"ok resolved, so it must be emitted; nodes={sorted(emitted)}") +failures = [r[1:] for r in rows if r[0] == 'failure'] +unscannable = [r[1:] for r in rows if r[0] == 'unscannable'] +module = 'demo:graph-failure:1.0' + +for coord, scope in expected.items(): + found = [f for f in failures if f[0] == coord] + if coord in emitted: + errors.append(f"{coord} failed to resolve, so it must not be emitted as a node") + if not keeps_failed: + scope = '' + if scope is None: + if found: + errors.append(f"{coord} is in an excluded scope and must not be reported, got {found}") + elif not found: + errors.append(f"{coord} emitted no failure record; failures={failures}") + elif any(f[2] != scope or f[3] != module for f in found): + errors.append(f"{coord} should fail in scope {scope!r} of {module}, got {found}") +invented = [f for f in failures if f[0] == module] +if invented: + errors.append(f"the module itself must not be reported as the unresolved dependency: {invented}") +if unscannable: + errors.append(f"each collection error names its artifact, so nothing is unscannable: {unscannable}") + +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print(f"PASS (graph failure): {expected}") +PY +} + +VERSION="$("$MVN" -v 2>/dev/null | head -1)" +echo "+ $VERSION (graph failure)" +case "$VERSION" in + *"Maven 3.2."*) KEEPS_FAILED=false ;; + *) KEEPS_FAILED=true ;; +esac +run +check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": "test", "demo.graphfail:ranged:[1.0,2.0)": ""}' +run -Dsocket.excludeConfigs=test +check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": null, "demo.graphfail:ranged:[1.0,2.0)": ""}' diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 4ae053bd24..7684065025 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -86,6 +86,7 @@ run_maven() { bash "$HERE/maven-compat/smoke-test-workspaces.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-repo-inheritance.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-duplicate-failure.sh" "$dir/bin/mvn" "$jar" + bash "$HERE/maven-compat/smoke-test-graph-failure.sh" "$dir/bin/mvn" "$jar" done } @@ -102,6 +103,7 @@ run_sbt() { use_jdk "$java" bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala" + bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore index b31ec4d229..77a47cca25 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/.gitignore +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -7,3 +7,8 @@ project/scala-version.sbt project/records.tsv project/workspaces-records.tsv project/target +failure/localrepo/ +failure/target/ +failure/project/ +failure/scala-version.sbt +failure/records.tsv diff --git a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt new file mode 100644 index 0000000000..a9b6ac99c7 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt @@ -0,0 +1,19 @@ +// Failure fixture for ../../../socket-facts.plugin.scala: ok resolves; lib and testlib name a parent POM +// that does not exist; ghost does not exist at all. Generated into `localrepo` by +// ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 and 1.x. +organization in ThisBuild := "demo" +version in ThisBuild := "0.1.0" + +lazy val root = (project in file(".")) + .settings( + name := "sbt-compat-failure", + resolvers := Seq( + "Socket Stubs" at s"file://${baseDirectory.value.getAbsolutePath}/localrepo", + "Ivy Releases" at "https://scala.jfrog.io/artifactory/ivy-releases/", + "Maven Central" at "https://repo1.maven.org/maven2/" + ) ++ resolvers.value, + libraryDependencies += "demo.fail" % "ok" % "1.0", + libraryDependencies += "demo.fail" % "lib" % "1.0", + libraryDependencies += "demo.fail" % "testlib" % "1.0" % Test, + libraryDependencies += "demo.fail" % "ghost" % "1.0" + ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh new file mode 100755 index 0000000000..bc82270005 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Regression guard: an unresolvable dependency must be reported, never emitted, and must not take the +# rest of the project down with it. ok must still be emitted; every module named in a failure record +# must be absent from the nodes. Which modules sbt names depends on its resolver: Ivy (sbt 0.13) names +# the declared dependencies whose parent POM is missing (lib, testlib), coursier (sbt 1.3+) names the +# missing parent POM itself; both name ghost. +# +# Usage: smoke-test-failure.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-failure.sh }" +SCALA_VERSION="${2:?usage: smoke-test-failure.sh }" +PLUGIN="$HERE/../../socket-facts.plugin.scala" +PROJECT="$HERE/failure" +RECORDS="$PROJECT/records.tsv" +REPO="$PROJECT/localrepo" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +IVY="$SOCKET_COMPAT_CACHE/ivy2" +export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" + +bash "$HERE/../make-stub-repo.sh" "$REPO" 'demo.fail:ok:1.0' +for lib in lib testlib; do + mkdir -p "$REPO/demo/fail/$lib/1.0" + cat > "$REPO/demo/fail/$lib/1.0/$lib-1.0.pom" < + 4.0.0 + + demo.fail + missing-parent + 1.0 + + $lib + +POM +done +rm -rf "$IVY/cache/demo.fail" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.scala" + +mkdir -p "$PROJECT/project" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsbt.ivy.home="$IVY" \ + -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +nodes = {f"{r[3]}:{r[4]}:{r[5]}" for r in rows if r[0] == 'node'} +failed = {r[1] for r in rows if r[0] == 'failure'} +unscannable = [r[1:] for r in rows if r[0] == 'unscannable'] +errors = [] + +if 'demo.fail:ok:1.0' not in nodes: + errors.append(f"ok resolved, so it must be emitted; nodes={sorted(nodes)}") +if 'demo.fail:ghost:1.0' not in failed: + errors.append(f"ghost does not exist, so it must be reported; failures={sorted(failed)}") +if not ({'demo.fail:lib:1.0', 'demo.fail:testlib:1.0'} <= failed or 'demo.fail:missing-parent:1.0' in failed): + errors.append(f"neither lib/testlib nor their missing parent was reported; failures={sorted(failed)}") +emitted = failed & nodes +if emitted: + errors.append(f"reported failures must not be emitted as nodes: {sorted(emitted)}") +if unscannable: + errors.append(f"the failures name their modules, so the project must not be unscannable: {unscannable}") + +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print(f"PASS (failure): reported {sorted(failed)}; ok still emitted") +PY diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 5634e54cad..b0f42b3a8b 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -22,6 +22,7 @@ import { import { suggestOrgSlug } from './suggest-org-slug.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' +import { warnSocketJsonOnlyFlags } from './warn-socket-json-only-flags.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -351,6 +352,8 @@ async function run( cmdFlagValueToArray(cli.flags['reachEcosystems']), ) + warnSocketJsonOnlyFlags(cli.flags, `${parentName} ${CMD_NAME}`) + const dryRun = !!cli.flags['dryRun'] const uvMembers = !!cli.flags['uvMembers'] diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts index 07054056a7..a36426b76e 100644 --- a/src/commands/scan/cmd-scan-reach.mts +++ b/src/commands/scan/cmd-scan-reach.mts @@ -11,6 +11,7 @@ import { } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' +import { warnSocketJsonOnlyFlags } from './warn-socket-json-only-flags.mts' import constants from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -139,6 +140,8 @@ async function run( parentName, }) + warnSocketJsonOnlyFlags(cli.flags, `${parentName} ${CMD_NAME}`) + const { cwd: cwdOverride, dynamicSbomInference, diff --git a/src/commands/scan/warn-socket-json-only-flags.mts b/src/commands/scan/warn-socket-json-only-flags.mts new file mode 100644 index 0000000000..26e2857969 --- /dev/null +++ b/src/commands/scan/warn-socket-json-only-flags.mts @@ -0,0 +1,27 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { camelToKebab } from '../../utils/strings.mts' +import { DSI_SETUP_COMMAND } from '../manifest/scripts/resolution-report-render.mts' + +// Build options that `socket manifest ` takes as flags but the scan +// commands read from each build's socket.json, so passing them does nothing. +const SOCKET_JSON_ONLY_FLAGS = [ + 'excludeConfigs', + 'ignoreUnresolved', + 'includeConfigs', + 'verbose', +] + +export function warnSocketJsonOnlyFlags( + flags: Record, + command: string, +): void { + const ignored = SOCKET_JSON_ONLY_FLAGS.filter( + name => flags[name] !== undefined, + ).map(name => `--${camelToKebab(name)}`) + if (ignored.length) { + logger.warn( + `Ignoring ${ignored.join(', ')}: \`${command}\` reads build options from each build's socket.json. Set them there, for example with \`${DSI_SETUP_COMMAND}\`.`, + ) + } +} From faa3bcc03a0d291f35646f269c28e737dabc0937 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 22 Oct 2026 12:32:41 +0200 Subject: [PATCH 2/5] test(manifest): put each compat JDK on PATH so sbt rows use it The sbt launcher runs java from PATH and ignores JAVA_HOME, so every sbt row ran on whatever JDK came first on PATH (and sbt 0.13 failed on a modern one). --- src/commands/manifest/scripts/test/run-compat.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 7684065025..475e199882 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -36,6 +36,8 @@ use_jdk() { local home="${!home_var:-}" if [ -n "$home" ]; then export JAVA_HOME="$home" + # The sbt launcher runs `java` from PATH and ignores JAVA_HOME. + export PATH="$home/bin:$PATH" echo " JDK $1: $home" else echo " JDK $1 not provided (set $home_var= for fidelity); using current java" From 4943bad19d37bfdc054fa14411af78e8f622c675 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 22 Oct 2026 13:15:20 +0200 Subject: [PATCH 3/5] fix(manifest): print a usable socket.json path and skip an empty abort warning A build root outside the shell's directory got a socket.json hint that climbed to the filesystem root; it now gets the absolute path. A failed last build root no longer warns that the remaining roots cannot be classified. --- .../manifest/generate-recursive-manifests.mts | 19 ++++++++++++++----- .../generate-recursive-manifests.test.mts | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index c126fcfa81..ce578f74b9 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -53,6 +53,12 @@ export type EcosystemBuildConfig = { verbose: boolean } +// Relative to the user's shell when inside it, else absolute. +function displayPath(file: string): string { + const rel = path.relative(process.cwd(), file) + return rel.startsWith('..') || path.isAbsolute(rel) ? file : rel +} + // A cascaded (not just root-level) disabled additionally skips this one build // root here, on top of its existing root-only ecosystem-wide meaning for // auto/gradle/etc. facts:false has no pom-mode equivalent here (facts-only), @@ -175,7 +181,7 @@ async function runEcosystemCandidates({ hints: { flags: optionFlags, setupCommand: DSI_SETUP_COMMAND, - socketJson: path.join(path.relative(process.cwd(), dir), SOCKET_JSON), + socketJson: displayPath(path.join(dir, SOCKET_JSON)), }, ignoreUnresolved: ignoreUnresolvedOverride ?? ignoreUnresolved, includeConfigs, @@ -188,10 +194,13 @@ async function runEcosystemCandidates({ if (result === null) { outcomes.push({ dir, ecosystem, status: 'failed' }) - logger.warn( - `Aborting ${ecosystem} discovery: ${dir} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`, - ) - for (const abortedDir of dirs.slice(dirIndex + 1)) { + const abortedDirs = dirs.slice(dirIndex + 1) + if (abortedDirs.length) { + logger.warn( + `Aborting ${ecosystem} discovery: ${dir} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`, + ) + } + for (const abortedDir of abortedDirs) { outcomes.push({ dir: abortedDir, ecosystem, status: 'aborted' }) } break diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 22bc94b41c..5bbfd45d71 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -576,8 +576,8 @@ describe('generateRecursiveManifests', () => { `${opts.ecosystem}:${relOf(opts.cwd)}` === 'maven:dual-marker-dir', )?.hints expect(hints?.flags.size).toBe(0) - expect(hints?.socketJson).toBe( - path.join(path.relative(process.cwd(), dualMarkerDir), 'socket.json'), + expect(path.resolve(hints?.socketJson ?? '')).toBe( + path.join(dualMarkerDir, 'socket.json'), ) }) From f4e53cebc925376de4eb0459187816a7f6acfb58 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 22 Oct 2026 14:00:13 +0200 Subject: [PATCH 4/5] fix(manifest): stream build-tool output to stderr With verbose set in socket.json, Maven's log was inherited onto stdout and broke --json output. Streamed build output is a diagnostic, so it now goes to stderr on every path. --- src/commands/manifest/scripts/run.mts | 9 ++++++-- src/commands/manifest/scripts/run.test.mts | 27 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/commands/manifest/scripts/run.test.mts diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index d2c07b16c7..5eaf7d25d2 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -55,7 +55,8 @@ export type ManifestRunResult = { factsFileName: string | undefined report: ResolutionReport artifactPaths: ResolvedArtifactPaths - // Captured build-tool output (empty when stdio is 'inherit'). + // Captured build-tool output (empty when stdio is 'inherit', which streams it + // to stderr). stderr: string stdout: string } @@ -83,6 +84,10 @@ function manifestScriptsPath(...parts: string[]): string { return path.join(constants.distPath, 'manifest-scripts', ...parts) } +// Streamed build output is a diagnostic: it goes to stderr so the command's own +// stdout (e.g. --json) stays machine-readable. +const STREAM_TO_STDERR = ['inherit', 2, 'inherit'] as const + // Don't throw on a non-zero exit: the script emits failure records, so a usable // records file still exists. A non-exit spawn error (e.g. missing executable) // propagates. @@ -94,7 +99,7 @@ async function runNeverThrow( try { const result = await spawn(bin, args, { cwd: opts.projectDir, - stdio: opts.stdio ?? 'inherit', + stdio: opts.stdio === 'pipe' ? 'pipe' : [...STREAM_TO_STDERR], ...(opts.env ? { env: opts.env } : {}), ...(opts.signal ? { signal: opts.signal } : {}), }) diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts new file mode 100644 index 0000000000..3d27007b6b --- /dev/null +++ b/src/commands/manifest/scripts/run.test.mts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { enumerateWorkspaces } from './run.mts' + +vi.mock('@socketsecurity/registry/lib/spawn', () => ({ + spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })), +})) + +describe('build tool output', () => { + it.each([ + ['inherit', ['inherit', 2, 'inherit']], + ['pipe', 'pipe'], + ] as const)( + 'maps stdio %s so stdout stays free of build logs', + async (stdio, expected) => { + vi.mocked(spawn).mockClear() + await enumerateWorkspaces('gradle', { + bin: 'gradle', + projectDir: process.cwd(), + stdio, + }).catch(() => {}) + expect(vi.mocked(spawn).mock.calls[0]?.[2]?.stdio).toEqual(expected) + }, + ) +}) From b7a14100f601df4a02bdd02dfa627ef744b6ab57 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 22 Oct 2026 14:00:32 +0200 Subject: [PATCH 5/5] fix(manifest): give each sbt failure its own module's message Every failure carried the whole batch resolve message, so the records grew quadratically with the number of failed modules. Each failure now keeps only its module's part of the message. --- .../manifest/scripts/socket-facts.plugin.scala | 16 +++++++++++++--- .../test/sbt-compat/smoke-test-failure.sh | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index f605cea87c..2b455276d5 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -232,15 +232,15 @@ object SocketFactsPlugin extends AutoPlugin { failed.foreach { m => // Absent from the graph: no configuration. Present only in excluded ones: not reported. confsByGav.get(gavKey(m)) match { - case None => failures += Failure(coordOf(m), detail, "", ref.project) - case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detail, c, ref.project)) + case None => failures += Failure(coordOf(m), detailFor(m, detail), "", ref.project) + case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detailFor(m, detail), c, ref.project)) } } Some((rep, failed.map(gavKey).toSet)) case Left(_) => // Even a lenient update failed: the whole project is unresolved, but the modules sbt // named are still reported. - failed.foreach(m => failures += Failure(coordOf(m), detail, "", ref.project)) + failed.foreach(m => failures += Failure(coordOf(m), detailFor(m, detail), "", ref.project)) unscannable += Unscannable(detail, ref.project) None } @@ -262,6 +262,16 @@ object SocketFactsPlugin extends AutoPlugin { } } + // One module's part of a batch resolve message, so records grow with the failure count, not its + // square: Coursier writes an indented "Error downloading org:name:rev" block per module, Ivy a line. + private def detailFor(m: ModuleID, batch: String): String = { + val lines = batch.split("\n").toList + val keys = Seq(coordOf(m), s"${orEmpty(m.organization)}#${orEmpty(m.name)};${orEmpty(m.revision)}") + val start = lines.indexWhere(l => keys.exists(k => l.contains(k))) + if (start < 0) lines.map(_.trim).find(_.nonEmpty).getOrElse(batch) + else (lines(start) :: lines.drop(start + 1).takeWhile(l => l.startsWith(" ") || l.startsWith("\t"))).mkString("\n").trim + } + private def incompleteMessage(inc: sbt.Incomplete): String = { def first(i: sbt.Incomplete): Option[String] = i.directCause.flatMap(ex => Option(ex.getMessage)).orElse(i.causes.view.flatMap(first).headOption) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index bc82270005..b0110cd11b 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -3,7 +3,7 @@ # rest of the project down with it. ok must still be emitted; every module named in a failure record # must be absent from the nodes. Which modules sbt names depends on its resolver: Ivy (sbt 0.13) names # the declared dependencies whose parent POM is missing (lib, testlib), coursier (sbt 1.3+) names the -# missing parent POM itself; both name ghost. +# missing parent POM itself; both name ghost. Each failure's detail is its own module's message. # # Usage: smoke-test-failure.sh set -euo pipefail @@ -54,7 +54,8 @@ python3 - "$RECORDS" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] nodes = {f"{r[3]}:{r[4]}:{r[5]}" for r in rows if r[0] == 'node'} -failed = {r[1] for r in rows if r[0] == 'failure'} +failure_rows = [r for r in rows if r[0] == 'failure'] +failed = {r[1] for r in failure_rows} unscannable = [r[1:] for r in rows if r[0] == 'unscannable'] errors = [] @@ -67,6 +68,12 @@ if not ({'demo.fail:lib:1.0', 'demo.fail:testlib:1.0'} <= failed or 'demo.fail:m emitted = failed & nodes if emitted: errors.append(f"reported failures must not be emitted as nodes: {sorted(emitted)}") +# Each failure carries its own module's message, not the whole batch. +for r in failure_rows: + for other in failed - {r[1]}: + org, name, rev = other.split(':') + if any(k in r[2] for k in (f"{org}:{name}:{rev}", f"{org}#{name};{rev}", f"/{name}/{rev}/")): + errors.append(f"{r[1]}'s detail also describes {other}: {r[2][:200]}") if unscannable: errors.append(f"the failures name their modules, so the project must not be unscannable: {unscannable}")