Skip to content

Commit 6619eae

Browse files
authored
feat(manifest): unify Gradle config-level resolution transparency (REA-519) (#1398)
* 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. * 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. * docs(changelog): note Gradle unscannable-config transparency under Unreleased * 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.
1 parent 2ea45bf commit 6619eae

13 files changed

Lines changed: 214 additions & 32 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [Unreleased]
8+
9+
### Changed
10+
- `socket manifest gradle --facts` no longer silently skips a Gradle
11+
configuration it can't resolve. Such configurations are now reported and stop
12+
the run unless you pass `--ignore-unresolved`, so an incomplete scan can't slip
13+
by unnoticed. Benign variant-selection ambiguity stays a one-line notice.
14+
715
## [1.1.135](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.135) - 2026-07-01
816

917
### Changed

src/commands/manifest/cmd-manifest-gradle.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const config: CliCommandConfig = {
4545
includeConfigs: {
4646
type: 'string',
4747
description:
48-
'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',
48+
'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',
4949
},
5050
excludeConfigs: {
5151
type: 'string',

src/commands/manifest/cmd-manifest-gradle.test.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('socket manifest gradle', async () => {
2828
--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
2929
--gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\`
3030
--ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)
31-
--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
31+
--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
3232
--pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`)
3333
--verbose Print debug messages
3434

src/commands/manifest/cmd-manifest-kotlin.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const config: CliCommandConfig = {
5050
includeConfigs: {
5151
type: 'string',
5252
description:
53-
'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',
53+
'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',
5454
},
5555
excludeConfigs: {
5656
type: 'string',

src/commands/manifest/cmd-manifest-kotlin.test.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('socket manifest kotlin', async () => {
2828
--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
2929
--gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\`
3030
--ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)
31-
--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
31+
--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
3232
--pom Generate \`pom.xml\` manifest file(s) instead of the default Socket facts file (\`.socket.facts.json\`)
3333
--verbose Print debug messages
3434

src/commands/manifest/run-manifest-facts.mts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export async function runManifestFacts({
111111
report.failures,
112112
report.scannedConfigs,
113113
ecosystem,
114-
{ ignoreUnresolved },
114+
{ ignoreUnresolved, unscannable: report.unscannable },
115115
)
116116

117117
if (rendered.hasBlockingFailures) {
@@ -143,7 +143,8 @@ export async function runManifestFacts({
143143
code !== 0 &&
144144
!facts.components.length &&
145145
!facts.projects?.length &&
146-
!report.failures.length
146+
!report.failures.length &&
147+
!report.unscannable.length
147148
) {
148149
if (!verbose) {
149150
const tail = tailBuildOutput(stdout, stderr)

src/commands/manifest/scripts/assemble.mts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,5 +443,14 @@ function buildReport(parsed: ParsedRecords): ResolutionReport {
443443
seen.add(key)
444444
return true
445445
})
446-
return { failures, scannedConfigs: parsed.scannedConfigs }
446+
const seenUnscannable = new Set<string>()
447+
const unscannable = parsed.unscannable.filter(u => {
448+
const key = `${u.config}|${u.detail}`
449+
if (seenUnscannable.has(key)) {
450+
return false
451+
}
452+
seenUnscannable.add(key)
453+
return true
454+
})
455+
return { failures, scannedConfigs: parsed.scannedConfigs, unscannable }
447456
}

src/commands/manifest/scripts/records.mts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { ResolutionFailure } from './resolution-report.mts'
1+
import type {
2+
ResolutionFailure,
3+
UnscannableConfig,
4+
} from './resolution-report.mts'
25

36
// Line-protocol the build-tool scripts emit to a records file (NOT stdout — sbt
47
// prints unsilenceable resolution noise there). One record per line, fields
@@ -16,6 +19,7 @@ import type { ResolutionFailure } from './resolution-report.mts'
1619
// file rootId coordId path (--with-files only)
1720
// scanned config
1821
// failure coord detail config
22+
// unscannable config detail
1923
//
2024
// A `root` is one (subproject, configuration) resolution root; `coordId` is the
2125
// coordinate key (`group:name:ext:classifier:version`, empty segments dropped),
@@ -64,6 +68,7 @@ export type ParsedRecords = {
6468
roots: Map<string, RawRoot>
6569
scannedConfigs: string[]
6670
failures: ResolutionFailure[]
71+
unscannable: UnscannableConfig[]
6772
}
6873

6974
export function unescapeField(s: string): string {
@@ -96,6 +101,7 @@ export function parseRecords(text: string): ParsedRecords {
96101
roots: new Map(),
97102
scannedConfigs: [],
98103
failures: [],
104+
unscannable: [],
99105
}
100106
const scanned = new Set<string>()
101107

@@ -213,6 +219,14 @@ export function parseRecords(text: string): ParsedRecords {
213219
})
214220
}
215221
break
222+
case 'unscannable':
223+
if (f[1]) {
224+
result.unscannable.push({
225+
config: f[1],
226+
detail: f[2] ?? '',
227+
})
228+
}
229+
break
216230
default:
217231
break
218232
}

src/commands/manifest/scripts/resolution-report-gradle.mts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ export function classifyGradleFailure(detail: string): FailureCategory {
1111
if (t.includes('conflict on capability')) {
1212
return 'capability-conflict'
1313
}
14-
// Zero compatible variants — the opposite of ambiguity below.
15-
if (t.includes('no matching variant') || t.includes('no variants of')) {
14+
// Zero compatible variants — the opposite of ambiguity below. Gradle phrases
15+
// this several ways depending on version and whether attributes were supplied.
16+
if (
17+
t.includes('no matching variant') ||
18+
t.includes('no variants of') ||
19+
t.includes('unable to find a matching variant') ||
20+
t.includes('no compatible variant')
21+
) {
1622
return 'no-matching-variant'
1723
}
1824
if (t.includes('cannot choose between')) {

src/commands/manifest/scripts/resolution-report-render.mts

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { SBT_DIALECT } from './resolution-report-ivy.mts'
33
import { MAVEN_DIALECT } from './resolution-report-maven.mts'
44

55
import type { BuildTool } from './build-tool.mts'
6-
import type { ResolutionFailure } from './resolution-report.mts'
6+
import type {
7+
ResolutionFailure,
8+
UnscannableConfig,
9+
} from './resolution-report.mts'
710

811
// Recognized from the build tool's message; drives wording AND whether the kind
912
// is blocking. An unrecognized message degrades to 'other' (blocking) — safe.
@@ -87,9 +90,14 @@ export function renderResolutionReport(
8790
failures: ResolutionFailure[],
8891
scannedConfigs: string[],
8992
dialect: ResolutionDialect,
90-
opts: { ignoreUnresolved?: boolean | undefined } = {},
93+
opts: {
94+
ignoreUnresolved?: boolean | undefined
95+
unscannable?: UnscannableConfig[] | undefined
96+
} = {},
9197
): RenderedResolutionReport {
9298
const name = dialect.label
99+
const unscannable = opts.unscannable ?? []
100+
const unscannableConfigs = new Set(unscannable.map(u => u.config))
93101
const specOf = new Map(dialect.categories.map(c => [c.key, c]))
94102
const isBlocking = (cat: FailureCategory): boolean =>
95103
specOf.get(cat)?.blocking ?? true
@@ -119,16 +127,32 @@ export function renderResolutionReport(
119127
}
120128
const allInfos = [...byKey.values()]
121129

122-
const blockingConfigs = new Set<string>()
130+
// A whole-config throw is classified by the same cause rules as a per-dep
131+
// failure: ambiguity stays lenient, every other cause is fail-closed.
132+
const unscannableInfos = unscannable.map(u => {
133+
const category = dialect.classify(u.detail)
134+
return { ...u, category, blocking: isBlocking(category) }
135+
})
136+
const blockingUnscannable = unscannableInfos.filter(u => u.blocking)
137+
const nonBlockingUnscannable = unscannableInfos.filter(u => !u.blocking)
138+
139+
const perDepBlockingConfigs = new Set<string>()
123140
for (const info of allInfos) {
124141
if (isBlocking(info.category)) {
125142
for (const c of info.configs) {
126-
blockingConfigs.add(c)
143+
perDepBlockingConfigs.add(c)
127144
}
128145
}
129146
}
147+
const blockingConfigs = new Set([
148+
...perDepBlockingConfigs,
149+
...blockingUnscannable.map(u => u.config),
150+
])
130151
const blockingFailed = [...blockingConfigs].sort()
131-
const succeeded = scannedConfigs.filter(c => !blockingConfigs.has(c)).sort()
152+
// An un-scannable config was attempted but resolved nothing, so it didn't succeed.
153+
const succeeded = scannedConfigs
154+
.filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c))
155+
.sort()
132156

133157
const groups = dialect.categories
134158
.map(spec => ({
@@ -141,27 +165,54 @@ export function renderResolutionReport(
141165
const blockingGroups = groups.filter(g => g.spec.blocking)
142166
const nonBlockingGroups = groups.filter(g => !g.spec.blocking)
143167
const blockingCount = blockingGroups.reduce((n, g) => n + g.infos.length, 0)
144-
const hasBlockingFailures = blockingCount > 0
168+
const hasBlockingFailures =
169+
blockingCount > 0 || blockingUnscannable.length > 0
145170
const willFail = hasBlockingFailures && !opts.ignoreUnresolved
146171

147172
const out: string[] = []
148173
if (hasBlockingFailures) {
149-
out.push(
150-
opts.ignoreUnresolved
151-
? `Ignored ${blockingCount} unresolved dependency(ies) in ${blockingFailed.length} configuration(s):`
152-
: `Could not resolve ${blockingCount} dependency(ies) in ${blockingFailed.length} configuration(s):`,
153-
)
154-
for (const { infos, spec } of blockingGroups) {
155-
out.push('')
156-
out.push(spec.header ? spec.header(name) : '')
157-
for (const info of infos.slice(0, RESOLUTION_REPORT_ARTIFACT_LIMIT)) {
158-
const fl = firstLine(info.detail)
159-
const reasonSuffix = spec.showReason && fl ? ` [${fl}]` : ''
160-
out.push(` - ${info.coord}${reasonSuffix}`)
174+
if (blockingCount > 0) {
175+
out.push(
176+
opts.ignoreUnresolved
177+
? `Ignored ${blockingCount} unresolved dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):`
178+
: `Could not resolve ${blockingCount} dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):`,
179+
)
180+
for (const { infos, spec } of blockingGroups) {
181+
out.push('')
182+
out.push(spec.header ? spec.header(name) : '')
183+
for (const info of infos.slice(0, RESOLUTION_REPORT_ARTIFACT_LIMIT)) {
184+
const fl = firstLine(info.detail)
185+
const reasonSuffix = spec.showReason && fl ? ` [${fl}]` : ''
186+
out.push(` - ${info.coord}${reasonSuffix}`)
187+
}
188+
if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) {
189+
out.push(
190+
` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`,
191+
)
192+
}
193+
}
194+
}
195+
if (blockingUnscannable.length) {
196+
// Separate from the per-dep block above, but only if there is one — otherwise
197+
// the summary would lead with a blank line (a dangling ✗ under logger.fail).
198+
if (out.length) {
199+
out.push('')
161200
}
162-
if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) {
201+
out.push(
202+
opts.ignoreUnresolved
203+
? `Ignored ${blockingUnscannable.length} configuration(s) that could not be scanned:`
204+
: `Could not scan ${blockingUnscannable.length} configuration(s) (reason from ${name}):`,
205+
)
206+
for (const u of blockingUnscannable.slice(
207+
0,
208+
RESOLUTION_REPORT_CONFIG_LIMIT,
209+
)) {
210+
const fl = firstLine(u.detail)
211+
out.push(` - ${u.config}${fl ? ` [${fl}]` : ''}`)
212+
}
213+
if (blockingUnscannable.length > RESOLUTION_REPORT_CONFIG_LIMIT) {
163214
out.push(
164-
` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`,
215+
` … and ${blockingUnscannable.length - RESOLUTION_REPORT_CONFIG_LIMIT} more`,
165216
)
166217
}
167218
}
@@ -196,6 +247,14 @@ export function renderResolutionReport(
196247
const configCount = new Set(infos.flatMap(i => [...i.configs])).size
197248
notices.push(spec.notice(name, infos.length, configCount))
198249
}
250+
// A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed —
251+
// matching the deliberately-lenient per-dep variant-ambiguity policy.
252+
if (nonBlockingUnscannable.length) {
253+
const n = new Set(nonBlockingUnscannable.map(u => u.config)).size
254+
notices.push(
255+
`Could not scan ${n} configuration(s) — re-run with --verbose for ${name}'s messages.`,
256+
)
257+
}
199258

200259
const detailLines = [`${name}'s full message for each unresolved dependency:`]
201260
for (const info of allInfos) {
@@ -205,6 +264,17 @@ export function renderResolutionReport(
205264
detailLines.push(` ${line}`)
206265
}
207266
}
267+
if (unscannable.length) {
268+
detailLines.push('')
269+
detailLines.push(`${name} configurations that could not be scanned:`)
270+
for (const u of unscannable) {
271+
detailLines.push('')
272+
detailLines.push(` ${u.config}:`)
273+
for (const line of (u.detail || '(no message)').split('\n')) {
274+
detailLines.push(` ${line}`)
275+
}
276+
}
277+
}
208278

209279
return {
210280
summary: out.join('\n'),
@@ -229,7 +299,10 @@ export function renderResolutionErrorReport(
229299
failures: ResolutionFailure[],
230300
scannedConfigs: string[] = [],
231301
tool: BuildTool = 'gradle',
232-
opts: { ignoreUnresolved?: boolean | undefined } = {},
302+
opts: {
303+
ignoreUnresolved?: boolean | undefined
304+
unscannable?: UnscannableConfig[] | undefined
305+
} = {},
233306
): RenderedResolutionReport {
234307
return renderResolutionReport(
235308
failures,

0 commit comments

Comments
 (0)