From 04d20d16b3c0204f36545798cae3967dfe745faa Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Sat, 31 Oct 2026 16:50:42 +0100 Subject: [PATCH 1/2] fix(manifest): anchor --exclude-paths at the scan root for every build root Dynamic SBOM inference re-anchored --exclude-paths onto each build root and dropped every pattern outside it, so a module the build reaches outside its own directory (a Maven `../x`, a Gradle project relocated with projectDir) could not be excluded and was resolved and uploaded anyway. Build roots now get scan-root-relative patterns plus the absolute scan root (socket.excludeBase, the build root by default). The Maven extension, Gradle init scripts and sbt plugins relativize each module's canonical directory against it, so a module outside the scan root never matches. New compat smoke tests cover a module outside the build root (Maven, Gradle) and a nested one (sbt, which refuses projects outside its build root). --- CHANGELOG.md | 5 +++ .../manifest/enumerate-workspaces.mts | 4 ++ .../manifest/generate-recursive-manifests.mts | 23 +++++------ .../generate-recursive-manifests.test.mts | 22 ++++++++++ src/commands/manifest/run-manifest-facts.mts | 4 ++ .../ext/CoanaFactsLifecycleParticipant.java | 1 + .../CoanaWorkspacesLifecycleParticipant.java | 1 + .../socket/SocketFactsRecordsEngine.java | 10 +++-- .../java/tech/coana/socket/SocketSupport.java | 24 +++++++++++ .../socket/SocketWorkspacesRecordsEngine.java | 7 +++- src/commands/manifest/scripts/run.mts | 12 ++++-- src/commands/manifest/scripts/run.test.mts | 29 ++++++++++++++ .../manifest/scripts/socket-facts.init.gradle | 18 +++++++-- .../scripts/socket-facts.plugin.scala | 15 +++++-- .../scripts/socket-workspaces.init.gradle | 14 ++++++- .../scripts/socket-workspaces.plugin.scala | 15 +++++-- .../exclude-base/app/build.gradle | 1 + .../exclude-base/app/core/build.gradle | 1 + .../exclude-base/app/settings.gradle | 4 ++ .../exclude-base/shared/build.gradle | 1 + .../gradle-compat/smoke-test-exclude-base.sh | 32 +++++++++++++++ .../exclude-base/app/core/pom.xml | 11 +++++ .../maven-compat/exclude-base/app/pom.xml | 12 ++++++ .../maven-compat/exclude-base/shared/pom.xml | 11 +++++ .../maven-compat/smoke-test-exclude-base.sh | 35 ++++++++++++++++ .../manifest/scripts/test/run-compat.sh | 3 ++ .../sbt-compat/exclude-base/app/build.sbt | 2 + .../sbt-compat/smoke-test-exclude-base.sh | 40 +++++++++++++++++++ .../setup-recursive-manifest-config.mts | 12 +++--- .../setup-recursive-manifest-config.test.mts | 7 +++- 30 files changed, 334 insertions(+), 42 deletions(-) create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/build.gradle create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/build.gradle create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/settings.gradle create mode 100644 src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/build.gradle create mode 100755 src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh create mode 100644 src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml create mode 100644 src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml create mode 100644 src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml create mode 100755 src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh create mode 100644 src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/build.sbt create mode 100755 src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 14322c1dd4..c91b64f9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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] + +### Fixed +- `--exclude-paths` now also excludes modules a build reaches outside its own directory, such as Maven `../` modules and relocated Gradle projects, when Socket facts are generated for each build root. + ## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30 ### Changed diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts index 524de6ee95..04c79a982a 100644 --- a/src/commands/manifest/enumerate-workspaces.mts +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -25,6 +25,7 @@ export async function enumerateWorkspaces({ cwd, ecosystem, entryFile, + excludeBase, excludePaths, javaHome, verbose, @@ -35,6 +36,8 @@ export async function enumerateWorkspaces({ ecosystem: BuildTool // See ManifestScriptOptions.entryFile. entryFile?: string | undefined + // See ManifestScriptOptions.excludeBase. + excludeBase?: string | undefined excludePaths?: string[] | undefined javaHome?: string | undefined verbose: boolean @@ -55,6 +58,7 @@ export async function enumerateWorkspaces({ const scriptOpts = { bin: bin || undefined, entryFile, + excludeBase, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. env: resolvedJavaHome diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index dfdfee445f..f9bcc23b08 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -169,12 +169,15 @@ async function runEcosystemCandidates({ }): Promise { const outcomes: RecursiveManifestOutcome[] = [] const covered = new Set() + // Relative to the scan root: a build can reach modules outside its own + // directory (Maven `../x` modules, relocated Gradle projects). + const scanExcludePaths = projectIgnorePathsToReachExcludePaths(excludePaths, { + cwd: realCwd, + target: realCwd, + }) for (const [ecosystem, roots] of candidatesByTool) { const disabledRoots: DisabledRoot[] = [] - const configByDir = new Map< - string, - EcosystemBuildConfig & { excludePaths: string[] } - >() + const configByDir = new Map() for (const { 0: rootIndex, 1: root } of roots.entries()) { const { dir, entryFile } = root if (isBuildRootCovered(covered, ecosystem, root)) { @@ -188,13 +191,7 @@ async function runEcosystemCandidates({ const sockJson = nearestRoot ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) : readSocketJsonCascade(dir, realCwd, rootSockJson) - dirConfig = { - ...resolveEcosystemConfig(ecosystem, dir, sockJson), - excludePaths: projectIgnorePathsToReachExcludePaths(excludePaths, { - cwd: realCwd, - target: dir, - }), - } + dirConfig = resolveEcosystemConfig(ecosystem, dir, sockJson) configByDir.set(dir, dirConfig) if (dirConfig.skipReason) { // Only warn for a genuinely new disabled root, not one already @@ -214,7 +211,6 @@ async function runEcosystemCandidates({ bin, buildOpts, excludeConfigs, - excludePaths: excludePathsForRoot, ignoreUnresolved, includeConfigs, javaHome, @@ -234,7 +230,8 @@ async function runEcosystemCandidates({ ecosystem, entryFile, excludeConfigs, - excludePaths: excludePathsForRoot, + excludeBase: realCwd, + excludePaths: scanExcludePaths, hints: { continueOnInstallErrorsFlag: withFiles ? REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 5bcf8afc29..80c7778abd 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -496,6 +496,28 @@ describe('generateRecursiveManifests', () => { expect(byKey.get('maven:dual-marker-dir')).toBe('empty') }) + it('passes every build root the scan-root-relative --exclude-paths and the scan root', async () => { + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ + cwd: monorepo, + excludePaths: ['reactor/moduleB'], + verbose: false, + }) + + const reactorCall = vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => opts) + .find(opts => opts.cwd === reactor) + expect(reactorCall).toMatchObject({ + excludeBase: await fs.realpath(monorepo), + excludePaths: ['reactor/moduleB'], + }) + }) + it('resolves each build root its own nearest socket.json instead of only the root config', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 1c09c4a377..8954f981aa 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -62,6 +62,7 @@ export async function runManifestFacts({ cwd, ecosystem, entryFile, + excludeBase, excludeConfigs, excludePaths, hints = FLAG_HINTS, @@ -79,6 +80,8 @@ export async function runManifestFacts({ ecosystem: BuildTool // See ManifestScriptOptions.entryFile. entryFile?: string | undefined + // See ManifestScriptOptions.excludeBase. + excludeBase?: string | undefined excludeConfigs: string excludePaths?: string[] | undefined // Where the user sets build options, for remedy hints. @@ -122,6 +125,7 @@ export async function runManifestFacts({ bin: bin || undefined, entryFile, excludeConfigs: excludeConfigs || undefined, + excludeBase, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. env: resolvedJavaHome 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 3ff79ec576..5a20e5ac26 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 @@ -59,6 +59,7 @@ public void afterSessionEnd(MavenSession session) throws MavenExecutionException opts.includeConfigs = opt(session, "socket.includeConfigs"); opts.excludeConfigs = opt(session, "socket.excludeConfigs"); opts.excludePaths = opt(session, "socket.excludePaths"); + opts.excludeBase = opt(session, "socket.excludeBase"); File rootDir = new File(session.getExecutionRootDirectory()); CollectionObserver observer = new CollectionObserver(); CoanaFactsEventSpy.observe(observer); diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java index 2e73b72d00..3001b546e7 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java @@ -41,6 +41,7 @@ public void afterProjectsRead(MavenSession session) throws MavenExecutionExcepti SocketWorkspacesRecordsEngine.Options opts = new SocketWorkspacesRecordsEngine.Options(); opts.recordsFile = recordsFile; opts.excludePaths = opt(session, "socket.excludePaths"); + opts.excludeBase = opt(session, "socket.excludeBase"); File rootDir = new File(session.getExecutionRootDirectory()); try { SocketWorkspacesRecordsEngine.run(session.getProjects(), rootDir, opts, runtimeInformation.getMavenVersion()); 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 27ad5081e0..4de0d60610 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 @@ -64,8 +64,10 @@ 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. + // `--exclude-paths` (CSV) relative to excludeBase: a wholly excluded reactor module is skipped. public String excludePaths; + // The directory excludePaths are relative to; the build root when unset. + public String excludeBase; public String recordsFile; } @@ -118,6 +120,7 @@ public void run(MavenSession session, List reactor, File rootDir, reactorGavs.add(p.getGroupId() + ":" + p.getArtifactId() + ":" + p.getVersion()); } List excludes = SocketSupport.parseExcludePatterns(opts.excludePaths); + java.nio.file.Path excludeBase = SocketSupport.excludeBase(opts.excludeBase, rootDir); List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); @@ -132,7 +135,7 @@ public void run(MavenSession session, List reactor, File rootDir, if (module.getBasedir() == null) continue; String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); String key = projectKey(module); - boolean excluded = SocketSupport.isExcludedPath(ws, excludes); + boolean excluded = SocketSupport.isExcludedDir(excludeBase, module.getBasedir().toPath(), excludes); if (excluded) { // Not analysed on its own, but a kept module depending on it is analysed with its code. if (opts.withFiles) rec(lines, "excludedProject", key); @@ -154,9 +157,8 @@ public void run(MavenSession session, List reactor, File rootDir, int rootIdx = 0; for (MavenProject module : reactor) { if (module.getBasedir() == null) continue; - 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; + if (SocketSupport.isExcludedDir(excludeBase, module.getBasedir().toPath(), excludes)) continue; Map nodes = new LinkedHashMap<>(); Set directIds = new HashSet<>(); // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots). 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 759df9142c..5fd26230e5 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 @@ -102,6 +102,30 @@ public static List parseExcludePatterns(String csv) { return out; } + /** + * Whether a module directory is covered by an {@code --exclude-paths} pattern, which is relative + * to {@code base} (the scan root). A module outside it, such as a {@code ../x} + * beyond the scan root, never is. + */ + public static boolean isExcludedDir(Path base, Path dir, List patterns) { + if (patterns == null || patterns.isEmpty()) return false; + String rel = base.relativize(canonical(dir)).toString().replace(File.separatorChar, '/'); + return !rel.equals("..") && !rel.startsWith("../") && isExcludedPath(rel.isEmpty() ? "." : rel, patterns); + } + + /** The canonical {@code isExcludedDir} base: {@code socket.excludeBase}, else the build root. */ + public static Path excludeBase(String excludeBase, File rootDir) { + return canonical((excludeBase != null && !excludeBase.isEmpty() ? new File(excludeBase) : rootDir).toPath()); + } + + private static Path canonical(Path p) { + try { + return p.toRealPath(); + } catch (java.io.IOException e) { + return p.toAbsolutePath().normalize(); + } + } + /** Whether a scan-root-relative POSIX path is covered by any {@code --exclude-paths} pattern. */ public static boolean isExcludedPath(String rel, List patterns) { if (patterns == null || patterns.isEmpty()) return false; diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java index 969a2acb1e..c6b34fcf9d 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java @@ -20,8 +20,10 @@ public final class SocketWorkspacesRecordsEngine { public static final class Options { - // Scan-root-relative `--exclude-paths` (CSV): a wholly excluded reactor module is skipped. + // `--exclude-paths` (CSV) relative to excludeBase: a wholly excluded reactor module is skipped. public String excludePaths; + // The directory excludePaths are relative to; the build root when unset. + public String excludeBase; public String recordsFile; } @@ -30,6 +32,7 @@ private SocketWorkspacesRecordsEngine() {} public static void run(List reactor, File rootDir, Options opts, String mavenVersion) throws IOException { List excludes = SocketSupport.parseExcludePatterns(opts.excludePaths); + java.nio.file.Path excludeBase = SocketSupport.excludeBase(opts.excludeBase, rootDir); List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); @@ -40,7 +43,7 @@ public static void run(List reactor, File rootDir, Options opts, S // own "no POM in this directory" error surface instead of an NPE. if (module.getBasedir() == null) continue; String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); - if (SocketSupport.isExcludedPath(ws, excludes)) continue; + if (SocketSupport.isExcludedDir(excludeBase, module.getBasedir().toPath(), excludes)) continue; rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); File pom = module.getFile(); if (pom != null && pom.isFile()) rec(lines, "projectBuild", ws, SocketSupport.relativePath(rootDir.toPath(), pom.toPath())); diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index e6c6e0db86..71379b25ff 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -38,10 +38,13 @@ 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. + // `--exclude-paths`, relative to excludeBase. 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 + // The scan root, which a build's modules can lie outside of (Maven `../x` + // modules); projectDir when unset. + excludeBase?: string | undefined toolOpts?: string[] | undefined stdio?: 'inherit' | 'pipe' | undefined env?: NodeJS.ProcessEnv | undefined @@ -238,6 +241,9 @@ function commonProps( const excludePatterns = serializeExcludePathPatterns(opts.excludePaths) if (excludePatterns) { props.push(`${prefix}socket.excludePaths=${excludePatterns}`) + props.push( + `${prefix}socket.excludeBase=${opts.excludeBase ?? opts.projectDir}`, + ) } return props } diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 9192a699f4..bb1a251d82 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -99,3 +99,32 @@ describe('maven entry file', () => { }, ) }) + +async function excludeProps(excludeBase: string | undefined) { + vi.mocked(spawn).mockClear() + await enumerateWorkspaces('gradle', { + bin: 'gradle', + excludeBase, + excludePaths: ['shared'], + projectDir: path.join(path.sep, 'repo', 'app'), + stdio: 'pipe', + }).catch(() => {}) + const args = (vi.mocked(spawn).mock.calls[0]?.[1] ?? []) as string[] + return args.filter(a => a.startsWith('-Psocket.exclude')) +} + +describe('--exclude-paths anchoring', () => { + it('tells the build script the scan root the patterns are relative to', async () => { + expect(await excludeProps(path.join(path.sep, 'repo'))).toEqual([ + expect.stringMatching(/^-Psocket\.excludePaths=/), + `-Psocket.excludeBase=${path.join(path.sep, 'repo')}`, + ]) + }) + + it('defaults the base to the build root', async () => { + expect(await excludeProps(undefined)).toEqual([ + expect.stringMatching(/^-Psocket\.excludePaths=/), + `-Psocket.excludeBase=${path.join(path.sep, 'repo', 'app')}`, + ]) + }) +}) diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index d10c683848..ef97e11b14 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -42,6 +42,18 @@ gradle.ext.socketIsExcluded = { String rel -> if (c.isEmpty()) { return false } gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() } } +// `-Psocket.excludeBase` (the scan root; the root project's dir by default) anchors `--exclude-paths`: +// a project relocated outside the build root still matches, one outside the scan root never does. +gradle.ext.socketExcludeBaseCache = null +gradle.ext.socketIsExcludedDir = { java.io.File dir -> + if (gradle.ext.socketExcludeMatchers.call().isEmpty()) { return false } + if (gradle.ext.socketExcludeBaseCache == null) { + def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString() + gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath() + } + def rel = gradle.ext.socketExcludeBaseCache.relativize(dir.canonicalFile.toPath()).toString().replace(File.separator, '/') + !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel) +} // Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every // collector and the root aggregator share one instance. @@ -94,7 +106,7 @@ gradle.projectsEvaluated { g -> // A wholly excluded subproject emits no project record and its configs are never resolved (see the // collector). The project maps above stay populated so a KEPT project depending on it // still recognizes it as a first-party module, and its paths are kept so it is analysed with its code. - def excluded = g.ext.socketIsExcluded.call(rel(p.projectDir)) + def excluded = g.ext.socketIsExcludedDir.call(p.projectDir) // Guarded: projects without the java `sourceSets` convention (pure aggregators) contribute empty // lists rather than failing. Kotlin and Android code lives outside it (see below). // ALL source sets are emitted (not just main/test) so custom ones (integrationTest, @@ -200,9 +212,7 @@ allprojects { project -> 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)) { + if (gradle.ext.socketIsExcludedDir.call(project.projectDir)) { return } // Per-RESOLUTION-ROOT accumulators, LOCAL to this collector: one (subproject, configuration) diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 37375252d9..3836c4efde 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -41,12 +41,19 @@ object SocketFactsPlugin extends AutoPlugin { // 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, "/") + def relTo(base: java.nio.file.Path)(f: File): String = { + val r = base.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) + def relOf(f: File): String = relTo(rootCanonPath)(f) + // `-Dsocket.excludeBase`: the directory `--exclude-paths` is relative to (the scan root; the + // build root by default); a project outside it never matches. + val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty) + .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath) + def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && { + val r = relTo(excludeBase)(extracted.get(baseDirectory.in(ref))) + r != ".." && !r.startsWith("../") && isExcludedPath(r, excludeMatchers) + } // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13). val hasUpdateFull = diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle index 5b1c4857b8..b43dff0a3d 100644 --- a/src/commands/manifest/scripts/socket-workspaces.init.gradle +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -39,6 +39,18 @@ gradle.ext.socketIsExcluded = { String rel -> if (c.isEmpty()) { return false } gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() } } +// `-Psocket.excludeBase` (the scan root; the root project's dir by default) anchors `--exclude-paths`: +// a project relocated outside the build root still matches, one outside the scan root never does. +gradle.ext.socketExcludeBaseCache = null +gradle.ext.socketIsExcludedDir = { java.io.File dir -> + if (gradle.ext.socketExcludeMatchers.call().isEmpty()) { return false } + if (gradle.ext.socketExcludeBaseCache == null) { + def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString() + gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath() + } + def rel = gradle.ext.socketExcludeBaseCache.relativize(dir.canonicalFile.toPath()).toString().replace(File.separator, '/') + !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel) +} gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([]) @@ -52,7 +64,7 @@ gradle.projectsEvaluated { g -> r.isEmpty() ? '.' : r } g.rootProject.allprojects.each { p -> - if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { + if (g.ext.socketIsExcludedDir.call(p.projectDir)) { return } def buildFiles = [] diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala index 511f8de4e0..423570e25e 100644 --- a/src/commands/manifest/scripts/socket-workspaces.plugin.scala +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -32,12 +32,19 @@ object SocketWorkspacesPlugin extends AutoPlugin { // emits no project record. Mirrors socket-facts.plugin.scala / the gradle / maven producers. 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, "/") + def relTo(base: java.nio.file.Path)(f: File): String = { + val r = base.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) + def relOf(f: File): String = relTo(rootCanonPath)(f) + // `-Dsocket.excludeBase`: the directory `--exclude-paths` is relative to (the scan root; the + // build root by default); a project outside it never matches. + val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty) + .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath) + def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && { + val r = relTo(excludeBase)(extracted.get(baseDirectory.in(ref))) + r != ".." && !r.startsWith("../") && isExcludedPath(r, excludeMatchers) + } def rootIdOf(ref: ProjectRef): ModuleID = { val sv = extracted.get(scalaVersion.in(ref)) diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/build.gradle b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/build.gradle new file mode 100644 index 0000000000..c76d5a512e --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/build.gradle @@ -0,0 +1 @@ +// Aggregator only. diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/build.gradle b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/build.gradle new file mode 100644 index 0000000000..a26022f97e --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/build.gradle @@ -0,0 +1 @@ +// No dependencies. diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/settings.gradle b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/settings.gradle new file mode 100644 index 0000000000..b787f45107 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/settings.gradle @@ -0,0 +1,4 @@ +rootProject.name = 'exclude-base' +include 'core', 'shared' +// A project outside the build root. +project(':shared').projectDir = file('../shared') diff --git a/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/build.gradle b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/build.gradle new file mode 100644 index 0000000000..a26022f97e --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/build.gradle @@ -0,0 +1 @@ +// No dependencies. diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh new file mode 100755 index 0000000000..c4f7bc9b91 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# --exclude-paths relative to the scan root (-Psocket.excludeBase), one level above the build root: +# a project relocated outside the build root is excluded by its scan-root path, and a nested one +# by the build root's path in the scan root. +# +# Usage: smoke-test-exclude-base.sh /path/to/gradle +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +GRADLE="${1:?usage: smoke-test-exclude-base.sh }" +INIT="$HERE/../../socket-workspaces.init.gradle" +SCAN_ROOT="$(cd "$HERE/exclude-base" && pwd -P)" +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT" "$SCAN_ROOT/app/.gradle"' EXIT + +echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1) (exclude base)" +projects() { + ( cd "$SCAN_ROOT/app" && "$GRADLE" --no-daemon --offline -g "$OUT/gradle-home" \ + --init-script "$INIT" -Psocket.recordsFile="$OUT/records.tsv" \ + "-Psocket.excludePaths=$1" "-Psocket.excludeBase=$SCAN_ROOT" socketWorkspaces -q >/dev/null ) + awk -F'\t' '$1 == "project" { print $2 }' "$OUT/records.tsv" | sort | tr '\n' ' ' +} +check() { + local got + got="$(projects "$1")" + if [ "$got" != "$2" ]; then + echo "FAIL: excluding $1 kept [$got], want [$2]" >&2 + exit 1 + fi +} +check '^(?:shared)(?:/.*)?$' ': :core ' +check '^(?:app/core)(?:/.*)?$' ': :shared ' +echo "PASS: projects inside and outside the build root are excluded by their scan-root paths" diff --git a/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml b/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml new file mode 100644 index 0000000000..197f06f8ab --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + + demo + app + 1.0 + ../pom.xml + + core + pom + diff --git a/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml new file mode 100644 index 0000000000..06e5a0a30c --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + demo + app + 1.0 + pom + + core + + ../shared + + diff --git a/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml b/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml new file mode 100644 index 0000000000..516382e76e --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + + demo + app + 1.0 + ../app/pom.xml + + shared + pom + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh new file mode 100755 index 0000000000..6b75a81d23 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# --exclude-paths relative to the scan root (-Dsocket.excludeBase), one level above the build root: +# a `../shared` outside the build root is excluded by its scan-root path, and a +# nested module by the build root's path in the scan root. +# +# Usage: smoke-test-exclude-base.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-exclude-base.sh }" +JAR="${2:?usage: smoke-test-exclude-base.sh }" +SCAN_ROOT="$(cd "$HERE/exclude-base" && pwd -P)" +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT" "$SCAN_ROOT"/app/target "$SCAN_ROOT"/app/core/target "$SCAN_ROOT"/shared/target' EXIT + +echo "+ $("$MVN" -v 2>/dev/null | head -1) (exclude base)" +modules() { + ( cd "$SCAN_ROOT/app" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-workspaces \ + "-Dsocket.recordsFile=$OUT/records.tsv" \ + "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=$SCAN_ROOT" \ + validate >/dev/null ) + awk -F'\t' '$1 == "project" { print $4 }' "$OUT/records.tsv" | sort | tr '\n' ' ' +} +check() { + local got + got="$(modules "$1")" + if [ "$got" != "$2" ]; then + echo "FAIL: excluding $1 kept [$got], want [$2]" >&2 + exit 1 + fi +} +check '^(?:shared)(?:/.*)?$' 'app core ' +check '^(?:app/core)(?:/.*)?$' 'app shared ' +echo "PASS: modules inside and outside the build root are excluded by their scan-root paths" diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index fb7b668ffd..6b2b0ab2a0 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -64,6 +64,7 @@ run_gradle() { fi bash "$HERE/gradle-compat/smoke-test.sh" "$dir/bin/gradle" bash "$HERE/gradle-compat/smoke-test-workspaces.sh" "$dir/bin/gradle" + bash "$HERE/gradle-compat/smoke-test-exclude-base.sh" "$dir/bin/gradle" bash "$HERE/gradle-compat/smoke-test-failures.sh" "$dir/bin/gradle" done } @@ -89,6 +90,7 @@ run_maven() { fi bash "$HERE/maven-compat/smoke-test.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-workspaces.sh" "$dir/bin/mvn" "$jar" + bash "$HERE/maven-compat/smoke-test-exclude-base.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" @@ -110,6 +112,7 @@ run_sbt() { if [ -z "$resolver" ]; then 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-exclude-base.sh" "$ver" "$scala" fi bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"} done diff --git a/src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/build.sbt new file mode 100644 index 0000000000..d90ea369ac --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/build.sbt @@ -0,0 +1,2 @@ +lazy val core = project +lazy val root = (project in file(".")).aggregate(core) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh new file mode 100755 index 0000000000..932683d2e8 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# --exclude-paths relative to the scan root (-Dsocket.excludeBase), one level above the build root: +# a subproject is excluded by its scan-root path, not its build-root one. sbt refuses projects +# outside the build root, so only the nested case exists. +# +# Usage: smoke-test-exclude-base.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-exclude-base.sh }" +SCALA_VERSION="${2:?usage: smoke-test-exclude-base.sh }" +PLUGIN="$HERE/../../socket-workspaces.plugin.scala" +SCAN_ROOT="$(cd "$HERE/exclude-base" && pwd -P)" +PROJECT="$SCAN_ROOT/app" +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT" "$PROJECT/project" "$PROJECT/target" "$PROJECT/core" "$PROJECT/scala-version.sbt"' EXIT + +mkdir -p "$OUT/global-base/plugins" "$PROJECT/project" +cp "$PLUGIN" "$OUT/global-base/plugins/SocketWorkspacesPlugin.scala" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (exclude base)" +projects() { + ( cd "$PROJECT" && sbt -Dsbt.global.base="$OUT/global-base" -Dsbt.server.autostart=false \ + "-Dsocket.recordsFile=$OUT/records.tsv" \ + "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=$SCAN_ROOT" --batch socketWorkspaces >/dev/null ) + awk -F'\t' '$1 == "project" { print $2 }' "$OUT/records.tsv" | sort | tr '\n' ' ' +} +check() { + local got + got="$(projects "$1")" + if [ "$got" != "$2" ]; then + echo "FAIL: excluding $1 kept [$got], want [$2]" >&2 + exit 1 + fi +} +check '^(?:app/core)(?:/.*)?$' 'root ' +# Build-root-relative, so it names nothing under the scan root. +check '^(?:core)(?:/.*)?$' 'core root ' +echo "PASS: subprojects are excluded by their scan-root paths" diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 13e3f25feb..6adb6897a3 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -236,10 +236,11 @@ export async function markWorkspaceCoverage({ return { ok: true, data: undefined } } - const excludePathsForDir = projectIgnorePathsToReachExcludePaths( - excludePaths, - { cwd, target: dir }, - ) + // Relative to the scan root, as generateRecursiveManifests passes them. + const scanExcludePaths = projectIgnorePathsToReachExcludePaths(excludePaths, { + cwd, + target: cwd, + }) for (const root of roots) { if (isBuildRootCovered(covered, ecosystem, root)) { @@ -252,7 +253,8 @@ export async function markWorkspaceCoverage({ cwd: dir, ecosystem, entryFile: root.entryFile, - excludePaths: excludePathsForDir, + excludeBase: cwd, + excludePaths: scanExcludePaths, javaHome, verbose: false, }) diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts index 8358f45633..fa7eb32e9b 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.test.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -487,7 +487,7 @@ describe('markWorkspaceCoverage', () => { ).toEqual(['all.pom.xml']) }) - it('forwards --exclude-paths, re-anchored to the candidate dir, so a member excluded specifically to dodge a broken resolution is actually skipped', async () => { + it('forwards --exclude-paths anchored at the scan root, so a member excluded specifically to dodge a broken resolution is actually skipped', async () => { vi.mocked(enumerateWorkspaces).mockResolvedValue({ buildRoot: reactor, projects: [], @@ -504,7 +504,10 @@ describe('markWorkspaceCoverage', () => { }) expect(enumerateWorkspaces).toHaveBeenCalledWith( - expect.objectContaining({ excludePaths: ['moduleB'] }), + expect.objectContaining({ + excludeBase: cwd, + excludePaths: ['reactor/moduleB'], + }), ) }) }) From e007d2e94672cef8cd13f4b4e5c8d254245ec004 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 2 Nov 2026 11:16:53 +0100 Subject: [PATCH 2/2] fix(manifest): harden scan-root --exclude-paths anchoring - Treat a module on another Windows drive than the scan root as outside it instead of letting Path.relativize throw, in every JVM exclusion helper. - Normalize `\` separators in the glob compiler, so single-root runs match Windows-style patterns too and callers pass --exclude-paths straight through. - Smoke-check that a module outside the scan root is never excluded, clear stale records between checks and keep build output visible. - Document the narrowed and re-anchored behaviour in the changelog and the --exclude-paths help. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++++- .../manifest/cmd-manifest-auto.test.mts | 2 +- ...md-manifest-dynamic-sbom-inference.test.mts | 2 +- .../manifest/cmd-manifest-gradle.test.mts | 2 +- .../manifest/cmd-manifest-kotlin.test.mts | 2 +- .../manifest/cmd-manifest-maven.test.mts | 2 +- .../manifest/cmd-manifest-scala.test.mts | 2 +- .../manifest/generate-recursive-manifests.mts | 11 +++-------- src/commands/manifest/manifest-flags.mts | 2 +- .../manifest/scripts/exclude-paths-glob.mts | 5 ++++- .../scripts/exclude-paths-glob.test.mts | 6 ++++++ .../java/tech/coana/socket/SocketSupport.java | 5 ++++- src/commands/manifest/scripts/run.test.mts | 2 +- .../manifest/scripts/socket-facts.init.gradle | 5 ++++- .../manifest/scripts/socket-facts.plugin.scala | 8 ++++++-- .../scripts/socket-workspaces.init.gradle | 5 ++++- .../scripts/socket-workspaces.plugin.scala | 8 ++++++-- .../gradle-compat/smoke-test-exclude-base.sh | 16 +++++++++++----- .../maven-compat/smoke-test-exclude-base.sh | 18 ++++++++++++------ .../test/sbt-compat/smoke-test-exclude-base.sh | 5 ++++- .../setup-recursive-manifest-config.mts | 13 ++----------- src/commands/scan/cmd-scan-create.test.mts | 2 +- src/commands/scan/cmd-scan-reach.test.mts | 2 +- src/commands/scan/reachability-flags.mts | 2 +- 24 files changed, 82 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c91b64f9ee..54bb3b26be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed +- Modules outside the scan root can no longer be excluded with `--exclude-paths`; `**/x` used to match a `../x` module by accident. Single-root runs whose Maven or Gradle options relocate the build root (`-f`, `-p`) now match patterns relative to the current directory, as documented. + ### Fixed -- `--exclude-paths` now also excludes modules a build reaches outside its own directory, such as Maven `../` modules and relocated Gradle projects, when Socket facts are generated for each build root. +- `--exclude-paths` patterns are now matched relative to the scan root in every build root. They now also exclude modules a build reaches outside its own directory (Maven `../` modules, relocated Gradle projects) and modules matched by wildcards that span a build-root boundary, such as `*/core`. +- `--exclude-paths` patterns written with Windows `\` separators now exclude JVM build modules in single-root runs too. ## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30 diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index 2560bb92b6..c8c7a1b12e 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -23,7 +23,7 @@ describe('socket manifest auto', async () => { $ socket manifest auto [options] [CWD=.] 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --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-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts index 17ff1284fd..59eb074f48 100644 --- a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -33,7 +33,7 @@ describe('socket manifest dynamic-sbom-inference', async () => { CWD itself. 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --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 diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index f11c5fca6f..629468872d 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -25,7 +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 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --facts Emit a Socket facts JSON file (\`gradle.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.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index a774a767e5..cd9c2893f8 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -25,7 +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 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --facts Emit a Socket facts JSON file (\`gradle.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.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index f11e19ca46..20cdb39f3e 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -24,7 +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 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --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.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 43a2256e3a..c34e002569 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -25,7 +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 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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. --facts Emit a Socket facts JSON file (\`sbt.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/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index f9bcc23b08..b281592267 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -24,7 +24,6 @@ import { readOrDefaultSocketJson, readSocketJsonCascade, } from '../../utils/socket-json.mts' -import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' import type { BuildRoot } from './discover-manifest-roots.mts' import type { BuildTool } from './scripts/build-tool.mts' @@ -169,12 +168,6 @@ async function runEcosystemCandidates({ }): Promise { const outcomes: RecursiveManifestOutcome[] = [] const covered = new Set() - // Relative to the scan root: a build can reach modules outside its own - // directory (Maven `../x` modules, relocated Gradle projects). - const scanExcludePaths = projectIgnorePathsToReachExcludePaths(excludePaths, { - cwd: realCwd, - target: realCwd, - }) for (const [ecosystem, roots] of candidatesByTool) { const disabledRoots: DisabledRoot[] = [] const configByDir = new Map() @@ -230,8 +223,10 @@ async function runEcosystemCandidates({ ecosystem, entryFile, excludeConfigs, + // Relative to the scan root: a build can reach modules outside its + // own directory (Maven `../x` modules, relocated Gradle projects). excludeBase: realCwd, - excludePaths: scanExcludePaths, + excludePaths, hints: { continueOnInstallErrorsFlag: withFiles ? REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG diff --git a/src/commands/manifest/manifest-flags.mts b/src/commands/manifest/manifest-flags.mts index 39e8b85f38..604b40d6dd 100644 --- a/src/commands/manifest/manifest-flags.mts +++ b/src/commands/manifest/manifest-flags.mts @@ -8,6 +8,6 @@ export const excludePathsFlag: MeowFlags = { type: 'string', isMultiple: true, description: - '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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project.', + '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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded.', }, } diff --git a/src/commands/manifest/scripts/exclude-paths-glob.mts b/src/commands/manifest/scripts/exclude-paths-glob.mts index 73cfb87710..33e5b0a9b6 100644 --- a/src/commands/manifest/scripts/exclude-paths-glob.mts +++ b/src/commands/manifest/scripts/exclude-paths-glob.mts @@ -129,10 +129,13 @@ export function excludePathGlobToRegexSource(glob: string): string { } } +// A Windows-style `foo\bar` means the path `foo/bar`. export function compileExcludePathPatterns( paths: readonly string[] | undefined, ): string[] { - return (paths ?? []).map(excludePathGlobToRegexSource) + return (paths ?? []).map(p => + excludePathGlobToRegexSource(p.replaceAll('\\', '/')), + ) } // Transport form handed to the build-tool scripts: comma-joined pattern diff --git a/src/commands/manifest/scripts/exclude-paths-glob.test.mts b/src/commands/manifest/scripts/exclude-paths-glob.test.mts index 4a24de7aa4..ba21e26453 100644 --- a/src/commands/manifest/scripts/exclude-paths-glob.test.mts +++ b/src/commands/manifest/scripts/exclude-paths-glob.test.mts @@ -108,6 +108,12 @@ const MATCH_VECTORS: Array<{ matches: ['ax', '&x'], rejects: ['bx'], }, + // A Windows path separator. + { + glob: 'app\\core', + matches: ['app/core', 'app/core/x'], + rejects: ['app', 'core'], + }, ] describe('exclude-paths-glob vectors (cross-language contract)', () => { 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 5fd26230e5..e14b981afa 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 @@ -109,7 +109,10 @@ public static List parseExcludePatterns(String csv) { */ public static boolean isExcludedDir(Path base, Path dir, List patterns) { if (patterns == null || patterns.isEmpty()) return false; - String rel = base.relativize(canonical(dir)).toString().replace(File.separatorChar, '/'); + Path path = canonical(dir); + // On another Windows drive than the scan root, so outside it. + if (!java.util.Objects.equals(path.getRoot(), base.getRoot())) return false; + String rel = base.relativize(path).toString().replace(File.separatorChar, '/'); return !rel.equals("..") && !rel.startsWith("../") && isExcludedPath(rel.isEmpty() ? "." : rel, patterns); } diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index bb1a251d82..05c7275623 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -121,7 +121,7 @@ describe('--exclude-paths anchoring', () => { ]) }) - it('defaults the base to the build root', async () => { + it('defaults the base to projectDir', async () => { expect(await excludeProps(undefined)).toEqual([ expect.stringMatching(/^-Psocket\.excludePaths=/), `-Psocket.excludeBase=${path.join(path.sep, 'repo', 'app')}`, diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index ef97e11b14..7558a68c60 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -51,7 +51,10 @@ gradle.ext.socketIsExcludedDir = { java.io.File dir -> def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString() gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath() } - def rel = gradle.ext.socketExcludeBaseCache.relativize(dir.canonicalFile.toPath()).toString().replace(File.separator, '/') + def path = dir.canonicalFile.toPath() + // On another Windows drive than the scan root, so outside it. + if (path.root != gradle.ext.socketExcludeBaseCache.root) { return false } + def rel = gradle.ext.socketExcludeBaseCache.relativize(path).toString().replace(File.separator, '/') !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel) } diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 3836c4efde..4d9d7a5b16 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -51,8 +51,12 @@ object SocketFactsPlugin extends AutoPlugin { val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty) .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath) def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && { - val r = relTo(excludeBase)(extracted.get(baseDirectory.in(ref))) - r != ".." && !r.startsWith("../") && isExcludedPath(r, excludeMatchers) + val dir = extracted.get(baseDirectory.in(ref)) + // Another Windows drive than the scan root is outside it. + dir.getCanonicalFile.toPath.getRoot == excludeBase.getRoot && { + val rel = relTo(excludeBase)(dir) + rel != ".." && !rel.startsWith("../") && isExcludedPath(rel, excludeMatchers) + } } // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13). diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle index b43dff0a3d..2592786d5e 100644 --- a/src/commands/manifest/scripts/socket-workspaces.init.gradle +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -48,7 +48,10 @@ gradle.ext.socketIsExcludedDir = { java.io.File dir -> def base = gradle.socketProp.call(gradle.rootProject, 'socket.excludeBase')?.toString() gradle.ext.socketExcludeBaseCache = (base ? new java.io.File(base) : gradle.rootProject.projectDir).canonicalFile.toPath() } - def rel = gradle.ext.socketExcludeBaseCache.relativize(dir.canonicalFile.toPath()).toString().replace(File.separator, '/') + def path = dir.canonicalFile.toPath() + // On another Windows drive than the scan root, so outside it. + if (path.root != gradle.ext.socketExcludeBaseCache.root) { return false } + def rel = gradle.ext.socketExcludeBaseCache.relativize(path).toString().replace(File.separator, '/') !(rel == '..' || rel.startsWith('../')) && gradle.ext.socketIsExcluded.call(rel.isEmpty() ? '.' : rel) } diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala index 423570e25e..7d838f842a 100644 --- a/src/commands/manifest/scripts/socket-workspaces.plugin.scala +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -42,8 +42,12 @@ object SocketWorkspacesPlugin extends AutoPlugin { val excludeBase = sys.props.get("socket.excludeBase").filter(_.nonEmpty) .map(b => new File(b).getCanonicalFile.toPath).getOrElse(rootCanonPath) def isExcludedRef(ref: ProjectRef): Boolean = excludeMatchers.nonEmpty && { - val r = relTo(excludeBase)(extracted.get(baseDirectory.in(ref))) - r != ".." && !r.startsWith("../") && isExcludedPath(r, excludeMatchers) + val dir = extracted.get(baseDirectory.in(ref)) + // Another Windows drive than the scan root is outside it. + dir.getCanonicalFile.toPath.getRoot == excludeBase.getRoot && { + val rel = relTo(excludeBase)(dir) + rel != ".." && !rel.startsWith("../") && isExcludedPath(rel, excludeMatchers) + } } def rootIdOf(ref: ProjectRef): ModuleID = { diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh index c4f7bc9b91..1eb0d9b266 100755 --- a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # --exclude-paths relative to the scan root (-Psocket.excludeBase), one level above the build root: # a project relocated outside the build root is excluded by its scan-root path, and a nested one -# by the build root's path in the scan root. +# by the build root's path in the scan root. A project outside the scan root is never excluded, +# even by a wildcard that would match its `../` path. # # Usage: smoke-test-exclude-base.sh /path/to/gradle set -euo pipefail @@ -14,19 +15,24 @@ trap 'rm -rf "$OUT" "$SCAN_ROOT/app/.gradle"' EXIT echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1) (exclude base)" projects() { + rm -f "$OUT/records.tsv" ( cd "$SCAN_ROOT/app" && "$GRADLE" --no-daemon --offline -g "$OUT/gradle-home" \ --init-script "$INIT" -Psocket.recordsFile="$OUT/records.tsv" \ - "-Psocket.excludePaths=$1" "-Psocket.excludeBase=$SCAN_ROOT" socketWorkspaces -q >/dev/null ) + "-Psocket.excludePaths=$1" "-Psocket.excludeBase=${2:-$SCAN_ROOT}" socketWorkspaces -q >&2 ) + # A build that wrote no records keeps nothing, which check reports. + [ -f "$OUT/records.tsv" ] || return 0 awk -F'\t' '$1 == "project" { print $2 }' "$OUT/records.tsv" | sort | tr '\n' ' ' } check() { local got - got="$(projects "$1")" + got="$(projects "$1" "${3:-}")" if [ "$got" != "$2" ]; then - echo "FAIL: excluding $1 kept [$got], want [$2]" >&2 + echo "FAIL: excluding $1 from ${3:-$SCAN_ROOT} kept [$got], want [$2]" >&2 exit 1 fi } check '^(?:shared)(?:/.*)?$' ': :core ' check '^(?:app/core)(?:/.*)?$' ': :shared ' -echo "PASS: projects inside and outside the build root are excluded by their scan-root paths" +# `**/shared`, scanned from app: ../shared is outside the scan root. +check '^(?:(?:[^/]+/)*shared)(?:/.*)?$' ': :core :shared ' "$SCAN_ROOT/app" +echo "PASS: projects are excluded by their scan-root paths, never outside the scan root" diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh index 6b75a81d23..96f564a348 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # --exclude-paths relative to the scan root (-Dsocket.excludeBase), one level above the build root: # a `../shared` outside the build root is excluded by its scan-root path, and a -# nested module by the build root's path in the scan root. +# nested module by the build root's path in the scan root. A module outside the scan root is never +# excluded, even by a wildcard that would match its `../` path. # # Usage: smoke-test-exclude-base.sh set -euo pipefail @@ -14,22 +15,27 @@ trap 'rm -rf "$OUT" "$SCAN_ROOT"/app/target "$SCAN_ROOT"/app/core/target "$SCAN_ echo "+ $("$MVN" -v 2>/dev/null | head -1) (exclude base)" modules() { + rm -f "$OUT/records.tsv" ( cd "$SCAN_ROOT/app" && "$MVN" --batch-mode -q \ "-Dmaven.ext.class.path=$JAR" \ -Dcoana.task=socket-workspaces \ "-Dsocket.recordsFile=$OUT/records.tsv" \ - "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=$SCAN_ROOT" \ - validate >/dev/null ) + "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=${2:-$SCAN_ROOT}" \ + validate >&2 ) + # A build that wrote no records keeps nothing, which check reports. + [ -f "$OUT/records.tsv" ] || return 0 awk -F'\t' '$1 == "project" { print $4 }' "$OUT/records.tsv" | sort | tr '\n' ' ' } check() { local got - got="$(modules "$1")" + got="$(modules "$1" "${3:-}")" if [ "$got" != "$2" ]; then - echo "FAIL: excluding $1 kept [$got], want [$2]" >&2 + echo "FAIL: excluding $1 from ${3:-$SCAN_ROOT} kept [$got], want [$2]" >&2 exit 1 fi } check '^(?:shared)(?:/.*)?$' 'app core ' check '^(?:app/core)(?:/.*)?$' 'app shared ' -echo "PASS: modules inside and outside the build root are excluded by their scan-root paths" +# `**/shared`, scanned from app: ../shared is outside the scan root. +check '^(?:(?:[^/]+/)*shared)(?:/.*)?$' 'app core shared ' "$SCAN_ROOT/app" +echo "PASS: modules are excluded by their scan-root paths, never outside the scan root" diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh index 932683d2e8..6c05f80527 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh @@ -21,9 +21,12 @@ echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (exclude base)" projects() { + rm -f "$OUT/records.tsv" ( cd "$PROJECT" && sbt -Dsbt.global.base="$OUT/global-base" -Dsbt.server.autostart=false \ "-Dsocket.recordsFile=$OUT/records.tsv" \ - "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=$SCAN_ROOT" --batch socketWorkspaces >/dev/null ) + "-Dsocket.excludePaths=$1" "-Dsocket.excludeBase=$SCAN_ROOT" --batch socketWorkspaces >&2 ) + # A build that wrote no records keeps nothing, which check reports. + [ -f "$OUT/records.tsv" ] || return 0 awk -F'\t' '$1 == "project" { print $2 }' "$OUT/records.tsv" | sort | tr '\n' ' ' } check() { diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 6adb6897a3..e5bc9c041f 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -26,10 +26,7 @@ import { readSocketJsonSync, writeSocketJson, } from '../../utils/socket-json.mts' -import { - excludePathToScanIgnores, - projectIgnorePathsToReachExcludePaths, -} from '../scan/exclude-paths.mts' +import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildRoot } from './discover-manifest-roots.mts' import type { BuildTool } from './scripts/build-tool.mts' @@ -236,12 +233,6 @@ export async function markWorkspaceCoverage({ return { ok: true, data: undefined } } - // Relative to the scan root, as generateRecursiveManifests passes them. - const scanExcludePaths = projectIgnorePathsToReachExcludePaths(excludePaths, { - cwd, - target: cwd, - }) - for (const root of roots) { if (isBuildRootCovered(covered, ecosystem, root)) { continue @@ -254,7 +245,7 @@ export async function markWorkspaceCoverage({ ecosystem, entryFile: root.entryFile, excludeBase: cwd, - excludePaths: scanExcludePaths, + excludePaths, javaHome, verbose: false, }) diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 95333b75da..b8aedc1bf7 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -105,7 +105,7 @@ describe('socket scan create', async () => { --cwd working directory, defaults to process.cwd() --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. Combine with --reach to split the reachability analysis per project/module. - --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside the scan root cannot be excluded. --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. --json Output as JSON --markdown Output as Markdown diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts index 14a1c7daf2..1f2b7c0e0a 100644 --- a/src/commands/scan/cmd-scan-reach.test.mts +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -38,7 +38,7 @@ describe('socket scan reach', async () => { Reachability Options --dynamic-sbom-inference For Gradle, sbt, and Maven: generate a Socket facts SBOM (produced directly by each package manager) per independent build root, instead of one synthetic root. The reachability analysis is split per project/module accordingly. - --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. + --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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside the scan root cannot be excluded. --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as multiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defaults to all advisories. --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. 512MB, 8GB). The default is 8GB. --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. diff --git a/src/commands/scan/reachability-flags.mts b/src/commands/scan/reachability-flags.mts index 01d3962a75..1e980b05f8 100644 --- a/src/commands/scan/reachability-flags.mts +++ b/src/commands/scan/reachability-flags.mts @@ -154,6 +154,6 @@ export const excludePathsFlag: MeowFlags = { type: 'string', isMultiple: true, description: - '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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project.', + '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. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside the scan root cannot be excluded.', }, }