diff --git a/CHANGELOG.md b/CHANGELOG.md index 14322c1dd4..54bb3b26be 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/). +## [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` 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 ### Changed 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/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..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' @@ -171,10 +170,7 @@ async function runEcosystemCandidates({ const covered = new Set() 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 +184,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 +204,6 @@ async function runEcosystemCandidates({ bin, buildOpts, excludeConfigs, - excludePaths: excludePathsForRoot, ignoreUnresolved, includeConfigs, javaHome, @@ -234,7 +223,10 @@ async function runEcosystemCandidates({ ecosystem, entryFile, excludeConfigs, - excludePaths: excludePathsForRoot, + // Relative to the scan root: a build can reach modules outside its + // own directory (Maven `../x` modules, relocated Gradle projects). + excludeBase: realCwd, + excludePaths, 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/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/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/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/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..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 @@ -102,6 +102,33 @@ 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; + 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); + } + + /** 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..05c7275623 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 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 d10c683848..7558a68c60 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -42,6 +42,21 @@ 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 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) +} // 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 +109,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 +215,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..4d9d7a5b16 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -41,12 +41,23 @@ 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 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). val hasUpdateFull = diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle index 5b1c4857b8..2592786d5e 100644 --- a/src/commands/manifest/scripts/socket-workspaces.init.gradle +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -39,6 +39,21 @@ 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 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) +} gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([]) @@ -52,7 +67,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..7d838f842a 100644 --- a/src/commands/manifest/scripts/socket-workspaces.plugin.scala +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -32,12 +32,23 @@ 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 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 = { 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..1eb0d9b266 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh @@ -0,0 +1,38 @@ +#!/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. 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 +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() { + 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=${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" "${3:-}")" + if [ "$got" != "$2" ]; then + echo "FAIL: excluding $1 from ${3:-$SCAN_ROOT} kept [$got], want [$2]" >&2 + exit 1 + fi +} +check '^(?:shared)(?:/.*)?$' ': :core ' +check '^(?:app/core)(?:/.*)?$' ': :shared ' +# `**/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/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..96f564a348 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh @@ -0,0 +1,41 @@ +#!/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. 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 +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() { + 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=${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" "${3:-}")" + if [ "$got" != "$2" ]; then + 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 ' +# `**/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/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..6c05f80527 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh @@ -0,0 +1,43 @@ +#!/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() { + 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 >&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")" + 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..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,11 +233,6 @@ export async function markWorkspaceCoverage({ return { ok: true, data: undefined } } - const excludePathsForDir = projectIgnorePathsToReachExcludePaths( - excludePaths, - { cwd, target: dir }, - ) - for (const root of roots) { if (isBuildRootCovered(covered, ecosystem, root)) { continue @@ -252,7 +244,8 @@ export async function markWorkspaceCoverage({ cwd: dir, ecosystem, entryFile: root.entryFile, - excludePaths: excludePathsForDir, + excludeBase: cwd, + excludePaths, 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'], + }), ) }) }) 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.', }, }