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