From 861f0a4b902d5bb0f9dba690445afe8fcb09ecb7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 2 Jul 2026 10:21:07 +0200 Subject: [PATCH 1/4] fix(manifest): recognize Gradle's alternate no-matching-variant phrasings Gradle phrases a zero-compatible-variant failure several ways depending on version and whether consumer attributes were supplied. The classifier only matched "no matching variant"/"no variants of", so "unable to find a matching variant" and "no compatible variant" fell through to the generic "other" category. Severity was unaffected (both blocking), but the report rendered under the wrong header; recognize the alternate phrasings so these surface under the accurate "No compatible variant" heading. --- .../manifest/scripts/resolution-report-gradle.mts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/manifest/scripts/resolution-report-gradle.mts b/src/commands/manifest/scripts/resolution-report-gradle.mts index d1ea189fa..4e71af729 100644 --- a/src/commands/manifest/scripts/resolution-report-gradle.mts +++ b/src/commands/manifest/scripts/resolution-report-gradle.mts @@ -11,8 +11,14 @@ export function classifyGradleFailure(detail: string): FailureCategory { if (t.includes('conflict on capability')) { return 'capability-conflict' } - // Zero compatible variants — the opposite of ambiguity below. - if (t.includes('no matching variant') || t.includes('no variants of')) { + // Zero compatible variants — the opposite of ambiguity below. Gradle phrases + // this several ways depending on version and whether attributes were supplied. + if ( + t.includes('no matching variant') || + t.includes('no variants of') || + t.includes('unable to find a matching variant') || + t.includes('no compatible variant') + ) { return 'no-matching-variant' } if (t.includes('cannot choose between')) { From 0c65404cc0d2c5b9f9aaebe60b1c264cdf3df43a Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 2 Jul 2026 10:22:15 +0200 Subject: [PATCH 2/4] feat(manifest): unify Gradle config-level resolution transparency A whole Gradle configuration whose resolution throws (as opposed to a single unresolved dependency within it) was previously caught and logged to stdout but never surfaced: it still counted as "scanned", so the user was never told we could not scan it. Record such configs as a new `unscannable` fact and classify them by the same cause rules as per-dependency failures: variant ambiguity stays lenient (a one-line notice), every other cause is fail-closed (blocking, overridable by --ignore-unresolved). The build script still exits cleanly and the TS layer owns the reporting; a config-level failure no longer trips the crashed-build path, which stays reserved for a build that produced nothing at all. Also drop the stale "--include-configs default excludes AGP instrumented-test classpaths" help text: the native-facts rewrite removed that exclusion, and AGP AndroidTest configs resolve like any other. --- src/commands/manifest/cmd-manifest-gradle.mts | 2 +- .../manifest/cmd-manifest-gradle.test.mts | 2 +- src/commands/manifest/cmd-manifest-kotlin.mts | 2 +- .../manifest/cmd-manifest-kotlin.test.mts | 2 +- src/commands/manifest/run-manifest-facts.mts | 5 +- src/commands/manifest/scripts/assemble.mts | 11 +- src/commands/manifest/scripts/records.mts | 16 ++- .../scripts/resolution-report-render.mts | 109 ++++++++++++++---- .../scripts/resolution-report-render.test.mts | 53 +++++++++ .../manifest/scripts/resolution-report.mts | 7 ++ .../manifest/scripts/socket-facts.init.gradle | 9 +- 11 files changed, 189 insertions(+), 29 deletions(-) diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index e09ac7de7..14e74afa4 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -45,7 +45,7 @@ const config: CliCommandConfig = { includeConfigs: { type: 'string', description: - 'When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, `*` and `?` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. Default: every resolvable configuration except AGP instrumented-test classpaths', + 'When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, `*` and `?` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. Default: every resolvable configuration', }, excludeConfigs: { type: 'string', diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index fafc155d6..071ac0e78 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -28,7 +28,7 @@ describe('socket manifest gradle', async () => { --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) - --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, \`*\` and \`?\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration except AGP instrumented-test classpaths + --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, \`*\` and \`?\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`) --verbose Print debug messages diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 606c3f9b9..6977a2e1b 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -50,7 +50,7 @@ const config: CliCommandConfig = { includeConfigs: { type: 'string', description: - 'When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, `*` and `?` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. Default: every resolvable configuration except AGP instrumented-test classpaths', + 'When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, `*` and `?` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. Default: every resolvable configuration', }, excludeConfigs: { type: 'string', diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index befb8bd76..610637606 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -28,7 +28,7 @@ describe('socket manifest kotlin', async () => { --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) - --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, \`*\` and \`?\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration except AGP instrumented-test classpaths + --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive, \`*\` and \`?\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`*CompileClasspath,*RuntimeClasspath\`. Default: every resolvable configuration --pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`) --verbose Print debug messages diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 52b631b59..ca8815daf 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -111,7 +111,7 @@ export async function runManifestFacts({ report.failures, report.scannedConfigs, ecosystem, - { ignoreUnresolved }, + { ignoreUnresolved, unscannable: report.unscannable }, ) if (rendered.hasBlockingFailures) { @@ -143,7 +143,8 @@ export async function runManifestFacts({ code !== 0 && !facts.components.length && !facts.projects?.length && - !report.failures.length + !report.failures.length && + !report.unscannable.length ) { if (!verbose) { const tail = tailBuildOutput(stdout, stderr) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 6d23bfa33..1d4749405 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -443,5 +443,14 @@ function buildReport(parsed: ParsedRecords): ResolutionReport { seen.add(key) return true }) - return { failures, scannedConfigs: parsed.scannedConfigs } + const seenUnscannable = new Set() + const unscannable = parsed.unscannable.filter(u => { + const key = `${u.config}|${u.detail}` + if (seenUnscannable.has(key)) { + return false + } + seenUnscannable.add(key) + return true + }) + return { failures, scannedConfigs: parsed.scannedConfigs, unscannable } } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 5e9fc749b..7a842812a 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -1,4 +1,7 @@ -import type { ResolutionFailure } from './resolution-report.mts' +import type { + ResolutionFailure, + UnscannableConfig, +} 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 @@ -16,6 +19,7 @@ import type { ResolutionFailure } from './resolution-report.mts' // file rootId coordId path (--with-files only) // scanned config // failure coord detail config +// unscannable config detail // // A `root` is one (subproject, configuration) resolution root; `coordId` is the // coordinate key (`group:name:ext:classifier:version`, empty segments dropped), @@ -64,6 +68,7 @@ export type ParsedRecords = { roots: Map scannedConfigs: string[] failures: ResolutionFailure[] + unscannable: UnscannableConfig[] } export function unescapeField(s: string): string { @@ -96,6 +101,7 @@ export function parseRecords(text: string): ParsedRecords { roots: new Map(), scannedConfigs: [], failures: [], + unscannable: [], } const scanned = new Set() @@ -213,6 +219,14 @@ export function parseRecords(text: string): ParsedRecords { }) } break + case 'unscannable': + if (f[1]) { + result.unscannable.push({ + config: f[1], + detail: f[2] ?? '', + }) + } + break default: break } diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 76751fda7..3eecb3557 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -3,7 +3,10 @@ import { SBT_DIALECT } from './resolution-report-ivy.mts' import { MAVEN_DIALECT } from './resolution-report-maven.mts' import type { BuildTool } from './build-tool.mts' -import type { ResolutionFailure } from './resolution-report.mts' +import type { + ResolutionFailure, + UnscannableConfig, +} 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. @@ -87,9 +90,14 @@ export function renderResolutionReport( failures: ResolutionFailure[], scannedConfigs: string[], dialect: ResolutionDialect, - opts: { ignoreUnresolved?: boolean | undefined } = {}, + opts: { + ignoreUnresolved?: boolean | undefined + unscannable?: UnscannableConfig[] | undefined + } = {}, ): RenderedResolutionReport { const name = dialect.label + const unscannable = opts.unscannable ?? [] + const unscannableConfigs = new Set(unscannable.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 @@ -119,16 +127,32 @@ export function renderResolutionReport( } const allInfos = [...byKey.values()] - const blockingConfigs = new Set() + // 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. + const unscannableInfos = unscannable.map(u => { + const category = dialect.classify(u.detail) + return { ...u, category, blocking: isBlocking(category) } + }) + const blockingUnscannable = unscannableInfos.filter(u => u.blocking) + const nonBlockingUnscannable = unscannableInfos.filter(u => !u.blocking) + + const perDepBlockingConfigs = new Set() for (const info of allInfos) { if (isBlocking(info.category)) { for (const c of info.configs) { - blockingConfigs.add(c) + perDepBlockingConfigs.add(c) } } } + const blockingConfigs = new Set([ + ...perDepBlockingConfigs, + ...blockingUnscannable.map(u => u.config), + ]) const blockingFailed = [...blockingConfigs].sort() - const succeeded = scannedConfigs.filter(c => !blockingConfigs.has(c)).sort() + // 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 groups = dialect.categories .map(spec => ({ @@ -141,27 +165,50 @@ export function renderResolutionReport( 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 + const hasBlockingFailures = + blockingCount > 0 || blockingUnscannable.length > 0 const willFail = hasBlockingFailures && !opts.ignoreUnresolved const out: string[] = [] if (hasBlockingFailures) { - out.push( - opts.ignoreUnresolved - ? `Ignored ${blockingCount} unresolved dependency(ies) in ${blockingFailed.length} configuration(s):` - : `Could not resolve ${blockingCount} dependency(ies) in ${blockingFailed.length} configuration(s):`, - ) - for (const { infos, spec } of blockingGroups) { + if (blockingCount > 0) { + 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):`, + ) + for (const { infos, spec } of blockingGroups) { + out.push('') + out.push(spec.header ? spec.header(name) : '') + 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}`) + } + if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) { + out.push( + ` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`, + ) + } + } + } + if (blockingUnscannable.length) { out.push('') - out.push(spec.header ? spec.header(name) : '') - 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( + 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, + )) { + const fl = firstLine(u.detail) + out.push(` - ${u.config}${fl ? ` [${fl}]` : ''}`) } - if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) { + if (blockingUnscannable.length > RESOLUTION_REPORT_CONFIG_LIMIT) { out.push( - ` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`, + ` … and ${blockingUnscannable.length - RESOLUTION_REPORT_CONFIG_LIMIT} more`, ) } } @@ -196,6 +243,14 @@ export function renderResolutionReport( const configCount = new Set(infos.flatMap(i => [...i.configs])).size notices.push(spec.notice(name, infos.length, configCount)) } + // Ambiguity-driven config-throws (e.g. AGP *AndroidTest*) are surfaced, not failed: + // deliberately lenient, matching the per-dep variant-ambiguity policy. + 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.`, + ) + } const detailLines = [`${name}'s full message for each unresolved dependency:`] for (const info of allInfos) { @@ -205,6 +260,17 @@ export function renderResolutionReport( detailLines.push(` ${line}`) } } + if (unscannable.length) { + detailLines.push('') + detailLines.push(`${name} configurations that could not be scanned:`) + for (const u of unscannable) { + detailLines.push('') + detailLines.push(` ${u.config}:`) + for (const line of (u.detail || '(no message)').split('\n')) { + detailLines.push(` ${line}`) + } + } + } return { summary: out.join('\n'), @@ -229,7 +295,10 @@ export function renderResolutionErrorReport( failures: ResolutionFailure[], scannedConfigs: string[] = [], tool: BuildTool = 'gradle', - opts: { ignoreUnresolved?: boolean | undefined } = {}, + opts: { + ignoreUnresolved?: boolean | undefined + unscannable?: UnscannableConfig[] | undefined + } = {}, ): RenderedResolutionReport { return renderResolutionReport( failures, diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index acc746afd..1b23af9ec 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -42,6 +42,22 @@ describe('resolution failure classification', () => { expect(r.nonBlockingNotice).toContain('ambiguous variant') }) + it("classifies Gradle's 'unable to find a matching variant' phrasing as blocking no-matching-variant", () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:lib:1.0', + "Unable to find a matching variant of com.example:lib:1.0:\n - Variant 'apiElements'", + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('No compatible variant') + expect(r.nonBlockingNotice).toBe('') + }) + it('classifies a Gradle capability conflict as blocking', () => { const r = renderResolutionErrorReport( [ @@ -127,4 +143,41 @@ describe('resolution failure classification', () => { expect(r.summary).toBe('') expect(r.nonBlockingNotice).toBe('') }) + + it('treats an ambiguity-driven config throw as a non-blocking notice', () => { + const r = renderResolutionErrorReport( + [], + ['debugAndroidTestCompileClasspath'], + 'gradle', + { + unscannable: [ + { + config: 'debugAndroidTestCompileClasspath', + detail: + 'Cannot choose between the following variants of com.example:foo:1.0', + }, + ], + }, + ) + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toContain('Could not scan 1 configuration(s)') + expect(r.details).toContain('debugAndroidTestCompileClasspath') + }) + + it('treats a non-ambiguity config throw as a blocking failure', () => { + const r = renderResolutionErrorReport([], ['runtimeClasspath'], 'gradle', { + unscannable: [ + { + config: 'runtimeClasspath', + detail: + 'Could not GET https://repo.example/foo. Received status code 401', + }, + ], + }) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Could not scan 1 configuration(s)') + expect(r.summary).toContain('runtimeClasspath') + expect(r.nonBlockingNotice).toBe('') + }) }) diff --git a/src/commands/manifest/scripts/resolution-report.mts b/src/commands/manifest/scripts/resolution-report.mts index 2d56683b5..eb6f645e6 100644 --- a/src/commands/manifest/scripts/resolution-report.mts +++ b/src/commands/manifest/scripts/resolution-report.mts @@ -6,7 +6,14 @@ export type ResolutionFailure = { config: string } +// A whole config whose resolution threw, vs a single unresolved dep (`ResolutionFailure`). +export type UnscannableConfig = { + config: string + detail: string +} + export type ResolutionReport = { failures: ResolutionFailure[] scannedConfigs: string[] + unscannable: UnscannableConfig[] } diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 2f2dae32e..356c15f40 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -18,6 +18,8 @@ gradle.ext.socketFactsState = [ directIds : Collections.synchronizedSet([] as Set), // `detail` is the build tool's failure message verbatim; Coana classifies the failure KIND from it. failures : Collections.synchronizedList([]), + // Whole configs whose resolution threw (vs a single unresolved dep in `failures`). + unscannable : Collections.synchronizedList([]), scannedConfigs : Collections.synchronizedSet([] as Set), projectKeys : Collections.synchronizedSet([] as Set), // Only populated with `-Psocket.withFiles=true`: reading `artifact.file` downloads the artifact. @@ -377,7 +379,9 @@ allprojects { project -> ] } } catch (Exception e) { - println "[socket-facts] skipping ${project.path}:${cfg.name}: ${e.message?.readLines()?.first()}" + // Record rather than swallow (e.g. AGP *AndroidTest* configs the init script can't resolve); + // the build still exits cleanly and the TS layer surfaces it. + state.unscannable << [config: cfg.name, detail: rawDetail(e)] } // Stash this resolution root's tree; the key only has to be unique per root (aggregator // groups by subtree hash). Skip empty configs — they'd contribute nothing. @@ -454,6 +458,9 @@ rootProject { rp -> def failures synchronized (state.failures) { failures = new ArrayList(state.failures) } failures.each { f -> rec(['failure', f.coord, f.detail, f.config]) } + def unscannable + synchronized (state.unscannable) { unscannable = new ArrayList(state.unscannable) } + unscannable.each { u -> rec(['unscannable', u.config, u.detail]) } def outFile = new File(recordsFileOverride ?: defaultRecordsFile) outFile.parentFile?.mkdirs() From bbc03eed6ebdda323472fbf1f1f884cb257b845b Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 2 Jul 2026 10:31:07 +0200 Subject: [PATCH 3/4] docs(changelog): note Gradle unscannable-config transparency under Unreleased --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15995c2a7..042a20560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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] + +### Changed +- `socket manifest gradle --facts` no longer silently skips a Gradle + configuration it can't resolve. Such configurations are now reported and stop + the run unless you pass `--ignore-unresolved`, so an incomplete scan can't slip + by unnoticed. Benign variant-selection ambiguity stays a one-line notice. + ## [1.1.135](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.135) - 2026-07-01 ### Changed From ed367504295d5f5fec2169cdce2489ad35ed158e Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 2 Jul 2026 12:45:45 +0200 Subject: [PATCH 4/4] fix(manifest): address review nits on config-transparency report - Don't lead the failure summary with a blank line when there are only blocking unscannable configs and no per-dependency failures (avoids a dangling marker under logger.fail); added a regression assertion. - Correct the config-throw comments: the path fires on config-LEVEL failures (resolution-rule throws, artifact-transform failures, broken repos), not AGP *AndroidTest* configs, which resolve cleanly per the validation. --- .../manifest/scripts/resolution-report-render.mts | 10 +++++++--- .../manifest/scripts/resolution-report-render.test.mts | 3 +++ src/commands/manifest/scripts/socket-facts.init.gradle | 5 +++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 3eecb3557..58b973d30 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -193,7 +193,11 @@ export function renderResolutionReport( } } if (blockingUnscannable.length) { - out.push('') + // 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). + if (out.length) { + out.push('') + } out.push( opts.ignoreUnresolved ? `Ignored ${blockingUnscannable.length} configuration(s) that could not be scanned:` @@ -243,8 +247,8 @@ export function renderResolutionReport( const configCount = new Set(infos.flatMap(i => [...i.configs])).size notices.push(spec.notice(name, infos.length, configCount)) } - // Ambiguity-driven config-throws (e.g. AGP *AndroidTest*) are surfaced, not failed: - // deliberately lenient, matching the per-dep variant-ambiguity policy. + // A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed — + // matching the deliberately-lenient per-dep variant-ambiguity policy. if (nonBlockingUnscannable.length) { const n = new Set(nonBlockingUnscannable.map(u => u.config)).size notices.push( diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index 1b23af9ec..ebc86ecb5 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -179,5 +179,8 @@ describe('resolution failure classification', () => { expect(r.summary).toContain('Could not scan 1 configuration(s)') expect(r.summary).toContain('runtimeClasspath') expect(r.nonBlockingNotice).toBe('') + // With no per-dep failures the summary must not lead with a blank line + // (which would render as a dangling ✗ under logger.fail). + expect(r.summary.startsWith('\n')).toBe(false) }) }) diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 356c15f40..ceb0b84c2 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -379,8 +379,9 @@ allprojects { project -> ] } } catch (Exception e) { - // Record rather than swallow (e.g. AGP *AndroidTest* configs the init script can't resolve); - // the build still exits cleanly and the TS layer surfaces it. + // 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)] } // Stash this resolution root's tree; the key only has to be unique per root (aggregator