socket 1.1.101 → 1.1.103

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 (36) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/dist/cli.js +1492 -128
  3. package/dist/cli.js.map +1 -1
  4. package/dist/constants.js +8 -4
  5. package/dist/constants.js.map +1 -1
  6. package/dist/socket-facts.init.gradle +353 -0
  7. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  8. package/dist/types/commands/manifest/bazel/bazel-pypi-discovery.d.mts +31 -0
  9. package/dist/types/commands/manifest/bazel/bazel-pypi-discovery.d.mts.map +1 -0
  10. package/dist/types/commands/manifest/bazel/bazel-pypi-parser.d.mts +46 -0
  11. package/dist/types/commands/manifest/bazel/bazel-pypi-parser.d.mts.map +1 -0
  12. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts +16 -2
  13. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts.map +1 -1
  14. package/dist/types/commands/manifest/bazel/bazel-repo-discovery.d.mts +3 -3
  15. package/dist/types/commands/manifest/bazel/bazel-repo-discovery.d.mts.map +1 -1
  16. package/dist/types/commands/manifest/bazel/cmd-manifest-bazel.d.mts +19 -0
  17. package/dist/types/commands/manifest/bazel/cmd-manifest-bazel.d.mts.map +1 -1
  18. package/dist/types/commands/manifest/bazel/extract_bazel_to_maven.d.mts +1 -0
  19. package/dist/types/commands/manifest/bazel/extract_bazel_to_maven.d.mts.map +1 -1
  20. package/dist/types/commands/manifest/bazel/extract_bazel_to_pypi.d.mts +20 -0
  21. package/dist/types/commands/manifest/bazel/extract_bazel_to_pypi.d.mts.map +1 -0
  22. package/dist/types/commands/manifest/cmd-manifest-gradle.d.mts.map +1 -1
  23. package/dist/types/commands/manifest/cmd-manifest-kotlin.d.mts.map +1 -1
  24. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts +7 -0
  25. package/dist/types/commands/manifest/convert-gradle-to-facts.d.mts.map +1 -0
  26. package/dist/types/commands/manifest/convert_gradle_to_maven.d.mts.map +1 -1
  27. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  28. package/dist/types/commands/scan/handle-create-new-scan.d.mts.map +1 -1
  29. package/dist/types/commands/scan/perform-reachability-analysis.d.mts.map +1 -1
  30. package/dist/types/constants.d.mts +4 -0
  31. package/dist/types/constants.d.mts.map +1 -1
  32. package/dist/types/utils/dlx.d.mts.map +1 -1
  33. package/dist/types/utils/socket-json.d.mts +1 -0
  34. package/dist/types/utils/socket-json.d.mts.map +1 -1
  35. package/dist/utils.js.map +1 -1
  36. package/package.json +2 -3
package/dist/cli.js CHANGED
@@ -1763,14 +1763,16 @@ async function performReachabilityAnalysis(options) {
1763
1763
  return sockSdkCResult;
1764
1764
  }
1765
1765
  const sockSdk = sockSdkCResult.data;
1766
-
1767
- // Exclude any .socket.facts.json files that happen to be in the scan
1768
- // folder before the analysis was run.
1769
- const filepathsToUpload = packagePaths.filter(p => path.basename(p).toLowerCase() !== constants.default.DOT_SOCKET_DOT_FACTS_JSON);
1770
1766
  spinner?.start('Uploading manifests for reachability analysis...');
1771
1767
 
1772
1768
  // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path
