diff --git a/CHANGELOG.md b/CHANGELOG.md index 1553b3d63..1d93ac4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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/). +## [1.1.142](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.142) - 2026-07-10 + +### Added +- The `socket manifest` commands (`auto`, `gradle`, `kotlin`, `maven`, `scala`) now accept `--exclude-paths`, keeping excluded subprojects and their dependencies out of generated manifests. + +### Fixed +- `--exclude-paths` is now fully honored across JVM, .NET, and Rust reachability analysis: excluded directories no longer contribute dependencies, code, or framework resources to scan results. Includes an update of the Coana CLI to v `15.8.7`. +- Invalid `--exclude-paths` glob patterns are rejected with a clear error instead of failing mid-scan. + ## [1.1.141](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.141) - 2026-07-10 ### Changed diff --git a/package.json b/package.json index edaf67a67..e9df6737a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.141", + "version": "1.1.142", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT", @@ -97,7 +97,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "15.8.6", + "@coana-tech/cli": "15.8.7", "@cyclonedx/cdxgen": "12.1.2", "@dotenvx/dotenvx": "1.49.0", "@eslint/compat": "1.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18b0894bf..c072943b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 15.8.6 - version: 15.8.6 + specifier: 15.8.7 + version: 15.8.7 '@cyclonedx/cdxgen': specifier: 12.1.2 version: 12.1.2 @@ -806,8 +806,8 @@ packages: resolution: {integrity: sha512-hAs5PPKPCQ3/Nha+1fo4A4/gL85fIfxZwHPehsjCJ+BhQH2/yw6/xReuaPA/RfNQr6iz1PcD7BZcE3ctyyl3EA==} cpu: [x64] - '@coana-tech/cli@15.8.6': - resolution: {integrity: sha512-abEfJFDBsltG72zWOgk2yd1Kj4VPe+3x/8keJsNKCKH3FEpmBT0Ab8f+bOngyLpOr2RNkNrydSLbiOrSrPIeLg==} + '@coana-tech/cli@15.8.7': + resolution: {integrity: sha512-18oqWR4JvCVfxatqSpYShda72XgBxTCJzQi1qJN01EOMbYqGQsv0jCIUlqlgDKPtxBObRTMD/9k5ok80uH5aFw==} hasBin: true '@colors/colors@1.5.0': @@ -5509,7 +5509,7 @@ snapshots: '@cdxgen/cdxgen-plugins-bin@2.0.2': optional: true - '@coana-tech/cli@15.8.6': {} + '@coana-tech/cli@15.8.7': {} '@colors/colors@1.5.0': optional: true diff --git a/src/commands/manifest/cmd-manifest-auto.mts b/src/commands/manifest/cmd-manifest-auto.mts index 41a215f9b..47e15f78d 100644 --- a/src/commands/manifest/cmd-manifest-auto.mts +++ b/src/commands/manifest/cmd-manifest-auto.mts @@ -7,10 +7,13 @@ import { detectManifestActions } from './detect-manifest-actions.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' import constants from '../../constants.mts' import { commonFlags } from '../../flags.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -23,6 +26,7 @@ const config: CliCommandConfig = { hidden: false, flags: { ...commonFlags, + ...excludePathsFlag, verbose: { type: 'boolean', default: false, @@ -117,9 +121,13 @@ async function run( return } + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + await generateAutoManifest({ detected, cwd, + excludePaths, outputKind, verbose, }) diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index 0acdf15ca..a6410f7e0 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -23,6 +23,7 @@ describe('socket manifest auto', async () => { $ socket manifest auto [options] [CWD=.] Options + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors Tries to figure out what language your target repo uses. If it finds a diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 416426b93..650a8f5cd 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -10,10 +10,13 @@ import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -52,6 +55,7 @@ const config: CliCommandConfig = { description: 'When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs)', }, + ...excludePathsFlag, ignoreUnresolved: { type: 'boolean', description: @@ -286,11 +290,15 @@ async function run( const parsedGradleOpts = parseBuildToolOpts(String(gradleOpts || '')) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + if (facts) { await convertGradleToFacts({ bin: String(bin), cwd, excludeConfigs: String(excludeConfigs || ''), + excludePaths, gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), @@ -302,6 +310,7 @@ async function run( await convertGradleToMaven({ bin: String(bin), cwd, + excludePaths, gradleOpts: parsedGradleOpts, verbose: Boolean(verbose), }) diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 08d859a44..a0efb72a6 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -25,6 +25,7 @@ describe('socket manifest gradle', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index f858b4d5a..1b5bf6b65 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -10,10 +10,13 @@ import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -57,6 +60,7 @@ const config: CliCommandConfig = { description: 'When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs)', }, + ...excludePathsFlag, ignoreUnresolved: { type: 'boolean', description: @@ -289,11 +293,15 @@ async function run( const parsedGradleOpts = parseBuildToolOpts(String(gradleOpts || '')) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + if (facts) { await convertGradleToFacts({ bin: String(bin), cwd, excludeConfigs: String(excludeConfigs || ''), + excludePaths, gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), @@ -305,6 +313,7 @@ async function run( await convertGradleToMaven({ bin: String(bin), cwd, + excludePaths, gradleOpts: parsedGradleOpts, verbose: Boolean(verbose), }) diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 565d6342d..4b5541555 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -25,6 +25,7 @@ describe('socket manifest kotlin', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index 7d41faef1..f82a7ed86 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -9,10 +9,13 @@ import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -41,6 +44,7 @@ const config: CliCommandConfig = { description: 'Comma-separated glob patterns; Maven scopes matching any pattern are skipped (applied after --include-configs)', }, + ...excludePathsFlag, ignoreUnresolved: { type: 'boolean', description: @@ -224,10 +228,14 @@ async function run( const parsedMavenOpts = parseBuildToolOpts(String(mavenOpts || '')) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + await convertMavenToFacts({ bin: String(bin), cwd, excludeConfigs: String(excludeConfigs || ''), + excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), mavenOpts: parsedMavenOpts, diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 18460c713..57412e426 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -24,6 +24,7 @@ describe('socket manifest maven', async () => { Options --bin Location of the maven binary to use, default: ./mvnw if present, else mvn on PATH --exclude-configs Comma-separated glob patterns; Maven scopes matching any pattern are skipped (applied after --include-configs) + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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 file) --include-configs Comma-separated glob patterns matched against Maven dependency scopes (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only scopes matching at least one pattern are resolved. e.g. \`compile,runtime\`. Default: every scope --maven-opts Additional options to pass on to maven, e.g. \`-P -s \` diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 5f3ab20e0..7dd1be59b 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -9,10 +9,13 @@ import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { getOutputKind } from '../../utils/get-output-kind.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' +import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -50,6 +53,7 @@ const config: CliCommandConfig = { description: 'When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied after --include-configs)', }, + ...excludePathsFlag, ignoreUnresolved: { type: 'boolean', description: @@ -340,11 +344,15 @@ async function run( const parsedSbtOpts = parseBuildToolOpts(String(sbtOpts || '')) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + if (facts) { await convertSbtToFacts({ bin: String(bin), cwd, excludeConfigs: String(excludeConfigs || ''), + excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), sbtOpts: parsedSbtOpts, diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 893d16f4b..38131d70e 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -25,6 +25,7 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use --exclude-configs When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied after --include-configs) + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`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. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against sbt configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`compile,test\`. Default: compile,optional,provided,runtime,test diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 6b7ad2144..011073fad 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -7,6 +7,7 @@ export async function convertGradleToFacts({ bin, cwd, excludeConfigs, + excludePaths, gradleOpts, ignoreUnresolved, includeConfigs, @@ -17,6 +18,7 @@ export async function convertGradleToFacts({ bin: string cwd: string excludeConfigs: string + excludePaths?: string[] | undefined gradleOpts: string[] ignoreUnresolved: boolean includeConfigs: string @@ -30,6 +32,7 @@ export async function convertGradleToFacts({ cwd, ecosystem: 'gradle', excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, sidecarAcc, diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index c9a8f16c0..a7f9c2472 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -7,6 +7,7 @@ export async function convertMavenToFacts({ bin, cwd, excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, mavenOpts, @@ -17,6 +18,7 @@ export async function convertMavenToFacts({ bin: string cwd: string excludeConfigs: string + excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string mavenOpts: string[] @@ -30,6 +32,7 @@ export async function convertMavenToFacts({ cwd, ecosystem: 'maven', excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, sidecarAcc, diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index 9bf913e17..b5c5b08cd 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -9,6 +9,7 @@ export async function convertSbtToFacts({ bin, cwd, excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, sbtOpts, @@ -19,6 +20,7 @@ export async function convertSbtToFacts({ bin: string cwd: string excludeConfigs: string + excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string sbtOpts: string[] @@ -32,6 +34,7 @@ export async function convertSbtToFacts({ cwd, ecosystem: 'sbt', excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, sidecarAcc, diff --git a/src/commands/manifest/convert_gradle_to_maven.mts b/src/commands/manifest/convert_gradle_to_maven.mts index 74953a28d..ec50691c1 100644 --- a/src/commands/manifest/convert_gradle_to_maven.mts +++ b/src/commands/manifest/convert_gradle_to_maven.mts @@ -9,11 +9,13 @@ import constants from '../../constants.mts' export async function convertGradleToMaven({ bin, cwd, + excludePaths, gradleOpts, verbose, }: { bin: string cwd: string + excludePaths?: string[] | undefined verbose: boolean gradleOpts: string[] }) { @@ -48,7 +50,19 @@ export async function convertGradleToMaven({ // I'd prefer something plain-text if it is to be committed. // Note: init.gradle will be exported by .config/rollup.dist.config.mjs const initLocation = path.join(constants.distPath, 'init.gradle') - const commandArgs = ['--init-script', initLocation, ...gradleOpts, 'pom'] + // CSV: `--exclude-paths` is comma-split at the CLI, so an entry can never + // contain a comma. The init script skips POM generation for excluded + // subprojects. + const excludeProps = excludePaths?.length + ? [`-Psocket.excludePaths=${excludePaths.join(',')}`] + : [] + const commandArgs = [ + '--init-script', + initLocation, + ...excludeProps, + ...gradleOpts, + 'pom', + ] if (verbose) { logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) } diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 3e2aa40e4..ceab747b3 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -50,6 +50,7 @@ export async function generateAutoManifest({ computeArtifactsSidecar, cwd, detected, + excludePaths, outputKind, verbose, }: { @@ -57,6 +58,9 @@ export async function generateAutoManifest({ computeArtifactsSidecar?: boolean | undefined detected: GeneratableManifests cwd: string + // Scan-root-relative `--exclude-paths`: skip excluded subprojects and drop + // excluded source roots from the resolved-paths sidecar. + excludePaths?: string[] | undefined outputKind: OutputKind verbose: boolean }): Promise { @@ -91,6 +95,7 @@ export async function generateAutoManifest({ await convertSbtToFacts({ ...sbtArgs, excludeConfigs: sockJson.defaults?.manifest?.sbt?.excludeConfigs ?? '', + excludePaths, ignoreUnresolved: Boolean( sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, ), @@ -133,6 +138,7 @@ export async function generateAutoManifest({ ...gradleArgs, excludeConfigs: sockJson.defaults?.manifest?.gradle?.excludeConfigs ?? '', + excludePaths, ignoreUnresolved: Boolean( sockJson.defaults?.manifest?.gradle?.ignoreUnresolved, ), @@ -162,6 +168,7 @@ export async function generateAutoManifest({ resolveBuildToolBin('maven', cwd), cwd, excludeConfigs: sockJson.defaults?.manifest?.maven?.excludeConfigs ?? '', + excludePaths, ignoreUnresolved: Boolean( sockJson.defaults?.manifest?.maven?.ignoreUnresolved, ), diff --git a/src/commands/manifest/init.gradle b/src/commands/manifest/init.gradle index be0db4ace..60f5f8637 100644 --- a/src/commands/manifest/init.gradle +++ b/src/commands/manifest/init.gradle @@ -22,8 +22,81 @@ initscript { } } +// `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. +gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } + +// `-Psocket.excludePaths` → glob PathMatchers, used only to skip POM generation for whole excluded +// subprojects. Each entry variant yields the entry itself and `entry/**` so it matches the dir and its +// subtree (same expansion as the SCA ignore path). A trailing `/**` is stripped first, so a user-written +// `dir/**` still excludes the `dir` directory itself, not only its contents. Standard glob semantics +// (anchored to the scan root, matching the CLI flag): `x` is root-level, `**/x` matches at any depth. +// NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's micromatch +// lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` occurrences dropped so +// both semantics hold. Mirrors the socket-facts producers. +gradle.ext.socketZeroDepthVariants = { String glob -> + def out = new LinkedHashSet() + def work = new ArrayDeque() + work.add(glob) + while (!work.isEmpty()) { + def cur = work.poll() + if (!out.add(cur)) { continue } + int idx = cur.indexOf('**/') + while (idx >= 0) { + if (idx == 0 || cur[idx - 1] == '/') { + def collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (!collapsed.isEmpty()) { work.add(collapsed) } + } + idx = cur.indexOf('**/', idx + 1) + } + } + out +} +gradle.ext.socketExcludeMatchersCache = null +gradle.ext.socketExcludeMatchers = { + if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache + def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() + def matchers = [] + if (raw != null && !raw.trim().isEmpty()) { + def fs = java.nio.file.FileSystems.getDefault() + raw.split(',').each { r -> + def g = r.trim().replace('\\', '/') + while (g.startsWith('/')) { g = g.substring(1) } + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + while (g.endsWith('/**')) { + g = g.substring(0, g.length() - 3) + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + } + if (!g.isEmpty()) { + gradle.ext.socketZeroDepthVariants.call(g).each { v -> + matchers << fs.getPathMatcher('glob:' + v) + matchers << fs.getPathMatcher('glob:' + v + '/**') + } + } + } + } + gradle.ext.socketExcludeMatchersCache = matchers + matchers +} +gradle.ext.socketIsExcluded = { String rel -> + def c = (rel == null ? '' : rel).replace('\\', '/') + while (c.startsWith('./')) { c = c.substring(2) } + while (c.startsWith('/')) { c = c.substring(1) } + while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } + if (c.isEmpty()) { return false } + def p = java.nio.file.Paths.get(c) + gradle.ext.socketExcludeMatchers.call().any { m -> m.matches(p) } +} + // Apply these configurations to all projects in the build gradle.allprojects { project -> + // Skip a wholly excluded subproject: `--exclude-paths` covering its dir means we don't + // generate a POM for it (source-file-level exclusion is left to the reachability analysis). + def excludeRelDir = project.rootProject.projectDir.toPath() + .relativize(project.projectDir.toPath()).toString().replace(File.separator, '/') + if (gradle.ext.socketIsExcluded.call(excludeRelDir.isEmpty() ? '.' : excludeRelDir)) { + return + } + // Create a unique name for the Maven publication // Example: project ':foo:bar' becomes 'maven-foo-bar' def publicationName = "maven-${project.path.replace(':', '-')}" diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index ca8815daf..681acc79e 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -36,6 +36,7 @@ export async function runManifestFacts({ cwd, ecosystem, excludeConfigs, + excludePaths, ignoreUnresolved, includeConfigs, sidecarAcc, @@ -47,6 +48,7 @@ export async function runManifestFacts({ cwd: string ecosystem: BuildTool excludeConfigs: string + excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string sidecarAcc?: SidecarAccumulator | undefined @@ -62,6 +64,7 @@ export async function runManifestFacts({ const scriptOpts = { bin: bin || undefined, excludeConfigs: excludeConfigs || undefined, + excludePaths: excludePaths?.length ? excludePaths : undefined, includeConfigs: includeConfigs || undefined, projectDir: cwd, // Stream the build tool's output only when asked; otherwise capture it and diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java index 4234edfc0..4f76a1332 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java @@ -57,6 +57,7 @@ public void afterSessionEnd(MavenSession session) throws MavenExecutionException opts.populateFilesFor = opt(session, "socket.populateFilesFor"); opts.includeConfigs = opt(session, "socket.includeConfigs"); opts.excludeConfigs = opt(session, "socket.excludeConfigs"); + opts.excludePaths = opt(session, "socket.excludePaths"); File rootDir = new File(session.getExecutionRootDirectory()); try { new SocketFactsRecordsEngine(repoSystem, dependencyGraphBuilder, runtimeInformation.getMavenVersion(), LOG) 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 270a6a9d6..472f1928a 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 @@ -48,6 +48,8 @@ public static final class Options { public String populateFilesFor; public String includeConfigs; public String excludeConfigs; + // Scan-root-relative `--exclude-paths` (CSV): a wholly excluded reactor module is skipped. + public String excludePaths; public String recordsFile; } @@ -77,16 +79,20 @@ public void run(MavenSession session, List reactor, File rootDir, // GAVs to materialize under --with-files (null = all). Scopes artifact downloads so reachability // doesn't fetch the whole dependency universe. Module src/tgt dirs are emitted regardless (no download). Set populateGavs = readPopulateGavs(opts); + // reactorGavs stays complete (all modules) so a KEPT module depending on an excluded one still + // recognizes it as internal; `excludes` only gates resolution + the project record. Set reactorGavs = new HashSet<>(); for (MavenProject p : reactor) { reactorGavs.add(p.getGroupId() + ":" + p.getArtifactId() + ":" + p.getVersion()); } + List excludes = SocketSupport.parseExcludeMatchers(opts.excludePaths); List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); for (MavenProject module : reactor) { String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + if (SocketSupport.isExcludedPath(ws, excludes)) continue; rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); if (opts.withFiles) { for (String s : collectSources(module)) rec(lines, "projectSrc", ws, s); @@ -100,6 +106,8 @@ public void run(MavenSession session, List reactor, File rootDir, int rootIdx = 0; for (MavenProject module : reactor) { String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + // A wholly excluded reactor module is not resolved (matches the project-record skip above). + if (SocketSupport.isExcludedPath(ws, excludes)) continue; Map nodes = new LinkedHashMap<>(); Set directIds = new HashSet<>(); collectModule(session, repoSession, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, failures); diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java index edcb5fe8c..8f757bd69 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java @@ -1,9 +1,16 @@ package tech.coana.socket; import java.io.File; +import java.nio.file.FileSystems; import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; /** @@ -76,6 +83,74 @@ else if (c == '[') { } } + /** + * Compile a comma-separated list of {@code --exclude-paths} into glob {@link PathMatcher}s, used + * only to skip whole excluded reactor modules. Each entry variant yields the entry itself and + * {@code entry/**} so it matches the dir and its subtree (same expansion as the SCA ignore path). + * A trailing {@code /**} is stripped first, so a user-written {@code dir/**} still excludes the + * {@code dir} directory itself, not only its contents. Standard glob semantics (anchored to the + * scan root, matching the CLI flag): {@code x} is root-level; {@code **}{@code /x} matches at any + * depth. Mirrors the gradle/sbt producers. + */ + public static List parseExcludeMatchers(String csv) { + List out = new ArrayList<>(); + if (csv == null || csv.trim().isEmpty()) return out; + for (String raw : csv.split(",")) { + String g = raw.trim().replace("\\", "/"); + while (g.startsWith("/")) g = g.substring(1); + while (g.endsWith("/")) g = g.substring(0, g.length() - 1); + while (g.endsWith("/**")) { + g = g.substring(0, g.length() - 3); + while (g.endsWith("/")) g = g.substring(0, g.length() - 1); + } + if (g.isEmpty()) continue; + for (String v : zeroDepthVariants(g)) { + out.add(FileSystems.getDefault().getPathMatcher("glob:" + v)); + out.add(FileSystems.getDefault().getPathMatcher("glob:" + v + "/**")); + } + } + return out; + } + + /** + * NIO glob requires a slash-adjacent {@code **} to consume at least one path segment, but the + * CLI's micromatch lets it match zero ({@code **}{@code /x} matches root-level {@code x}). Emit + * every variant with {@code **}{@code /} occurrences dropped so both semantics hold. + */ + private static Set zeroDepthVariants(String glob) { + Set out = new LinkedHashSet<>(); + Deque work = new ArrayDeque<>(); + work.add(glob); + while (!work.isEmpty()) { + String cur = work.poll(); + if (!out.add(cur)) continue; + int idx = cur.indexOf("**/"); + while (idx >= 0) { + if (idx == 0 || cur.charAt(idx - 1) == '/') { + String collapsed = cur.substring(0, idx) + cur.substring(idx + 3); + if (!collapsed.isEmpty()) work.add(collapsed); + } + idx = cur.indexOf("**/", idx + 1); + } + } + return out; + } + + /** Whether a scan-root-relative POSIX path is covered by any {@code --exclude-paths} matcher. */ + public static boolean isExcludedPath(String rel, List matchers) { + if (matchers == null || matchers.isEmpty()) return false; + String c = (rel == null ? "" : rel).replace("\\", "/"); + while (c.startsWith("./")) c = c.substring(2); + while (c.startsWith("/")) c = c.substring(1); + while (c.endsWith("/")) c = c.substring(0, c.length() - 1); + if (c.isEmpty()) return false; + Path p = Paths.get(c); + for (PathMatcher m : matchers) { + if (m.matches(p)) return true; + } + return false; + } + /** Parse a comma-separated list of globs into case-sensitive patterns. */ public static List parsePatterns(String csv) { List out = new ArrayList<>(); diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 13544c602..1119260e5 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -23,6 +23,10 @@ export type ManifestScriptOptions = { populateFilesFor?: string | undefined includeConfigs?: string | undefined excludeConfigs?: string | undefined + // Scan-root-relative `--exclude-paths`. Passed to the build script, which skips + // resolving a wholly excluded subproject. Source-file-level exclusion is left to + // the reachability analysis (coana's --exclude-dirs), not applied here. + excludePaths?: string[] | undefined toolOpts?: string[] | undefined stdio?: 'inherit' | 'pipe' | undefined env?: NodeJS.ProcessEnv | undefined @@ -177,6 +181,11 @@ function commonProps( if (opts.excludeConfigs) { props.push(`${prefix}socket.excludeConfigs=${opts.excludeConfigs}`) } + if (opts.excludePaths?.length) { + // CSV: `--exclude-paths` is comma-split at the CLI, so an entry can never + // contain a comma. + props.push(`${prefix}socket.excludePaths=${opts.excludePaths.join(',')}`) + } return props } diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index bb9269d11..04093b6ac 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -11,6 +11,69 @@ import java.util.Collections // `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } +// `-Psocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects +// (source-file-level exclusion is left to the reachability analysis). Each entry variant yields the entry +// itself and `entry/**` so it matches the dir and its subtree (same expansion as the SCA ignore path). A +// trailing `/**` is stripped first, so a user-written `dir/**` still excludes the `dir` directory itself, +// not only its contents. Standard glob semantics (anchored to the scan root, matching the CLI flag): `x` +// is root-level, `**/x` matches at any depth. Mirrors the sbt/maven producers. +// NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's +// micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` +// occurrences dropped so both semantics hold. +gradle.ext.socketZeroDepthVariants = { String glob -> + def out = new LinkedHashSet() + def work = new ArrayDeque() + work.add(glob) + while (!work.isEmpty()) { + def cur = work.poll() + if (!out.add(cur)) { continue } + int idx = cur.indexOf('**/') + while (idx >= 0) { + if (idx == 0 || cur[idx - 1] == '/') { + def collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (!collapsed.isEmpty()) { work.add(collapsed) } + } + idx = cur.indexOf('**/', idx + 1) + } + } + out +} +gradle.ext.socketExcludeMatchersCache = null +gradle.ext.socketExcludeMatchers = { + if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache + def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() + def matchers = [] + if (raw != null && !raw.trim().isEmpty()) { + def fs = java.nio.file.FileSystems.getDefault() + raw.split(',').each { r -> + def g = r.trim().replace('\\', '/') + while (g.startsWith('/')) { g = g.substring(1) } + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + while (g.endsWith('/**')) { + g = g.substring(0, g.length() - 3) + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + } + if (!g.isEmpty()) { + gradle.ext.socketZeroDepthVariants.call(g).each { v -> + matchers << fs.getPathMatcher('glob:' + v) + matchers << fs.getPathMatcher('glob:' + v + '/**') + } + } + } + } + gradle.ext.socketExcludeMatchersCache = matchers + matchers +} +gradle.ext.socketIsExcluded = { String rel -> + def c = (rel == null ? '' : rel).replace('\\', '/') + while (c.startsWith('./')) { c = c.substring(2) } + while (c.startsWith('/')) { c = c.substring(1) } + while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } + if (c.isEmpty()) { return false } + def p = java.nio.file.Paths.get(c) + gradle.ext.socketExcludeMatchers.call().any { m -> m.matches(p) } +} + // Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every // collector and the root aggregator share one instance. gradle.ext.socketFactsState = [ @@ -55,15 +118,22 @@ gradle.projectsEvaluated { g -> } } catch (Exception ignore) {} g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt + // A wholly excluded subproject emits no project record and its configs are never resolved (see the + // collector). projectKeys/projectArtifactExt above stay populated so a KEPT project depending on it + // still recognizes it as a first-party module. + if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { + return + } // Guarded: projects without the java `sourceSets` convention (pure aggregators, AGP-only // Android modules) contribute empty lists rather than failing. + // ALL source sets are emitted (not just main/test) so custom ones (integrationTest, + // functionalTest, ...) reach the analysis; source-file-level exclusion is coana's job. // `classesDirs` (Gradle 4+) falls back to the legacy single `classesDir` on older Gradle. def sources = [] as Set def targets = [] as Set try { if (p.hasProperty('sourceSets')) { - ['main', 'test'].each { sn -> - def ss = p.sourceSets.findByName(sn) + p.sourceSets.each { ss -> if (ss != null) { ss.allSource.srcDirs.each { d -> sources << d.absolutePath } try { @@ -95,6 +165,13 @@ allprojects { project -> doLast { def state = gradle.socketFactsState + // Skip resolving a wholly excluded subproject: `--exclude-paths` covering its dir means we don't + // resolve for it at all (matches the project-record skip in projectsEvaluated). + def excludeRootPath = project.rootProject.projectDir.toPath() + def excludeRelDir = excludeRootPath.relativize(project.projectDir.toPath()).toString().replace(File.separator, '/') + if (gradle.ext.socketIsExcluded.call(excludeRelDir.isEmpty() ? '.' : excludeRelDir)) { + return + } // Per-RESOLUTION-ROOT accumulators, LOCAL to this collector: one (subproject, configuration) // classpath resolved into its own coordinate-keyed tree. Reassigned per config below (the // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared. diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 5d1f518ac..36dd4ac0d 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -38,6 +38,17 @@ object SocketFactsPlugin extends AutoPlugin { val extracted = Project.extract(st) val allRefs = extracted.structure.allProjectRefs + // `-Dsocket.excludePaths` (scan-root-relative globs): a subproject whose dir is wholly excluded is + // never resolved and emits no project record. Source-file-level exclusion is left to the analysis. + val excludeMatchers = parseExcludeMatchers() + val rootCanonPath = buildRoot.getCanonicalFile.toPath + def relOf(f: File): String = { + val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") + if (r.isEmpty) "." else r + } + def isExcludedRef(ref: ProjectRef): Boolean = + isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers) + // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13). val hasUpdateFull = extracted.structure.settings.map(_.key.key).exists(_.label == "updateFull") @@ -53,9 +64,11 @@ object SocketFactsPlugin extends AutoPlugin { val moduleExts = buildModuleExts(allRefs, extracted) allRefs.foreach { ref => - runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => - foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { - case (rootKey, tree) => perSub(rootKey) = tree + if (!isExcludedRef(ref)) { + runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + case (rootKey, tree) => perSub(rootKey) = tree + } } } } @@ -63,12 +76,6 @@ object SocketFactsPlugin extends AutoPlugin { val moduleDirs: Map[String, (Seq[String], Seq[String])] = if (withFiles) buildModuleDirs(allRefs, extracted) else Map.empty - val rootPath = buildRoot.getCanonicalFile.toPath - def relOf(f: File): String = { - val r = rootPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") - if (r.isEmpty) "." else r - } - val sb = new StringBuilder def rec(fields: String*): Unit = { sb.append(fields.map(esc).mkString("\t")); sb.append('\n') @@ -76,16 +83,19 @@ object SocketFactsPlugin extends AutoPlugin { rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) - // One `project` record per build module (sources/targets only with --with-files). + // One `project` record per build module (sources/targets only with --with-files). Excluded + // subprojects are omitted (they were also skipped during resolution above). allRefs.foreach { ref => - val mid = rootIdOf(extracted, ref) - val ver = if (mid.revision == null) "" else mid.revision - rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) - if (withFiles) { - moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach { - case (sources, targets) => - sources.foreach(s => rec("projectSrc", ref.project, s)) - targets.foreach(t => rec("projectTgt", ref.project, t)) + if (!isExcludedRef(ref)) { + val mid = rootIdOf(extracted, ref) + val ver = if (mid.revision == null) "" else mid.revision + rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) + if (withFiles) { + moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach { + case (sources, targets) => + sources.foreach(s => rec("projectSrc", ref.project, s)) + targets.foreach(t => rec("projectTgt", ref.project, t)) + } } } } @@ -141,6 +151,8 @@ object SocketFactsPlugin extends AutoPlugin { // Absolute source roots + compiled-output dirs per build module, keyed by GAV. --with-files only; // absolute because reachability locates an internal module's code on THIS machine (no registry jar). + // ALL of the project's configurations are scanned (not just Compile/Test) so custom ones + // (IntegrationTest, ...) reach the analysis; source-file-level exclusion is coana's job. private def buildModuleDirs( allRefs: Seq[ProjectRef], extracted: Extracted @@ -148,10 +160,12 @@ object SocketFactsPlugin extends AutoPlugin { allRefs.map { ref => val mid = rootIdOf(extracted, ref) val ver = if (mid.revision == null) "" else mid.revision - val sources = Seq(Compile, Test) + val configs: Seq[Configuration] = + extracted.getOpt(thisProject.in(ref)).map(_.configurations).getOrElse(Seq(Compile, Test)) + val sources = configs .flatMap(c => extracted.getOpt(sourceDirectories.in(ref).in(c)).getOrElse(Nil)) .map(_.getAbsolutePath).distinct.sorted - val targets = Seq(Compile, Test) + val targets = configs .flatMap(c => extracted.getOpt(classDirectory.in(ref).in(c))) .map(_.getAbsolutePath).distinct.sorted (mid.organization + ":" + mid.name + ":" + ver) -> ((sources, targets)) @@ -378,6 +392,69 @@ object SocketFactsPlugin extends AutoPlugin { catch { case _: Throwable => c.toString } } + // `-Dsocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each + // entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same expansion + // as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` still excludes the + // `dir` directory itself, not only its contents. Standard glob semantics (anchored to the scan root, matching + // the CLI flag): `x` is root-level, `**/x` matches at any depth. Mirrors the gradle/maven producers. + private def parseExcludeMatchers(): Seq[java.nio.file.PathMatcher] = { + sys.props.get("socket.excludePaths").map(_.trim).filter(_.nonEmpty) match { + case None => Nil + case Some(raw) => + val fs = java.nio.file.FileSystems.getDefault + raw.split(",").toSeq.flatMap { r => + var g = r.trim.replace("\\", "/") + while (g.startsWith("/")) g = g.substring(1) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + while (g.endsWith("/**")) { + g = g.substring(0, g.length - 3) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + } + if (g.isEmpty) Nil + else zeroDepthVariants(g).flatMap { v => + Seq(fs.getPathMatcher("glob:" + v), fs.getPathMatcher("glob:" + v + "/**")) + } + } + } + } + + // NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's + // micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` + // occurrences dropped so both semantics hold. + private def zeroDepthVariants(glob: String): Seq[String] = { + val out = mutable.LinkedHashSet[String]() + val work = mutable.Queue(glob) + while (work.nonEmpty) { + val cur = work.dequeue() + if (out.add(cur)) { + var idx = cur.indexOf("**/") + while (idx >= 0) { + if (idx == 0 || cur.charAt(idx - 1) == '/') { + val collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (collapsed.nonEmpty) work.enqueue(collapsed) + } + idx = cur.indexOf("**/", idx + 1) + } + } + } + out.toSeq + } + + private def isExcludedPath(rel: String, matchers: Seq[java.nio.file.PathMatcher]): Boolean = { + if (matchers.isEmpty) false + else { + var c = (if (rel == null) "" else rel).replace("\\", "/") + while (c.startsWith("./")) c = c.substring(2) + while (c.startsWith("/")) c = c.substring(1) + while (c.endsWith("/")) c = c.substring(0, c.length - 1) + if (c.isEmpty) false + else { + val p = java.nio.file.Paths.get(c) + matchers.exists(_.matches(p)) + } + } + } + private def isTestConf(name: String): Boolean = name.toLowerCase.contains("test") // Only feeds the informational `dev` flag, never gates analysis. diff --git a/src/commands/scan/exclude-paths.mts b/src/commands/scan/exclude-paths.mts index 4f16bcce2..82570ae75 100644 --- a/src/commands/scan/exclude-paths.mts +++ b/src/commands/scan/exclude-paths.mts @@ -110,12 +110,50 @@ const DEGENERATE_EXCLUDE_PATHS = new Set([ '/**', ]) +/** + * NIO glob syntax (used by the JVM manifest producers) throws on unbalanced + * `[`/`{` and nested `{...}` groups, where micromatch silently falls back to a + * literal match. Detect those shapes so the CLI can reject them up front + * instead of crashing mid-build inside gradle/maven/sbt. Backslashes are + * normalized to `/` throughout the pipeline, so no escape handling is needed. + */ +function hasNioIncompatibleGlobBrackets(pattern: string): boolean { + let braceDepth = 0 + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i] + if (ch === '[') { + let j = i + 1 + if (pattern[j] === '!' || pattern[j] === '^') { + j += 1 + } + // NIO glob has no literal-`]`-first convention: `[]x` / `[!]x` throws + // as an empty/unclosed class. + if (j >= pattern.length || pattern[j] === ']') { + return true + } + const close = pattern.indexOf(']', j + 1) + if (close === -1) { + return true + } + i = close + } else if (ch === '{') { + braceDepth += 1 + if (braceDepth > 1) { + return true + } + } else if (ch === '}' && braceDepth > 0) { + braceDepth -= 1 + } + } + return braceDepth !== 0 +} + /** * Validates --exclude-paths entries before they reach either exclusion sink. * Rejects gitignore-style negations (coana's --exclude-dirs has no negation * form), absolute paths (the flag is scan-root relative), patterns escaping - * the scan root via `..`, and degenerate match-everything sentinels like `.`, - * `**`, `/`. + * the scan root via `..`, degenerate match-everything sentinels like `.`, + * `**`, `/`, and bracket shapes the JVM producers' NIO glob parser rejects. */ export function assertValidExcludePaths(paths: readonly string[]): void { for (const p of paths) { @@ -140,6 +178,11 @@ export function assertValidExcludePaths(paths: readonly string[]): void { `--exclude-paths cannot escape the scan root with '..'. Got: '${p}'.`, ) } + if (hasNioIncompatibleGlobBrackets(posix)) { + throw new InputError( + `--exclude-paths contains a glob with unbalanced or nested '[' / '{' brackets: '${p}'. Note that '{a,b}' alternations are not supported (values are comma-split).`, + ) + } } } diff --git a/src/commands/scan/exclude-paths.test.mts b/src/commands/scan/exclude-paths.test.mts index 1478e82ee..23e45cab4 100644 --- a/src/commands/scan/exclude-paths.test.mts +++ b/src/commands/scan/exclude-paths.test.mts @@ -77,6 +77,31 @@ describe('exclude-paths', () => { ) }, ) + + it.each([ + 'src/[abc', + 'foo{bar', + 'a/{b/{c}}', + 'x}y{z', + 'src/[]]x', + 'src/[!]x', + ])('rejects NIO-glob-incompatible bracket pattern %j', input => { + expect(() => assertValidExcludePaths([input])).toThrow( + /unbalanced or nested/, + ) + }) + + it.each([ + 'src/[abc]', + 'src/[!abc]', + 'src/[^abc]', + 'src/[a]b]', + 'src/[[]', + 'a/{b}/c', + 'x}y', + ])('allows balanced bracket pattern %j', input => { + expect(() => assertValidExcludePaths([input])).not.toThrow() + }) }) describe('excludePathToScanIgnores', () => { diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index ae9925ce2..9e18e879c 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -147,6 +147,7 @@ export async function handleCreateNewScan({ computeArtifactsSidecar: reach.runReachabilityAnalysis, cwd, detected, + excludePaths: reach.excludePaths, outputKind, verbose: false, })