From 46f530c201f3a1e0c7cb81f59bfc61f400bb3151 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 13:45:56 +0200 Subject: [PATCH 1/2] fix(manifest): block on a non-blocking JVM failure whose module resolved nowhere Variant ambiguity is lenient because the module is almost always captured through another configuration. The report now checks that: an ambiguous module that resolved in no configuration of its project is missing from the SBOM, so it blocks. Reachability scans also name --reach-continue-on-install-errors next to ignoreUnresolved, since reachability cannot install a dependency that was let through. --- CHANGELOG.md | 2 + .../manifest/generate-recursive-manifests.mts | 8 +- .../manifest/generate_auto_manifest.mts | 13 ++- src/commands/manifest/run-manifest-facts.mts | 7 +- .../manifest/run-manifest-facts.test.mts | 7 +- src/commands/manifest/scripts/assemble.mts | 18 +++- .../scripts/resolution-report-gradle.mts | 6 +- .../scripts/resolution-report-render.mts | 98 ++++++++++++++----- .../scripts/resolution-report-render.test.mts | 93 ++++++++++++++++++ .../manifest/scripts/resolution-report.mts | 8 ++ 10 files changed, 226 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7939ddbd92..4b9d2cb488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code. - One unresolvable Maven dependency no longer strips the jar paths from every other dependency of its build, which made reachability analysis call their vulnerabilities unreachable. Jars that cannot be fetched are reported with their real cause, Maven 4 included. - Maven dependencies with missing or invalid POMs are reported in every module that uses them, `system`-scope jars reach reachability analysis, and a failing dependency no longer fails scopes that `excludeConfigs` leaves out or the build when it lost a version conflict. +- Reachability scans that hit unresolved JVM dependencies now point out that ignoring them also needs `--reach-continue-on-install-errors`. +- A Gradle dependency whose variant is ambiguous in every configuration of its project now fails the run instead of quietly dropping out of the scan. ## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22 diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index f5a0d605a8..3a0061f763 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -12,7 +12,10 @@ 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 { + DSI_SETUP_COMMAND, + REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, +} from './scripts/resolution-report-render.mts' import { SOCKET_JSON } from '../../constants.mts' import { withTmpDir } from '../../utils/fs.mts' import { @@ -179,6 +182,9 @@ async function runEcosystemCandidates({ excludeConfigs, excludePaths: excludePathsForRoot, hints: { + continueOnInstallErrorsFlag: withFiles + ? REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG + : undefined, flags: optionFlags, setupCommand: DSI_SETUP_COMMAND, socketJson: displayPath(path.join(dir, SOCKET_JSON)), diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 55f5d7ee5f..fc449d56c9 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -11,6 +11,7 @@ import { convertSbtToMaven } from './convert_sbt_to_maven.mts' import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG } from './scripts/resolution-report-render.mts' import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts' import { ENVIRONMENT_YML, @@ -91,6 +92,12 @@ export async function generateAutoManifest({ }): Promise { const sockJson = readOrDefaultSocketJson(cwd) const generatedFiles: string[] = [] + const hints: BuildOptionHints = computeArtifactsSidecar + ? { + ...AUTO_MANIFEST_HINTS, + continueOnInstallErrorsFlag: REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, + } + : AUTO_MANIFEST_HINTS // Resolved paths across all JVM roots, serialized to one sidecar at the end. const sidecarAcc: SidecarAccumulator | undefined = computeArtifactsSidecar @@ -127,7 +134,7 @@ export async function generateAutoManifest({ ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined, - hints: AUTO_MANIFEST_HINTS, + hints, sidecarAcc, tmpDir, withFiles: computeArtifactsSidecar, @@ -173,7 +180,7 @@ export async function generateAutoManifest({ includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined, - hints: AUTO_MANIFEST_HINTS, + hints, sidecarAcc, withFiles: computeArtifactsSidecar, }) @@ -206,7 +213,7 @@ export async function generateAutoManifest({ mavenOpts: parseBuildToolOpts( sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined, ), - hints: AUTO_MANIFEST_HINTS, + hints, sidecarAcc, verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose), withFiles: computeArtifactsSidecar, diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index b9598a44d2..1a69346a43 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -179,7 +179,12 @@ export async function runManifestFacts({ report.failures, report.scannedConfigs, ecosystem, - { hints, ignoreUnresolved, unscannable: report.unscannable }, + { + hints, + ignoreUnresolved, + resolvedModules: report.resolvedModules, + unscannable: report.unscannable, + }, ) if (rendered.hasBlockingFailures) { diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index e3aadb200f..45b45cf77f 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -26,7 +26,12 @@ function okResult(buildRoot: string): ManifestRunResult { projects: [], }, factsFileName: 'pom.xml.socket.facts.json', - report: { failures: [], scannedConfigs: [], unscannable: [] }, + report: { + failures: [], + resolvedModules: new Set(), + scannedConfigs: [], + unscannable: [], + }, artifactPaths: { pathsById: new Map(), classpathByProject: new Map(), diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 2dbd3b3529..682844970c 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -8,6 +8,7 @@ import { type SocketFactsSbomMetadata, type SocketFactsSbomProject, } from './facts.mts' +import { moduleKey } from './resolution-report.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' @@ -475,5 +476,20 @@ function buildReport(parsed: ParsedRecords): ResolutionReport { seenUnscannable.add(key) return true }) - return { failures, scannedConfigs: parsed.scannedConfigs, unscannable } + const resolvedModules = new Set() + if (failures.length) { + for (const r of parsed.roots.values()) { + for (const n of r.nodes.values()) { + resolvedModules.add( + moduleKey(r.projectKey, `${n.coord.group}:${n.coord.name}`), + ) + } + } + } + return { + failures, + resolvedModules, + scannedConfigs: parsed.scannedConfigs, + unscannable, + } } diff --git a/src/commands/manifest/scripts/resolution-report-gradle.mts b/src/commands/manifest/scripts/resolution-report-gradle.mts index 4e71af729f..1b181956dc 100644 --- a/src/commands/manifest/scripts/resolution-report-gradle.mts +++ b/src/commands/manifest/scripts/resolution-report-gradle.mts @@ -45,8 +45,8 @@ export function classifyGradleFailure(detail: string): FailureCategory { // Every kind blocks except variant-ambiguity: the module demonstrably exists // and is almost always captured via another configuration, so it's benign for -// the SBOM (non-blocking, count only). A module that resolves in NO config -// surfaces as not-found / no-matching-variant, which stay blocking. +// the SBOM (non-blocking, count only). The renderer blocks it anyway when the +// module resolved in no configuration of its project. export const GRADLE_DIALECT: ResolutionDialect = { label: 'Gradle', classify: classifyGradleFailure, @@ -89,6 +89,8 @@ export const GRADLE_DIALECT: ResolutionDialect = { }, { key: 'variant-ambiguity', + header: n => + ` Ambiguous variant — ${n} could not choose a variant, and the module resolved in no other configuration:`, blocking: false, notice: (n, depCount, configCount) => `Skipped ${depCount} dependency(ies) with ambiguous variant selection in ${configCount} configuration(s) — re-run with --verbose for ${n}'s messages.`, diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 6e2efe3e9c..916911a625 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -1,6 +1,7 @@ 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 { moduleKey } from './resolution-report.mts' import constants from '../../../constants.mts' import { camelToKebab } from '../../../utils/strings.mts' @@ -43,6 +44,9 @@ 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 = { + // Reachability scans only: reachability cannot install a dependency that + // `ignoreUnresolved` lets through, so it must be told to continue too. + continueOnInstallErrorsFlag?: string | undefined // Taken as flags; every other option is set in `socketJson`. flags: ReadonlySet // Wizard that writes `socketJson`. @@ -53,6 +57,9 @@ export type BuildOptionHints = { export const DSI_SETUP_COMMAND = 'socket manifest setup --dynamic-sbom-inference' +export const REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG = + '--reach-continue-on-install-errors' + // The single-build manifest commands take every option as a flag. export const FLAG_HINTS: BuildOptionHints = { flags: new Set([ @@ -79,19 +86,27 @@ const RESOLUTION_REPORT_ARTIFACT_LIMIT = 15 const RESOLUTION_REPORT_CONFIG_LIMIT = 20 // Drop a bare "group:name" when a versioned "group:name:v" of the same module -// is also present: the lenient resolver reports both forms for one failure. -function dedupCoords(coords: Iterable): string[] { - const set = new Set(coords) +// failed in the same projects: the lenient resolver reports both forms for one +// failure. +function dedupInfos }>( + infos: T[], +): T[] { const versioned = new Set() - for (const c of set) { - const p = c.split(':') + for (const i of infos) { + const p = i.coord.split(':') if (p.length >= 3) { - versioned.add(`${p[0]}:${p[1]}`) + for (const project of i.projects) { + versioned.add(moduleKey(project, i.coord)) + } } } - return [...set] - .filter(c => c.split(':').length >= 3 || !versioned.has(c)) - .sort() + return infos + .filter( + i => + i.coord.split(':').length >= 3 || + ![...i.projects].every(p => versioned.has(moduleKey(p, i.coord))), + ) + .sort((a, b) => (a.coord < b.coord ? -1 : a.coord > b.coord ? 1 : 0)) } function firstLine(s: string): string { @@ -122,6 +137,21 @@ function inProjects(projects: string | Iterable): string { return list.length ? ` (in ${fmtList(list, 3)})` : '' } +function installErrorsHint( + hints: BuildOptionHints, + ignored: boolean, +): string[] { + const flag = hints.continueOnInstallErrorsFlag + if (!flag) { + return [] + } + return [ + ignored + ? `Reachability analysis cannot install the ignored dependencies; pass ${flag} unless you already do.` + : `If you ignore unresolved dependencies, also pass ${flag}: reachability analysis cannot install them.`, + ] +} + // `excludableConfigs` is empty when excluding configurations cannot clear // every blocking failure. function proceedHint( @@ -164,9 +194,8 @@ function proceedHint( ] } -// 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. +// What resolved elsewhere is consulted only by module (group:name): a failed +// selector carries no classifier/type to relate it to one artifact. export function renderResolutionReport( failures: ResolutionFailure[], scannedConfigs: string[], @@ -174,6 +203,7 @@ export function renderResolutionReport( opts: { hints?: BuildOptionHints | undefined ignoreUnresolved?: boolean | undefined + resolvedModules?: ReadonlySet | undefined unscannable?: Unscannable[] | undefined } = {}, ): RenderedResolutionReport { @@ -227,6 +257,20 @@ export function renderResolutionReport( } } const allInfos = [...byKey.values()] + // A non-blocking kind is only benign when its module resolved anyway: one + // that resolved in no configuration of its project is missing from the SBOM. + const resolved = opts.resolvedModules + const blockingInfos = new Set( + allInfos.filter( + info => + isBlocking(info.category) || + (!!resolved && + ![...info.projects].some(p => + resolved.has(moduleKey(p, info.coord)), + )), + ), + ) + const isInfoBlocking = (info: CoordInfo): boolean => blockingInfos.has(info) // A whole-config throw is classified by the same cause rules as a per-dep // failure: ambiguity stays lenient, every other cause is fail-closed. @@ -241,7 +285,7 @@ export function renderResolutionReport( const perDepBlockingConfigs = new Set() for (const info of allInfos) { - if (isBlocking(info.category)) { + if (isInfoBlocking(info)) { for (const c of info.configs) { perDepBlockingConfigs.add(c) } @@ -256,7 +300,7 @@ export function renderResolutionReport( // its project incomplete, so none can be reported as succeeded. const hasBlockingConfiglessFailure = blockingUnscannableProjects.length > 0 || - allInfos.some(i => i.configless && isBlocking(i.category)) + allInfos.some(i => i.configless && isInfoBlocking(i)) // An un-scannable config was attempted but resolved nothing, so it didn't succeed. const succeeded = hasBlockingConfiglessFailure ? [] @@ -264,16 +308,16 @@ export function renderResolutionReport( .filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c)) .sort() - const groups = dialect.categories - .map(spec => ({ - spec, - infos: dedupCoords( - allInfos.filter(i => i.category === spec.key).map(i => i.coord), - ).map(c => byKey.get(keyOf(c, spec.key))!), - })) + const groups = dialect.categories.map(spec => ({ + spec, + infos: dedupInfos(allInfos.filter(i => i.category === spec.key)), + })) + const blockingGroups = groups + .map(g => ({ ...g, infos: g.infos.filter(isInfoBlocking) })) + .filter(g => g.infos.length) + const nonBlockingGroups = groups + .map(g => ({ ...g, infos: g.infos.filter(i => !isInfoBlocking(i)) })) .filter(g => g.infos.length) - const blockingGroups = groups.filter(g => g.spec.blocking) - const nonBlockingGroups = groups.filter(g => !g.spec.blocking) const blockingCount = blockingGroups.reduce((n, g) => n + g.infos.length, 0) const hasBlockingFailures = blockingCount > 0 || blockingUnscannable.length > 0 @@ -366,7 +410,10 @@ export function renderResolutionReport( tool, hasBlockingConfiglessFailure ? [] : blockingFailed, ), + ...installErrorsHint(hints, false), ) + } else if (hints.continueOnInstallErrorsFlag) { + out.push('', ...installErrorsHint(hints, true)) } out.push('') out.push( @@ -384,8 +431,8 @@ export function renderResolutionReport( const configCount = new Set(infos.flatMap(i => [...i.configs])).size notices.push(spec.notice(name, infos.length, configCount)) } - // A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed — - // matching the deliberately-lenient per-dep variant-ambiguity policy. + // A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed: many + // configurations are marked resolvable yet too underspecified to resolve. if (nonBlockingUnscannable.length) { const n = new Set(nonBlockingUnscannable.map(u => u.config)).size notices.push( @@ -443,6 +490,7 @@ export function renderResolutionErrorReport( opts: { hints?: BuildOptionHints | undefined ignoreUnresolved?: boolean | undefined + resolvedModules?: ReadonlySet | undefined unscannable?: Unscannable[] | undefined } = {}, ): RenderedResolutionReport { diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index 6049085ac7..19a3be69de 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { DSI_SETUP_COMMAND, + REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, renderResolutionErrorReport, } from './resolution-report-render.mts' @@ -322,6 +323,26 @@ describe('resolution failure remedies', () => { expect(r.summary).not.toMatch(/--ignore-unresolved|--exclude-configs/) }) + it('tells a reachability scan to also continue on install errors', () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { + hints: { + ...socketJsonOnly, + continueOnInstallErrorsFlag: REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, + }, + }) + expect(r.summary).toContain(' "ignoreUnresolved": true') + expect(r.summary).toContain( + 'If you ignore unresolved dependencies, also pass --reach-continue-on-install-errors: reachability analysis cannot install them.', + ) + }) + + it('does not mention install errors outside a reachability scan', () => { + const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { + hints: socketJsonOnly, + }) + expect(r.summary).not.toContain('install') + }) + it("names the failing build root's own socket.json", () => { const r = renderResolutionErrorReport([mavenMiss], ['compile'], 'maven', { hints: { ...socketJsonOnly, socketJson: 'tools/codegen/socket.json' }, @@ -371,6 +392,78 @@ describe('resolution failure remedies', () => { }) }) +describe('failures checked against what resolved', () => { + const ambiguous = f( + 'com.example:amb:1.0', + 'Cannot choose between the following variants of com.example:amb:1.0', + 'runtimeClasspath', + ':app', + ) + + it('keeps variant ambiguity lenient when the module resolved in its project', () => { + const r = renderResolutionErrorReport( + [ambiguous], + ['runtimeClasspath'], + 'gradle', + { resolvedModules: new Set([':app\tcom.example:amb']) }, + ) + expect(r.hasBlockingFailures).toBe(false) + expect(r.nonBlockingNotice).toContain('ambiguous variant selection') + }) + + it('blocks on variant ambiguity when the module resolved nowhere in its project', () => { + const r = renderResolutionErrorReport( + [ambiguous], + ['runtimeClasspath'], + 'gradle', + { resolvedModules: new Set([':lib\tcom.example:amb']) }, + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Ambiguous variant') + expect(r.summary).toContain(' - com.example:amb:1.0 (in `:app`)') + expect(r.nonBlockingNotice).toBe('') + }) + + it('keeps a bare module failure from another project than its versioned form', () => { + const r = renderResolutionErrorReport( + [ + f('com.example:x', 'Could not find com.example:x.', 'compile', ':a'), + f( + 'com.example:x:1.0', + 'Could not find com.example:x:1.0.', + 'compile', + ':b', + ), + ], + ['compile'], + 'gradle', + ) + expect(r.summary).toContain(' - com.example:x (in `:a`)') + expect(r.summary).toContain(' - com.example:x:1.0 (in `:b`)') + }) + + it('still reminds a reachability scan of install errors once failures are ignored', () => { + const r = renderResolutionErrorReport( + [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], + ['runtimeClasspath'], + 'gradle', + { + hints: { + continueOnInstallErrorsFlag: REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, + flags: new Set(), + setupCommand: 'socket manifest setup', + socketJson: 'socket.json', + }, + ignoreUnresolved: true, + }, + ) + expect(r.summary).toContain('Ignored 1 unresolved dependency(ies)') + expect(r.summary).toContain( + 'Reachability analysis cannot install the ignored dependencies; pass --reach-continue-on-install-errors unless you already do.', + ) + }) +}) + const mavenReport = (detail: string) => renderResolutionErrorReport( [f('com.example:x:jar:1.0', detail, 'compile')], diff --git a/src/commands/manifest/scripts/resolution-report.mts b/src/commands/manifest/scripts/resolution-report.mts index 5bd09cdd88..37244f828e 100644 --- a/src/commands/manifest/scripts/resolution-report.mts +++ b/src/commands/manifest/scripts/resolution-report.mts @@ -18,8 +18,16 @@ export type Unscannable = { project: string } +// A module (`group:name`, from any longer coordinate) within one project. +export function moduleKey(project: string, coord: string): string { + return `${project}\t${coord.split(':').slice(0, 2).join(':')}` +} + export type ResolutionReport = { failures: ResolutionFailure[] + // moduleKey of every module a project resolved in some configuration; empty + // when nothing failed. + resolvedModules: ReadonlySet scannedConfigs: string[] unscannable: Unscannable[] } From ccfff3aa00815fce9ff508304eef943abebd7161 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 14:32:02 +0200 Subject: [PATCH 2/2] fix(manifest): judge a lenient JVM failure per project Failures were merged by coordinate across projects before checking whether the module resolved, so one project that resolved it kept the failure lenient for another project whose SBOM lacked it. Blocking is now decided per failure, and failures aggregate by coordinate, category and that decision. --- .../scripts/resolution-report-render.mts | 38 +++++++++---------- .../scripts/resolution-report-render.test.mts | 13 +++++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 916911a625..2fccc81f93 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -218,11 +218,24 @@ export function renderResolutionReport( 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 and projects. + // A non-blocking kind is only benign when its module resolved anyway: one + // that resolved in no configuration of its project is missing from that + // project's SBOM. + const resolved = opts.resolvedModules + const isFailureBlocking = ( + category: FailureCategory, + f: ResolutionFailure, + ): boolean => + isBlocking(category) || + (!!resolved && !resolved.has(moduleKey(f.project, f.coord))) + + // Aggregate by (coord, category, blocking): one module can fail with + // different causes across configs, and block in only some projects. Keep + // first-seen detail, union the configs and projects. type CoordInfo = { coord: string category: FailureCategory + blocking: boolean detail: string configs: Set projects: Set @@ -230,16 +243,16 @@ export function renderResolutionReport( configless: boolean } const byKey = new Map() - const keyOf = (coord: string, category: FailureCategory): string => - `${coord} ${category}` for (const f of failures) { const category = dialect.classify(f.detail) - const key = keyOf(f.coord, category) + const blocking = isFailureBlocking(category, f) + const key = `${f.coord} ${category} ${blocking}` let info = byKey.get(key) if (!info) { info = { coord: f.coord, category, + blocking, detail: f.detail, configs: new Set(), projects: new Set(), @@ -257,20 +270,7 @@ export function renderResolutionReport( } } const allInfos = [...byKey.values()] - // A non-blocking kind is only benign when its module resolved anyway: one - // that resolved in no configuration of its project is missing from the SBOM. - const resolved = opts.resolvedModules - const blockingInfos = new Set( - allInfos.filter( - info => - isBlocking(info.category) || - (!!resolved && - ![...info.projects].some(p => - resolved.has(moduleKey(p, info.coord)), - )), - ), - ) - const isInfoBlocking = (info: CoordInfo): boolean => blockingInfos.has(info) + const isInfoBlocking = (info: CoordInfo): boolean => info.blocking // A whole-config throw is classified by the same cause rules as a per-dep // failure: ambiguity stays lenient, every other cause is fail-closed. diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index 19a3be69de..cfb79bc9aa 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -424,6 +424,19 @@ describe('failures checked against what resolved', () => { expect(r.nonBlockingNotice).toBe('') }) + it('blocks on variant ambiguity in the project where the module resolved nowhere', () => { + const r = renderResolutionErrorReport( + [ambiguous, { ...ambiguous, project: ':lib' }], + ['runtimeClasspath'], + 'gradle', + { resolvedModules: new Set([':app\tcom.example:amb']) }, + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain(' - com.example:amb:1.0 (in `:lib`)') + expect(r.summary).not.toContain('`:app`') + expect(r.nonBlockingNotice).toContain('ambiguous variant selection') + }) + it('keeps a bare module failure from another project than its versioned form', () => { const r = renderResolutionErrorReport( [