Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/commands/manifest/cmd-manifest-gradle.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/manifest/cmd-manifest-gradle.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/commands/manifest/cmd-manifest-kotlin.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/manifest/cmd-manifest-kotlin.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/commands/manifest/run-manifest-facts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function runManifestFacts({
report.failures,
report.scannedConfigs,
ecosystem,
{ ignoreUnresolved },
{ ignoreUnresolved, unscannable: report.unscannable },
)

if (rendered.hasBlockingFailures) {
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion src/commands/manifest/scripts/assemble.mts
Original file line number Diff line number Diff line change
Expand Up @@ -443,5 +443,14 @@ function buildReport(parsed: ParsedRecords): ResolutionReport {
seen.add(key)
return true
})
return { failures, scannedConfigs: parsed.scannedConfigs }
const seenUnscannable = new Set<string>()
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 }
}
16 changes: 15 additions & 1 deletion src/commands/manifest/scripts/records.mts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -64,6 +68,7 @@ export type ParsedRecords = {
roots: Map<string, RawRoot>
scannedConfigs: string[]
failures: ResolutionFailure[]
unscannable: UnscannableConfig[]
}

export function unescapeField(s: string): string {
Expand Down Expand Up @@ -96,6 +101,7 @@ export function parseRecords(text: string): ParsedRecords {
roots: new Map(),
scannedConfigs: [],
failures: [],
unscannable: [],
}
const scanned = new Set<string>()

Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 8 additions & 2 deletions src/commands/manifest/scripts/resolution-report-gradle.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
115 changes: 94 additions & 21 deletions src/commands/manifest/scripts/resolution-report-render.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -119,16 +127,32 @@ export function renderResolutionReport(
}
const allInfos = [...byKey.values()]

const blockingConfigs = new Set<string>()
// 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<string>()
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 => ({
Expand All @@ -141,27 +165,54 @@ 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) {
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 (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) {
// 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('')
}
if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) {
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 (blockingUnscannable.length > RESOLUTION_REPORT_CONFIG_LIMIT) {
out.push(
` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`,
` … and ${blockingUnscannable.length - RESOLUTION_REPORT_CONFIG_LIMIT} more`,
)
}
}
Expand Down Expand Up @@ -196,6 +247,14 @@ 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.
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) {
Expand All @@ -205,6 +264,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'),
Expand All @@ -229,7 +299,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,
Expand Down
56 changes: 56 additions & 0 deletions src/commands/manifest/scripts/resolution-report-render.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -127,4 +143,44 @@ 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('')
// 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)
})
})
Loading
Loading