@socketsecurity/cli-with-sentry 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +5 -5
  3. package/dist/cli.js +983 -447
  4. package/dist/cli.js.map +1 -1
  5. package/dist/constants.js +4 -4
  6. package/dist/constants.js.map +1 -1
  7. package/dist/manifest-scripts/maven-extension/coana-maven-extension.jar +0 -0
  8. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  9. package/dist/types/commands/ci/handle-ci.d.mts.map +1 -1
  10. package/dist/types/commands/fix/handle-fix.d.mts.map +1 -1
  11. package/dist/types/commands/manifest/bazel/bazel-bin-detect.d.mts +1 -1
  12. package/dist/types/commands/manifest/bazel/bazel-bin-detect.d.mts.map +1 -1
  13. package/dist/types/commands/manifest/bazel/bazel-cleanup.d.mts +8 -0
  14. package/dist/types/commands/manifest/bazel/bazel-cleanup.d.mts.map +1 -0
  15. package/dist/types/commands/manifest/bazel/bazel-cquery.d.mts.map +1 -1
  16. package/dist/types/commands/manifest/bazel/bazel-java-shim.d.mts +1 -9
  17. package/dist/types/commands/manifest/bazel/bazel-java-shim.d.mts.map +1 -1
  18. package/dist/types/commands/manifest/bazel/bazel-process.d.mts +25 -0
  19. package/dist/types/commands/manifest/bazel/bazel-process.d.mts.map +1 -0
  20. package/dist/types/commands/manifest/bazel/bazel-python-shim.d.mts +2 -5
  21. package/dist/types/commands/manifest/bazel/bazel-python-shim.d.mts.map +1 -1
  22. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts +1 -9
  23. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts.map +1 -1
  24. package/dist/types/commands/manifest/bazel/cmd-manifest-bazel.d.mts.map +1 -1
  25. package/dist/types/commands/manifest/bazel/extract_bazel_to_maven.d.mts.map +1 -1
  26. package/dist/types/commands/manifest/bazel/extract_bazel_to_pypi.d.mts.map +1 -1
  27. package/dist/types/commands/manifest/discover-manifest-roots.d.mts +16 -4
  28. package/dist/types/commands/manifest/discover-manifest-roots.d.mts.map +1 -1
  29. package/dist/types/commands/manifest/enumerate-workspaces.d.mts +3 -1
  30. package/dist/types/commands/manifest/enumerate-workspaces.d.mts.map +1 -1
  31. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts +2 -2
  32. package/dist/types/commands/manifest/generate-recursive-manifests.d.mts.map +1 -1
  33. package/dist/types/commands/manifest/output-manifest-dynamic-sbom-inference.d.mts.map +1 -1
  34. package/dist/types/commands/manifest/run-manifest-facts.d.mts +3 -1
  35. package/dist/types/commands/manifest/run-manifest-facts.d.mts.map +1 -1
  36. package/dist/types/commands/manifest/scripts/build-tool.d.mts +14 -0
  37. package/dist/types/commands/manifest/scripts/build-tool.d.mts.map +1 -1
  38. package/dist/types/commands/manifest/scripts/run.d.mts +2 -0
  39. package/dist/types/commands/manifest/scripts/run.d.mts.map +1 -1
  40. package/dist/types/commands/manifest/setup-recursive-manifest-config.d.mts +14 -16
  41. package/dist/types/commands/manifest/setup-recursive-manifest-config.d.mts.map +1 -1
  42. package/dist/types/commands/scan/cmd-scan-create.d.mts.map +1 -1
  43. package/dist/types/commands/scan/cmd-scan-reach.d.mts.map +1 -1
  44. package/dist/types/commands/scan/perform-reachability-analysis.d.mts +2 -0
  45. package/dist/types/commands/scan/perform-reachability-analysis.d.mts.map +1 -1
  46. package/dist/types/commands/scan/reach-advisories.d.mts +7 -0
  47. package/dist/types/commands/scan/reach-advisories.d.mts.map +1 -0
  48. package/dist/types/commands/scan/reachability-flags.d.mts.map +1 -1
  49. package/dist/types/constants.d.mts +1 -1
  50. package/dist/types/constants.d.mts.map +1 -1
  51. package/dist/types/utils/advisory-ids.d.mts +14 -0
  52. package/dist/types/utils/advisory-ids.d.mts.map +1 -0
  53. package/dist/types/utils/config.d.mts +1 -0
  54. package/dist/types/utils/config.d.mts.map +1 -1
  55. package/dist/types/utils/cve-to-ghsa.d.mts +2 -0
  56. package/dist/types/utils/cve-to-ghsa.d.mts.map +1 -1
  57. package/dist/utils.js +82 -3
  58. package/dist/utils.js.map +1 -1
  59. package/dist/vendor.js +553 -550
  60. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -19,8 +19,9 @@ var prompts = require('../external/@socketsecurity/registry/lib/prompts');
19
19
  var require$$0 = require('node:crypto');
20
20
  var os = require('node:os');
21
21
  var spawn = require('../external/@socketsecurity/registry/lib/spawn');
22
- var bin = require('../external/@socketsecurity/registry/lib/bin');
23
22
  var childProcess = require('node:child_process');
23
+ var bin = require('../external/@socketsecurity/registry/lib/bin');
24
+ var require$$7 = require('node:timers/promises');
24
25
  var fs$1 = require('../external/@socketsecurity/registry/lib/fs');
25
26
  var strings = require('../external/@socketsecurity/registry/lib/strings');
26
27
  var path$1 = require('../external/@socketsecurity/registry/lib/path');
@@ -2010,7 +2011,7 @@ async function performReachabilityAnalysis(options) {
2010
2011
  // Build Coana arguments.
2011
2012
  const coanaArgs = ['run', analysisTarget, '--output-dir', path.dirname(outputFilePath), '--socket-mode', outputFilePath, '--disable-report-submission', ...(isOmittedReachValue(reachabilityOptions.reachAnalysisTimeout) ? [] : ['--analysis-timeout', reachabilityOptions.reachAnalysisTimeout]), ...(isOmittedReachValue(reachabilityOptions.reachAnalysisMemoryLimit) ? [] : ['--memory-limit', reachabilityOptions.reachAnalysisMemoryLimit]), ...(reachabilityOptions.reachConcurrency ? ['--concurrency', `${reachabilityOptions.reachConcurrency}`] : []), ...(reachabilityOptions.reachContinueOnAnalysisErrors ? ['--reach-continue-on-analysis-errors'] : []), ...(reachabilityOptions.reachContinueOnInstallErrors ? ['--reach-continue-on-install-errors'] : []), ...(reachabilityOptions.reachContinueOnMissingLockFiles ? ['--reach-continue-on-missing-lock-files'] : []), ...(reachabilityOptions.reachContinueOnNoSourceFiles ? ['--reach-continue-on-no-source-files'] : []), ...(reachabilityOptions.reachDebug ? ['--debug'] : []), ...(reachabilityOptions.reachDetailedAnalysisLogFile ? ['--print-analysis-log-file'] : []), ...(reachabilityOptions.reachDisableAnalytics ? ['--disable-analytics-sharing'] : []), ...(reachabilityOptions.reachDisableExternalToolChecks ? ['--disable-external-tool-checks'] : []), ...(reachabilityOptions.reachEnableAnalysisSplitting ? [] : ['--disable-analysis-splitting']), '--run-without-docker', '--manifests-tar-hash', tarHash,
2012
2013
  // Empty reachEcosystems implies scanning all ecosystems.
2013
- ...(reachabilityOptions.reachEcosystems.length ? ['--purl-types', ...reachabilityOptions.reachEcosystems] : []), ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []),
2014
+ ...(reachabilityOptions.reachEcosystems.length ? ['--purl-types', ...reachabilityOptions.reachEcosystems] : []), ...(reachabilityOptions.reachAdvisories.length ? ['--ghsas', ...reachabilityOptions.reachAdvisories] : []), ...(reachabilityOptions.reachExcludePaths.length ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] : []),
2014
2015
  // sidecarPath is always set above when this is true - Coana rejects this
2015
2016
  // flag without one.
2016
2017
  ...(reachabilityOptions.dynamicSbomInference ? ['--maven-use-only-socket-facts'] : []), ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), ...(reachabilityOptions.reachUseOnlyPregeneratedSboms ? ['--use-only-pregenerated-sboms'] : []), ...(sidecarPath ? ['--compute-artifacts-sidecar', sidecarPath] : [])];
@@ -2081,23 +2082,87 @@ async function performReachabilityAnalysis(options) {
2081
2082
  }
2082
2083
  }
2083
2084
 
2084
- const BUILD_TOOLS = ['gradle', 'maven', 'sbt'];
2085
+ // PATH fallback when no `bin` and no project wrapper.
2086
+ const DEFAULT_BUILD_TOOL_BIN = {
2087
+ __proto__: null,
2088
+ gradle: 'gradle',
2089
+ maven: 'mvn',
2090
+ sbt: 'sbt'
2091
+ };
2085
2092
 