1773
- const uploadCResult = await utils.handleApiCall(sockSdk.uploadManifestFiles(orgSlug, filepathsToUpload, path.resolve(cwd, analysisTarget)), {
1769
+ // NOTE: previously stripped any `.socket.facts.json` from packagePaths
1770
+ // here to avoid uploading leftover post-reachability output. With the
1771
+ // producer flow (`socket manifest gradle --facts`) those files are
1772
+ // legitimate INPUT to compute-artifacts, so we now upload them. Stale
1773
+ // facts files are cleaned up downstream — see the post-success
1774
+ // deletion in handle-create-new-scan.mts.
1775
+ const uploadCResult = await utils.handleApiCall(sockSdk.uploadManifestFiles(orgSlug, packagePaths, path.resolve(cwd, analysisTarget)), {
1774
1776
  description: 'upload manifests',
1775
1777
  spinner
1776
1778
  });
@@ -2243,6 +2245,8 @@ async function provisionPythonShim() {
2243
2245
  // Default per-invocation timeout for bazel queries. Bazel cold-cache starts
2244
2246
  // can take several minutes; 10 minutes is generous while still bounding CI hangs.
2245
2247
  const BAZEL_QUERY_TIMEOUT_MS = 600_000;
2248
+ const STDERR_TAIL_BYTES = 4_096;
2249
+ const STDOUT_EXCERPT_BYTES = 1_024;
2246
2250
 
2247
2251
  // Splits the user-supplied --bazel-flags string on whitespace.
2248
2252
  // Empty / undefined returns []. No shell parsing — quoted args with embedded
@@ -2263,11 +2267,22 @@ function buildBazelModShowVisibleReposArgv(opts) {
2263
2267
  startup.push(`--output_base=${opts.bazelOutputBase}`);
2264
2268
  }
2265
2269
  const userFlags = splitBazelFlags(opts.bazelFlags);
2266
- return [...startup, 'mod', 'show_repo', '--all_visible_repos', '--output=streamed_jsonproto', ...userFlags];
2270
+ return [...startup, 'mod', 'dump_repo_mapping', '', '--output=json', ...userFlags];
2271
+ }
2272
+ function buildBazelModShowPipExtensionArgv(opts) {
2273
+ const startup = [];
2274
+ if (opts.bazelRc) {
2275
+ startup.push(`--bazelrc=${opts.bazelRc}`);
2276
+ }
2277
+ if (opts.bazelOutputBase) {
2278
+ startup.push(`--output_base=${opts.bazelOutputBase}`);
2279
+ }
2280
+ const userFlags = splitBazelFlags(opts.bazelFlags);
2281
+ return [...startup, 'mod', 'show_extension', '@rules_python//python/extensions:pip.bzl%pip', '--extension_usages=<root>', ...userFlags];
2267
2282
  }
2268
- function buildBazelArgv(queryStr, opts) {
2283
+ function buildBazelArgv(queryStr, opts, output = 'build') {
2269
2284
  // Startup flags MUST precede the `query` subcommand.
2270
- // Bazel argv shape: <startup> query <queryFlags> <invocationFlags> <queryStr> --output=build <userFlags>
2285
+ // Bazel argv shape: <startup> query <queryFlags> <invocationFlags> <queryStr> --output=<output> <userFlags>
2271
2286
  const startup = [];
2272
2287
  if (opts.bazelRc) {
2273
2288
  startup.push(`--bazelrc=${opts.bazelRc}`);
@@ -2278,7 +2293,7 @@ function buildBazelArgv(queryStr, opts) {
2278
2293
  // Keep query output stable and avoid updating Bazel lockfiles while extracting.
2279
2294
  const queryFlags = ['--lockfile_mode=off', '--noshow_progress'];
2280
2295
  const userFlags = splitBazelFlags(opts.bazelFlags);
2281
- return [...startup, 'query', ...queryFlags, ...opts.invocationFlags, queryStr, '--output=build', ...userFlags];
2296
+ return [...startup, 'query', ...queryFlags, ...opts.invocationFlags, queryStr, `--output=${output}`, ...userFlags];
2282
2297
  }
2283
2298
  function stringField(value) {
2284
2299
  return typeof value === 'string' ? value : '';
@@ -2286,6 +2301,46 @@ function stringField(value) {
2286
2301
  function numericExitCode(value) {
2287
2302
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
2288
2303
  }
2304
+ function byteLength(value) {
2305
+ return Buffer.byteLength(value, 'utf8');
2306
+ }
2307
+ function excerpt(value, maxBytes) {
2308
+ if (byteLength(value) <= maxBytes) {
2309
+ return value;
2310
+ }
2311
+ return value.slice(0, maxBytes) + '\n[truncated]';
2312
+ }
2313
+ function logBazelTrace({
2314
+ argv,
2315
+ durationMs,
2316
+ opts,
2317
+ result,
2318
+ step
2319
+ }) {
2320
+ if (!opts.verbose) {
2321
+ return;
2322
+ }
2323
+ const stderrBytes = byteLength(result.stderr);
2324
+ const stdoutBytes = byteLength(result.stdout);
2325
+ const category = result.code === 0 ? 'ok' : 'bazel-query-failed';
2326
+ logger.logger.log('[VERBOSE] bazel subprocess trace:', `category=${category}`, {
2327
+ argv,
2328
+ category,
2329
+ code: result.code,
2330
+ cwd: opts.cwd,
2331
+ durationMs,
2332
+ stderrBytes,
2333
+ stdoutBytes,
2334
+ step,
2335
+ timedOut: false,
2336
+ timeoutMs: BAZEL_QUERY_TIMEOUT_MS
2337
+ });
2338
+ if (result.code !== 0 && result.stderr) {
2339
+ logger.logger.log('[VERBOSE] bazel stderr tail:', excerpt(result.stderr.slice(-STDERR_TAIL_BYTES), STDERR_TAIL_BYTES));
2340
+ } else if (result.stdout && stdoutBytes <= STDOUT_EXCERPT_BYTES) {
2341
+ logger.logger.log('[VERBOSE] bazel stdout excerpt:', result.stdout);
2342
+ }
2343
+ }
2289
2344
  function normalizeSpawnError(error) {
2290
2345
  const e = error;
2291
2346
  return {
@@ -2301,11 +2356,12 @@ function normalizeSpawnError(error) {
2301
2356
  * and fails on non-zero exit. Rejected spawn calls are normalized into a
2302
2357
  * BazelQueryResult so retry/skip handling can inspect stderr.
2303
2358
  */
2304
- async function runBazelQuery(queryStr, opts) {
2305
- const argv = buildBazelArgv(queryStr, opts);
2359
+ async function runBazelQuery(queryStr, opts, output) {
2360
+ const argv = buildBazelArgv(queryStr, opts, output);
2306
2361
  if (opts.verbose) {
2307
2362
  logger.logger.log('[VERBOSE] Executing:', opts.bin, ', args:', argv);
2308
2363
  }
2364
+ const startedAt = Date.now();
2309
2365
  const {
2310
2366
  spinner
2311
2367
  } = constants.default;
@@ -2340,19 +2396,30 @@ async function runBazelQuery(queryStr, opts) {
2340
2396
  } else {
2341
2397
  spinner.failAndStop(`bazel query failed (${truncated}).`);
2342
2398
  }
2399
+ if (result) {
2400
+ logBazelTrace({
2401
+ argv,
2402
+ durationMs: Date.now() - startedAt,
2403
+ opts,
2404
+ result,
2405
+ step: `bazel query ${truncated}`
2406
+ });
2407
+ }
2343
2408
  }
2344
2409
  }
2345
2410
 
2346
2411
  /**
2347
2412
  * Bzlmod-native visible repository enumeration. This is only a candidate
2348
2413
  * source; callers must still validate each returned apparent repo name with a
2349
- * semantic query for generated JVM Maven rules.
2414
+ * semantic query for generated ecosystem rules.
2350
2415
  */
2351
2416
  async function runBazelModShowVisibleRepos(opts) {
2352
2417
  const argv = buildBazelModShowVisibleReposArgv(opts);
2353
2418
  if (opts.verbose) {
2354
2419
  logger.logger.log('[VERBOSE] Executing:', opts.bin, ', args:', argv);
2355
2420
  }
2421
+ const startedAt = Date.now();
2422
+ let result;
2356
2423
  try {
2357
2424
  const output = await spawn.spawn(opts.bin, argv, {
2358
2425
  cwd: opts.cwd,
@@ -2366,14 +2433,65 @@ async function runBazelModShowVisibleRepos(opts) {
2366
2433
  stderr,
2367
2434
  stdout
2368
2435
  } = output;
2369
- return {
2436
+ result = {
2437
+ code,
2438
+ stdout,
2439
+ stderr
2440
+ };
2441
+ } catch (e) {
2442
+ result = normalizeSpawnError(e);
2443
+ }
2444
+ logBazelTrace({
2445
+ argv,
2446
+ durationMs: Date.now() - startedAt,
2447
+ opts,
2448
+ result,
2449
+ step: 'bazel mod dump_repo_mapping'
2450
+ });
2451
+ return result;
2452
+ }
2453
+
2454
+ /**
2455
+ * Bzlmod-native rules_python pip extension usage inspection. This is the
2456
+ * authoritative source for root-module pip.parse metadata when Bazel supports
2457
+ * the command; callers keep bounded static parsing as fallback.
2458
+ */
2459
+ async function runBazelModShowPipExtension(opts) {
2460
+ const argv = buildBazelModShowPipExtensionArgv(opts);
2461
+ if (opts.verbose) {
2462
+ logger.logger.log('[VERBOSE] Executing:', opts.bin, ', args:', argv);
2463
+ }
2464
+ const startedAt = Date.now();
2465
+ let result;
2466
+ try {
2467
+ const output = await spawn.spawn(opts.bin, argv, {
2468
+ cwd: opts.cwd,
2469
+ timeout: BAZEL_QUERY_TIMEOUT_MS,
2470
+ ...(opts.env ? {
2471
+ env: opts.env
2472
+ } : {})
2473
+ });
2474
+ const {
2475
+ code,
2476
+ stderr,
2477
+ stdout
2478
+ } = output;
2479
+ result = {
2370
2480
  code,
2371
2481
  stdout,
2372
2482
  stderr
2373
2483
  };
2374
2484
  } catch (e) {
2375
- return normalizeSpawnError(e);
2485
+ result = normalizeSpawnError(e);
2376
2486
  }
2487
+ logBazelTrace({
2488
+ argv,
2489
+ durationMs: Date.now() - startedAt,
2490
+ opts,
2491
+ result,
2492
+ step: 'bazel mod show_extension rules_python pip'
2493
+ });
2494
+ return result;
2377
2495
  }
2378
2496
 
2379
2497
  /**
@@ -2392,13 +2510,31 @@ function buildProbeFor(opts) {
2392
2510
  };
2393
2511
  }
2394
2512
 
2513
+ /**
2514
+ * Build a `RepoProbe` for validating pip hub candidates.
2515
+ * Queries the hub for package targets (e.g. `@<hub>//...`) and returns
2516
+ * stdout so the caller can check for `:pkg` labels or alias rules.
2517
+ * Does NOT require `pypi_name=` tags in the hub output, because those
2518
+ * tags live on spoke repos, not the hub alias layer.
2519
+ */
2520
+ function buildPypiProbeFor(opts) {
2521
+ return async hubName => {
2522
+ const queryStr = `@${hubName}//...`;
2523
+ const result = await runBazelQuery(queryStr, opts);
2524
+ return {
2525
+ stdout: result.stdout,
2526
+ code: result.code
2527
+ };
2528
+ };
2529
+ }
2530
+
2395
2531
  // Maximum size (bytes) we will read for any single Bazel workspace file.
2396
2532
  // Prevents DoS via maliciously large MODULE.bazel / WORKSPACE / .bzl files.
2397
- const MAX_WORKSPACE_FILE_BYTES = 5 * 1024 * 1024;
2533
+ const MAX_WORKSPACE_FILE_BYTES$1 = 5 * 1024 * 1024;
2398
2534
 
2399
2535
  // Maximum candidate count we will return (deduped) before truncating.
2400
2536
  // Real repos have <20; this is a hard ceiling against pathological inputs.
2401
- const MAX_CANDIDATES = 256;
2537
+ const MAX_CANDIDATES$1 = 256;
2402
2538
 
2403
2539
  // Regex strategy: anchored, bounded character classes, no nested quantifiers.
2404
2540
  // Match `use_repo(maven, "X", "Y", ...)` with a bounded arg-list window to
@@ -2419,13 +2555,13 @@ const MAVEN_COORDINATES_MARKER_RE = /\bmaven_coordinates\s*=/;
2419
2555
 
2420
2556
  // Reads file contents, refusing files that exceed MAX_WORKSPACE_FILE_BYTES.
2421
2557
  // Returns null when the file is missing, oversized, or unreadable.
2422
- function safeReadFile(file) {
2558
+ function safeReadFile$1(file) {
2423
2559
  if (!fs$1.existsSync(file)) {
2424
2560
  return null;
2425
2561
  }
2426
2562
  try {
2427
2563
  const stat = fs$1.statSync(file);
2428
- if (stat.size > MAX_WORKSPACE_FILE_BYTES) {
2564
+ if (stat.size > MAX_WORKSPACE_FILE_BYTES$1) {
2429
2565
  return null;
2430
2566
  }
2431
2567
  return fs$1.readFileSync(file, 'utf8');
@@ -2437,7 +2573,7 @@ function safeReadFile(file) {
2437
2573
  // Walks workspace root for legacy Starlark sources we can scan: WORKSPACE
2438
2574
  // (and WORKSPACE.bazel) plus top-level .bzl files. Non-recursive by design;
2439
2575
  // Phase 1 explicitly avoids static Starlark parsing at depth.
2440
- function listLegacyStarlarkFiles(cwd) {
2576
+ function listLegacyStarlarkFiles$1(cwd) {
2441
2577
  const files = [];
2442
2578
  const candidates = ['WORKSPACE', 'WORKSPACE.bazel'];
2443
2579
  for (const c of candidates) {
@@ -2467,7 +2603,7 @@ function uniqueSorted(items) {
2467
2603
  if (!seen.has(item)) {
2468
2604
  seen.add(item);
2469
2605
  out.push(item);
2470
- if (out.length >= MAX_CANDIDATES) {
2606
+ if (out.length >= MAX_CANDIDATES$1) {
2471
2607
  break;
2472
2608
  }
2473
2609
  }
@@ -2491,14 +2627,29 @@ function apparentNameFromJsonValue(value) {
2491
2627
  }
2492
2628
  return undefined;
2493
2629
  }
2630
+ function apparentNamesFromRepoMapping(value) {
2631
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2632
+ return [];
2633
+ }
2634
+ const candidates = [];
2635
+ for (const [name, canonicalName] of Object.entries(value)) {
2636
+ if (name.startsWith('@') || typeof canonicalName !== 'string') {
2637
+ continue;
2638
+ }
2639
+ if (BAZEL_REPO_NAME_RE.test(name)) {
2640
+ candidates.push(name);
2641
+ }
2642
+ }
2643
+ return candidates;
2644
+ }
2494
2645
  function normalizeRepoName(name) {
2495
2646
  const repo = name.startsWith('@') ? name.slice(1) : name;
2496
2647
  return BAZEL_REPO_NAME_RE.test(repo) ? repo : undefined;
2497
2648
  }
2498
2649
 
2499
- // Parse `bazel mod show_repo --all_visible_repos --output=streamed_jsonproto`
2500
- // output. Bazel's JSON proto field casing may vary by formatter; accept both
2501
- // lowerCamel and snake_case, and tolerate wrapper objects around Repository.
2650
+ // Parse `bazel mod dump_repo_mapping "" --output=json` output. Also accept the
2651
+ // older streamed jsonproto shape in case older Bazel versions or fixtures still
2652
+ // return repository records with apparentName fields.
2502
2653
  function parseVisibleRepoCandidates(output) {
2503
2654
  const candidates = [];
2504
2655
  for (const line of output.split(/\r?\n/)) {
@@ -2508,6 +2659,7 @@ function parseVisibleRepoCandidates(output) {
2508
2659
  }
2509
2660
  try {
2510
2661
  const parsed = JSON.parse(trimmed);
2662
+ candidates.push(...apparentNamesFromRepoMapping(parsed));
2511
2663
  const apparentName = apparentNameFromJsonValue(parsed);
2512
2664
  if (apparentName) {
2513
2665
  const repo = normalizeRepoName(apparentName);
@@ -2529,7 +2681,7 @@ function parseMavenRepoCandidates(cwd, verbose) {
2529
2681
 
2530
2682
  // Bzlmod path: parse MODULE.bazel for use_repo(maven, ...).
2531
2683
  const moduleBazel = path.join(cwd, 'MODULE.bazel');
2532
- const moduleContent = safeReadFile(moduleBazel);
2684
+ const moduleContent = safeReadFile$1(moduleBazel);
2533
2685
  if (moduleContent) {
2534
2686
  const bzlmodHits = [];
2535
2687
  for (const m of moduleContent.matchAll(USE_REPO_RE)) {
@@ -2547,12 +2699,12 @@ function parseMavenRepoCandidates(cwd, verbose) {
2547
2699
  }
2548
2700
 
2549
2701
  // Legacy path: scan WORKSPACE + top-level .bzl files for maven_install(name=...).
2550
- const legacyFiles = listLegacyStarlarkFiles(cwd);
2702
+ const legacyFiles = listLegacyStarlarkFiles$1(cwd);
2551
2703
  if (verbose) {
2552
2704
  logger.logger.log('[VERBOSE] discovery: legacy files considered:', legacyFiles.length ? legacyFiles : '(none)');
2553
2705
  }
2554
2706
  for (const file of legacyFiles) {
2555
- const content = safeReadFile(file);
2707
+ const content = safeReadFile$1(file);
2556
2708
  if (!content) {
2557
2709
  continue;
2558
2710
  }
@@ -3023,8 +3175,18 @@ async function extractBazelToMaven(opts) {
3023
3175
  });
3024
3176
  }
3025
3177
  if (!allArtifacts.length) {
3026
- process.exitCode = 1;
3027
- logger.logger.fail('No Maven artifacts extracted. See warnings above.');
3178
+ if (!repos.size) {
3179
+ if (verbose) {
3180
+ logger.logger.info('No Maven artifacts extracted. failureCategory=no-supported-ecosystem');
3181
+ }
3182
+ return {
3183
+ artifactCount: 0,
3184
+ manifestPath,
3185
+ noEcosystemFound: true,
3186
+ ok: false
3187
+ };
3188
+ }
3189
+ logger.logger.fail(`Discovered Maven repo(s) ${repoNames.join(', ')} but extracted zero artifacts. failureCategory=ecosystem-detected-but-empty`);
3028
3190
  return {
3029
3191
  artifactCount: 0,
3030
3192
  manifestPath,
@@ -3038,7 +3200,6 @@ async function extractBazelToMaven(opts) {
3038
3200
  ok: true
3039
3201
  };
3040
3202
  } catch (e) {
3041
- process.exitCode = 1;
3042
3203
  // Always surface the error message; users should not have to
3043
3204
  // re-run a multi-minute bazel build with --verbose just to see whether
3044
3205
  // the failure was a missing dependency, permission error, or network blip.
@@ -3057,6 +3218,130 @@ async function extractBazelToMaven(opts) {
3057
3218
  }
3058
3219
  }
3059
3220
 
3221
+ async function convertGradleToFacts({
3222
+ bin,
3223
+ cwd,
3224
+ gradleOpts,
3225
+ verbose
3226
+ }) {
3227
+ const rBin = path.resolve(cwd, bin);
3228
+ const binExists = fs$1.existsSync(rBin);
3229
+ const cwdExists = fs$1.existsSync(cwd);
3230
+ logger.logger.group('gradle2facts:');
3231
+ logger.logger.info(`- executing: \`${rBin}\``);
3232
+ if (!binExists) {
3233
+ logger.logger.warn(`Warning: It appears the executable could not be found. An error might be printed later because of that.`);
3234
+ }
3235
+ logger.logger.info(`- src dir: \`${cwd}\``);
3236
+ if (!cwdExists) {
3237
+ logger.logger.warn(`Warning: It appears the src dir could not be found. An error might be printed later because of that.`);
3238
+ }
3239
+ logger.logger.groupEnd();
3240
+ try {
3241
+ // The init script is bundled alongside the existing pom-generating one.
3242
+ // See .config/rollup.dist.config.mjs:copySocketFactsInitGradle.
3243
+ const initLocation = path.join(constants.default.distPath, 'socket-facts.init.gradle');
3244
+ const commandArgs = ['--init-script', initLocation, ...gradleOpts, 'socketFacts'];
3245
+ if (verbose) {
3246
+ logger.logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs);
3247
+ }
3248
+ logger.logger.log(`Generating Socket facts from \`${bin}\` on \`${cwd}\` ...`);
3249
+ const output = await execGradle$1(rBin, commandArgs, cwd, verbose);
3250
+ if (output.code) {
3251
+ process.exitCode = 1;
3252
+ logger.logger.fail(`Gradle exited with exit code ${output.code}`);
3253
+ if (!verbose) {
3254
+ logger.logger.group('stderr:');
3255
+ logger.logger.error(output.stderr);
3256
+ logger.logger.groupEnd();
3257
+ }
3258
+ return;
3259
+ }
3260
+ logger.logger.success('Executed gradle successfully');
3261
+ if (verbose) {
3262
+ // Output already streamed; the "Reported exports:" summary lines were
3263
+ // visible inline. No need to repeat them from a captured stdout.
3264
+ logger.logger.log('');
3265
+ logger.logger.log('Next step is to generate a Scan by running the `socket scan create` command on the same directory.');
3266
+ return;
3267
+ }
3268
+ const exports = Array.from(output.stdout.matchAll(/^Socket facts file written to: (.*)/gm), m => m[1]);
3269
+ if (exports.length) {
3270
+ logger.logger.log('Reported exports:');
3271
+ for (const fn of exports) {
3272
+ logger.logger.log('- ', fn);
3273
+ }
3274
+ } else {
3275
+ // Gradle script may have skipped emission when no resolvable
3276
+ // dependencies were found (see the `components.isEmpty()` branch in
3277
+ // socket-facts.init.gradle). Surface the skip reason if present so
3278
+ // the user understands why nothing was written.
3279
+ const skipMatch = output.stdout.match(/^\[socket-facts\] no resolvable dependencies.*/m);
3280
+ if (skipMatch) {
3281
+ logger.logger.warn(skipMatch[0]);
3282
+ }
3283
+ }
3284
+ logger.logger.log('');
3285
+ logger.logger.log('Next step is to generate a Scan by running the `socket scan create` command on the same directory.');
3286
+ } catch (e) {
3287
+ process.exitCode = 1;
3288
+ logger.logger.fail('There was an unexpected error while generating Socket facts' + (verbose ? '' : ' (use --verbose for details)'));
3289
+ if (verbose) {
3290
+ logger.logger.group('[VERBOSE] error:');
3291
+ logger.logger.log(e);
3292
+ logger.logger.groupEnd();
3293
+ }
3294
+ }
3295
+ }
3296
+ async function execGradle$1(bin, commandArgs, cwd, verbose) {
3297
+ // When verbose, stream gradle stdout/stderr directly to the user's
3298
+ // terminal — no spinner, no capture. The trade-off is that the post-run
3299
+ // "Reported exports:" summary is skipped (the lines were already visible
3300
+ // inline). For huge builds where the user wants to see progress, this is
3301
+ // the right default. Non-verbose runs still get the spinner + summary.
3302
+ if (verbose) {
3303
+ logger.logger.info('(Running gradle with output streaming. This can take a while.)');
3304
+ const output = await spawn.spawn(bin, commandArgs, {
3305
+ cwd,
3306
+ stdio: 'inherit'
3307
+ });
3308
+ return {
3309
+ code: output.code,
3310
+ stdout: '',
3311
+ stderr: ''
3312
+ };
3313
+ }
3314
+ const {
3315
+ spinner
3316
+ } = constants.default;
3317
+ let pass = false;
3318
+ try {
3319
+ logger.logger.info('(Running gradle can take a while, depending on the size of the project)');
3320
+ logger.logger.info('(No live output. Pass --verbose to stream gradle output instead.)');
3321
+ spinner.start(`Running gradlew...`);
3322
+ const output = await spawn.spawn(bin, commandArgs, {
3323
+ cwd
3324
+ });
3325
+ pass = true;
3326
+ const {
3327
+ code,
3328
+ stderr,
3329
+ stdout
3330
+ } = output;
3331
+ return {
3332
+ code,
3333
+ stdout,
3334
+ stderr
3335
+ };
3336
+ } finally {
3337
+ if (pass) {
3338
+ spinner.successAndStop('Gracefully completed gradlew execution.');
3339
+ } else {
3340
+ spinner.failAndStop('There was an error while trying to run gradlew.');
3341
+ }
3342
+ }
3343
+ }
3344
+
3060
3345
  async function convertGradleToMaven({
3061
3346
  bin,
3062
3347
  cwd,
@@ -3093,16 +3378,10 @@ async function convertGradleToMaven({
3093
3378
  logger.logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs);
3094
3379
  }
3095
3380
  logger.logger.log(`Converting gradle to maven from \`${bin}\` on \`${cwd}\` ...`);
3096
- const output = await execGradleWithSpinner(rBin, commandArgs, cwd);
3097
- if (verbose) {
3098
- logger.logger.group('[VERBOSE] gradle stdout:');
3099
- logger.logger.log(output);
3100
- logger.logger.groupEnd();
3101
- }
3381
+ const output = await execGradle(rBin, commandArgs, cwd, verbose);
3102
3382
  if (output.code) {
3103
3383
  process.exitCode = 1;
3104
3384
  logger.logger.fail(`Gradle exited with exit code ${output.code}`);
3105
- // (In verbose mode, stderr was printed above, no need to repeat it)
3106
3385
  if (!verbose) {
3107
3386
  logger.logger.group('stderr:');
3108
3387
  logger.logger.error(output.stderr);
@@ -3111,6 +3390,13 @@ async function convertGradleToMaven({
3111
3390
  return;
3112
3391
  }
3113
3392
  logger.logger.success('Executed gradle successfully');
3393
+ if (verbose) {
3394
+ // Output already streamed; "POM file copied to:" lines were visible
3395
+ // inline. Skip the captured-stdout summary.
3396
+ logger.logger.log('');
3397
+ logger.logger.log('Next step is to generate a Scan by running the `socket scan create` command on the same directory');
3398
+ return;
3399
+ }
3114
3400
  logger.logger.log('Reported exports:');
3115
3401
  output.stdout.replace(/^POM file copied to: (.*)/gm, (_all, fn) => {
3116
3402
  logger.logger.log('- ', fn);
@@ -3128,20 +3414,32 @@ async function convertGradleToMaven({
3128
3414
  }
3129
3415
  }
3130
3416
  }
3131
- async function execGradleWithSpinner(bin, commandArgs, cwd) {
3417
+ async function execGradle(bin, commandArgs, cwd, verbose) {
3418
+ // When verbose, stream gradle stdout/stderr directly to the user's
3419
+ // terminal — no spinner, no capture. The trade-off is that the post-run
3420
+ // "Reported exports:" summary is skipped (the lines were already visible
3421
+ // inline). Non-verbose runs still get the spinner + summary.
3422
+ if (verbose) {
3423
+ logger.logger.info('(Running gradle with output streaming. This can take a while.)');
3424
+ const output = await spawn.spawn(bin, commandArgs, {
3425
+ cwd,
3426
+ stdio: 'inherit'
3427
+ });
3428
+ return {
3429
+ code: output.code,
3430
+ stdout: '',
3431
+ stderr: ''
3432
+ };
3433
+ }
3132
3434
  const {
3133
3435
  spinner
3134
3436
  } = constants.default;
3135
3437
  let pass = false;
3136
3438
  try {
3137
- logger.logger.info('(Running gradle can take a while, it depends on how long gradlew has to run)');
3138
- logger.logger.info('(It will show no output, you can use --verbose to see its output)');
3439
+ logger.logger.info('(Running gradle can take a while, depending on the size of the project)');
3440
+ logger.logger.info('(No live output. Pass --verbose to stream gradle output instead.)');
3139
3441
  spinner.start(`Running gradlew...`);
3140
3442
  const output = await spawn.spawn(bin, commandArgs, {
3141
- // We can pipe the output through to have the user see the result
3142
- // of running gradlew, but then we can't (easily) gather the output
3143
- // to discover the generated files... probably a flag we should allow?
3144
- // stdio: isDebug() ? 'inherit' : undefined,
3145
3443
  cwd
3146
3444
  });
3147
3445
  pass = true;
@@ -3512,8 +3810,7 @@ async function generateAutoManifest({
3512
3810
  });
3513
3811
  }
3514
3812
  if (!sockJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) {
3515
- logger.logger.log('Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...');
3516
- await convertGradleToMaven({
3813
+ const gradleArgs = {
3517
3814
  // Note: `gradlew` is more likely to be resolved against cwd.
3518
3815
  // Note: .resolve() won't butcher an absolute path.
3519
3816
  // TODO: `gradlew` (or anything else given) may want to resolve against PATH.
@@ -3521,7 +3818,14 @@ async function generateAutoManifest({
3521
3818
  cwd,
3522
3819
  verbose: Boolean(sockJson.defaults?.manifest?.gradle?.verbose),
3523
3820
  gradleOpts: sockJson.defaults?.manifest?.gradle?.gradleOpts?.split(' ').map(s => s.trim()).filter(Boolean) ?? []
3524
- });
3821
+ };
3822
+ if (sockJson.defaults?.manifest?.gradle?.facts) {
3823
+ logger.logger.log('Detected a gradle build (Gradle, Kotlin, Scala), generating Socket facts...');
3824
+ await convertGradleToFacts(gradleArgs);
3825
+ } else {
3826
+ logger.logger.log('Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...');
3827
+ await convertGradleToMaven(gradleArgs);
3828
+ }
3525
3829
  }
3526
3830
  if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) {
3527
3831
  logger.logger.log('Detected an environment.yml file, running default Conda generator...');
@@ -3536,24 +3840,23 @@ async function generateAutoManifest({
3536
3840
  if (!sockJson?.defaults?.manifest?.bazel?.disabled && detected.bazel) {
3537
3841
  const bazelConfig = sockJson?.defaults?.manifest?.bazel;
3538
3842
  logger.logger.log('Detected a Bazel workspace, extracting Maven dependencies via bazel query...');
3539
- const bazelResult = await extractBazelToMaven({
3843
+ const mavenResult = await extractBazelToMaven({
3540
3844
  bazelFlags: bazelConfig?.bazelFlags,
3541
3845
  bazelOutputBase: bazelConfig?.bazelOutputBase,
3542
3846
  bazelRc: bazelConfig?.bazelRc,
3543
3847
  bin: bazelConfig?.bazel ?? bazelConfig?.bin,
3544
3848
  cwd,
3545
- // Auto-manifest writes into a sibling directory instead of the repo root
3546
- // so scan discovery can pick it up without colliding with a checked-in
3547
- // rules_jvm_external lockfile or repo-root gitignore patterns.
3548
3849
  out: bazelConfig?.out ?? cwd,
3549
3850
  outLayout: 'flat',
3550
3851
  verbose: Boolean(bazelConfig?.verbose) || verbose
3551
3852
  });
3552
- if (!bazelResult.ok) {
3553
- throw new Error('Bazel auto-manifest generation failed');
3853
+ if (!mavenResult.ok && !mavenResult.noEcosystemFound) {
3854
+ throw new Error('Bazel auto-manifest generation failed for ecosystem(s): maven');
3554
3855
  }
3555
- if (bazelResult.manifestPath) {
3556
- generatedFiles.push(bazelResult.manifestPath);
3856
+ if (mavenResult.ok && mavenResult.manifestPath) {
3857
+ generatedFiles.push(mavenResult.manifestPath);
3858
+ } else if (mavenResult.noEcosystemFound) {
3859
+ logger.logger.info('No supported Bazel Maven ecosystem detected.');
3557
3860
  }
3558
3861
  }
3559
3862
  return {
@@ -3575,19 +3878,11 @@ function getCdxSpdxPatterns(supportedFiles) {
3575
3878
  }
3576
3879
  return patterns;
3577
3880
  }
3578
- function filterToCdxSpdxAndFactsFiles(filepaths, supportedFiles) {
3881
+ function filterToCdxSpdxOnly(filepaths, supportedFiles) {
3579
3882
  const patterns = getCdxSpdxPatterns(supportedFiles);
3580
- return filepaths.filter(filepath => {
3581
- const basename = path.basename(filepath).toLowerCase();
3582
- // Include .socket.facts.json files.
3583
- if (basename === constants.default.DOT_SOCKET_DOT_FACTS_JSON) {
3584
- return true;
3585
- }
3586
- // Include CDX and SPDX files.
3587
- return vendor.micromatchExports.some(filepath, patterns, {
3588
- nocase: true
3589
- });
3590
- });
3883
+ return filepaths.filter(filepath => vendor.micromatchExports.some(filepath, patterns, {
3884
+ nocase: true
3885
+ }));
3591
3886
  }
3592
3887
  async function handleCreateNewScan({
3593
3888
  autoManifest,
@@ -3706,6 +4001,7 @@ async function handleCreateNewScan({
3706
4001
  }
3707
4002
  let scanPaths = packagePaths;
3708
4003
  let tier1ReachabilityScanId;
4004
+ let reachabilityReport;
3709
4005
 
3710
4006
  // If reachability is enabled, perform reachability analysis.
3711
4007
  if (reach.runReachabilityAnalysis) {
@@ -3735,14 +4031,14 @@ async function handleCreateNewScan({
3735
4031
  return;
3736
4032
  }
3737
4033
  logger.logger.success('Reachability analysis completed successfully');
3738
- const reachabilityReport = reachResult.data?.reachabilityReport;
4034
+ reachabilityReport = reachResult.data?.reachabilityReport;
3739
4035
 
3740
4036
  // Ensure the .socket.facts.json isn't duplicated in case it happened
3741
4037
  // to be in the scan folder before the analysis was run.
3742
- const filteredPackagePaths = packagePaths.filter(p => path.basename(p).toLowerCase() !== constants.default.DOT_SOCKET_DOT_FACTS_JSON);
4038
+ const filteredPackagePaths = packagePaths.filter(p => path.basename(p) !== constants.default.DOT_SOCKET_DOT_FACTS_JSON);
3743
4039
 
3744
4040
  // When using pregenerated SBOMs only, filter to CDX/SPDX files.
3745
- const pathsForScan = reach.reachUseOnlyPregeneratedSboms ? filterToCdxSpdxAndFactsFiles(filteredPackagePaths, supportedFiles) : filteredPackagePaths;
4041
+ const pathsForScan = reach.reachUseOnlyPregeneratedSboms ? filterToCdxSpdxOnly(filteredPackagePaths, supportedFiles) : filteredPackagePaths;
3746
4042
  scanPaths = [...pathsForScan, ...(reachabilityReport ? [reachabilityReport] : [])];
3747
4043
  tier1ReachabilityScanId = reachResult.data?.tier1ReachabilityScanId;
3748
4044
  }
@@ -3778,6 +4074,22 @@ async function handleCreateNewScan({
3778
4074
  if (reach && scanId && tier1ReachabilityScanId) {
3779
4075
  await finalizeTier1Scan(tier1ReachabilityScanId, scanId);
3780
4076
  }
4077
+
4078
+ // On a successful scan, clean up the `.socket.facts.json` coana wrote at
4079
+ // the path we instructed it to write to (via `--socket-mode`). Failed
4080
+ // scans leave the file in place for debugging. Producer-written files
4081
+ // (e.g. from `socket manifest gradle --facts`) are NOT touched here —
4082
+ // those are user-owned input that the user can clean up themselves; in
4083
+ // the --reach path coana overwrites that file with its enriched output
4084
+ // anyway, so it's the same path that gets removed.
4085
+ if (fullScanCResult.ok && scanId && reachabilityReport) {
4086
+ try {
4087
+ await fs.unlink(path.resolve(cwd, reachabilityReport));
4088
+ require$$9.debugFn('notice', `[socket-facts] removed coana output after successful scan: ${reachabilityReport}`);
4089
+ } catch {
4090
+ // Best-effort — file may already be gone or unwritable.
4091
+ }
4092
+ }
3781
4093
  if (report && fullScanCResult.ok) {
3782
4094
  if (scanId) {
3783
4095
  await handleScanReport({
@@ -7148,57 +7460,956 @@ async function run$G(argv, importMeta, context) {
7148
7460
  await spawnPromise;
7149
7461
  }
7150
7462
 
7151
- const config$e = {
7152
- commandName: 'bazel',
7153
- description: '[beta] Bazel JVM SBOM support generate manifest files (`maven_install.json`) for a Bazel/Maven project',
7154
- hidden: false,
7155
- flags: {
7156
- ...flags.commonFlags,
7157
- bazel: {
7158
- type: 'string',
7159
- description: 'Path to bazel/bazelisk binary; default: $(which bazelisk) || $(which bazel)'
7160
- },
7161
- bazelFlags: {
7162
- type: 'string',
7163
- description: 'Flags forwarded to every bazel invocation (single quoted string)'
7164
- },
7165
- bazelOutputBase: {
7166
- type: 'string',
7167
- description: 'Bazel --output_base for read-only-cache CI environments'
7168
- },
7169
- bazelRc: {
7170
- type: 'string',
7171
- description: 'Path to additional .bazelrc fragments forwarded to bazel'
7172
- },
7173
- out: {
7174
- type: 'string',
7175
- description: 'Output directory for generated manifests; default: ./.socket/bazel-manifests/'
7176
- },
7177
- verbose: {
7178
- type: 'boolean',
7179
- description: 'Stream bazel stdout/stderr'
7180
- }
7181
- },
7182
- help: (command, config) => `
7183
- Usage
7184
- $ ${command} [options] [CWD=.]
7185
-
7186
- Options
7187
- ${utils.getFlagListOutput(config.flags)}
7188
-
7189
- [beta] Generates Bazel JVM SBOM manifests (\`maven_install.json\`-shaped)
7190
- by running \`bazel query\` against discovered Maven repos. Output is
7191
- consumed by \`socket scan create\`'s server-side parser.
7463
+ // Maximum size (bytes) we will read for any single Bazel workspace file.
7464
+ // Prevents DoS via maliciously large MODULE.bazel / WORKSPACE / .bzl files.
7465
+ const MAX_WORKSPACE_FILE_BYTES = 5 * 1024 * 1024;
7192
7466
 
7193
- Note: this command generates Maven dependency manifests for Bazel JVM
7194
- workspaces. It does not run reachability analysis.
7467
+ // Maximum candidate count we will return (deduped) before failing.
7468
+ // Real repos have <20; this is a hard ceiling against pathological inputs.
7469
+ const MAX_CANDIDATES = 256;
7195
7470
 
7196
- To generate AND upload in one step, use \`socket scan create --auto-manifest\`
7197
- instead — it detects Bazel workspaces, runs the same extraction, and uploads
7198
- the result. This subcommand is for generation only.
7471
+ // Regex strategy: anchored, bounded character classes, no nested quantifiers.
7199
7472
 
7200
- Examples
7473
+ // Bzlmod: discover `use_extension(..., "pip")` bindings, then match
7474
+ // `${binding}.parse(...)` to find pip hub declarations.
7475
+ // Bounded: matches up to ~256 chars of path to avoid catastrophic backtracking.
7476
+ const USE_EXTENSION_PIP_RE = /(\w+)\s*=\s*use_extension\s*\(\s*["'][^"']{0,256}pip\.bzl["']\s*,\s*["']pip["']\s*\)/g;
7477
+
7478
+ // Extract hub_name, requirements_lock, and python_version from a pip.parse
7479
+ // argument blob. Bounded character classes and length caps.
7480
+ const HUB_NAME_ATTR_RE = /hub_name\s*=\s*(["'])([A-Za-z0-9_]{1,129})\1/;
7481
+ const REQUIREMENTS_LOCK_ATTR_RE = /requirements_lock\s*=\s*(["'])([^"']{1,512})\1/;
7482
+ const PYTHON_VERSION_ATTR_RE = /python_version\s*=\s*(["'])([0-9._+!]{1,32})\1/;
7483
+
7484
+ // Legacy WORKSPACE patterns: pip_parse, pip_install, pip_repository.
7485
+ // Bounded: matches up to ~8KB of argument list.
7486
+ const PIP_PARSE_NAME_RE = /pip_parse\s*\(\s*([^)]{0,8192})\)/g;
7487
+ const PIP_INSTALL_NAME_RE = /pip_install\s*\(\s*([^)]{0,8192})\)/g;
7488
+ const PIP_REPOSITORY_NAME_RE = /pip_repository\s*\(\s*([^)]{0,8192})\)/g;
7489
+ const NAME_ATTR_RE = /name\s*=\s*(["'])([A-Za-z0-9_]{1,129})\1/;
7490
+ const LEGACY_REQ_LOCK_RE = /requirements_lock\s*=\s*(["'])([^"']{1,512})\1/;
7491
+ const MOD_SHOW_PIP_PARSE_RE = /pip\.parse\s*\(\s*([^)]{0,8192})\)/g;
7492
+ const MOD_SHOW_USE_REPO_RE = /use_repo\s*\(\s*\w+\s*,\s*(["'])([A-Za-z0-9_]{1,129})\1\s*\)/g;
7493
+
7494
+ // Hub validation: accept alias rules or `:pkg` targets in probe stdout.
7495
+ // Does NOT require `pypi_name=` (that marker lives on spoke repos).
7496
+ const PYPI_HUB_MARKER_RE = /:pkg\b|alias\s*\(/;
7497
+ function parseBazelModPipExtensionCandidates(stdout, verbose) {
7498
+ const useRepoNames = new Set();
7499
+ for (const m of stdout.matchAll(MOD_SHOW_USE_REPO_RE)) {
7500
+ useRepoNames.add(m[2]);
7501
+ }
7502
+ const candidates = [];
7503
+ for (const m of stdout.matchAll(MOD_SHOW_PIP_PARSE_RE)) {
7504
+ const info = extractHubInfoFromArgBlob(m[1] ?? '', 'bazel-mod-show-extension', 'bzlmod');
7505
+ if (!info) {
7506
+ continue;
7507
+ }
7508
+ if (useRepoNames.size && !useRepoNames.has(info.hubName)) {
7509
+ if (verbose) {
7510
+ logger.logger.log(`[VERBOSE] discovery: dropping pip.parse hub '${info.hubName}' because show_extension did not report matching use_repo.`);
7511
+ }
7512
+ continue;
7513
+ }
7514
+ candidates.push(info);
7515
+ }
7516
+ if (verbose) {
7517
+ logger.logger.log('[VERBOSE] discovery: bazel mod show_extension pip.parse hits:', candidates.length, 'use_repo:', Array.from(useRepoNames));
7518
+ }
7519
+ return dedupCapped(candidates, verbose);
7520
+ }
7521
+
7522
+ // Reads file contents, refusing files that exceed MAX_WORKSPACE_FILE_BYTES.
7523
+ // Returns null when the file is missing, oversized, or unreadable.
7524
+ function safeReadFile(file) {
7525
+ if (!fs$1.existsSync(file)) {
7526
+ return null;
7527
+ }
7528
+ try {
7529
+ const stat = fs$1.statSync(file);
7530
+ if (stat.size > MAX_WORKSPACE_FILE_BYTES) {
7531
+ return null;
7532
+ }
7533
+ return fs$1.readFileSync(file, 'utf8');
7534
+ } catch {
7535
+ return null;
7536
+ }
7537
+ }
7538
+
7539
+ // Walks workspace root for legacy Starlark sources we can scan: WORKSPACE
7540
+ // (and WORKSPACE.bazel) plus top-level .bzl files. Non-recursive by design;
7541
+ // Phase 1 explicitly avoids static Starlark parsing at depth.
7542
+ function listLegacyStarlarkFiles(cwd) {
7543
+ const files = [];
7544
+ const candidates = ['WORKSPACE', 'WORKSPACE.bazel'];
7545
+ for (const c of candidates) {
7546
+ const p = path.join(cwd, c);
7547
+ if (fs$1.existsSync(p)) {
7548
+ files.push(p);
7549
+ }
7550
+ }
7551
+ // Top-level .bzl files only.
7552
+ try {
7553
+ for (const entry of fs$1.readdirSync(cwd)) {
7554
+ if (entry.endsWith('.bzl')) {
7555
+ files.push(path.join(cwd, entry));
7556
+ }
7557
+ }
7558
+ } catch {
7559
+ // Ignore unreadable cwd.
7560
+ }
7561
+ return files;
7562
+ }
7563
+
7564
+ // Returns deduplicated list of items, capped at MAX_CANDIDATES.
7565
+ // Precedence: the first occurrence of a given hubName wins. Callers
7566
+ // must order inputs so the preferred source comes first (e.g., Bzlmod
7567
+ // hits before legacy WORKSPACE hits during migration).
7568
+ // Throws a clear error if the cap is exceeded so callers do not silently
7569
+ // truncate. Emits a verbose warning when a later entry is dropped due to
7570
+ // a name collision so users can see implicit precedence at work.
7571
+ function dedupCapped(items, verbose) {
7572
+ const seen = new Map();
7573
+ const out = [];
7574
+ for (const item of items) {
7575
+ const existing = seen.get(item.hubName);
7576
+ if (!existing) {
7577
+ seen.set(item.hubName, item);
7578
+ out.push(item);
7579
+ if (out.length >= MAX_CANDIDATES) {
7580
+ throw new Error(`Discovered more than ${MAX_CANDIDATES} pip hub candidates. ` + 'This exceeds the safety ceiling; aborting discovery.');
7581
+ }
7582
+ } else if (verbose) {
7583
+ logger.logger.log(`[VERBOSE] discovery: dropping duplicate pip hub candidate '${item.hubName}' ` + `(kept first occurrence from ${existing.source}/${existing.workspaceMode}, ` + `dropped ${item.source}/${item.workspaceMode}).`);
7584
+ }
7585
+ }
7586
+ return out;
7587
+ }
7588
+
7589
+ // Build a dynamic regex for `${binding}.parse(...)` given a validated binding
7590
+ // name (word characters only, so safe to embed). Bounded arg list.
7591
+ function buildPipParseRe(binding) {
7592
+ return new RegExp(`${binding}\\.parse\\s*\\(\\s*([^)]{0,8192})\\)`, 'g');
7593
+ }
7594
+
7595
+ // Extract candidate hub fields from a pip.parse / pip_parse / pip_install /
7596
+ // pip_repository argument blob (without probeStdout or visibleRepoNames).
7597
+ function extractHubInfoFromArgBlob(argBlob, source, workspaceMode) {
7598
+ const hubMatch = HUB_NAME_ATTR_RE.exec(argBlob);
7599
+ const nameMatch = NAME_ATTR_RE.exec(argBlob);
7600
+ const hubName = hubMatch?.[2] ?? nameMatch?.[2];
7601
+ if (!hubName) {
7602
+ return undefined;
7603
+ }
7604
+ const lockMatch = REQUIREMENTS_LOCK_ATTR_RE.exec(argBlob) ?? LEGACY_REQ_LOCK_RE.exec(argBlob);
7605
+ const pythonVersion = PYTHON_VERSION_ATTR_RE.exec(argBlob)?.[2];
7606
+ return {
7607
+ hubName,
7608
+ source,
7609
+ workspaceMode,
7610
+ pythonVersion,
7611
+ requirementsLockLabel: lockMatch?.[2]
7612
+ };
7613
+ }
7614
+
7615
+ // Step 1: parse candidate pip hub names from Bzlmod MODULE.bazel and legacy
7616
+ // WORKSPACE / .bzl entry points.
7617
+ //
7618
+ // Precedence: Bzlmod (MODULE.bazel pip.parse) hits are pushed first, then
7619
+ // legacy (pip_parse / pip_install / pip_repository) hits. dedupCapped keeps
7620
+ // the first occurrence, so during migration scenarios where both
7621
+ // MODULE.bazel and WORKSPACE define a hub with the same name, the Bzlmod
7622
+ // entry wins implicitly. Pass verbose=true to surface dropped duplicates.
7623
+ function parsePypiHubCandidates(cwd, verbose) {
7624
+ const candidates = [];
7625
+
7626
+ // Bzlmod path: parse MODULE.bazel for use_extension bindings to pip,
7627
+ // then match ${binding}.parse(...).
7628
+ const moduleBazel = path.join(cwd, 'MODULE.bazel');
7629
+ const moduleContent = safeReadFile(moduleBazel);
7630
+ if (moduleContent) {
7631
+ const bindings = [];
7632
+ for (const m of moduleContent.matchAll(USE_EXTENSION_PIP_RE)) {
7633
+ bindings.push(m[1]);
7634
+ }
7635
+ if (verbose) {
7636
+ logger.logger.log('[VERBOSE] discovery: scanned', moduleBazel, `(${bindings.length} use_extension pip binding(s))`);
7637
+ }
7638
+ for (const binding of bindings) {
7639
+ const parseRe = buildPipParseRe(binding);
7640
+ for (const m of moduleContent.matchAll(parseRe)) {
7641
+ const argBlob = m[1] ?? '';
7642
+ const info = extractHubInfoFromArgBlob(argBlob, 'MODULE.bazel', 'bzlmod');
7643
+ if (info) {
7644
+ candidates.push(info);
7645
+ }
7646
+ }
7647
+ }
7648
+ if (verbose) {
7649
+ logger.logger.log('[VERBOSE] discovery: MODULE.bazel pip.parse hits:', candidates.length);
7650
+ }
7651
+ } else if (verbose) {
7652
+ logger.logger.log('[VERBOSE] discovery:', moduleBazel, 'not present (skipping bzlmod scan)');
7653
+ }
7654
+
7655
+ // Legacy path: scan WORKSPACE + top-level .bzl files for pip_parse,
7656
+ // pip_install, and pip_repository.
7657
+ const legacyFiles = listLegacyStarlarkFiles(cwd);
7658
+ if (verbose) {
7659
+ logger.logger.log('[VERBOSE] discovery: legacy files considered:', legacyFiles.length ? legacyFiles : '(none)');
7660
+ }
7661
+ for (const file of legacyFiles) {
7662
+ const content = safeReadFile(file);
7663
+ if (!content) {
7664
+ continue;
7665
+ }
7666
+ const fileHits = [];
7667
+ const source = file.endsWith('.bzl') ? '.bzl' : path.basename(file) === 'WORKSPACE.bazel' ? 'WORKSPACE.bazel' : 'WORKSPACE';
7668
+ for (const m of content.matchAll(PIP_PARSE_NAME_RE)) {
7669
+ const info = extractHubInfoFromArgBlob(m[1] ?? '', source, 'legacy');
7670
+ if (info) {
7671
+ fileHits.push(info);
7672
+ }
7673
+ }
7674
+ for (const m of content.matchAll(PIP_INSTALL_NAME_RE)) {
7675
+ const info = extractHubInfoFromArgBlob(m[1] ?? '', source, 'legacy');
7676
+ if (info) {
7677
+ fileHits.push(info);
7678
+ }
7679
+ }
7680
+ for (const m of content.matchAll(PIP_REPOSITORY_NAME_RE)) {
7681
+ const info = extractHubInfoFromArgBlob(m[1] ?? '', source, 'legacy');
7682
+ if (info) {
7683
+ fileHits.push(info);
7684
+ }
7685
+ }
7686
+ candidates.push(...fileHits);
7687
+ if (verbose) {
7688
+ logger.logger.log('[VERBOSE] discovery: scanned', file, `(${fileHits.length} legacy pip hub match(es))`);
7689
+ }
7690
+ }
7691
+ return dedupCapped(candidates, verbose);
7692
+ }
7693
+
7694
+ // Step 2: validate a candidate by running the probe and confirming
7695
+ // `:pkg` labels or alias rules appear in stdout. Does NOT require
7696
+ // `pypi_name=` (that marker lives on spoke repos).
7697
+ async function validatePypiHub(hubName, probe, verbose) {
7698
+ try {
7699
+ const result = await probe(hubName);
7700
+ if (result.code !== 0) {
7701
+ if (verbose) {
7702
+ logger.logger.log(`[VERBOSE] discovery: probe @${hubName}: REJECT (code=${result.code})`);
7703
+ }
7704
+ return {
7705
+ valid: false,
7706
+ stdout: result.stdout
7707
+ };
7708
+ }
7709
+ const valid = PYPI_HUB_MARKER_RE.test(result.stdout);
7710
+ if (verbose) {
7711
+ logger.logger.log(`[VERBOSE] discovery: probe @${hubName}:`, valid ? 'ACCEPT (hub alias/pkg marker found)' : 'REJECT (no hub alias/pkg marker in probe stdout)');
7712
+ }
7713
+ return {
7714
+ valid,
7715
+ stdout: result.stdout
7716
+ };
7717
+ } catch (e) {
7718
+ if (verbose) {
7719
+ logger.logger.log(`[VERBOSE] discovery: probe @${hubName}: REJECT (probe threw):`, utils.getErrorCause(e));
7720
+ }
7721
+ return {
7722
+ valid: false,
7723
+ stdout: ''
7724
+ };
7725
+ }
7726
+ }
7727
+
7728
+ // The default pip hub name when no explicit hub_name/name is given.
7729
+ // Included as a seed so repos whose pip.parse is in a sub-module (not
7730
+ // found by static scanning) can still be discovered via probe validation.
7731
+ const DEFAULT_PYPI_HUB_SEED = 'pypi';
7732
+
7733
+ // Composition: parse, then validate each candidate; return validated subset
7734
+ // as a Map keyed by hub name with the validated PypiHubInfo.
7735
+ // Always seeds with the default 'pypi' hub name first.
7736
+ async function discoverPypiHubs(cwd, probe, nativeCandidates, verbose, bazelCommandCandidates) {
7737
+ // Always run the static parse so MODULE.bazel pip.parse metadata
7738
+ // (requirements_lock, python_version) is available for downstream
7739
+ // lockfile resolution. Native repo-mapping candidates are intentionally
7740
+ // corroborating data only: many non-PyPI repositories expose alias or :pkg
7741
+ // targets, so bare visible repos are too broad to probe as PyPI hubs.
7742
+ const parsedAll = bazelCommandCandidates?.length ? dedupCapped(bazelCommandCandidates, verbose) : parsePypiHubCandidates(cwd, verbose);
7743
+ const parsed = parsedAll;
7744
+ if (verbose) {
7745
+ logger.logger.log('[VERBOSE] discovery: candidate source:', bazelCommandCandidates?.length ? `bazel mod show_extension (${parsed.length})` : nativeCandidates && nativeCandidates.length ? `static parse (${parsed.length}) with bzlmod visible-repos (${nativeCandidates.length}) as corroboration` : `static parse (${parsed.length})`);
7746
+ }
7747
+ // Prepend the default hub seed unless parsed metadata already covers it.
7748
+ const candidates = parsed.some(c => c.hubName === DEFAULT_PYPI_HUB_SEED) ? parsed : [{
7749
+ hubName: DEFAULT_PYPI_HUB_SEED,
7750
+ source: 'default-seed',
7751
+ workspaceMode: 'unknown'
7752
+ }, ...parsed];
7753
+ if (verbose) {
7754
+ logger.logger.log('[VERBOSE] discovery: candidate set to probe (seed-first, deduped):', candidates.map(c => c.hubName));
7755
+ }
7756
+ const validated = new Map();
7757
+ for (const c of candidates) {
7758
+ // eslint-disable-next-line no-await-in-loop
7759
+ const result = await validatePypiHub(c.hubName, probe, verbose);
7760
+ if (result.valid) {
7761
+ validated.set(c.hubName, {
7762
+ ...c,
7763
+ probeStdout: result.stdout
7764
+ });
7765
+ }
7766
+ }
7767
+ if (verbose) {
7768
+ logger.logger.log('[VERBOSE] discovery: validated pip hubs:', Array.from(validated.keys()));
7769
+ }
7770
+ return validated;
7771
+ }
7772
+
7773
+ /**
7774
+ * Parse Bazel PyPI extraction inputs into the pinned `name==version` lines
7775
+ * needed for generated `requirements.txt` output.
7776
+ *
7777
+ * This is deliberately not a general-purpose requirements.txt parser. It only
7778
+ * accepts pinned lockfile-style entries needed to map reached Bazel labels to
7779
+ * exact package versions; depscan remains the owner of full PEP 508
7780
+ * requirements ingestion during scan processing.
7781
+ *
7782
+ * Security gate: every regex uses bounded character classes to prevent
7783
+ * catastrophic backtracking on hostile input.
7784
+ */
7785
+
7786
+
7787
+ // Maximum size (bytes) we will read for any requirements lockfile.
7788
+ // Prevents DoS via maliciously large lockfiles.
7789
+ const MAX_REQUIREMENTS_FILE_BYTES = 5 * 1024 * 1024;
7790
+ // Normalize a PyPI package name per PEP 503:
7791
+ // lowercase, then collapse `.`, `_`, and `-` runs to a single `-`.
7792
+ function normalizePypiName(name) {
7793
+ return name.toLowerCase().replace(/[._-]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
7794
+ }
7795
+
7796
+ // Convert a Bazel underscore_name to a PyPI hyphenated-name.
7797
+ function bazelNameToPypiName(bazelName) {
7798
+ return bazelName.replace(/_/g, '-');
7799
+ }
7800
+
7801
+ // Validate that a resolved path stays within the workspace root.
7802
+ function isWithinWorkspace(resolved, cwd) {
7803
+ const rel = path.relative(cwd, resolved);
7804
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
7805
+ }
7806
+
7807
+ // Resolves a Bazel label or workspace-relative path to a filesystem path.
7808
+ // Returns undefined for labels that cannot be resolved locally.
7809
+ function resolveRequirementsLockPath(label, cwd) {
7810
+ if (!label) {
7811
+ return undefined;
7812
+ }
7813
+ // Reject labels with path-traversal segments.
7814
+ if (label.includes('..')) {
7815
+ return undefined;
7816
+ }
7817
+ // Reject external repository labels.
7818
+ if (label.startsWith('@')) {
7819
+ return undefined;
7820
+ }
7821
+ // Bazel local label forms:
7822
+ // //:requirements_lock.txt
7823
+ // //subdir:requirements_lock.txt
7824
+ // :requirements_lock.txt
7825
+ let filePart;
7826
+ if (label.startsWith('//')) {
7827
+ const colon = label.indexOf(':');
7828
+ if (colon < 0) {
7829
+ return undefined;
7830
+ }
7831
+ const pkgPath = label.slice(2, colon);
7832
+ const filePart = label.slice(colon + 1);
7833
+ if (!filePart) {
7834
+ return undefined;
7835
+ }
7836
+ const resolved = path.join(cwd, pkgPath, filePart);
7837
+ if (!isWithinWorkspace(resolved, cwd)) {
7838
+ return undefined;
7839
+ }
7840
+ return resolved;
7841
+ }
7842
+ if (label.startsWith(':')) {
7843
+ filePart = label.slice(1);
7844
+ if (!filePart) {
7845
+ return undefined;
7846
+ }
7847
+ const resolved = path.join(cwd, filePart);
7848
+ if (!isWithinWorkspace(resolved, cwd)) {
7849
+ return undefined;
7850
+ }
7851
+ return resolved;
7852
+ }
7853
+ // Reject absolute paths (only for non-label inputs).
7854
+ if (path.isAbsolute(label)) {
7855
+ return undefined;
7856
+ }
7857
+ // Bare workspace-relative path (no leading // or :).
7858
+ const resolved = path.join(cwd, label);
7859
+ if (!isWithinWorkspace(resolved, cwd)) {
7860
+ return undefined;
7861
+ }
7862
+ return resolved;
7863
+ }
7864
+
7865
+ // Parses a single pinned `name==version` lockfile line.
7866
+ // Group 1 = package name, Group 2 = version string (includes ==).
7867
+ const REQUIREMENT_LINE_RE = /^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9._+!]+)/;
7868
+ const BAZEL_STRING_LABEL_RE = /[@A-Za-z0-9_~/.:+-]+/;
7869
+ const ALIAS_ACTUAL_RE = new RegExp(`actual\\s*=\\s*(["'])(${BAZEL_STRING_LABEL_RE.source})\\1`);
7870
+
7871
+ // Skippable line prefixes.
7872
+ function shouldSkipLine(line) {
7873
+ const trimmed = line.trim();
7874
+ if (!trimmed) {
7875
+ return true;
7876
+ }
7877
+ if (trimmed.startsWith('#')) {
7878
+ return true;
7879
+ }
7880
+ // Hash continuations start with `--hash=`.
7881
+ if (trimmed.startsWith('--hash=')) {
7882
+ return true;
7883
+ }
7884
+ // Index options, constraint options, editable installs, includes, direct URLs.
7885
+ if (trimmed.startsWith('--') || trimmed.startsWith('-e ') || trimmed.startsWith('-r ') || trimmed.startsWith('https://') || trimmed.startsWith('http://')) {
7886
+ return true;
7887
+ }
7888
+ return false;
7889
+ }
7890
+
7891
+ // Parse a `requirements_lock.txt`-style file into a map keyed by normalized
7892
+ // PyPI name. This intentionally ignores unpinned PEP 508 requirement forms
7893
+ // because the Bazel extractor must emit exact package versions.
7894
+ function parseRequirementsLock(text) {
7895
+ const out = new Map();
7896
+ const lines = text.split('\n');
7897
+ for (let i = 0; i < lines.length; i++) {
7898
+ const rawLine = lines[i];
7899
+ if (rawLine === undefined) {
7900
+ continue;
7901
+ }
7902
+ if (shouldSkipLine(rawLine)) {
7903
+ continue;
7904
+ }
7905
+ // Handle trailing backslash continuation by concatenating subsequent lines.
7906
+ let line = rawLine.trimEnd();
7907
+ while (line.endsWith('\\') && i + 1 < lines.length) {
7908
+ i++;
7909
+ const next = lines[i];
7910
+ if (next !== undefined) {
7911
+ line = line.slice(0, -1).trimEnd() + ' ' + next.trimStart();
7912
+ }
7913
+ }
7914
+ const m = REQUIREMENT_LINE_RE.exec(line);
7915
+ if (!m) {
7916
+ continue;
7917
+ }
7918
+ const [, rawName, version] = m;
7919
+ if (!rawName || !version) {
7920
+ continue;
7921
+ }
7922
+ const bazelName = rawName.replace(/-/g, '_');
7923
+ const normalized = normalizePypiName(rawName);
7924
+ const existing = out.get(normalized);
7925
+ if (existing) {
7926
+ if (existing.version !== version) {
7927
+ throw new Error(`Conflicting versions for normalized PyPI package ${normalized}: ` + `${existing.originalLine ?? existing.name + '==' + existing.version} ` + `conflicts with ${line}.`);
7928
+ }
7929
+ continue;
7930
+ }
7931
+ out.set(normalized, {
7932
+ name: rawName,
7933
+ version,
7934
+ bazelName,
7935
+ source: 'lockfile',
7936
+ originalLine: line
7937
+ });
7938
+ }
7939
+ return out;
7940
+ }
7941
+
7942
+ // Read and parse a requirements lockfile from a resolved path, capping file
7943
+ // size. Returns undefined when the file is missing, oversized, or unreadable.
7944
+ function readRequirementsLockFile(resolvedPath) {
7945
+ if (!resolvedPath) {
7946
+ return undefined;
7947
+ }
7948
+ if (!fs$1.existsSync(resolvedPath)) {
7949
+ return undefined;
7950
+ }
7951
+ let text;
7952
+ try {
7953
+ const stat = fs$1.statSync(resolvedPath);
7954
+ if (stat.size > MAX_REQUIREMENTS_FILE_BYTES) {
7955
+ return undefined;
7956
+ }
7957
+ text = fs$1.readFileSync(resolvedPath, 'utf8');
7958
+ } catch {
7959
+ return undefined;
7960
+ }
7961
+ return parseRequirementsLock(text);
7962
+ }
7963
+
7964
+ // Extract `pypi_name=` and `pypi_version=` tags from `--output=build` text of a
7965
+ // spoke target. Returns null when either tag is missing.
7966
+ const PYPI_NAME_TAG_RE = /pypi_name=\s*([A-Za-z0-9][A-Za-z0-9._-]*)/;
7967
+ const PYPI_VERSION_TAG_RE = /pypi_version=\s*([A-Za-z0-9._+!]+)/;
7968
+ function parsePypiTagsFromBuildOutput(text) {
7969
+ const nameM = PYPI_NAME_TAG_RE.exec(text);
7970
+ const versionM = PYPI_VERSION_TAG_RE.exec(text);
7971
+ if (!nameM || !versionM) {
7972
+ return null;
7973
+ }
7974
+ const rawName = nameM[1];
7975
+ const version = versionM[1];
7976
+ if (!rawName || !version) {
7977
+ return null;
7978
+ }
7979
+ return {
7980
+ name: rawName,
7981
+ version,
7982
+ bazelName: rawName.replace(/-/g, '_'),
7983
+ source: 'spoke-tag'
7984
+ };
7985
+ }
7986
+ function parseAliasActualFromBuildOutput(text) {
7987
+ const match = ALIAS_ACTUAL_RE.exec(text);
7988
+ return match?.[2];
7989
+ }
7990
+
7991
+ // Extract hub package labels from `bazel query` output that match
7992
+ // `@<hub>//<name>:pkg` patterns (both line-start and embedded in
7993
+ // `--output=build` deps arrays).
7994
+ function filterReachedPypiPackages(queryOutput, hubName) {
7995
+ const out = [];
7996
+ const prefix = `@${hubName}//`;
7997
+ // Match from the start of a label token (preceded by whitespace, quote, or
7998
+ // start of line) to improve robustness across output formats.
7999
+ const labelRe = new RegExp(`(?:^|[\\s"])${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s:"]+):pkg`, 'g');
8000
+ let m;
8001
+ while ((m = labelRe.exec(queryOutput)) !== null) {
8002
+ const pkgPart = m[1];
8003
+ if (!pkgPart) {
8004
+ continue;
8005
+ }
8006
+ const bazelName = pkgPart;
8007
+ const normalized = normalizePypiName(bazelNameToPypiName(bazelName));
8008
+ const apparentLabel = `${prefix}${bazelName}:pkg`;
8009
+ out.push({
8010
+ hubName,
8011
+ originalLabel: apparentLabel,
8012
+ bazelName,
8013
+ normalizedName: normalized,
8014
+ apparentLabel
8015
+ });
8016
+ }
8017
+ return out;
8018
+ }
8019
+
8020
+ // Collect name==version pairs for the reached closure, resolving versions
8021
+ // from the lockfile fast path or spoke-tag fallback. Enforces version
8022
+ // conflict detection and deterministic output.
8023
+ function collectPypiPackages(reached, lockfile, spokeTagLookup) {
8024
+ const collected = new Map();
8025
+ for (const r of reached) {
8026
+ const normalized = r.normalizedName;
8027
+ // Lockfile fast path.
8028
+ const lockEntry = lockfile?.get(normalized);
8029
+ if (lockEntry) {
8030
+ const existing = collected.get(normalized);
8031
+ if (existing && existing.version !== lockEntry.version) {
8032
+ throw new Error(`Conflicting versions for ${normalized}: ${existing.label} has ${existing.version}, ${r.originalLabel} has ${lockEntry.version} (lockfile).`);
8033
+ }
8034
+ if (!existing) {
8035
+ collected.set(normalized, {
8036
+ name: lockEntry.name,
8037
+ version: lockEntry.version,
8038
+ source: 'lockfile',
8039
+ label: r.originalLabel
8040
+ });
8041
+ }
8042
+ continue;
8043
+ }
8044
+ // Spoke-tag fallback.
8045
+ const spokeEntry = spokeTagLookup?.get(normalized);
8046
+ if (spokeEntry) {
8047
+ const existing = collected.get(normalized);
8048
+ if (existing && existing.version !== spokeEntry.version) {
8049
+ throw new Error(`Conflicting versions for ${normalized}: ${existing.label} has ${existing.version}, ${r.originalLabel} has ${spokeEntry.version} (spoke tag).`);
8050
+ }
8051
+ if (!existing) {
8052
+ collected.set(normalized, {
8053
+ name: spokeEntry.name,
8054
+ version: spokeEntry.version,
8055
+ source: 'spoke-tag',
8056
+ label: r.originalLabel
8057
+ });
8058
+ }
8059
+ continue;
8060
+ }
8061
+ // Unresolvable package — fail rather than emit an unpinned entry.
8062
+ throw new Error(`No version found for ${r.originalLabel}. ` + 'Check that the package is present in the requirements_lock.txt ' + 'or reachable via a spoke target with pypi_name and pypi_version tags.');
8063
+ }
8064
+ return Array.from(collected.values());
8065
+ }
8066
+
8067
+ // Sort package lines deterministically (locale-aware, lowercase comparison).
8068
+ function sortPackageLines(lines) {
8069
+ return lines.sort((a, b) => {
8070
+ const aLow = a.name.toLowerCase();
8071
+ const bLow = b.name.toLowerCase();
8072
+ if (aLow < bLow) {
8073
+ return -1;
8074
+ }
8075
+ if (aLow > bLow) {
8076
+ return 1;
8077
+ }
8078
+ return a.name.localeCompare(b.name);
8079
+ });
8080
+ }
8081
+ async function extractBazelToPypi(opts) {
8082
+ const {
8083
+ cwd,
8084
+ out,
8085
+ verbose
8086
+ } = opts;
8087
+ logger.logger.group('bazel2pypi:');
8088
+ logger.logger.info(`- src dir: \`${cwd}\``);
8089
+ logger.logger.info(`- out dir: \`${out}\``);
8090
+ if (!fs$1.existsSync(cwd)) {
8091
+ logger.logger.warn(`Warning: cwd does not exist: ${cwd}`);
8092
+ }
8093
+ logger.logger.groupEnd();
8094
+ try {
8095
+ // Validate caller-provided Bazel filesystem settings before invoking Bazel.
8096
+ if (opts.bazelOutputBase) {
8097
+ validateOutputBase(opts.bazelOutputBase, opts.cwd);
8098
+ }
8099
+ // Python shim (for rules_python workspace discovery).
8100
+ const shim = await provisionPythonShim();
8101
+ const baseEnv = shim.augmentedEnv ?? opts.env;
8102
+
8103
+ // Step 1: workspace detection.
8104
+ const mode = detectWorkspaceMode(cwd);
8105
+ logger.logger.info(`Workspace mode: bzlmod=${mode.bzlmod} workspace=${mode.workspace}`);
8106
+ const invocationFlags = getBazelInvocationFlags(mode);
8107
+
8108
+ // Step 2: bazel binary resolution.
8109
+ const bin = await resolveBazelBinary(opts.bin);
8110
+ logger.logger.info(`Using bazel: ${bin}`);
8111
+ if (verbose) {
8112
+ logger.logger.log('[VERBOSE] resolved options:', {
8113
+ bin,
8114
+ bazelRc: opts.bazelRc ?? '(unset)',
8115
+ bazelOutputBase: opts.bazelOutputBase ?? '(unset)',
8116
+ bazelFlags: opts.bazelFlags ?? '(unset)',
8117
+ invocationFlags
8118
+ });
8119
+ }
8120
+
8121
+ // Step 3: build the shared query options object.
8122
+ const queryOpts = {
8123
+ bin,
8124
+ cwd,
8125
+ invocationFlags,
8126
+ ...(opts.bazelRc ? {
8127
+ bazelRc: opts.bazelRc
8128
+ } : {}),
8129
+ ...(opts.bazelFlags ? {
8130
+ bazelFlags: opts.bazelFlags
8131
+ } : {}),
8132
+ ...(opts.bazelOutputBase ? {
8133
+ bazelOutputBase: opts.bazelOutputBase
8134
+ } : {}),
8135
+ ...(baseEnv ? {
8136
+ env: baseEnv
8137
+ } : {}),
8138
+ verbose
8139
+ };
8140
+
8141
+ // Step 4: discover validated PyPI hubs via the two-step recipe.
8142
+ let bazelCommandCandidates;
8143
+ let nativeCandidates;
8144
+ if (mode.bzlmod) {
8145
+ const extensionResult = await runBazelModShowPipExtension(queryOpts);
8146
+ if (extensionResult.code === 0) {
8147
+ bazelCommandCandidates = parseBazelModPipExtensionCandidates(extensionResult.stdout, verbose);
8148
+ } else if (verbose) {
8149
+ logger.logger.log('[VERBOSE] bazel mod show_extension failed; falling back to bounded static candidate parsing:', extensionResult.stderr);
8150
+ }
8151
+ const visibleRepos = await runBazelModShowVisibleRepos(queryOpts);
8152
+ if (visibleRepos.code === 0) {
8153
+ nativeCandidates = parseVisibleRepoCandidates(visibleRepos.stdout);
8154
+ if (verbose) {
8155
+ logger.logger.log('[VERBOSE] Bzlmod visible repo candidates:', nativeCandidates);
8156
+ }
8157
+ } else if (verbose) {
8158
+ logger.logger.log('[VERBOSE] bazel mod show_repo failed; falling back to static candidate parsing:', visibleRepos.stderr);
8159
+ }
8160
+ }
8161
+ const probe = buildPypiProbeFor(queryOpts);
8162
+ const hubs = await discoverPypiHubs(cwd, probe, nativeCandidates, verbose, bazelCommandCandidates);
8163
+ const hubNames = Array.from(hubs.keys());
8164
+ logger.logger.info(`Discovered ${hubs.size} PyPI hub(s): ${hubNames.join(', ') || '(none)'}`);
8165
+ if (!hubs.size) {
8166
+ if (verbose) {
8167
+ logger.logger.info('No PyPI hubs discovered. failureCategory=no-supported-ecosystem');
8168
+ }
8169
+ return {
8170
+ artifactCount: 0,
8171
+ ok: false,
8172
+ noEcosystemFound: true
8173
+ };
8174
+ }
8175
+
8176
+ // Step 5: for each hub, resolve the requirements lockfile (fast path),
8177
+ // run the reached-closure query, and collect name==version pairs.
8178
+ const allLines = [];
8179
+ const warnings = [];
8180
+ for (const [hubName, hubInfo] of hubs) {
8181
+ // eslint-disable-next-line no-await-in-loop
8182
+ const lockfileMap = await resolveHubLockfile(hubInfo, cwd, verbose);
8183
+ // eslint-disable-next-line no-await-in-loop
8184
+ const reached = await queryReachedPypiLabels(hubName, queryOpts, verbose);
8185
+ const labelsToQuery = lockfileMap ? reached.filter(label => !lockfileMap.has(label.normalizedName)) : reached;
8186
+ const divergenceLabels = lockfileMap && verbose ? reached : labelsToQuery;
8187
+ // eslint-disable-next-line no-await-in-loop
8188
+ const spokeTagLookup = await buildSpokeTagLookup(divergenceLabels, queryOpts, verbose);
8189
+
8190
+ // Check for lockfile-vs-spoke-tag divergence and log warnings.
8191
+ if (lockfileMap) {
8192
+ for (const label of reached) {
8193
+ const lockEntry = lockfileMap.get(label.normalizedName);
8194
+ const spokeEntry = spokeTagLookup?.get(label.normalizedName);
8195
+ if (lockEntry && spokeEntry && lockEntry.version !== spokeEntry.version) {
8196
+ warnings.push(`Version divergence for ${label.originalLabel}: lockfile says ${lockEntry.version}, spoke tag says ${spokeEntry.version}. Using lockfile.`);
8197
+ }
8198
+ }
8199
+ }
8200
+ const lines = collectPypiPackages(reached, lockfileMap, spokeTagLookup);
8201
+ for (const l of lines) {
8202
+ allLines.push({
8203
+ name: l.name,
8204
+ version: l.version,
8205
+ source: l.source
8206
+ });
8207
+ }
8208
+ logger.logger.info(`@${hubName}: ${lines.length} package(s)`);
8209
+ }
8210
+
8211
+ // Step 6: cross-hub conflict check (same normalized name, different
8212
+ // version across multiple hubs).
8213
+ const crossHubVersions = new Map();
8214
+ for (const l of allLines) {
8215
+ const normalized = normalizePypiName(l.name);
8216
+ const existing = crossHubVersions.get(normalized);
8217
+ if (existing && existing !== l.version) {
8218
+ throw new Error(`Conflicting versions for ${l.name}: ${existing} vs ${l.version} across hubs.`);
8219
+ }
8220
+ crossHubVersions.set(normalized, l.version);
8221
+ }
8222
+
8223
+ // Step 7: sort and write requirements.txt.
8224
+ const sorted = sortPackageLines(allLines);
8225
+ const lines = sorted.map(p => `${p.name}==${p.version}\n`);
8226
+ const layout = opts.outLayout ?? 'standalone';
8227
+ const manifestDir = layout === 'flat' ? path.join(out, '.socket-auto-manifest') : out;
8228
+ fs$1.mkdirSync(manifestDir, {
8229
+ recursive: true
8230
+ });
8231
+ const manifestPath = path.join(manifestDir, 'requirements.txt');
8232
+ await fs$1.promises.writeFile(manifestPath, lines.join(''), 'utf8');
8233
+ if (verbose) {
8234
+ logger.logger.log('[VERBOSE] outputs:', {
8235
+ artifactCount: allLines.length,
8236
+ generatedManifest: path.relative(out, manifestPath),
8237
+ layout,
8238
+ manifest: manifestPath,
8239
+ pypiHubs: hubNames,
8240
+ tool: 'socket manifest bazel',
8241
+ workspace: {
8242
+ bzlmod: mode.bzlmod,
8243
+ legacyWorkspace: mode.workspace
8244
+ }
8245
+ });
8246
+ }
8247
+ for (const w of warnings) {
8248
+ logger.logger.warn(w);
8249
+ }
8250
+ if (!allLines.length) {
8251
+ logger.logger.fail('No PyPI packages extracted. failureCategory=ecosystem-detected-but-empty. See warnings above.');
8252
+ return {
8253
+ artifactCount: 0,
8254
+ manifestPath,
8255
+ ok: false
8256
+ };
8257
+ }
8258
+ logger.logger.success(`Wrote ${allLines.length} package(s) to ${path.relative(cwd, manifestPath)}.`);
8259
+ return {
8260
+ artifactCount: allLines.length,
8261
+ manifestPath,
8262
+ ok: true
8263
+ };
8264
+ } catch (e) {
8265
+ logger.logger.fail(`Unexpected error in bazel2pypi: ${utils.getErrorCause(e)}`);
8266
+ if (verbose) {
8267
+ logger.logger.group('[VERBOSE] error:');
8268
+ logger.logger.log(e);
8269
+ logger.logger.groupEnd();
8270
+ } else {
8271
+ logger.logger.info('Re-run with --verbose for the full stack.');
8272
+ }
8273
+ return {
8274
+ artifactCount: 0,
8275
+ ok: false
8276
+ };
8277
+ }
8278
+ }
8279
+
8280
+ // Resolve lockfile path and read/parse if within bounds.
8281
+ async function resolveHubLockfile(hubInfo, cwd, verbose) {
8282
+ const resolved = hubInfo.requirementsLockPath ?? resolveRequirementsLockPath(hubInfo.requirementsLockLabel, cwd);
8283
+ if (verbose) {
8284
+ logger.logger.log('[VERBOSE] lockfile resolved:', resolved ?? '(none from label/path)');
8285
+ }
8286
+ const result = readRequirementsLockFile(resolved);
8287
+ if (verbose && result) {
8288
+ logger.logger.log('[VERBOSE] lockfile parsed:', result.size, 'package(s)');
8289
+ }
8290
+ return result;
8291
+ }
8292
+
8293
+ // Run the reached-closure query for Python targets and filter to hub labels.
8294
+ async function queryReachedPypiLabels(hubName, queryOpts, verbose) {
8295
+ const queryStr = 'deps(kind("py_library|py_binary|py_test", //...))';
8296
+ const result = await runBazelQuery(queryStr, queryOpts, 'label');
8297
+ if (result.code !== 0) {
8298
+ if (verbose) {
8299
+ logger.logger.log(`[VERBOSE] reached query failed for ${hubName}:`, result.stderr);
8300
+ }
8301
+ return [];
8302
+ }
8303
+ return filterReachedPypiPackages(result.stdout, hubName);
8304
+ }
8305
+
8306
+ // Build a spoke-tag lookup map for reached labels that don't have lockfile
8307
+ // entries. For each reached label, if the lockfile missed it, resolve the
8308
+ // actual target via `--output=build` and extract pypi_name/pypi_version.
8309
+ async function buildSpokeTagLookup(reached, queryOpts, verbose) {
8310
+ const lookup = new Map();
8311
+ for (const label of reached) {
8312
+ // Only query the spoke if we haven't already resolved it.
8313
+ if (lookup.has(label.normalizedName)) {
8314
+ continue;
8315
+ }
8316
+ // eslint-disable-next-line no-await-in-loop
8317
+ const buildResult = await runBazelQuery(`${label.apparentLabel}`, {
8318
+ ...queryOpts,
8319
+ verbose: false
8320
+ });
8321
+ if (buildResult.code !== 0) {
8322
+ if (verbose) {
8323
+ logger.logger.log(`[VERBOSE] spoke build query failed for ${label.apparentLabel}:`, buildResult.stderr);
8324
+ }
8325
+ continue;
8326
+ }
8327
+ let parsed = parsePypiTagsFromBuildOutput(buildResult.stdout);
8328
+ if (!parsed) {
8329
+ const actualLabel = parseAliasActualFromBuildOutput(buildResult.stdout);
8330
+ if (actualLabel && actualLabel !== label.apparentLabel) {
8331
+ // eslint-disable-next-line no-await-in-loop
8332
+ const actualResult = await runBazelQuery(actualLabel, {
8333
+ ...queryOpts,
8334
+ verbose: false
8335
+ });
8336
+ if (actualResult.code === 0) {
8337
+ parsed = parsePypiTagsFromBuildOutput(actualResult.stdout);
8338
+ } else if (verbose) {
8339
+ logger.logger.log(`[VERBOSE] spoke actual query failed for ${actualLabel}:`, actualResult.stderr);
8340
+ }
8341
+ }
8342
+ }
8343
+ if (parsed) {
8344
+ lookup.set(normalizePypiName(parsed.name), parsed);
8345
+ }
8346
+ }
8347
+ return lookup;
8348
+ }
8349
+
8350
+ const config$e = {
8351
+ commandName: 'bazel',
8352
+ description: '[beta] Bazel SBOM support — generate manifest files for a Bazel project (Maven, PyPI)',
8353
+ hidden: false,
8354
+ flags: {
8355
+ ...flags.commonFlags,
8356
+ bazel: {
8357
+ type: 'string',
8358
+ description: 'Path to bazel/bazelisk binary; default: $(which bazelisk) || $(which bazel)'
8359
+ },
8360
+ bazelFlags: {
8361
+ type: 'string',
8362
+ description: 'Flags forwarded to every bazel invocation (single quoted string)'
8363
+ },
8364
+ bazelOutputBase: {
8365
+ type: 'string',
8366
+ description: 'Bazel --output_base for read-only-cache CI environments'
8367
+ },
8368
+ bazelRc: {
8369
+ type: 'string',
8370
+ description: 'Path to additional .bazelrc fragments forwarded to bazel'
8371
+ },
8372
+ ecosystem: {
8373
+ type: 'string',
8374
+ isMultiple: true,
8375
+ description: 'Ecosystem(s) to extract; repeatable. Supported: maven, pypi. Default: maven.'
8376
+ },
8377
+ out: {
8378
+ type: 'string',
8379
+ description: 'Output directory for generated manifests; default: ./.socket/bazel-manifests/'
8380
+ },
8381
+ verbose: {
8382
+ type: 'boolean',
8383
+ description: 'Emit bounded Bazel diagnostics with argv, duration, exit status, and output sizes'
8384
+ }
8385
+ },
8386
+ help: (command, config) => `
8387
+ Usage
8388
+ $ ${command} [options] [CWD=.]
8389
+
8390
+ Options
8391
+ ${utils.getFlagListOutput(config.flags)}
8392
+
8393
+ [beta] Generates Bazel SBOM manifests for Maven (\`maven_install.json\`)
8394
+ by running \`bazel query\` against discovered dependency repos.
8395
+ PyPI requirements generation is available with \`--ecosystem pypi\`.
8396
+ Output is consumed by
8397
+ \`socket scan create\`'s server-side parser.
8398
+
8399
+ --ecosystem may be repeated to select which ecosystems to extract.
8400
+ When omitted, Maven is generated by default. PyPI is explicit opt-in.
8401
+
8402
+ Note: this command generates dependency manifests for Bazel workspaces.
8403
+ It does not run reachability analysis.
8404
+
8405
+ To generate AND upload in one step, use \`socket scan create --auto-manifest\`
8406
+ instead — it detects Bazel workspaces, generates Maven manifests by
8407
+ default, and uploads the result. This subcommand is for generation only.
8408
+
8409
+ Examples
7201
8410
  $ ${command} .
8411
+ $ ${command} --ecosystem pypi .
8412
+ $ ${command} --ecosystem maven --ecosystem pypi .
7202
8413
  $ ${command} --bazel=/usr/local/bin/bazelisk .
7203
8414
  `
7204
8415
  };
@@ -7207,6 +8418,42 @@ const cmdManifestBazel = {
7207
8418
  hidden: config$e.hidden,
7208
8419
  run: run$F
7209
8420
  };
8421
+ // Pure outcome-matrix evaluator. Exported so dispatcher behavior can be
8422
+ // unit-tested without spawning the CLI binary. Throws InputError on
8423
+ // failures that must propagate to a non-zero CLI exit; returns void on
8424
+ // success.
8425
+ //
8426
+ // - Hard failure: ok === false && !noEcosystemFound. The ecosystem was
8427
+ // detected (or the runner crashed), but extraction failed. Always a
8428
+ // non-zero exit, even when another ecosystem succeeded.
8429
+ // - No-discovery: noEcosystemFound === true. Genuinely absent ecosystem.
8430
+ // Auto-detect mode tolerates this when at least one other ecosystem
8431
+ // succeeded; explicit mode treats it as an error.
8432
+ function evaluateEcosystemOutcomes(outcomes, isExplicit) {
8433
+ const hardFailures = outcomes.filter(o => !o.ok && !o.noEcosystemFound);
8434
+ const noDiscoveries = outcomes.filter(o => o.noEcosystemFound);
8435
+ const successes = outcomes.filter(o => o.ok && o.manifestPath);
8436
+ if (!isExplicit) {
8437
+ if (hardFailures.length) {
8438
+ throw new utils.InputError(`Bazel auto-manifest generation hit hard failure(s) in ecosystem(s): ${hardFailures.map(f => f.ecosystem).join(', ')}.`);
8439
+ }
8440
+ if (successes.length) {
8441
+ return;
8442
+ }
8443
+ if (noDiscoveries.length === outcomes.length) {
8444
+ throw new utils.InputError('No supported Bazel ecosystems detected (maven, pypi). Ensure rules_jvm_external, rules_python pip_parse/pip_install/pip_repository, or pip.parse is configured.');
8445
+ }
8446
+ return;
8447
+ }
8448
+
8449
+ // Explicit mode: every requested ecosystem must succeed.
8450
+ if (noDiscoveries.length) {
8451
+ throw new utils.InputError(`No Bazel rules found for explicitly requested ecosystem(s): ${noDiscoveries.map(f => f.ecosystem).join(', ')}.`);
8452
+ }
8453
+ if (hardFailures.length) {
8454
+ throw new utils.InputError(`Bazel manifest generation failed for explicitly requested ecosystem(s): ${hardFailures.map(f => f.ecosystem).join(', ')}.`);
8455
+ }
8456
+ }
7210
8457
  async function run$F(argv, importMeta, {
7211
8458
  parentName
7212
8459
  }) {
@@ -7230,6 +8477,9 @@ async function run$F(argv, importMeta, {
7230
8477
  cwd = path.resolve(process.cwd(), cwd);
7231
8478
  const sockJson = utils.readOrDefaultSocketJson(cwd);
7232
8479
  require$$9.debugFn('inspect', `override: ${constants.SOCKET_JSON} bazel`, sockJson?.defaults?.manifest?.bazel);
8480
+ const {
8481
+ ecosystem
8482
+ } = cli.flags;
7233
8483
  let {
7234
8484
  bazel,
7235
8485
  bazelFlags,
@@ -7312,15 +8562,56 @@ async function run$F(argv, importMeta, {
7312
8562
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
7313
8563
  return;
7314
8564
  }
7315
- await extractBazelToMaven({
7316
- bazelFlags: bazelFlags,
7317
- bazelOutputBase: bazelOutputBase,
7318
- bazelRc: bazelRc,
7319
- bin: bazel,
7320
- cwd,
7321
- out: out,
7322
- verbose: Boolean(verbose)
7323
- });
8565
+
8566
+ // Ecosystem dispatch: Maven is the default. PyPI is explicit opt-in because
8567
+ // its no-lockfile recovery value is narrower than Maven's inline-decl path.
8568
+ const wasExplicitEcosystemSelection = Array.isArray(ecosystem) && ecosystem.length > 0;
8569
+ const ecosystems = wasExplicitEcosystemSelection ? ecosystem : ['maven'];
8570
+ for (const eco of ecosystems) {
8571
+ if (!['maven', 'pypi'].includes(eco)) {
8572
+ throw new utils.InputError(`Unsupported --ecosystem value: ${eco}. Supported values: maven, pypi.`);
8573
+ }
8574
+ }
8575
+ const outcomes = [];
8576
+ for (const eco of ecosystems) {
8577
+ if (eco === 'maven') {
8578
+ // eslint-disable-next-line no-await-in-loop
8579
+ const mavenResult = await extractBazelToMaven({
8580
+ bazelFlags: bazelFlags,
8581
+ bazelOutputBase: bazelOutputBase,
8582
+ bazelRc: bazelRc,
8583
+ bin: bazel,
8584
+ cwd,
8585
+ out: out,
8586
+ verbose: Boolean(verbose)
8587
+ });
8588
+ outcomes.push({
8589
+ ecosystem: 'maven',
8590
+ ok: mavenResult.ok,
8591
+ noEcosystemFound: mavenResult.noEcosystemFound,
8592
+ manifestPath: mavenResult.manifestPath
8593
+ });
8594
+ } else if (eco === 'pypi') {
8595
+ // eslint-disable-next-line no-await-in-loop
8596
+ const pypiResult = await extractBazelToPypi({
8597
+ bazelFlags: bazelFlags,
8598
+ bazelOutputBase: bazelOutputBase,
8599
+ bazelRc: bazelRc,
8600
+ bin: bazel,
8601
+ cwd,
8602
+ out: out,
8603
+ verbose: Boolean(verbose),
8604
+ explicitEcosystem: wasExplicitEcosystemSelection
8605
+ });
8606
+ outcomes.push({
8607
+ ecosystem: 'pypi',
8608
+ ok: pypiResult.ok,
8609
+ noEcosystemFound: pypiResult.noEcosystemFound,
8610
+ manifestPath: pypiResult.manifestPath
8611
+ });
8612
+ }
8613
+ }
8614
+ evaluateEcosystemOutcomes(outcomes, wasExplicitEcosystemSelection);
7324
8615
  }
7325
8616
 
7326
8617
  const config$d = {
@@ -7583,6 +8874,10 @@ const config$b = {
7583
8874
  type: 'string',
7584
8875
  description: 'Location of gradlew binary to use, default: CWD/gradlew'
7585
8876
  },
8877
+ facts: {
8878
+ type: 'boolean',
8879
+ description: 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files'
8880
+ },
7586
8881
  gradleOpts: {
7587
8882
  type: 'string',
7588
8883
  description: 'Additional options to pass on to ./gradlew, see `./gradlew --help`'
@@ -7655,6 +8950,7 @@ async function run$C(argv, importMeta, {
7655
8950
  require$$9.debugFn('inspect', `override: ${constants.SOCKET_JSON} gradle`, sockJson?.defaults?.manifest?.gradle);
7656
8951
  let {
7657
8952
  bin,
8953
+ facts,
7658
8954
  gradleOpts,
7659
8955
  verbose
7660
8956
  } = cli.flags;
@@ -7684,6 +8980,14 @@ async function run$C(argv, importMeta, {
7684
8980
  verbose = false;
7685
8981
  }
7686
8982
  }
8983
+ if (facts === undefined) {
8984
+ if (sockJson.defaults?.manifest?.gradle?.facts !== undefined) {
8985
+ facts = sockJson.defaults?.manifest?.gradle?.facts;
8986
+ logger.logger.info(`Using default --facts from ${constants.SOCKET_JSON}:`, facts);
8987
+ } else {
8988
+ facts = false;
8989
+ }
8990
+ }
7687
8991
  if (verbose) {
7688
8992
  logger.logger.group('- ', parentName, config$b.commandName, ':');
7689
8993
  logger.logger.group('- flags:', cli.flags);
@@ -7715,10 +9019,20 @@ async function run$C(argv, importMeta, {
7715
9019
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
7716
9020
  return;
7717
9021
  }
9022
+ const parsedGradleOpts = String(gradleOpts || '').split(' ').map(s => s.trim()).filter(Boolean);
9023
+ if (facts) {
9024
+ await convertGradleToFacts({
9025
+ bin: String(bin),
9026
+ cwd,
9027
+ gradleOpts: parsedGradleOpts,
9028
+ verbose: Boolean(verbose)
9029
+ });
9030
+ return;
9031
+ }
7718
9032
  await convertGradleToMaven({
7719
9033
  bin: String(bin),
7720
9034
  cwd,
7721
- gradleOpts: String(gradleOpts || '').split(' ').map(s => s.trim()).filter(Boolean),
9035
+ gradleOpts: parsedGradleOpts,
7722
9036
  verbose: Boolean(verbose)
7723
9037
  });
7724
9038
  }
@@ -7738,6 +9052,10 @@ const config$a = {
7738
9052
  type: 'string',
7739
9053
  description: 'Location of gradlew binary to use, default: CWD/gradlew'
7740
9054
  },
9055
+ facts: {
9056
+ type: 'boolean',
9057
+ description: 'Emit a Socket facts JSON file (`.socket.facts.json`) describing the resolved dependency graph instead of generating `pom.xml` files'
9058
+ },
7741
9059
  gradleOpts: {
7742
9060
  type: 'string',
7743
9061
  description: 'Additional options to pass on to ./gradlew, see `./gradlew --help`'
@@ -7810,6 +9128,7 @@ async function run$B(argv, importMeta, {
7810
9128
  require$$9.debugFn('inspect', `override: ${constants.SOCKET_JSON} gradle`, sockJson?.defaults?.manifest?.gradle);
7811
9129
  let {
7812
9130
  bin,
9131
+ facts,
7813
9132
  gradleOpts,
7814
9133
  verbose
7815
9134
  } = cli.flags;
@@ -7839,6 +9158,14 @@ async function run$B(argv, importMeta, {
7839
9158
  verbose = false;
7840
9159
  }
7841
9160
  }
9161
+ if (facts === undefined) {
9162
+ if (sockJson.defaults?.manifest?.gradle?.facts !== undefined) {
9163
+ facts = sockJson.defaults?.manifest?.gradle?.facts;
9164
+ logger.logger.info(`Using default --facts from ${constants.SOCKET_JSON}:`, facts);
9165
+ } else {
9166
+ facts = false;
9167
+ }
9168
+ }
7842
9169
  if (verbose) {
7843
9170
  logger.logger.group('- ', parentName, config$a.commandName, ':');
7844
9171
  logger.logger.group('- flags:', cli.flags);
@@ -7870,10 +9197,20 @@ async function run$B(argv, importMeta, {
7870
9197
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
7871
9198
  return;
7872
9199
  }
9200
+ const parsedGradleOpts = String(gradleOpts || '').split(' ').map(s => s.trim()).filter(Boolean);
9201
+ if (facts) {
9202
+ await convertGradleToFacts({
9203
+ bin: String(bin),
9204
+ cwd,
9205
+ gradleOpts: parsedGradleOpts,
9206
+ verbose: Boolean(verbose)
9207
+ });
9208
+ return;
9209
+ }
7873
9210
  await convertGradleToMaven({
7874
9211
  bin: String(bin),
7875
9212
  cwd,
7876
- gradleOpts: String(gradleOpts || '').split(' ').map(s => s.trim()).filter(Boolean),
9213
+ gradleOpts: parsedGradleOpts,
7877
9214
  verbose: Boolean(verbose)
7878
9215
  });
7879
9216
  }
@@ -8291,6 +9628,14 @@ async function setupGradle(config) {
8291
9628
  } else {
8292
9629
  delete config.gradleOpts;
8293
9630
  }
9631
+ const facts = await askForFactsFlag(config.facts);
9632
+ if (facts === undefined) {
9633
+ return canceledByUser$1();
9634
+ } else if (facts === 'yes' || facts === 'no') {
9635
+ config.facts = facts === 'yes';
9636
+ } else {
9637
+ delete config.facts;
9638
+ }
8294
9639
  const verbose = await askForVerboseFlag(config.verbose);
8295
9640
  if (verbose === undefined) {
8296
9641
  return canceledByUser$1();
@@ -8439,6 +9784,25 @@ async function askForVerboseFlag(current) {
8439
9784
  default: current === true ? 'yes' : current === false ? 'no' : ''
8440
9785
  });
8441
9786
  }
9787
+ async function askForFactsFlag(current) {
9788
+ return await prompts.select({
9789
+ message: '(--facts) Emit a Socket facts JSON file instead of generating pom.xml?',
9790
+ choices: [{
9791
+ name: 'no',
9792
+ value: 'no',
9793
+ description: 'Generate pom.xml files (default behavior)'
9794
+ }, {
9795
+ name: 'yes',
9796
+ value: 'yes',
9797
+ description: 'Generate a .socket.facts.json file describing the resolved dependency graph'
9798
+ }, {
9799
+ name: '(leave default)',
9800
+ value: '',
9801
+ description: 'Do not store a setting for this'
9802
+ }],
9803
+ default: current === true ? 'yes' : current === false ? 'no' : ''
9804
+ });
9805
+ }
8442
9806
  function canceledByUser$1() {
8443
9807
  logger.logger.log('');
8444
9808
  logger.logger.info('User canceled');
@@ -17255,5 +18619,5 @@ process.on('unhandledRejection', async (reason, promise) => {
17255
18619
  // eslint-disable-next-line n/no-process-exit
17256
18620
  process.exit(1);
17257
18621
  });
17258
- //# debugId=70895ae2-8c82-49e4-a1fb-3cbc0ccb2c57
18622
+ //# debugId=932c44e2-d146-411e-8818-31f6bf237a5b
17259
18623
  //# sourceMappingURL=cli.js.map