2086
- // One glob-suffix set per ecosystem's build-descriptor marker(s); `settings.gradle(.kts)`
2087
- // covers Kotlin-DSL roots that have no root `build.gradle`.
2088
- const MARKERS_BY_TOOL = {
2093
+ // Project-local wrapper, preferred because it pins the expected build-tool
2094
+ // version. sbt has no wrapper convention. POSIX names only (no win32 target).
2095
+ const BUILD_TOOL_WRAPPER = {
2089
2096
  __proto__: null,
2090
- gradle: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'],
2091
- maven: ['pom.xml'],
2092
- sbt: ['build.sbt']
2097
+ gradle: 'gradlew',
2098
+ maven: 'mvnw'
2093
2099
  };
2094
- const TOOL_BY_MARKER = {
2095
- __proto__: null
2100
+
2101
+ // Maven builds by file (`mvn --file`), so a directory can hold several: the
2102
+ // POMs a scan uploads, matched case-insensitively. `walkGlob` is a
2103
+ // case-sensitive superset of `nocaseGlob`, for the walk.
2104
+
2105
+ // `settings.gradle(.kts)` covers Kotlin-DSL roots that have no root
2106
+ // `build.gradle`.
2107
+ const BUILD_FILES = {
2108
+ __proto__: null,
2109
+ gradle: {
2110
+ byFile: false,
2111
+ names: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts']
2112
+ },
2113
+ maven: {
2114
+ byFile: true,
2115
+ nocaseGlob: '{*-*.,}pom{.xml,}',
2116
+ walkGlob: '*[pP][oO][mM]*'
2117
+ },
2118
+ sbt: {
2119
+ byFile: false,
2120
+ names: ['build.sbt']
2121
+ }
2096
2122
  };
2097
- for (const tool of BUILD_TOOLS) {
2098
- for (const marker of MARKERS_BY_TOOL[tool]) {
2099
- TOOL_BY_MARKER[marker] = tool;
2123
+
2124
+ // sbt happily runs in any directory, synthesizing a default project from its
2125
+ // name, so an sbt run outside a build yields a plausible but bogus SBOM. Maven
2126
+ // and Gradle refuse such a directory themselves.
2127
+ function looksLikeSbtBuild(projectDir) {
2128
+ return u.existsSync(path.resolve(projectDir, 'build.sbt')) || u.existsSync(path.resolve(projectDir, 'project'));
2129
+ }
2130
+ function resolveBuildToolBin(tool, projectDir, bin) {
2131
+ if (bin) {
2132
+ return bin;
2133
+ }
2134
+ const wrapperName = BUILD_TOOL_WRAPPER[tool];
2135
+ if (wrapperName && u.existsSync(path.resolve(projectDir, wrapperName))) {
2136
+ return `./${wrapperName}`;
2137
+ }
2138
+ return DEFAULT_BUILD_TOOL_BIN[tool];
2139
+ }
2140
+
2141
+ // Distinct for every build sharing a directory.
2142
+ function socketFactsFileName(tool, entryFile) {
2143
+ if (!BUILD_FILES[tool].byFile) {
2144
+ return `${tool}${constants.default.DOT_SOCKET_DOT_FACTS_JSON}`;
2100
2145
  }
2146
+ return entryFile ? `${path.basename(entryFile)}${constants.default.DOT_SOCKET_DOT_FACTS_JSON}` : undefined;
2147
+ }
2148
+
2149
+ const BUILD_TOOLS = ['gradle', 'maven', 'sbt'];
2150
+ const BUILD_FILE_MATCHERS = new Map(BUILD_TOOLS.map(tool => {
2151
+ const files = BUILD_FILES[tool];
2152
+ return [tool, files.byFile ? vendor.micromatchExports.matcher(files.nocaseGlob, {
2153
+ dot: true,
2154
+ nocase: true
2155
+ }) : basename => files.names.includes(basename)];
2156
+ }));
2157
+
2158
+ // `entryFile` (a basename in `dir`) is set only for a tool that addresses its
2159
+ // builds by file, where one directory can hold several builds.
2160
+
2161
+ function buildRootPath(root) {
2162
+ return path.join(root.dir, root.entryFile ?? '');
2163
+ }
2164
+ function buildFileTool(tools, basename) {
2165
+ return tools.find(tool => BUILD_FILE_MATCHERS.get(tool)?.(basename));
2101
2166
  }
2102
2167
 
2103
2168
  // A disabled ecosystem is skipped entirely by the glob scan below, so a
@@ -2148,20 +2213,24 @@ async function addCoveredBuildFiles(covered, buildRoot, projects) {
2148
2213
  }
2149
2214
  }
2150
2215
 
2151
- // `dir` must be realpath-resolved, as findBuildToolCandidates returns it.
2152
- function isBuildRootCovered(covered, tool, dir) {
2153
- return MARKERS_BY_TOOL[tool].some(marker => covered.has(path.join(dir, marker)));
2216
+ // `root.dir` must be realpath-resolved, as findBuildToolCandidates returns it.
2217
+ function isBuildRootCovered(covered, tool, root) {
2218
+ const files = BUILD_FILES[tool];
2219
+ if (files.byFile) {
2220
+ return !!root.entryFile && covered.has(buildRootPath(root));
2221
+ }
2222
+ return files.names.some(name => covered.has(path.join(root.dir, name)));
2154
2223
  }
2155
- function sortByDepthThenPath(dirs, cwd) {
2156
- return [...dirs].sort((a, b) => {
2157
- const relA = path.relative(cwd, a);
2158
- const relB = path.relative(cwd, b);
2224
+ function compareStrings(a, b) {
2225
+ return a < b ? -1 : a > b ? 1 : 0;
2226
+ }
2227
+ function sortByDepthThenPath(roots, cwd) {
2228
+ return [...roots].sort((a, b) => {
2229
+ const relA = path.relative(cwd, a.dir);
2230
+ const relB = path.relative(cwd, b.dir);
2159
2231
  const depthA = relA === '' ? 0 : relA.split(path.sep).length;
2160
2232
  const depthB = relB === '' ? 0 : relB.split(path.sep).length;
2161
- if (depthA !== depthB) {
2162
- return depthA - depthB;
2163
- }
2164
- return relA < relB ? -1 : relA > relB ? 1 : 0;
2233
+ return depthA - depthB || compareStrings(relA, relB) || compareStrings(a.entryFile ?? '', b.entryFile ?? '');
2165
2234
  });
2166
2235
  }
2167
2236
 
@@ -2170,36 +2239,56 @@ function sortByDepthThenPath(dirs, cwd) {
2170
2239
  async function findBuildToolCandidates({
2171
2240
  cwd,
2172
2241
  excludePaths,
2173
- sockJson
2242
+ sockJson,
2243
+ socketConfig
2174
2244
  }) {
2175
2245
  const enabledTools = BUILD_TOOLS.filter(tool => !sockJson.defaults?.manifest?.[tool]?.disabled);
2176
2246
  const result = new Map();
2177
2247
  if (!enabledTools.length) {
2178
2248
  return result;
2179
2249
  }
2180
- const patterns = enabledTools.flatMap(tool => MARKERS_BY_TOOL[tool].map(marker => `**/${marker}`));
2181
2250
  const additionalIgnores = (excludePaths ?? []).flatMap(excludePathToScanIgnores);
2182
- const hits = await utils.globWithGitIgnore(patterns, {
2251
+ // Build files are matched after the walk, as a scan matches them after its
2252
+ // ignores, so --exclude-paths and .gitignore stay case-sensitive.
2253
+ const hits = await utils.globWithGitIgnore(enabledTools.flatMap(tool => {
2254
+ const files = BUILD_FILES[tool];
2255
+ return (files.byFile ? [files.walkGlob] : files.names).map(glob => `**/${glob}`);
2256
+ }), {
2183
2257
  absolute: true,
2184
2258
  additionalIgnores,
2185
- cwd
2186
- });
2187
- const dirSetsByTool = new Map(enabledTools.map(tool => [tool, new Set()]));
2188
- const resolvedHits = await Promise.all(hits.map(async hit => ({
2189
- dir: await utils.realpathOrResolved(path.dirname(hit)),
2190
- tool: TOOL_BY_MARKER[path.basename(hit)]
2259
+ cwd,
2260
+ socketConfig
2261
+ });
2262
+ const rootsByTool = new Map(enabledTools.map(tool => [tool, new Map()]));
2263
+ const matched = hits.flatMap(hit => {
2264
+ const basename = path.basename(hit);
2265
+ const tool = buildFileTool(enabledTools, basename);
2266
+ return tool ? [{
2267
+ basename,
2268
+ hit,
2269
+ tool
2270
+ }] : [];
2271
+ });
2272
+ const roots = await Promise.all(matched.map(async ({
2273
+ basename,
2274
+ hit,
2275
+ tool
2276
+ }) => ({
2277
+ root: {
2278
+ dir: await utils.realpathOrResolved(path.dirname(hit)),
2279
+ entryFile: BUILD_FILES[tool].byFile ? basename : undefined
2280
+ },
2281
+ tool
2191
2282
  })));
2192
2283
  for (const {
2193
- dir,
2284
+ root,
2194
2285
  tool
2195
- } of resolvedHits) {
2196
- if (tool) {
2197
- dirSetsByTool.get(tool)?.add(dir);
2198
- }
2286
+ } of roots) {
2287
+ rootsByTool.get(tool)?.set(buildRootPath(root), root);
2199
2288
  }
2200
2289
  const realCwd = await utils.realpathOrResolved(cwd);
2201
2290
  for (const tool of enabledTools) {
2202
- result.set(tool, sortByDepthThenPath([...(dirSetsByTool.get(tool) ?? [])], realCwd));
2291
+ result.set(tool, sortByDepthThenPath([...(rootsByTool.get(tool)?.values() ?? [])], realCwd));
2203
2292
  }
2204
2293
  return result;
2205
2294
  }
@@ -2292,56 +2381,6 @@ function formatMissingEnvVarRefs(missing) {
2292
2381
  return `references ${names}, which ${verb} not set in this environment`;
2293
2382
  }
2294
2383
 
2295
- // PATH fallback when no `bin` and no project wrapper.
2296
- const DEFAULT_BUILD_TOOL_BIN = {
2297
- __proto__: null,
2298
- gradle: 'gradle',
2299
- maven: 'mvn',
2300
- sbt: 'sbt'
2301
- };
2302
-
2303
- // Project-local wrapper, preferred because it pins the expected build-tool
2304
- // version. sbt has no wrapper convention. POSIX names only (no win32 target).
2305
- const BUILD_TOOL_WRAPPER = {
2306
- __proto__: null,
2307
- gradle: 'gradlew',
2308
- maven: 'mvnw'
2309
- };
2310
-
2311
- // Gradle (8+) and sbt hold one build per directory; Maven builds are addressed
2312
- // by POM file, so `mvn -f other-pom.xml` puts a second build in the directory.
2313
- const ADDRESSED_BY_FILE = {
2314
- __proto__: null,
2315
- gradle: false,
2316
- maven: true,
2317
- sbt: false
2318
- };
2319
-
2320
- // sbt happily runs in any directory, synthesizing a default project from its
2321
- // name, so an sbt run outside a build yields a plausible but bogus SBOM. Maven
2322
- // and Gradle refuse such a directory themselves.
2323
- function looksLikeSbtBuild(projectDir) {
2324
- return u.existsSync(path.resolve(projectDir, 'build.sbt')) || u.existsSync(path.resolve(projectDir, 'project'));
2325
- }
2326
- function resolveBuildToolBin(tool, projectDir, bin) {
2327
- if (bin) {
2328
- return bin;
2329
- }
2330
- const wrapperName = BUILD_TOOL_WRAPPER[tool];
2331
- if (wrapperName && u.existsSync(path.resolve(projectDir, wrapperName))) {
2332
- return `./${wrapperName}`;
2333
- }
2334
- return DEFAULT_BUILD_TOOL_BIN[tool];
2335
- }
2336
-
2337
- // Distinct for every build sharing a directory.
2338
- function socketFactsFileName(tool, entryFile) {
2339
- if (!ADDRESSED_BY_FILE[tool]) {
2340
- return `${tool}${constants.default.DOT_SOCKET_DOT_FACTS_JSON}`;
2341
- }
2342
- return entryFile ? `${path.basename(entryFile)}${constants.default.DOT_SOCKET_DOT_FACTS_JSON}` : undefined;
2343
- }
2344
-
2345
2384
  // Gradle's variant-aware resolver: distinct exceptions give mutually-exclusive
2346
2385
  // phrasing, so most-specific-first substring checks classify reliably.
2347
2386
  function classifyGradleFailure(detail) {
@@ -3805,7 +3844,8 @@ async function invokeMaven(opts, coanaTask, tmpDirPrefix) {
3805
3844
  // `validate` is the cheapest phase that triggers the extension; no compile
3806
3845
  // needed (analysis uses configured paths, not classes).
3807
3846
  const props = [`-Dmaven.ext.class.path=${jarPath}`, `-Dcoana.task=${coanaTask}`, `-Dsocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-D')];
3808
- const args = [...props, ...(opts.toolOpts ?? []), '--batch-mode', 'validate'];
3847
+ const fileOpts = opts.entryFile ? ['--file', opts.entryFile] : [];
3848
+ const args = [...props, ...fileOpts, ...(opts.toolOpts ?? []), '--batch-mode', 'validate'];
3809
3849
  const out = await runNeverThrow(bin, args, opts);
3810
3850
  return await assembleFromRecords('maven', out, recordsFile);
3811
3851
  });
@@ -3841,6 +3881,7 @@ async function runManifestFacts({
3841
3881
  buildOpts,
3842
3882
  cwd,
3843
3883
  ecosystem,
3884
+ entryFile,
3844
3885
  excludeConfigs,
3845
3886
  excludePaths,
3846
3887
  hints = FLAG_HINTS,
@@ -3867,9 +3908,14 @@ async function runManifestFacts({
3867
3908
  logger.logger.fail(`No sbt build found at \`${cwd}\` (expected a build.sbt file or a project directory).`);
3868
3909
  return null;
3869
3910
  }
3870
- logger.logger.info(`Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`);
3911
+ const location = buildRootPath({
3912
+ dir: cwd,
3913
+ entryFile
3914
+ });
3915
+ logger.logger.info(`Generating Socket facts for the ${ecosystem} project at \`${location}\` ...`);
3871
3916
  const scriptOpts = {
3872
3917
  bin: bin || undefined,
3918
+ entryFile,
3873
3919
  excludeConfigs: excludeConfigs || undefined,
3874
3920
  excludePaths: excludePaths?.length ? excludePaths : undefined,
3875
3921
  // `env` replaces the spawned process's whole environment, not just JAVA_HOME.
@@ -3969,7 +4015,7 @@ async function runManifestFacts({
3969
4015
  // regardless of `ignoreUnresolved` — that flag only tolerates dependencies a
3970
4016
  // successful run couldn't resolve.
3971
4017
  process.exitCode = 1;
3972
- logger.logger.fail(`The ${ecosystem} build failed (exit code ${code}) before producing any Socket facts.`);
4018
+ logger.logger.fail(`The ${ecosystem} build at \`${location}\` failed (exit code ${code}) before producing any Socket facts.`);
3973
4019
  return null;
3974
4020
  }
3975
4021
 
@@ -4032,13 +4078,40 @@ function getSkipReason(ecosystem, disabled, facts) {
4032
4078
  }
4033
4079
  return undefined;
4034
4080
  }
4035
- // Nearest already-confirmed-disabled ancestor of `dir`, if any - lets the
4081
+ // Maven 3's options starting with `f`; its GnuParser takes a long name after
4082
+ // one dash too, and splits `-fX` and `-f=X` into `-f X`.
4083
+ const MAVEN_FILE_OPTS = new Set(['f', 'file']);
4084
+ const MAVEN_OTHER_F_OPTS = new Set(['fae', 'fail-at-end', 'fail-fast', 'fail-never', 'ff', 'fn']);
4085
+
4086
+ // Each POM is built by its own `--file`, so a user one is dropped.
4087
+ function withoutMavenFileOpts(dir, opts) {
4088
+ const kept = [];
4089
+ for (let i = 0, {
4090
+ length
4091
+ } = opts; i < length; i += 1) {
4092
+ const opt = opts[i];
4093
+ const name = opt.replace(/^--?/, '');
4094
+ if (opt === name || MAVEN_OTHER_F_OPTS.has(name)) {
4095
+ kept.push(opt);
4096
+ } else if (MAVEN_FILE_OPTS.has(name)) {
4097
+ i += 1;
4098
+ } else if (!MAVEN_FILE_OPTS.has(name.split('=')[0]) && !(opt.startsWith('-f') && !opt.startsWith('--'))) {
4099
+ kept.push(opt);
4100
+ }
4101
+ }
4102
+ if (kept.length !== opts.length) {
4103
+ logger.logger.warn(`Ignoring --file in mavenOpts for ${dir}: each POM there is built by its own file.`);
4104
+ }
4105
+ return kept;
4106
+ }
4107
+
4108
+ // Nearest already-confirmed-disabled `dir` or ancestor of it, if any - lets the
4036
4109
  // caller shorten `readSocketJsonCascade`'s walk to start there instead of
4037
4110
  // all the way back at `cwd`, without skipping any nested override.
4038
4111
  function nearestDisabledRoot(dir, disabledRoots) {
4039
4112
  let nearest;
4040
4113
  for (const root of disabledRoots) {
4041
- if (dir.startsWith(`${root.dir}${path.sep}`) && (!nearest || root.dir.length > nearest.dir.length)) {
4114
+ if ((dir === root.dir || dir.startsWith(`${root.dir}${path.sep}`)) && (!nearest || root.dir.length > nearest.dir.length)) {
4042
4115
  nearest = root;
4043
4116
  }
4044
4117
  }
@@ -4058,54 +4131,71 @@ async function runEcosystemCandidates({
4058
4131
  }) {
4059
4132
  const outcomes = [];
4060
4133
  const covered = new Set();
4061
- for (const [ecosystem, dirs] of candidatesByTool) {
4134
+ for (const [ecosystem, roots] of candidatesByTool) {
4062
4135
  const disabledRoots = [];
4063
- for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) {
4064
- const dir = dirs[dirIndex];
4065
- if (isBuildRootCovered(covered, ecosystem, dir)) {
4136
+ const configByDir = new Map();
4137
+ for (const {
4138
+ 0: rootIndex,
4139
+ 1: root
4140
+ } of roots.entries()) {
4141
+ const {
4142
+ dir,
4143
+ entryFile
4144
+ } = root;
4145
+ if (isBuildRootCovered(covered, ecosystem, root)) {
4066
4146
  outcomes.push({
4067
- dir,
4147
+ ...root,
4068
4148
  ecosystem,
4069
4149
  status: 'skippedCovered'
4070
4150
  });
4071
4151
  continue;
4072
4152
  }
4073
- const nearestRoot = nearestDisabledRoot(dir, disabledRoots);
4074
- const sockJson = nearestRoot ? utils.readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) : utils.readSocketJsonCascade(dir, realCwd, rootSockJson);
4153
+ let dirConfig = configByDir.get(dir);
4154
+ if (!dirConfig) {
4155
+ const nearestRoot = nearestDisabledRoot(dir, disabledRoots);
4156
+ const sockJson = nearestRoot ? utils.readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) : utils.readSocketJsonCascade(dir, realCwd, rootSockJson);
4157
+ dirConfig = {
4158
+ ...resolveEcosystemConfig(ecosystem, dir, sockJson),
4159
+ excludePaths: projectIgnorePathsToReachExcludePaths(excludePaths, {
4160
+ cwd: realCwd,
4161
+ target: dir
4162
+ })
4163
+ };
4164
+ configByDir.set(dir, dirConfig);
4165
+ if (dirConfig.skipReason) {
4166
+ // Only warn for a genuinely new disabled root, not one already
4167
+ // covered by an ancestor's warning above - otherwise a big disabled
4168
+ // reactor with hundreds of nested poms would spam one warning line
4169
+ // per pom for what's really a single root cause. The aggregate
4170
+ // count still shows up in the final summary either way.
4171
+ if (!nearestRoot) {
4172
+ logger.logger.warn(`Skipping ${dir} (${ecosystem}): ${dirConfig.skipReason}.`);
4173
+ }
4174
+ disabledRoots.push({
4175
+ dir,
4176
+ sockJson
4177
+ });
4178
+ }
4179
+ }
4075
4180
  const {
4076
4181
  bin,
4077
4182
  buildOpts,
4078
4183
  excludeConfigs,
4184
+ excludePaths: excludePathsForRoot,
4079
4185
  ignoreUnresolved,
4080
4186
  includeConfigs,
4081
4187
  javaHome,
4082
4188
  skipReason,
4083
4189
  verbose: configVerbose
4084
- } = resolveEcosystemConfig(ecosystem, dir, sockJson);
4190
+ } = dirConfig;
4085
4191
  if (skipReason) {
4086
- // Only warn for a genuinely new disabled root, not one already
4087
- // covered by an ancestor's warning above - otherwise a big disabled
4088
- // reactor with hundreds of nested poms would spam one warning line
4089
- // per pom for what's really a single root cause. The aggregate
4090
- // count still shows up in the final summary either way.
4091
- if (!nearestRoot) {
4092
- logger.logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`);
4093
- }
4094
4192
  outcomes.push({
4095
- dir,
4193
+ ...root,
4096
4194
  ecosystem,
4097
4195
  status: 'skippedDisabled'
4098
4196
  });
4099
- disabledRoots.push({
4100
- dir,
4101
- sockJson
4102
- });
4103
4197
  continue;
4104
4198
  }
4105
- const excludePathsForRoot = projectIgnorePathsToReachExcludePaths(excludePaths, {
4106
- cwd: realCwd,
4107
- target: dir
4108
- });
4109
4199
 
4110
4200
  // eslint-disable-next-line no-await-in-loop
4111
4201
  const result = await runManifestFacts({
@@ -4113,6 +4203,7 @@ async function runEcosystemCandidates({
4113
4203
  buildOpts,
4114
4204
  cwd: dir,
4115
4205
  ecosystem,
4206
+ entryFile,
4116
4207
  excludeConfigs,
4117
4208
  excludePaths: excludePathsForRoot,
4118
4209
  hints: {
@@ -4131,17 +4222,17 @@ async function runEcosystemCandidates({
4131
4222
  });
4132
4223
  if (result === null) {
4133
4224
  outcomes.push({
4134
- dir,
4225
+ ...root,
4135
4226
  ecosystem,
4136
4227
  status: 'failed'
4137
4228
  });
4138
- const abortedDirs = dirs.slice(dirIndex + 1);
4139
- if (abortedDirs.length) {
4140
- logger.logger.warn(`Aborting ${ecosystem} discovery: ${dir} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`);
4229
+ const abortedRoots = roots.slice(rootIndex + 1);
4230
+ if (abortedRoots.length) {
4231
+ logger.logger.warn(`Aborting ${ecosystem} discovery: ${buildRootPath(root)} failed to generate Socket facts, and without them the remaining build roots cannot be safely classified as covered or independent.`);
4141
4232
  }
4142
- for (const abortedDir of abortedDirs) {
4233
+ for (const aborted of abortedRoots) {
4143
4234
  outcomes.push({
4144
- dir: abortedDir,
4235
+ ...aborted,
4145
4236
  ecosystem,
4146
4237
  status: 'aborted'
4147
4238
  });
@@ -4150,18 +4241,18 @@ async function runEcosystemCandidates({
4150
4241
  }
4151
4242
  if (!result) {
4152
4243
  outcomes.push({
4153
- dir,
4244
+ ...root,
4154
4245
  ecosystem,
4155
4246
  status: 'empty'
4156
4247
  });
4157
4248
  continue;
4158
4249
  }
4159
4250
 
4160
- // `-f`/`-p` can root the reactor away from `dir`.
4251
+ // Gradle's `-p` can root the build away from `dir`.
4161
4252
  // eslint-disable-next-line no-await-in-loop
4162
4253
  await addCoveredBuildFiles(covered, path.dirname(result.factsPath), result.projects);
4163
4254
  outcomes.push({
4164
- dir,
4255
+ ...root,
4165
4256
  ecosystem,
4166
4257
  factsPath: result.factsPath,
4167
4258
  status: 'generated'
@@ -4194,6 +4285,7 @@ async function generateRecursiveManifests({
4194
4285
  const candidatesByTool = await findBuildToolCandidates({
4195
4286
  cwd,
4196
4287
  excludePaths,
4288
+ socketConfig: utils.loadSocketConfig(cwd),
4197
4289
  sockJson: withoutDisabledFlags(rootSockJson)
4198
4290
  });
4199
4291
  const runAll = resolvedSbtTmpDir => runEcosystemCandidates({
@@ -4259,7 +4351,7 @@ function resolveEcosystemConfig(ecosystem, dir, sockJson) {
4259
4351
  const bin = config?.bin ?? undefined;
4260
4352
  return {
4261
4353
  bin: bin ?? resolveBuildToolBin('maven', dir),
4262
- buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined),
4354
+ buildOpts: withoutMavenFileOpts(dir, parseBuildToolOpts(config?.mavenOpts ?? undefined)),
4263
4355
  excludeConfigs: config?.excludeConfigs ?? '',
4264
4356
  ignoreUnresolved: Boolean(config?.ignoreUnresolved),
4265
4357
  includeConfigs: config?.includeConfigs ?? '',
@@ -4377,6 +4469,234 @@ sockJson, cwd = process.cwd()) {
4377
4469
  return output;
4378
4470
  }
4379
4471
 
4472
+ function findEnvironmentKey(env, name, platform = process.platform) {
4473
+ if (platform !== 'win32') {
4474
+ return name;
4475
+ }
4476
+ const lower = name.toLowerCase();
4477
+ return Object.keys(env).find(key => key.toLowerCase() === lower) ?? name;
4478
+ }
4479
+ function getBazelEnvironment(overrides = {}, platform = process.platform) {
4480
+ const env = {
4481
+ ...process.env
4482
+ };
4483
+ for (const [key, value] of Object.entries(overrides)) {
4484
+ env[findEnvironmentKey(env, key, platform)] = value;
4485
+ }
4486
+ return env;
4487
+ }
4488
+ function getBazelEnvironmentValue(env, name) {
4489
+ return env[findEnvironmentKey(env, name)];
4490
+ }
4491
+ function prependBazelPath(env, dir) {
4492
+ const key = findEnvironmentKey(env, 'PATH');
4493
+ return {
4494
+ ...env,
4495
+ [key]: `${dir}${path.delimiter}${env[key] ?? ''}`
4496
+ };
4497
+ }
4498
+ async function findBazelExecutable(name, env, cwd) {
4499
+ const searchPath = (getBazelEnvironmentValue(env, 'PATH') ?? '').split(path.delimiter).map(entry => path.resolve(cwd, entry.replace(/^"(.*)"$/, '$1'))).join(path.delimiter);
4500
+ const found = await vendor.libExports$1(name, {
4501
+ nothrow: true,
4502
+ path: searchPath,
4503
+ pathExt: getBazelEnvironmentValue(env, 'PATHEXT')
4504
+ });
4505
+ return found ? resolveBazelExecutable(found) : undefined;
4506
+ }
4507
+ function resolveBazelExecutable(file) {
4508
+ const resolved = u.realpathSync(file);
4509
+ const extension = path.extname(resolved).toLowerCase();
4510
+ if (process.platform !== 'win32' || !['', '.cmd', '.ps1'].includes(extension)) {
4511
+ return resolved;
4512
+ }
4513
+ try {
4514
+ return bin.resolveBinPathSync(resolved);
4515
+ } catch (e) {
4516
+ if (extension === '.ps1') {
4517
+ throw new utils.InputError('This PowerShell Bazel launcher could not be resolved to a Node entry point. Pass a native .exe, a .cmd or .bat wrapper, or a JavaScript entry point with --bazel.');
4518
+ }
4519
+ if (utils.isErrnoException(e) && e.code === 'ENOENT') {
4520
+ return resolved;
4521
+ }
4522
+ throw e;
4523
+ }
4524
+ }
4525
+ function isNodeScript(bin) {
4526
+ if (/\.[cm]?js$/i.test(bin)) {
4527
+ return true;
4528
+ }
4529
+ if (path.extname(bin) || process.platform !== 'win32' || !u.existsSync(bin)) {
4530
+ return false;
4531
+ }
4532
+ const fd = u.openSync(bin, 'r');
4533
+ try {
4534
+ const header = Buffer.alloc(256);
4535
+ const length = u.readSync(fd, header);
4536
+ return /^#![^\r\n]*\bnode(?:\s|$)/.test(header.toString('utf8', 0, length));
4537
+ } finally {
4538
+ u.closeSync(fd);
4539
+ }
4540
+ }
4541
+ const BATCH_METACHARACTERS = /[ !%^&()<>|"]/g;
4542
+ function escapeBatchMetacharacters(value) {
4543
+ return value.replace(BATCH_METACHARACTERS, '^$&');
4544
+ }
4545
+ function escapeBatchArgument(value) {
4546
+ if (/[\r\n\0]/.test(value)) {
4547
+ throw new utils.InputError('Bazel batch launchers cannot accept arguments containing newlines or NUL characters. Use a native .exe launcher.');
4548
+ }
4549
+ const quoted = `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
4550
+ // Batch forwarding parses arguments twice, so protect each shell metacharacter twice.
4551
+ return escapeBatchMetacharacters(escapeBatchMetacharacters(quoted));
4552
+ }
4553
+ function buildBazelProcessInvocation(bin, args, env) {
4554
+ if (isNodeScript(bin)) {
4555
+ return {
4556
+ command: process.execPath,
4557
+ args: [bin, ...args],
4558
+ windowsVerbatimArguments: false
4559
+ };
4560
+ }
4561
+ if (process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(bin)) {
4562
+ const script = [escapeBatchMetacharacters(bin), ...args.map(escapeBatchArgument)].join(' ');
4563
+ return {
4564
+ command: getBazelEnvironmentValue(env, 'ComSpec') ?? 'cmd.exe',
4565
+ args: ['/d', '/v:off', '/s', '/c', `"${script}"`],
4566
+ windowsVerbatimArguments: true
4567
+ };
4568
+ }
4569
+ return {
4570
+ command: bin,
4571
+ args,
4572
+ windowsVerbatimArguments: false
4573
+ };
4574
+ }
4575
+ class BazelTimeoutError extends Error {
4576
+ constructor(timeout, cause) {
4577
+ super(`Bazel did not finish within ${timeout}ms`, {
4578
+ cause
4579
+ });
4580
+ this.stdout = readOutput(cause, 'stdout');
4581
+ this.stderr = readOutput(cause, 'stderr');
4582
+ }
4583
+ }
4584
+ function readOutput(error, key) {
4585
+ if (error && typeof error === 'object' && key in error) {
4586
+ const value = Reflect.get(error, key);
4587
+ return typeof value === 'string' ? value : '';
4588
+ }
4589
+ return '';
4590
+ }
4591
+ function makeAbortError(reason) {
4592
+ const error = new Error('The operation was aborted', {
4593
+ cause: reason
4594
+ });
4595
+ error.name = 'AbortError';
4596
+ return error;
4597
+ }
4598
+
4599
+ // Launchers can leave Bazel holding the output pipes after the launcher is killed.
4600
+ const PIPE_RELEASE_GRACE_MS = 5_000;
4601
+ async function killProcessTree(child) {
4602
+ const {
4603
+ pid
4604
+ } = child;
4605
+ if (pid === undefined) {
4606
+ return;
4607
+ }
4608
+ if (process.platform === 'win32') {
4609
+ await new Promise(resolve => {
4610
+ childProcess.execFile('taskkill', ['/pid', String(pid), '/t', '/f'], () => resolve());
4611
+ });
4612
+ return;
4613
+ }
4614
+ try {
4615
+ process.kill(-pid, 'SIGTERM');
4616
+ } catch (e) {
4617
+ if (!utils.isErrnoException(e) || e.code !== 'ESRCH') {
4618
+ throw e;
4619
+ }
4620
+ }
4621
+ }
4622
+ async function spawnBazel(bin, args, {
4623
+ signal = constants.default.abortSignal,
4624
+ timeout,
4625
+ ...options
4626
+ }) {
4627
+ const env = getBazelEnvironment(options.env);
4628
+ const invocation = buildBazelProcessInvocation(bin, args, env);
4629
+ const controller = new AbortController();
4630
+ const pending = spawn.spawn(invocation.command, invocation.args, {
4631
+ ...options,
4632
+ // A process group of its own lets a timeout or abort reach everything the launcher started.
4633
+ detached: process.platform !== 'win32',
4634
+ env,
4635
+ shell: false,
4636
+ signal: controller.signal,
4637
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
4638
+ });
4639
+ let timedOut = false;
4640
+ let releaseTimer;
4641
+ const stop = () => {
4642
+ releaseTimer ??= setTimeout(() => {
4643
+ pending.process.stdout?.destroy();
4644
+ pending.process.stderr?.destroy();
4645
+ }, PIPE_RELEASE_GRACE_MS);
4646
+ return killProcessTree(pending.process);
4647
+ };
4648
+ const onAbort = () => {
4649
+ // taskkill finds descendants through the launcher, so the launcher must outlive the tree kill.
4650
+ void stop().finally(() => controller.abort(signal.reason));
4651
+ };
4652
+ if (signal.aborted) {
4653
+ onAbort();
4654
+ } else {
4655
+ signal.addEventListener('abort', onAbort, {
4656
+ once: true
4657
+ });
4658
+ }
4659
+ const timer = timeout ? setTimeout(() => {
4660
+ timedOut = true;
4661
+ void stop();
4662
+ }, timeout) : undefined;
4663
+ let result;
4664
+ try {
4665
+ result = await pending;
4666
+ } catch (e) {
4667
+ if (timedOut && timeout) {
4668
+ throw new BazelTimeoutError(timeout, e);
4669
+ }
4670
+ // On Windows the tree kill ends the launcher first, so the spawn rejects with its exit code.
4671
+ if (signal.aborted && !(e instanceof Error && e.name === 'AbortError')) {
4672
+ throw makeAbortError(signal.reason);
4673
+ }
4674
+ throw e;
4675
+ } finally {
4676
+ clearTimeout(timer);
4677
+ clearTimeout(releaseTimer);
4678
+ signal.removeEventListener('abort', onAbort);
4679
+ }
4680
+ // Output is incomplete when the pipes were released after a timeout.
4681
+ if (timedOut && timeout) {
4682
+ throw new BazelTimeoutError(timeout, result);
4683
+ }
4684
+ return result;
4685
+ }
4686
+ function buildBazelStartupFlags(opts) {
4687
+ const startup = [];
4688
+ if (opts.bazelRc) {
4689
+ startup.push(`--bazelrc=${opts.bazelRc}`);
4690
+ }
4691
+ if (opts.outputUserRoot) {
4692
+ startup.push(`--output_user_root=${opts.outputUserRoot}`);
4693
+ }
4694
+ if (opts.bazelOutputBase) {
4695
+ startup.push(`--output_base=${opts.bazelOutputBase}`);
4696
+ }
4697
+ return startup;
4698
+ }
4699
+
4380
4700
  /**
4381
4701
  * Resolve the bazel binary to invoke for `socket manifest bazel`.
4382
4702
  *
@@ -4386,70 +4706,285 @@ sockJson, cwd = process.cwd()) {
4386
4706
  * 3. Fall back to `bazel` on PATH.
4387
4707
  * 4. If neither is found, throw InputError with install instructions.
4388
4708
  */
4389
- async function resolveBazelBinary(explicit) {
4709
+ async function resolveBazelBinary(explicit, env, cwd) {
4390
4710
  if (explicit) {
4391
- if (!u.existsSync(explicit)) {
4392
- throw new utils.InputError(`--bazel path does not exist: ${explicit}. Install bazelisk or bazel, or pass an existing path via --bazel.`);
4711
+ const resolved = path.resolve(cwd, explicit);
4712
+ if (!u.existsSync(resolved)) {
4713
+ throw new utils.InputError(`--bazel path does not exist: ${resolved}. Install bazelisk or bazel, or pass an existing path via --bazel.`);
4393
4714
  }
4394
- return explicit;
4715
+ return resolveBazelExecutable(resolved);
4395
4716
  }
4396
4717
  // Prefer bazelisk: respects .bazelversion in the workspace.
4397
- const bazelisk = await bin.whichBin('bazelisk', {
4398
- nothrow: true
4399
- });
4718
+ const bazelisk = await findBazelExecutable('bazelisk', env, cwd);
4400
4719
  if (bazelisk) {
4401
4720
  return bazelisk;
4402
4721
  }
4403
- const bazel = await bin.whichBin('bazel', {
4404
- nothrow: true
4405
- });
4722
+ const bazel = await findBazelExecutable('bazel', env, cwd);
4406
4723
  if (bazel) {
4407
4724
  return bazel;
4408
4725
  }
4409
4726
  throw new utils.InputError('Could not find bazelisk or bazel on PATH. ' + 'Install bazelisk (recommended; https://github.com/bazelbuild/bazelisk) ' + 'or bazel, or pass --bazel <path>.');
4410
4727
  }
4411
4728
 
4729
+ const BAZEL_OUTPUT_ROOT_PREFIX = 'socket-bazel-';
4730
+ function makeBazelOutputUserRoot(env, cwd) {
4731
+ const parent = getBazelEnvironmentValue(env, 'SOCKET_BAZEL_OUTPUT_ROOT');
4732
+ const resolved = parent ? path.resolve(cwd, parent) : os.tmpdir();
4733
+ u.mkdirSync(resolved, {
4734
+ recursive: true
4735
+ });
4736
+ return u.mkdtempSync(path.join(resolved, BAZEL_OUTPUT_ROOT_PREFIX));
4737
+ }
4738
+ const RM_OPTIONS = {
4739
+ recursive: true,
4740
+ force: true,
4741
+ maxRetries: 5,
4742
+ retryDelay: 200
4743
+ };
4744
+ async function removeBazelTempdir(dir) {
4745
+ try {
4746
+ try {
4747
+ await u.promises.rm(dir, RM_OPTIONS);
4748
+ } catch (e) {
4749
+ if (!utils.isErrnoException(e) || !['EACCES', 'EPERM'].includes(e.code ?? '')) {
4750
+ throw e;
4751
+ }
4752
+ await makeDirectoriesWritable(dir);
4753
+ await u.promises.rm(dir, RM_OPTIONS);
4754
+ }
4755
+ return true;
4756
+ } catch (error) {
4757
+ logger.logger.warn(`Bazel temporary directory could not be removed: ${dir}. ${utils.getErrorCause(error)}`);
4758
+ return false;
4759
+ }
4760
+ }
4761
+ async function makeDirectoriesWritable(root) {
4762
+ const pending = [root];
4763
+ while (pending.length) {
4764
+ const directory = pending.pop();
4765
+ if (!directory) {
4766
+ continue;
4767
+ }
4768
+ try {
4769
+ // Repository rules can unpack read-only directories. Keep links to user files untouched.
4770
+ // eslint-disable-next-line no-await-in-loop
4771
+ const stat = await u.promises.lstat(directory);
4772
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
4773
+ continue;
4774
+ }
4775
+ // eslint-disable-next-line no-await-in-loop
4776
+ await u.promises.chmod(directory, 0o700);
4777
+ // eslint-disable-next-line no-await-in-loop
4778
+ const entries = await u.promises.readdir(directory, {
4779
+ withFileTypes: true
4780
+ });
4781
+ for (const entry of entries) {
4782
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
4783
+ pending.push(path.join(directory, entry.name));
4784
+ }
4785
+ }
4786
+ } catch (e) {
4787
+ if (!utils.isErrnoException(e) || e.code !== 'ENOENT') {
4788
+ throw e;
4789
+ }
4790
+ }
4791
+ }
4792
+ }
4793
+ const SHUTDOWN_TIMEOUT_MS = 10_000;
4794
+ const SERVER_EXIT_TIMEOUT_MS = 5_000;
4795
+ async function cleanUpBazelOutputRoot(root, contexts) {
4796
+ // Ctrl-C aborts the scan's signal while the CLI keeps running, and cleanup must still run.
4797
+ const signal = AbortSignal.timeout(3 * SHUTDOWN_TIMEOUT_MS);
4798
+ let listed;
4799
+ try {
4800
+ listed = findServerPids(root);
4801
+ } catch (e) {
4802
+ logger.logger.warn(`Bazel output root kept because its servers could not be checked: ${root}. ${utils.getErrorCause(e)}`);
4803
+ }
4804
+ const servers = new Set(listed);
4805
+ const shutDown = await Promise.all(Array.from(contexts.values(), opts => shutDownBazelServer(opts, signal)));
4806
+ for (const pid of shutDown) {
4807
+ if (pid !== undefined) {
4808
+ servers.add(pid);
4809
+ }
4810
+ }
4811
+ const stopped = await Promise.all(Array.from(servers, stopBazelServer));
4812
+ if (!stopped.every(Boolean)) {
4813
+ logger.logger.warn(`Bazel output root kept because its server is still running: ${root}`);
4814
+ return false;
4815
+ }
4816
+ if (!listed) {
4817
+ return false;
4818
+ }
4819
+ return await removeBazelTempdir(root);
4820
+ }
4821
+
4822
+ // Returns the pid of a server this scan owns, read before shutdown removes its pid file.
4823
+ async function shutDownBazelServer(opts, signal) {
4824
+ const root = opts.outputUserRoot;
4825
+ if (!root) {
4826
+ return undefined;
4827
+ }
4828
+ const startupFlags = buildBazelStartupFlags(opts);
4829
+ const spawnOptions = {
4830
+ cwd: opts.cwd,
4831
+ ...(opts.env ? {
4832
+ env: opts.env
4833
+ } : {}),
4834
+ signal,
4835
+ timeout: SHUTDOWN_TIMEOUT_MS
4836
+ };
4837
+ let pid;
4838
+ try {
4839
+ // Only this scan can have started a server whose install or output base is in its fresh root.
4840
+ // An rc file can point the scan at a caller's install and output base.
4841
+ const info = await spawnBazel(opts.bin, [...startupFlags, 'info', 'install_base', 'output_base'], spawnOptions);
4842
+ const bases = parseBazelInfo(info.stdout);
4843
+ const outputBase = bases.get('output_base');
4844
+ if (!outputBase || ![bases.get('install_base'), outputBase].some(dir => dir && isPathInside(dir, root))) {
4845
+ return undefined;
4846
+ }
4847
+ pid = readServerPid(outputBase);
4848
+ await spawnBazel(opts.bin, [...startupFlags, 'shutdown'], spawnOptions);
4849
+ } catch (error) {
4850
+ logger.logger.warn(`Bazel shutdown failed in ${opts.cwd}. ${utils.getErrorCause(error)}`);
4851
+ }
4852
+ return pid;
4853
+ }
4854
+ function findServerPids(root) {
4855
+ if (!u.existsSync(root)) {
4856
+ return [];
4857
+ }
4858
+ const pids = [];
4859
+ for (const entry of u.readdirSync(root, {
4860
+ withFileTypes: true
4861
+ })) {
4862
+ const pid = entry.isDirectory() ? readServerPid(path.join(root, entry.name)) : undefined;
4863
+ if (pid !== undefined) {
4864
+ pids.push(pid);
4865
+ }
4866
+ }
4867
+ return pids;
4868
+ }
4869
+ function readServerPid(outputBase) {
4870
+ let contents;
4871
+ try {
4872
+ contents = u.readFileSync(path.join(outputBase, 'server', 'server.pid.txt'), 'utf8');
4873
+ } catch (e) {
4874
+ if (utils.isErrnoException(e) && e.code === 'ENOENT') {
4875
+ return undefined;
4876
+ }
4877
+ throw e;
4878
+ }
4879
+ const pid = Number(contents.trim());
4880
+ return Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid) ? pid : undefined;
4881
+ }
4882
+ function isProcessAlive(pid) {
4883
+ try {
4884
+ process.kill(pid, 0);
4885
+ return true;
4886
+ } catch (e) {
4887
+ return utils.isErrnoException(e) && e.code === 'EPERM';
4888
+ }
4889
+ }
4890
+ async function waitForProcessExit(pid) {
4891
+ const deadline = Date.now() + SERVER_EXIT_TIMEOUT_MS;
4892
+ while (isProcessAlive(pid)) {
4893
+ if (Date.now() >= deadline) {
4894
+ return false;
4895
+ }
4896
+ // eslint-disable-next-line no-await-in-loop
4897
+ await require$$7.setTimeout(100);
4898
+ }
4899
+ return true;
4900
+ }
4901
+
4902
+ // A shutdown that exits successfully can still leave the server running, for example with
4903
+ // `shutdown --iff_heap_size_greater_than` in an rc file.
4904
+ async function stopBazelServer(pid) {
4905
+ if (await waitForProcessExit(pid)) {
4906
+ return true;
4907
+ }
4908
+ try {
4909
+ process.kill(pid, 'SIGKILL');
4910
+ } catch (e) {
4911
+ if (!utils.isErrnoException(e) || e.code !== 'ESRCH') {
4912
+ logger.logger.warn(`Bazel server ${pid} could not be stopped. ${utils.getErrorCause(e)}`);
4913
+ return false;
4914
+ }
4915
+ }
4916
+ return await waitForProcessExit(pid);
4917
+ }
4918
+ function parseBazelInfo(stdout) {
4919
+ const values = new Map();
4920
+ for (const line of stdout.split(/\r?\n/)) {
4921
+ const separator = line.indexOf(': ');
4922
+ if (separator > 0) {
4923
+ values.set(line.slice(0, separator), line.slice(separator + 2).trim());
4924
+ }
4925
+ }
4926
+ return values;
4927
+ }
4928
+ function isPathInside(child, parent) {
4929
+ const relative = path.relative(u.realpathSync.native(parent), u.realpathSync.native(child));
4930
+ return relative !== '' && relative.split(path.sep)[0] !== '..' && !path.isAbsolute(relative);
4931
+ }
4932
+
4412
4933
  // Default per-invocation timeout for bazel queries. Bazel cold-cache starts
4413
4934
  // can take several minutes; 10 minutes is generous while still bounding CI hangs.
4414
4935
  const BAZEL_QUERY_TIMEOUT_MS = 600_000;
4415
4936
  const STDERR_TAIL_BYTES = 4_096;
4416
4937
  const STDOUT_EXCERPT_BYTES = 1_024;
4417
-
4418
- // Splits the user-supplied --bazel-flags string on whitespace.
4419
- // Empty / undefined returns []. No shell parsing — quoted args with embedded
4420
- // whitespace are not supported (documented limitation; same trust model as
4421
- // gradleOpts).
4422
4938
  function splitBazelFlags(flags) {
4423
4939
  if (!flags) {
4424
4940
  return [];
4425
4941
  }
4426
- return flags.split(/\s+/).filter(Boolean);
4427
- }
4428
-
4429
- // Build the shared startup-flag prefix for any bazel invocation. Centralised
4430
- // so `--output_user_root` propagates to every spawn — principle 7 of the
4431
- // Maven design requires per-invocation server isolation across query,
4432
- // cquery, and `bazel mod` commands alike.
4433
- function buildStartupFlags(opts) {
4434
- const startup = [];
4435
- if (opts.bazelRc) {
4436
- startup.push(`--bazelrc=${opts.bazelRc}`);
4942
+ const args = [];
4943
+ let argument = '';
4944
+ let quote;
4945
+ let started = false;
4946
+ for (let index = 0; index < flags.length; index += 1) {
4947
+ const character = flags.charAt(index);
4948
+ if (quote) {
4949
+ if (character === quote) {
4950
+ if (flags.charAt(index + 1) === quote) {
4951
+ argument += quote;
4952
+ index += 1;
4953
+ } else {
4954
+ quote = undefined;
4955
+ }
4956
+ } else {
4957
+ argument += character;
4958
+ }
4959
+ } else if ((character === '"' || character === "'") && (!started || argument.endsWith('='))) {
4960
+ quote = character;
4961
+ started = true;
4962
+ } else if (/\s/.test(character)) {
4963
+ if (started) {
4964
+ args.push(argument);
4965
+ argument = '';
4966
+ started = false;
4967
+ }
4968
+ } else {
4969
+ argument += character;
4970
+ started = true;
4971
+ }
4437
4972
  }
4438
- if (opts.outputUserRoot) {
4439
- startup.push(`--output_user_root=${opts.outputUserRoot}`);
4973
+ if (quote) {
4974
+ throw new utils.InputError('--bazel-flags contains an unterminated quote. Close each quoted value. Backslashes are preserved literally.');
4440
4975
  }
4441
- if (opts.bazelOutputBase) {
4442
- startup.push(`--output_base=${opts.bazelOutputBase}`);
4976
+ if (started) {
4977
+ args.push(argument);
4443
4978
  }
4444
- return startup;
4979
+ return args;
4445
4980
  }
4446
4981
  function buildBazelModShowVisibleReposArgv(opts) {
4447
4982
  const userFlags = splitBazelFlags(opts.bazelFlags);
4448
- return [...buildStartupFlags(opts), 'mod', 'dump_repo_mapping', '', '--output=json', ...userFlags];
4983
+ return [...buildBazelStartupFlags(opts), 'mod', 'dump_repo_mapping', '', '--output=json', ...userFlags];
4449
4984
  }
4450
4985
  function buildBazelModShowMavenExtensionArgv(opts) {
4451
4986
  const userFlags = splitBazelFlags(opts.bazelFlags);
4452
- return [...buildStartupFlags(opts), 'mod', 'show_extension', '@rules_jvm_external//:extensions.bzl%maven',
4987
+ return [...buildBazelStartupFlags(opts), 'mod', 'show_extension', '@rules_jvm_external//:extensions.bzl%maven',
4453
4988
  // A read-only scan must never rewrite the user's MODULE.bazel.lock; pin
4454
4989
  // the lockfile read-only before user flags, mirroring the query/cquery
4455
4990
  // argv builders.
@@ -4462,7 +4997,7 @@ function buildBazelModShowMavenExtensionArgv(opts) {
4462
4997
  }
4463
4998
  function buildBazelModShowPipExtensionArgv(opts) {
4464
4999
  const userFlags = splitBazelFlags(opts.bazelFlags);
4465
- return [...buildStartupFlags(opts), 'mod', 'show_extension', '@rules_python//python/extensions:pip.bzl%pip',
5000
+ return [...buildBazelStartupFlags(opts), 'mod', 'show_extension', '@rules_python//python/extensions:pip.bzl%pip',
4466
5001
  // A read-only scan must never rewrite the user's MODULE.bazel.lock; pin
4467
5002
  // the lockfile read-only before user flags, mirroring the query/cquery
4468
5003
  // argv builders.
@@ -4474,7 +5009,7 @@ function buildBazelArgv(queryStr, opts, output = 'build') {
4474
5009
  // Keep query output stable and avoid updating Bazel lockfiles while extracting.
4475
5010
  const queryFlags = ['--lockfile_mode=off', '--noshow_progress'];
4476
5011
  const userFlags = splitBazelFlags(opts.bazelFlags);
4477
- return [...buildStartupFlags(opts), 'query', ...queryFlags, ...opts.invocationFlags, queryStr, `--output=${output}`, ...userFlags];
5012
+ return [...buildBazelStartupFlags(opts), 'query', ...queryFlags, ...opts.invocationFlags, queryStr, `--output=${output}`, ...userFlags];
4478
5013
  }
4479
5014
 
4480
5015
  // Lightweight presence-check cquery used by the tri-state probe classifier.
@@ -4484,7 +5019,7 @@ function buildBazelArgv(queryStr, opts, output = 'build') {
4484
5019
  // `bazel-cquery.mts` needs but the probe does not).
4485
5020
  function buildBazelProbeCqueryArgv(repoName, opts) {
4486
5021
  const userFlags = splitBazelFlags(opts.bazelFlags);
4487
- return [...buildStartupFlags(opts), 'cquery', '--lockfile_mode=off', '--noshow_progress', ...opts.invocationFlags, `@${repoName}//...`, '--output=label', '--keep_going', ...userFlags];
5022
+ return [...buildBazelStartupFlags(opts), 'cquery', '--lockfile_mode=off', '--noshow_progress', ...opts.invocationFlags, `@${repoName}//...`, '--output=label', '--keep_going', ...userFlags];
4488
5023
  }
4489
5024
  function stringField(value) {
4490
5025
  return typeof value === 'string' ? value : '';
@@ -4559,7 +5094,7 @@ async function runBazelQuery(queryStr, opts, output) {
4559
5094
  let result;
4560
5095
  try {
4561
5096
  spinner.start(`Running bazel query (${queryStr.slice(0, 80)})...`);
4562
- const output = await spawn.spawn(opts.bin, argv, {
5097
+ const output = await spawnBazel(opts.bin, argv, {
4563
5098
  cwd: opts.cwd,
4564
5099
  timeout: BAZEL_QUERY_TIMEOUT_MS,
4565
5100
  ...(opts.env ? {
@@ -4605,7 +5140,7 @@ async function runBazelOneShot(argv, opts, step) {
4605
5140
  const startedAt = Date.now();
4606
5141
  let result;
4607
5142
  try {
4608
- const output = await spawn.spawn(opts.bin, argv, {
5143
+ const output = await spawnBazel(opts.bin, argv, {
4609
5144
  cwd: opts.cwd,
4610
5145
  timeout: BAZEL_QUERY_TIMEOUT_MS,
4611
5146
  ...(opts.env ? {
@@ -4770,16 +5305,7 @@ function buildMetadataCqueryExpr(repoName) {
4770
5305
  // Build the full cquery argv for a per-repo metadata cquery. Exposed for
4771
5306
  // argv-shape unit tests without touching `spawn`.
4772
5307
  function buildMetadataCqueryArgv(repoName, opts) {
4773
- const startup = [];
4774
- if (opts.bazelRc) {
4775
- startup.push(`--bazelrc=${opts.bazelRc}`);
4776
- }
4777
- if (opts.outputUserRoot) {
4778
- startup.push(`--output_user_root=${opts.outputUserRoot}`);
4779
- }
4780
- if (opts.bazelOutputBase) {
4781
- startup.push(`--output_base=${opts.bazelOutputBase}`);
4782
- }
5308
+ const startup = buildBazelStartupFlags(opts);
4783
5309
  const userFlags = splitBazelFlags(opts.bazelFlags);
4784
5310
  return [...startup, 'cquery', '--lockfile_mode=off', '--noshow_progress', ...opts.invocationFlags, buildMetadataCqueryExpr(repoName), '--output=jsonproto', '--proto:output_rule_attrs=tags,maven_coordinates,deps,exports,runtime_deps', '--keep_going', ...userFlags];
4785
5311
  }
@@ -5117,7 +5643,7 @@ async function runMetadataCqueryForRepo(args) {
5117
5643
  const argv = buildMetadataCqueryArgv(repoName, opts);
5118
5644
  const startedAt = Date.now();
5119
5645
  try {
5120
- const result = await spawn.spawn(opts.bin, argv, {
5646
+ const result = await spawnBazel(opts.bin, argv, {
5121
5647
  cwd: workspaceRoot,
5122
5648
  timeout: timeoutMs,
5123
5649
  ...(opts.env ? {
@@ -5146,10 +5672,7 @@ async function runMetadataCqueryForRepo(args) {
5146
5672
  const err = e;
5147
5673
  const stdout = typeof err.stdout === 'string' ? err.stdout : '';
5148
5674
  const stderr = typeof err.stderr === 'string' ? err.stderr : '';
5149
- // On a `timeout`, the registry spawn kills the child, so Node sets
5150
- // `killed: true` and `signal: 'SIGTERM'` (or `SIGKILL`). There is no
5151
- // `timedOut` flag on the real rejection, so do not test for one.
5152
- const timedOut = err.killed === true || err.signal === 'SIGTERM' || err.signal === 'SIGKILL';
5675
+ const timedOut = e instanceof BazelTimeoutError;
5153
5676
  const {
5154
5677
  artifacts,
5155
5678
  unresolvedLabels
@@ -5176,25 +5699,19 @@ async function runMetadataCqueryForRepo(args) {
5176
5699
  }
5177
5700
  }
5178
5701
 
5179
- let probed = false;
5180
-
5181
- // Verifies `java` is functional in the current execution environment. Bazel
5182
- // JVM manifest extraction (rules_jvm_external → Coursier) requires a real
5183
- // JDK; the CLI does not attempt to discover Homebrew installs or mutate the
5184
- // caller's PATH/JAVA_HOME. If `java -version` fails we throw with an
5185
- // actionable message so the surfaced error names the prerequisite directly
5186
- // instead of relying on Bazel's downstream diagnostic.
5187
- function ensureJavaOnPath() {
5188
- if (probed) {
5189
- return;
5190
- }
5702
+ function ensureJavaOnPath(env, cwd) {
5703
+ const javaHome = getBazelEnvironmentValue(env, 'JAVA_HOME');
5704
+ const executable = process.platform === 'win32' ? 'java.exe' : 'java';
5705
+ const java = javaHome ? path.resolve(cwd, javaHome, 'bin', executable) : executable;
5191
5706
  try {
5192
- childProcess.execSync('java -version', {
5193
- stdio: 'ignore'
5707
+ childProcess.execFileSync(java, ['-version'], {
5708
+ cwd,
5709
+ env,
5710
+ stdio: 'ignore',
5711
+ timeout: 10_000
5194
5712
  });
5195
- probed = true;
5196
5713
  } catch {
5197
- throw new Error('Java is required for Bazel JVM manifest extraction ' + '(rules_jvm_external invokes Coursier, which needs a JDK). ' + 'Install a JDK (e.g. Temurin or OpenJDK) and ensure `java` is on PATH.');
5714
+ throw new utils.InputError('Java is required for Bazel JVM manifest extraction. Install a JDK and ensure JAVA_HOME points to it or java is on PATH.');
5198
5715
  }
5199
5716
  }
5200
5717
 
@@ -5233,75 +5750,55 @@ function validateOutputBase(outputBase, cwd) {
5233
5750
  }
5234
5751
  }
5235
5752
 
5236
- // Stable shim dir name — same process will get the same dir; concurrent
5237
- // socket-cli invocations on the same machine share it. The symlink target
5238
- // is whatever python3 resolves to NOW; if PATH changes between invocations
5239
- // we replace the symlink.
5240
- const SHIM_SUBDIR = 'socket-cli-bazel-python-shim';
5241
-
5242
- // Cache the result for the lifetime of this process.
5243
- let cached = null;
5244
-
5245
- // Safe wrapper around whichBin that returns null instead of throwing when
5246
- // nothrow semantics are broken in older registry versions (realpath 'null' bug).
5247
- async function safeWhichBin(name) {
5753
+ function isPython3(executable, env, cwd) {
5248
5754
  try {
5249
- return (await bin.whichBin(name, {
5250
- nothrow: true
5251
- })) ?? null;
5755
+ const version = childProcess.execFileSync(executable, ['-c', 'import sys\nprint(sys.version_info.major)'], {
5756
+ cwd,
5757
+ env,
5758
+ encoding: 'utf8',
5759
+ stdio: ['ignore', 'pipe', 'pipe'],
5760
+ timeout: 10_000
5761
+ });
5762
+ return version.trim() === '3';
5252
5763
  } catch {
5253
- return null;
5764
+ // Windows application aliases can resolve without a working interpreter.
5765
+ return false;
5254
5766
  }
5255
5767
  }
5256
- async function provisionPythonShim() {
5257
- if (cached) {
5258
- return cached;
5259
- }
5260
- const pythonOnPath = await safeWhichBin('python');
5261
- if (pythonOnPath) {
5262
- cached = {
5263
- augmentedEnv: undefined,
5264
- shimDir: undefined
5265
- };
5266
- return cached;
5267
- }
5268
- const python3OnPath = await safeWhichBin('python3');
5269
- if (!python3OnPath) {
5270
- throw new utils.InputError('Neither `python` nor `python3` found on PATH. Older versions of ' + 'rules_jvm_external require a `python` interpreter for repository ' + 'rules. Install Python 3 and ensure it is on PATH, then retry.');
5271
- }
5272
- const shimDir = path.join(os.tmpdir(), SHIM_SUBDIR);
5273
- u.mkdirSync(shimDir, {
5274
- recursive: true
5275
- });
5276
- const linkPath = path.join(shimDir, 'python');
5277
- // Replace the symlink defensively in case python3's resolved path moved.
5278
- if (u.existsSync(linkPath)) {
5279
- try {
5280
- u.unlinkSync(linkPath);
5281
- } catch {
5282
- // Tolerate races; the next symlinkSync may still succeed.
5768
+ function notPython3Error(executable) {
5769
+ return new utils.InputError(`Python 3 is required for Bazel manifest extraction. ${executable} did not run as Python 3. Install Python 3 and ensure python${process.platform === 'win32' ? '.exe' : ''} is on PATH.`);
5770
+ }
5771
+ async function provisionPythonShim(env, cwd) {
5772
+ const python = await findBazelExecutable('python', env, cwd);
5773
+ if (python) {
5774
+ if (process.platform === 'win32' && path.extname(python).toLowerCase() !== '.exe') {
5775
+ throw new utils.InputError('Bazel on Windows requires a working Python 3 python.exe on PATH. Python batch launchers are not supported.');
5283
5776
  }
5284
- }
5285
- // The shim dir is process-shared (os.tmpdir()/socket-cli-bazel-python-shim),
5286
- // so a concurrent socket-cli invocation may re-create the link between our
5287
- // unlinkSync and symlinkSync. Tolerate EEXIST when the link is back: the
5288
- // other process won the race and left a usable shim in place.
5289
- try {
5290
- u.symlinkSync(python3OnPath, linkPath);
5291
- } catch (e) {
5292
- if (e.code === 'EEXIST' && u.existsSync(linkPath)) ; else {
5293
- throw e;
5777
+ if (isPython3(python, env, cwd)) {
5778
+ return {
5779
+ env,
5780
+ shimDir: undefined
5781
+ };
5782
+ }
5783
+ if (process.platform === 'win32') {
5784
+ throw notPython3Error(python);
5294
5785
  }
5786
+ } else if (process.platform === 'win32') {
5787
+ throw new utils.InputError('Bazel on Windows requires a working Python 3 python.exe on PATH. Install Python 3 with the PATH option enabled, then retry.');
5295
5788
  }
5296
- const augmentedEnv = {
5297
- ...process.env,
5298
- PATH: `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`
5299
- };
5300
- cached = {
5301
- augmentedEnv,
5789
+ const python3 = await findBazelExecutable('python3', env, cwd);
5790
+ if (!python3) {
5791
+ throw python ? notPython3Error(python) : new utils.InputError('Neither python nor python3 was found on PATH. Install Python 3 and ensure it is on PATH, then retry.');
5792
+ }
5793
+ if (!isPython3(python3, env, cwd)) {
5794
+ throw notPython3Error(python3);
5795
+ }
5796
+ const shimDir = u.mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-python-'));
5797
+ u.symlinkSync(python3, path.join(shimDir, 'python'));
5798
+ return {
5799
+ env: prependBazelPath(env, shimDir),
5302
5800
  shimDir
5303
5801
  };
5304
- return cached;
5305
5802
  }
5306
5803
 
5307
5804
  /**
@@ -5761,7 +6258,6 @@ function findWorkspaceRoots(opts) {
5761
6258
  // hard failure when nothing else was analyzable, and to a partial otherwise.
5762
6259
 
5763
6260
  const DEFAULT_PER_REPO_TIMEOUT_MS = 60_000;
5764
- const REAP_TIMEOUT_MS = 10_000;
5765
6261
 
5766
6262
  // Machine-readable completeness signal emitted alongside the synthetic
5767
6263
  // manifests. A `complete: false` summary tells a downstream consumer (e.g.
@@ -6156,39 +6652,6 @@ async function discoverCandidatesForWorkspace(workspaceRoot, mode, queryOpts, ve
6156
6652
  };
6157
6653
  }
6158
6654
 
6159
- // Best-effort reap of a Bazel server. Spawned with a short timeout so
6160
- // a wedged server can't itself hang the cleanup; failures are swallowed
6161
- // because the caller will `rm -rf` the output_user_root regardless.
6162
- async function reapBazelServer(bin, outputUserRoot, verbose) {
6163
- try {
6164
- await spawn.spawn(bin, [`--output_user_root=${outputUserRoot}`, 'shutdown'], {
6165
- timeout: REAP_TIMEOUT_MS
6166
- });
6167
- } catch (e) {
6168
- // Server may already be dead, or shutdown itself timed out — the
6169
- // tempdir removal below is sufficient cleanup.
6170
- if (verbose) {
6171
- logger.logger.log(`[VERBOSE] reapBazelServer: shutdown failed for ${outputUserRoot} (${utils.getErrorCause(e)}); tempdir removal will still run`);
6172
- }
6173
- }
6174
- }
6175
- async function removeTempdir(dir, verbose) {
6176
- try {
6177
- await u.promises.rm(dir, {
6178
- recursive: true,
6179
- force: true
6180
- });
6181
- } catch (e) {
6182
- // Best effort. The next CLI invocation lands a fresh tempdir.
6183
- if (verbose) {
6184
- logger.logger.log(`[VERBOSE] removeTempdir: ${dir} not fully removed (${utils.getErrorCause(e)}); a stale dir may linger until the next OS tempdir sweep`);
6185
- }
6186
- }
6187
- }
6188
- function makeOutputUserRoot() {
6189
- return u.mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-'));
6190
- }
6191
-
6192
6655
  // Construct the BazelQueryOptions shape used for a single workspace's
6193
6656
  // queries. Lifted to module scope (out of the per-workspace loop) so
6194
6657
  // ESLint's consistent-function-scoping is happy; takes everything it
@@ -6209,17 +6672,15 @@ function buildQueryOpts(args) {
6209
6672
  invocationFlags,
6210
6673
  outputUserRoot,
6211
6674
  ...(opts.bazelRc ? {
6212
- bazelRc: opts.bazelRc
6675
+ bazelRc: path.resolve(opts.cwd, opts.bazelRc)
6213
6676
  } : {}),
6214
6677
  ...(opts.bazelFlags ? {
6215
6678
  bazelFlags: opts.bazelFlags
6216
6679
  } : {}),
6217
6680
  ...(opts.bazelOutputBase ? {
6218
- bazelOutputBase: opts.bazelOutputBase
6219
- } : {}),
6220
- ...(baseEnv ? {
6221
- env: baseEnv
6681
+ bazelOutputBase: path.resolve(opts.cwd, opts.bazelOutputBase)
6222
6682
  } : {}),
6683
+ env: baseEnv,
6223
6684
  verbose
6224
6685
  };
6225
6686
  }
@@ -6240,16 +6701,23 @@ async function extractBazelToMaven(opts) {
6240
6701
 
6241
6702
  // Validate config + ensure toolchains BEFORE we mint a tempdir.
6242
6703
  let bin;
6243
- let baseEnv;
6704
+ let baseEnv = getBazelEnvironment(opts.env);
6705
+ let pythonShimDir;
6706
+ let outputUserRoot;
6244
6707
  try {
6245
6708
  if (opts.bazelOutputBase) {
6246
6709
  validateOutputBase(opts.bazelOutputBase, opts.cwd);
6247
6710
  }
6248
- ensureJavaOnPath();
6249
- const shim = await provisionPythonShim();
6250
- baseEnv = shim.augmentedEnv ?? opts.env;
6251
- bin = await resolveBazelBinary(opts.bin);
6711
+ ensureJavaOnPath(baseEnv, cwd);
6712
+ bin = await resolveBazelBinary(opts.bin, baseEnv, cwd);
6713
+ const shim = await provisionPythonShim(baseEnv, cwd);
6714
+ pythonShimDir = shim.shimDir;
6715
+ baseEnv = shim.env;
6716
+ outputUserRoot = makeBazelOutputUserRoot(baseEnv, cwd);
6252
6717
  } catch (e) {
6718
+ if (pythonShimDir) {
6719
+ await removeBazelTempdir(pythonShimDir);
6720
+ }
6253
6721
  logger.logger.fail(`Unexpected error in bazel2maven: ${utils.getErrorCause(e)}`);
6254
6722
  if (verbose) {
6255
6723
  logger.logger.group('[VERBOSE] error:');
@@ -6268,8 +6736,7 @@ async function extractBazelToMaven(opts) {
6268
6736
 
6269
6737
  // Track every output_user_root we mint so we can reap them all in
6270
6738
  // the cleanup pass, even if a per-repo timeout forced a re-mint.
6271
- let outputUserRoot = makeOutputUserRoot();
6272
- const mintedRoots = [outputUserRoot];
6739
+ const mintedRoots = new Map([[outputUserRoot, new Map()]]);
6273
6740
  if (verbose) {
6274
6741
  logger.logger.log(`[VERBOSE] initial --output_user_root=${outputUserRoot} (will be reaped on completion)`);
6275
6742
  }
@@ -6296,7 +6763,7 @@ async function extractBazelToMaven(opts) {
6296
6763
  // Always apply the default prune policy so no caller can forget it;
6297
6764
  // callers EXTEND it via ignoreDirNames/ignoreDirPrefixes.
6298
6765
  const ignoreDirNames = new Set([...DEFAULT_BAZEL_WALKER_IGNORE_DIR_NAMES, ...(opts.ignoreDirNames ?? [])]);
6299
- const ignoreDirPrefixes = [...DEFAULT_BAZEL_WALKER_IGNORE_DIR_PREFIXES, ...(opts.ignoreDirPrefixes ?? [])];
6766
+ const ignoreDirPrefixes = [...DEFAULT_BAZEL_WALKER_IGNORE_DIR_PREFIXES, BAZEL_OUTPUT_ROOT_PREFIX, ...(opts.ignoreDirPrefixes ?? [])];
6300
6767
  const workspaceRoots = findWorkspaceRoots({
6301
6768
  cwd,
6302
6769
  ignoreDirNames,
@@ -6343,15 +6810,19 @@ async function extractBazelToMaven(opts) {
6343
6810
  }
6344
6811
  logger.logger.info(`Workspace ${relPath || '.'}: bzlmod=${mode.bzlmod} workspace=${mode.workspace}`);
6345
6812
  const invocationFlags = getBazelInvocationFlags(mode);
6346
- const queryOptsFor = userRoot => buildQueryOpts({
6347
- baseEnv,
6348
- bin,
6349
- invocationFlags,
6350
- opts,
6351
- outputUserRoot: userRoot,
6352
- spawnCwd: workspaceRoot,
6353
- verbose
6354
- });
6813
+ const queryOptsFor = userRoot => {
6814
+ const queryOpts = buildQueryOpts({
6815
+ baseEnv,
6816
+ bin,
6817
+ invocationFlags,
6818
+ opts,
6819
+ outputUserRoot: userRoot,
6820
+ spawnCwd: workspaceRoot,
6821
+ verbose
6822
+ });
6823
+ mintedRoots.get(userRoot)?.set(workspaceRoot, queryOpts);
6824
+ return queryOpts;
6825
+ };
6355
6826
  const {
6356
6827
  candidates,
6357
6828
  discoveryIndeterminate,
@@ -6430,11 +6901,12 @@ async function extractBazelToMaven(opts) {
6430
6901
  state: 'failed'
6431
6902
  });
6432
6903
  // eslint-disable-next-line no-await-in-loop
6433
- await reapBazelServer(bin, outputUserRoot, verbose);
6434
- // eslint-disable-next-line no-await-in-loop
6435
- await removeTempdir(outputUserRoot, verbose);
6436
- outputUserRoot = makeOutputUserRoot();
6437
- mintedRoots.push(outputUserRoot);
6904
+ const cleaned = await cleanUpBazelOutputRoot(outputUserRoot, mintedRoots.get(outputUserRoot) ?? new Map());
6905
+ if (cleaned) {
6906
+ mintedRoots.delete(outputUserRoot);
6907
+ }
6908
+ outputUserRoot = makeBazelOutputUserRoot(baseEnv, cwd);
6909
+ mintedRoots.set(outputUserRoot, new Map());
6438
6910
  if (verbose) {
6439
6911
  logger.logger.log(`[VERBOSE] minted fresh --output_user_root=${outputUserRoot} after timeout`);
6440
6912
  }
@@ -6663,11 +7135,9 @@ async function extractBazelToMaven(opts) {
6663
7135
  workspaceOutcomes
6664
7136
  };
6665
7137
  } finally {
6666
- for (const dir of mintedRoots) {
6667
- // eslint-disable-next-line no-await-in-loop
6668
- await reapBazelServer(bin, dir, verbose);
6669
- // eslint-disable-next-line no-await-in-loop
6670
- await removeTempdir(dir, verbose);
7138
+ await Promise.all(Array.from(mintedRoots, ([root, contexts]) => cleanUpBazelOutputRoot(root, contexts)));
7139
+ if (pythonShimDir) {
7140
+ await removeBazelTempdir(pythonShimDir);
6671
7141
  }
6672
7142
  }
6673
7143
  }
@@ -7806,6 +8276,7 @@ async function handleCi(autoManifest) {
7806
8276
  reach: {
7807
8277
  dynamicSbomInference: false,
7808
8278
  excludePaths: [],
8279
+ reachAdvisories: [],
7809
8280
  reachAnalysisMemoryLimit: '',
7810
8281
  reachAnalysisTimeout: '',
7811
8282
  reachConcurrency: 1,
@@ -9934,8 +10405,6 @@ async function outputFixResult(result, outputKind) {
9934
10405
  logger.logger.success('Finished!');
9935
10406
  }
9936
10407
 
9937
- const GHSA_FORMAT_REGEXP = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/;
9938
- const CVE_FORMAT_REGEXP = /^CVE-\d{4}-\d{4,}$/;
9939
10408
  /**
9940
10409
  * Converts mixed CVE/GHSA/PURL IDs to GHSA IDs only.
9941
10410
  * Filters out invalid IDs and logs conversion results.
@@ -9951,51 +10420,12 @@ async function convertIdsToGhsas(ids, options) {
9951
10420
  debug.debugDir('inspect', {
9952
10421
  ids
9953
10422
  });
9954
- const validGhsas = [];
9955
- const errors = [];
9956
- for (const id of ids) {
9957
- const trimmedId = id.trim();
9958
- if (trimmedId.startsWith('GHSA-')) {
9959
- // Already a GHSA ID, validate format
9960
- if (GHSA_FORMAT_REGEXP.test(trimmedId)) {
9961
- validGhsas.push(trimmedId);
9962
- } else {
9963
- errors.push(`Invalid GHSA format: ${trimmedId}`);
9964
- }
9965
- } else if (trimmedId.startsWith('CVE-')) {
9966
- // Convert CVE to GHSA
9967
- if (!CVE_FORMAT_REGEXP.test(trimmedId)) {
9968
- errors.push(`Invalid CVE format: ${trimmedId}`);
9969
- continue;
9970
- }
9971
-
9972
- // eslint-disable-next-line no-await-in-loop
9973
- const conversionResult = await utils.convertCveToGhsa(trimmedId);
9974
- if (conversionResult.ok) {
9975
- validGhsas.push(conversionResult.data);
9976
- if (!silence) {
9977
- logger.logger.info(`Converted ${trimmedId} to ${conversionResult.data}`);
9978
- }
9979
- } else {
9980
- errors.push(`${trimmedId}: ${conversionResult.message}`);
9981
- }
9982
- } else if (trimmedId.startsWith('pkg:')) {
9983
- // Convert PURL to GHSAs.
9984
- // eslint-disable-next-line no-await-in-loop
9985
- const conversionResult = await utils.convertPurlToGhsas(trimmedId);
9986
- if (conversionResult.ok && conversionResult.data.length) {
9987
- validGhsas.push(...conversionResult.data);
9988
- if (!silence) {
9989
- logger.logger.info(`Converted ${trimmedId} to ${conversionResult.data.length} GHSA(s): ${arrays.joinAnd(conversionResult.data)}`);
9990
- }
9991
- } else {
9992
- errors.push(`${trimmedId}: ${conversionResult.message || 'No GHSAs found'}`);
9993
- }
9994
- } else {
9995
- // Neither CVE, GHSA, nor PURL, skip
9996
- errors.push(`Unsupported ID format (expected CVE, GHSA, or PURL): ${trimmedId}`);
9997
- }
9998
- }
10423
+ const {
10424
+ errors,
10425
+ ghsas: validGhsas
10426
+ } = await utils.resolveAdvisoryIds(ids, {
10427
+ silence
10428
+ });
9999
10429
  if (errors.length) {
10000
10430
  if (!silence) {
10001
10431
  logger.logger.warn(`Skipped ${errors.length} invalid IDs:\n${errors.map(e => ` - ${e}`).join('\n')}`);
@@ -10112,6 +10542,11 @@ const reachabilityFlags = {
10112
10542
  type: 'string',
10113
10543
  description: `Override the version of @coana-tech/cli used for reachability analysis. Default: ${constants.COANA_CLI_VERSION}.`
10114
10544
  },
10545
+ reachAdvisories: {
10546
+ type: 'string',
10547
+ isMultiple: true,
10548
+ description: '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.'
10549
+ },
10115
10550
  reachAnalysisMemoryLimit: {
10116
10551
  type: 'string',
10117
10552
  default: '8192',
@@ -12262,14 +12697,20 @@ async function extractBazelToPypi(opts) {
12262
12697
  logger.logger.warn(`Warning: cwd does not exist: ${cwd}`);
12263
12698
  }
12264
12699
  logger.logger.groupEnd();
12700
+ let pythonShimDir;
12265
12701
  try {
12702
+ if (process.platform === 'win32') {
12703
+ throw new utils.InputError('Bazel PyPI extraction on Windows is not supported yet. Platform-specific dependency selection still needs verification. Use --ecosystem maven for Windows SCA scans.');
12704
+ }
12266
12705
  // Validate caller-provided Bazel filesystem settings before invoking Bazel.
12267
12706
  if (opts.bazelOutputBase) {
12268
12707
  validateOutputBase(opts.bazelOutputBase, opts.cwd);
12269
12708
  }
12270
12709
  // Python shim (for rules_python workspace discovery).
12271
- const shim = await provisionPythonShim();
12272
- const baseEnv = shim.augmentedEnv ?? opts.env;
12710
+ const env = getBazelEnvironment(opts.env);
12711
+ const shim = await provisionPythonShim(env, cwd);
12712
+ pythonShimDir = shim.shimDir;
12713
+ const baseEnv = shim.env;
12273
12714
 
12274
12715
  // Step 1: workspace detection.
12275
12716
  const mode = detectWorkspaceMode(cwd);
@@ -12277,7 +12718,7 @@ async function extractBazelToPypi(opts) {
12277
12718
  const invocationFlags = getBazelInvocationFlags(mode);
12278
12719
 
12279
12720
  // Step 2: bazel binary resolution.
12280
- const bin = await resolveBazelBinary(opts.bin);
12721
+ const bin = await resolveBazelBinary(opts.bin, baseEnv, cwd);
12281
12722
  logger.logger.info(`Using bazel: ${bin}`);
12282
12723
  if (verbose) {
12283
12724
  logger.logger.log('[VERBOSE] resolved options:', {
@@ -12303,9 +12744,7 @@ async function extractBazelToPypi(opts) {
12303
12744
  ...(opts.bazelOutputBase ? {
12304
12745
  bazelOutputBase: opts.bazelOutputBase
12305
12746
  } : {}),
12306
- ...(baseEnv ? {
12307
- env: baseEnv
12308
- } : {}),
12747
+ env: baseEnv,
12309
12748
  verbose
12310
12749
  };
12311
12750
 
@@ -12445,6 +12884,10 @@ async function extractBazelToPypi(opts) {
12445
12884
  artifactCount: 0,
12446
12885
  ok: false
12447
12886
  };
12887
+ } finally {
12888
+ if (pythonShimDir) {
12889
+ await removeBazelTempdir(pythonShimDir);
12890
+ }
12448
12891
  }
12449
12892
  }
12450
12893
 
@@ -12530,7 +12973,7 @@ const config$g = {
12530
12973
  },
12531
12974
  bazelFlags: {
12532
12975
  type: 'string',
12533
- description: 'Flags forwarded to every bazel invocation (single quoted string)'
12976
+ description: 'Bazel command flags, with quoted values and literal backslashes'
12534
12977
  },
12535
12978
  bazelOutputBase: {
12536
12979
  type: 'string',
@@ -12577,6 +13020,20 @@ const config$g = {
12577
13020
  Note: this command generates dependency manifests for Bazel workspaces.
12578
13021
  It does not run reachability analysis.
12579
13022
 
13023
+ Native Windows support currently covers Maven. Use PowerShell or
13024
+ Command Prompt with a working Python 3 python.exe and a JDK on PATH.
13025
+ PyPI extraction on Windows is not supported yet.
13026
+
13027
+ Set SOCKET_BAZEL_OUTPUT_ROOT to a short, writable parent directory,
13028
+ such as C:/sb, to shorten connector-owned Bazel output paths. Each
13029
+ invocation gets a separate directory. An explicit --bazel-output-base
13030
+ takes precedence for Bazel's cache and is kept after the scan. The
13031
+ scan only stops Bazel servers it started.
13032
+
13033
+ In --bazel-flags, single or double quotes at the start of a value or
13034
+ after = group values containing spaces. Backslashes stay literal. Double a matching quote to include
13035
+ it within a quoted value. Shell variables are not expanded.
13036
+
12580
13037
  Maven hub discovery: under Bzlmod, hubs are enumerated from
12581
13038
  \`bazel mod show_extension\` and filtered to the root module's own hubs.
12582
13039
  Under legacy WORKSPACE mode (no \`show_extension\`), only conventionally
@@ -12725,7 +13182,9 @@ async function run$H(argv, importMeta, {
12725
13182
  let perRepoTimeout = cli.flags['perRepoTimeout'];
12726
13183
 
12727
13184
  // Set defaults for any flag/arg that is not given. Check socket.json first.
12728
- if (!bazel) {
13185
+ if (bazel) {
13186
+ bazel = path.resolve(process.cwd(), String(bazel));
13187
+ } else {
12729
13188
  const defaultBazel = sockJson.defaults?.manifest?.bazel?.bazel ?? sockJson.defaults?.manifest?.bazel?.bin;
12730
13189
  if (defaultBazel) {
12731
13190
  bazel = defaultBazel;
@@ -12804,7 +13263,7 @@ async function run$H(argv, importMeta, {
12804
13263
  logger.logger.groupEnd();
12805
13264
  }
12806
13265
  if (dryRun) {
12807
- logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
13266
+ logger.logger.log(constants.DRY_RUN_BAILING_NOW);
12808
13267
  return;
12809
13268
  }
12810
13269
 
@@ -13126,7 +13585,7 @@ async function run$F(argv, importMeta, {
13126
13585
 
13127
13586
  function renderTable(outcomes) {
13128
13587
  // A covered reactor member is implied by its parent's line above it.
13129
- return outcomes.filter(o => o.status !== 'skippedCovered').map(o => `- ${o.dir} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`).join('\n');
13588
+ return outcomes.filter(o => o.status !== 'skippedCovered').map(o => `- ${buildRootPath(o)} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`).join('\n');
13130
13589
  }
13131
13590
  function summarize(outcomes) {
13132
13591
  const generated = outcomes.filter(o => o.status === 'generated').length;
@@ -14940,6 +15399,7 @@ async function enumerateWorkspaces({
14940
15399
  buildOpts,
14941
15400
  cwd,
14942
15401
  ecosystem,
15402
+ entryFile,
14943
15403
  excludePaths,
14944
15404
  javaHome,
14945
15405
  verbose
@@ -14956,6 +15416,7 @@ async function enumerateWorkspaces({
14956
15416
  }
14957
15417
  const scriptOpts = {
14958
15418
  bin: bin || undefined,
15419
+ entryFile,
14959
15420
  excludePaths: excludePaths?.length ? excludePaths : undefined,
14960
15421
  // `env` replaces the spawned process's whole environment, not just JAVA_HOME.
14961
15422
  env: resolvedJavaHome ? {
@@ -14990,8 +15451,8 @@ async function enumerateWorkspaces({
14990
15451
  };
14991
15452
  }
14992
15453
 
14993
- // A single discovered build root - a directory with its own gradle/sbt/maven
14994
- // marker file.
15454
+ // One prompt per directory, as settings are per directory; Maven may have
15455
+ // several POMs, so several roots, there.
14995
15456
 
14996
15457
  const ROOT_ECOSYSTEMS = ['maven', 'gradle', 'sbt'];
14997
15458
  const ECOSYSTEM_LABELS = {
@@ -15026,23 +15487,6 @@ function notCanceled$1() {
15026
15487
  function getEcosystemSection(sockJson, ecosystem) {
15027
15488
  return sockJson.defaults?.manifest?.[ecosystem] ?? {};
15028
15489
  }
15029
-
15030
- // Depth-then-path sort: for exclusion roots it's just stable log ordering;
15031
- // for candidates it's load-bearing, since a parent must be prompted before
15032
- // its children so a child's shown default reflects a parent's just-written
15033
- // cascade instead of its pre-run value.
15034
- function sortCandidatesForDisplay(candidates, cwd) {
15035
- return [...candidates].sort((a, b) => {
15036
- const relA = path.relative(cwd, a.dir);
15037
- const relB = path.relative(cwd, b.dir);
15038
- const depthA = relA.split(path.sep).length;
15039
- const depthB = relB.split(path.sep).length;
15040
- if (depthA !== depthB) {
15041
- return depthA - depthB;
15042
- }
15043
- return relA < relB ? -1 : relA > relB ? 1 : 0;
15044
- });
15045
- }
15046
15490
  function toPosixRelative(cwd, dir) {
15047
15491
  return path.relative(cwd, dir).split(path.sep).join('/');
15048
15492
  }
@@ -15070,12 +15514,15 @@ async function scanBuildRoots({
15070
15514
  excludePaths,
15071
15515
  sockJson
15072
15516
  }) {
15517
+ const socketConfig = utils.loadSocketConfig(cwd);
15073
15518
  const [fullByTool, includedByTool] = await Promise.all([findBuildToolCandidates({
15074
15519
  cwd,
15520
+ socketConfig,
15075
15521
  sockJson
15076
15522
  }), findBuildToolCandidates({
15077
15523
  cwd,
15078
15524
  excludePaths,
15525
+ socketConfig,
15079
15526
  sockJson
15080
15527
  })]);
15081
15528
  return {
@@ -15098,20 +15545,47 @@ async function discoverBuildRoots({
15098
15545
  const included = [];
15099
15546
  const ignorePatterns = (excludePaths ?? []).flatMap(excludePathToScanIgnores);
15100
15547
  const ecosystemsByRoot = new Map();
15101
- for (const [ecosystem, fullDirs] of fullByTool) {
15102
- const includedDirs = new Set(includedByTool.get(ecosystem) ?? []);
15103
- for (const dir of fullDirs) {
15104
- if (dir === realCwd) {
15548
+ for (const [ecosystem, fullRoots] of fullByTool) {
15549
+ const includedPaths = new Set((includedByTool.get(ecosystem) ?? []).map(buildRootPath));
15550
+ const rootsByDir = new Map();
15551
+ for (const root of fullRoots) {
15552
+ if (root.dir === realCwd) {
15105
15553
  continue;
15106
15554
  }
15107
- if (includedDirs.has(dir)) {
15555
+ const roots = rootsByDir.get(root.dir);
15556
+ if (roots) {
15557
+ roots.push(root);
15558
+ } else {
15559
+ rootsByDir.set(root.dir, [root]);
15560
+ }
15561
+ }
15562
+ for (const {
15563
+ 0: dir,
15564
+ 1: roots
15565
+ } of rootsByDir) {
15566
+ const relDir = toPosixRelative(realCwd, dir);
15567
+ const includedRoots = [];
15568
+ const excludedRoots = [];
15569
+ for (const root of roots) {
15570
+ if (includedPaths.has(buildRootPath(root))) {
15571
+ includedRoots.push(root);
15572
+ } else {
15573
+ excludedRoots.push(root);
15574
+ }
15575
+ }
15576
+ if (includedRoots.length) {
15108
15577
  included.push({
15109
15578
  dir,
15110
- ecosystem
15579
+ ecosystem,
15580
+ roots: includedRoots
15111
15581
  });
15582
+ // `disabled` is per directory, so it cannot exclude one build file
15583
+ // without its included siblings.
15584
+ for (const root of excludedRoots) {
15585
+ logger.logger.warn(`Not disabling ${ecosystem} in ${relDir}: --exclude-paths matches ${buildRootPath(root)} but not the other build files there. Pass the same --exclude-paths to the scan instead.`);
15586
+ }
15112
15587
  continue;
15113
15588
  }
15114
- const relDir = toPosixRelative(realCwd, dir);
15115
15589
  const rootRelDir = findExclusionRoot(relDir, ignorePatterns);
15116
15590
  const rootDir = path.join(realCwd, rootRelDir);
15117
15591
  const ecosystems = ecosystemsByRoot.get(rootDir) ?? new Set();
@@ -15129,48 +15603,65 @@ async function discoverBuildRoots({
15129
15603
  }
15130
15604
 
15131
15605
  // Fails closed: without the members' build files, covered and independent
15132
- // candidates can't be told apart.
15606
+ // candidates can't be told apart. In order, as a root's enumeration can cover
15607
+ // the roots after it.
15133
15608
  async function markWorkspaceCoverage({
15134
- candidate,
15135
15609
  covered,
15136
15610
  cwd,
15611
+ dir,
15612
+ ecosystem,
15137
15613
  excludePaths,
15138
- rootSockJson
15614
+ rootSockJson,
15615
+ roots
15139
15616
  }) {
15140
- const cascade = utils.readSocketJsonCascade(candidate.dir, cwd, rootSockJson);
15617
+ if (roots.every(root => isBuildRootCovered(covered, ecosystem, root))) {
15618
+ return {
15619
+ ok: true,
15620
+ data: undefined
15621
+ };
15622
+ }
15623
+ const cascade = utils.readSocketJsonCascade(dir, cwd, rootSockJson);
15141
15624
  const {
15142
15625
  bin,
15143
15626
  buildOpts,
15144
15627
  javaHome,
15145
15628
  skipReason
15146
- } = resolveEcosystemConfig(candidate.ecosystem, candidate.dir, cascade);
15629
+ } = resolveEcosystemConfig(ecosystem, dir, cascade);
15147
15630
  if (skipReason) {
15148
15631
  return {
15149
15632
  ok: true,
15150
15633
  data: undefined
15151
15634
  };
15152
15635
  }
15153
- const excludePathsForCandidate = projectIgnorePathsToReachExcludePaths(excludePaths, {
15636
+ const excludePathsForDir = projectIgnorePathsToReachExcludePaths(excludePaths, {
15154
15637
  cwd,
15155
- target: candidate.dir
15638
+ target: dir
15156
15639
  });
15157
- const enumResult = await enumerateWorkspaces({
15158
- bin,
15159
- buildOpts,
15160
- cwd: candidate.dir,
15161
- ecosystem: candidate.ecosystem,
15162
- excludePaths: excludePathsForCandidate,
15163
- javaHome,
15164
- verbose: false
15165
- });
15166
- if (!enumResult) {
15167
- const relDir = path.relative(cwd, candidate.dir) || '.';
15168
- return {
15169
- ok: false,
15170
- message: `Could not determine ${candidate.ecosystem} workspace layout for ${relDir}; aborting rather than risk misclassifying its members.`
15171
- };
15640
+ for (const root of roots) {
15641
+ if (isBuildRootCovered(covered, ecosystem, root)) {
15642
+ continue;
15643
+ }
15644
+ // eslint-disable-next-line no-await-in-loop
15645
+ const enumResult = await enumerateWorkspaces({
15646
+ bin,
15647
+ buildOpts,
15648
+ cwd: dir,
15649
+ ecosystem,
15650
+ entryFile: root.entryFile,
15651
+ excludePaths: excludePathsForDir,
15652
+ javaHome,
15653
+ verbose: false
15654
+ });
15655
+ if (!enumResult) {
15656
+ const relRoot = path.relative(cwd, buildRootPath(root)) || '.';
15657
+ return {
15658
+ ok: false,
15659
+ message: `Could not determine ${ecosystem} workspace layout for ${relRoot}; aborting rather than risk misclassifying its members.`
15660
+ };
15661
+ }
15662
+ // eslint-disable-next-line no-await-in-loop
15663
+ await addCoveredBuildFiles(covered, enumResult.buildRoot, enumResult.projects);
15172
15664
  }
15173
- await addCoveredBuildFiles(covered, enumResult.buildRoot, enumResult.projects);
15174
15665
  return {
15175
15666
  ok: true,
15176
15667
  data: undefined
@@ -15580,7 +16071,7 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15580
16071
  logger.logger.log('');
15581
16072
  logger.logger.log(`Configuring ${cwd} ...`);
15582
16073
  const initialSockJson = utils.readOrDefaultSocketJson(cwd);
15583
- // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's
16074
+ // Resolved once here for sortByDepthThenPath/disableExclusionRoot's
15584
16075
  // relative-path math, consistent with discoverBuildRoots' own internal
15585
16076
  // resolution (see its comment for why this matters).
15586
16077
  const realCwd = await utils.realpathOrResolved(cwd);
@@ -15619,7 +16110,7 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15619
16110
 
15620
16111
  // Which detected ecosystems cwd is itself a build root for - see
15621
16112
  // setupRecursiveRootDefaults' wording split.
15622
- const ecosystemsAtCwd = ROOT_ECOSYSTEMS.filter(ecosystem => (fullByTool.get(ecosystem) ?? []).includes(realCwd));
16113
+ const ecosystemsAtCwd = ROOT_ECOSYSTEMS.filter(ecosystem => (fullByTool.get(ecosystem) ?? []).some(root => root.dir === realCwd));
15623
16114
  const rootResult = await setupRecursiveRootDefaults(cwd, defaultOnReadError, detectedEcosystems, ecosystemsAtCwd);
15624
16115
  if (!rootResult.ok) {
15625
16116
  return rootResult;
@@ -15646,7 +16137,7 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15646
16137
  if (!excluded.length) {
15647
16138
  logger.logger.log('None matched --exclude-paths; nothing disabled.');
15648
16139
  } else {
15649
- const orderedExcluded = sortCandidatesForDisplay(excluded, realCwd);
16140
+ const orderedExcluded = sortByDepthThenPath(excluded, realCwd);
15650
16141
  for (const candidate of orderedExcluded) {
15651
16142
  // eslint-disable-next-line no-await-in-loop
15652
16143
  const result = await disableExclusionRoot({
@@ -15684,17 +16175,16 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15684
16175
  // cwd never appears in `included`, but a reactor root often sits exactly
15685
16176
  // at cwd - seed coverage from it first so its declared members still get
15686
16177
  // recognized.
15687
- for (const ecosystem of ecosystemsAtCwd) {
16178
+ for (const ecosystem of ROOT_ECOSYSTEMS) {
15688
16179
  // eslint-disable-next-line no-await-in-loop
15689
16180
  const coverageResult = await markWorkspaceCoverage({
15690
- candidate: {
15691
- dir: realCwd,
15692
- ecosystem
15693
- },
15694
16181
  covered,
15695
16182
  cwd: realCwd,
16183
+ dir: realCwd,
16184
+ ecosystem,
15696
16185
  excludePaths,
15697
- rootSockJson
16186
+ rootSockJson,
16187
+ roots: (includedByTool.get(ecosystem) ?? []).filter(root => root.dir === realCwd)
15698
16188
  });
15699
16189
  if (!coverageResult.ok) {
15700
16190
  return coverageResult;
@@ -15707,9 +16197,14 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15707
16197
  reenabled: 0,
15708
16198
  skipped: 0
15709
16199
  };
15710
- const orderedIncluded = sortCandidatesForDisplay(included, realCwd);
16200
+ // A parent is prompted before its children, so a child's shown default
16201
+ // reflects the parent's just-written cascade.
16202
+ const orderedIncluded = sortByDepthThenPath(included, realCwd);
15711
16203
  for (const candidate of orderedIncluded) {
15712
- if (isBuildRootCovered(covered, candidate.ecosystem, candidate.dir)) {
16204
+ const {
16205
+ ecosystem
16206
+ } = candidate;
16207
+ if (candidate.roots.every(root => isBuildRootCovered(covered, ecosystem, root))) {
15713
16208
  counts.covered += 1;
15714
16209
  continue;
15715
16210
  }
@@ -15718,7 +16213,7 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15718
16213
  const result = await processCandidate({
15719
16214
  cwd: realCwd,
15720
16215
  dir: candidate.dir,
15721
- ecosystem: candidate.ecosystem,
16216
+ ecosystem,
15722
16217
  rootSockJson
15723
16218
  });
15724
16219
  if (!result.ok) {
@@ -15745,11 +16240,13 @@ async function setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePath
15745
16240
  // build-tool invocation only runs for a project already prompted about.
15746
16241
  // eslint-disable-next-line no-await-in-loop
15747
16242
  const coverageResult = await markWorkspaceCoverage({
15748
- candidate,
15749
16243
  covered,
15750
16244
  cwd: realCwd,
16245
+ dir: candidate.dir,
16246
+ ecosystem,
15751
16247
  excludePaths,
15752
- rootSockJson
16248
+ rootSockJson,
16249
+ roots: candidate.roots
15753
16250
  });
15754
16251
  if (!coverageResult.ok) {
15755
16252
  return coverageResult;
@@ -20058,6 +20555,37 @@ function resolveUvMemberDirs(targets, cwd) {
20058
20555
  return Array.from(memberDirs);
20059
20556
  }
20060
20557
 
20558
+ // GitHub publishes GHSA IDs with a lowercase body and CVE IDs uppercase.
20559
+ function normalizeAdvisoryId(id) {
20560
+ const trimmed = id.trim();
20561
+ return /^ghsa-/i.test(trimmed) ? `GHSA-${trimmed.slice(5).toLowerCase()}` : trimmed.toUpperCase();
20562
+ }
20563
+
20564
+ /**
20565
+ * Resolves the --reach-advisories IDs to GHSA IDs, converting CVE IDs through
20566
+ * the GitHub API. Any ID that cannot be resolved fails the command, since
20567
+ * dropping it would leave that advisory without reachability analysis.
20568
+ */
20569
+ async function resolveReachAdvisories(ids) {
20570
+ const normalizedIds = arrays.arrayUnique(ids.map(normalizeAdvisoryId));
20571
+ // Checked up front so PURLs never reach resolveAdvisoryIds and a typo fails
20572
+ // before any GitHub lookup.
20573
+ const invalidIds = normalizedIds.filter(id => !utils.GHSA_FORMAT_REGEXP.test(id) && !utils.CVE_FORMAT_REGEXP.test(id));
20574
+ if (invalidIds.length) {
20575
+ throw new utils.InputError(`Invalid --reach-advisories IDs: ${invalidIds.join(', ')}. Use GHSA IDs (GHSA-xxxx-xxxx-xxxx) or CVE IDs (CVE-YYYY-NNNN).`);
20576
+ }
20577
+ const {
20578
+ errors,
20579
+ ghsas
20580
+ } = await utils.resolveAdvisoryIds(normalizedIds, {
20581
+ silence: true
20582
+ });
20583
+ if (errors.length) {
20584
+ throw new utils.InputError(`Could not resolve --reach-advisories IDs to GHSA IDs:\n${errors.map(e => ` - ${e}`).join('\n')}`);
20585
+ }
20586
+ return arrays.arrayUnique(ghsas);
20587
+ }
20588
+
20061
20589
  async function suggestTarget() {
20062
20590
  // We could prefill this with sub-dirs of the current
20063
20591
  // dir ... but is that going to be useful?
@@ -20487,8 +21015,10 @@ async function run$d(argv, importMeta, {
20487
21015
  const excludePaths = utils.cmdFlagValueToArray(cli.flags['excludePaths']);
20488
21016
  assertValidExcludePaths(excludePaths);
20489
21017
  const reachExcludePaths = utils.cmdFlagValueToArray(cli.flags['reachExcludePaths']);
21018
+ const reachAdvisoriesRaw = utils.cmdFlagValueToArray(cli.flags['reachAdvisories']);
20490
21019
 
20491
21020
  // Validation helpers for better readability.
21021
+ const hasReachAdvisories = reachAdvisoriesRaw.length > 0;
20492
21022
  const hasReachEcosystems = reachEcosystems.length > 0;
20493
21023
  const hasReachExcludePaths = reachExcludePaths.length > 0;
20494
21024
 
@@ -20506,7 +21036,7 @@ async function run$d(argv, importMeta, {
20506
21036
  // added flags require --reach automatically instead of relying on a
20507
21037
  // hand-maintained list. Deprecated no-ops are excluded on purpose.
20508
21038
  const isUsingAnyBooleanReachFlag = Object.entries(reachabilityFlags).some(([name, flag]) => flag.type === 'boolean' && !DEPRECATED_NO_OP_REACH_FLAGS.has(name) && cli.flags[name] !== flag.default);
20509
- const isUsingAnyReachabilityFlags = hasReachEcosystems || hasReachExcludePaths || isUsingAnyBooleanReachFlag || isUsingNonDefaultConcurrency || isUsingNonDefaultMemoryLimit || isUsingNonDefaultTimeout || isUsingNonDefaultVersion;
21039
+ const isUsingAnyReachabilityFlags = hasReachAdvisories || hasReachEcosystems || hasReachExcludePaths || isUsingAnyBooleanReachFlag || isUsingNonDefaultConcurrency || isUsingNonDefaultMemoryLimit || isUsingNonDefaultTimeout || isUsingNonDefaultVersion;
20510
21040
 
20511
21041
  // Validate target constraints when --reach is enabled.
20512
21042
  const reachTargetValidation = reach ? await validateReachabilityTarget(targets, cwd) : {
@@ -20583,6 +21113,7 @@ async function run$d(argv, importMeta, {
20583
21113
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
20584
21114
  return;
20585
21115
  }
21116
+ const reachAdvisories = await resolveReachAdvisories(reachAdvisoriesRaw);
20586
21117
  await handleCreateNewScan({
20587
21118
  autoManifest: Boolean(autoManifest),
20588
21119
  branchName: branchName,
@@ -20600,6 +21131,7 @@ async function run$d(argv, importMeta, {
20600
21131
  reach: {
20601
21132
  dynamicSbomInference: Boolean(dynamicSbomInference),
20602
21133
  excludePaths,
21134
+ reachAdvisories,
20603
21135
  reachAnalysisMemoryLimit,
20604
21136
  reachAnalysisTimeout,
20605
21137
  reachConcurrency: Number(reachConcurrency),
@@ -21318,6 +21850,7 @@ async function scanOneRepo(repoSlug, {
21318
21850
  reach: {
21319
21851
  dynamicSbomInference: false,
21320
21852
  excludePaths: [],
21853
+ reachAdvisories: [],
21321
21854
  reachAnalysisMemoryLimit: '',
21322
21855
  reachAnalysisTimeout: '',
21323
21856
  reachConcurrency: 1,
@@ -22746,6 +23279,7 @@ async function run$7(argv, importMeta, {
22746
23279
 
22747
23280
  // Process comma-separated values for isMultiple flags.
22748
23281
  const excludePaths = utils.cmdFlagValueToArray(cli.flags['excludePaths']);
23282
+ const reachAdvisoriesRaw = utils.cmdFlagValueToArray(cli.flags['reachAdvisories']);
22749
23283
  const reachEcosystemsRaw = utils.cmdFlagValueToArray(cli.flags['reachEcosystems']);
22750
23284
  const reachExcludePaths = utils.cmdFlagValueToArray(cli.flags['reachExcludePaths']);
22751
23285
  assertValidExcludePaths(excludePaths);
@@ -22818,6 +23352,7 @@ async function run$7(argv, importMeta, {
22818
23352
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
22819
23353
  return;
22820
23354
  }
23355
+ const reachAdvisories = await resolveReachAdvisories(reachAdvisoriesRaw);
22821
23356
  await handleScanReach({
22822
23357
  cwd,
22823
23358
  interactive,
@@ -22827,6 +23362,7 @@ async function run$7(argv, importMeta, {
22827
23362
  reachabilityOptions: {
22828
23363
  dynamicSbomInference: Boolean(dynamicSbomInference),
22829
23364
  excludePaths,
23365
+ reachAdvisories,
22830
23366
  reachAnalysisMemoryLimit,
22831
23367
  reachAnalysisTimeout,
22832
23368
  reachConcurrency: Number(reachConcurrency),
@@ -24791,5 +25327,5 @@ process.on('unhandledRejection', async (reason, promise) => {
24791
25327
  // eslint-disable-next-line n/no-process-exit
24792
25328
  process.exit(1);
24793
25329
  });
24794
- //# debugId=3195b2a9-2bce-44ed-8eea-bd5e763d40fa
25330
+ //# debugId=b77ced41-382f-4a5e-a03f-154a33fa6ec
24795
25331
  //# sourceMappingURL=cli.js.map