socket 1.1.102 → 1.1.104

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 (25) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli.js +1311 -174
  3. package/dist/cli.js.map +1 -1
  4. package/dist/constants.js +4 -4
  5. package/dist/constants.js.map +1 -1
  6. package/dist/tsconfig.dts.tsbuildinfo +1 -1
  7. package/dist/types/commands/manifest/bazel/bazel-pypi-discovery.d.mts +31 -0
  8. package/dist/types/commands/manifest/bazel/bazel-pypi-discovery.d.mts.map +1 -0
  9. package/dist/types/commands/manifest/bazel/bazel-pypi-parser.d.mts +46 -0
  10. package/dist/types/commands/manifest/bazel/bazel-pypi-parser.d.mts.map +1 -0
  11. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts +16 -2
  12. package/dist/types/commands/manifest/bazel/bazel-query-runner.d.mts.map +1 -1
  13. package/dist/types/commands/manifest/bazel/bazel-repo-discovery.d.mts +3 -3
  14. package/dist/types/commands/manifest/bazel/bazel-repo-discovery.d.mts.map +1 -1
  15. package/dist/types/commands/manifest/bazel/cmd-manifest-bazel.d.mts +19 -0
  16. package/dist/types/commands/manifest/bazel/cmd-manifest-bazel.d.mts.map +1 -1
  17. package/dist/types/commands/manifest/bazel/extract_bazel_to_maven.d.mts +1 -0
  18. package/dist/types/commands/manifest/bazel/extract_bazel_to_maven.d.mts.map +1 -1
  19. package/dist/types/commands/manifest/bazel/extract_bazel_to_pypi.d.mts +20 -0
  20. package/dist/types/commands/manifest/bazel/extract_bazel_to_pypi.d.mts.map +1 -0
  21. package/dist/types/commands/manifest/generate_auto_manifest.d.mts.map +1 -1
  22. package/dist/types/utils/dlx.d.mts.map +1 -1
  23. package/dist/utils.js +27 -3
  24. package/dist/utils.js.map +1 -1
  25. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -2245,6 +2245,8 @@ async function provisionPythonShim() {
2245
2245
  // Default per-invocation timeout for bazel queries. Bazel cold-cache starts
2246
2246
  // can take several minutes; 10 minutes is generous while still bounding CI hangs.
2247
2247
  const BAZEL_QUERY_TIMEOUT_MS = 600_000;
2248
+ const STDERR_TAIL_BYTES = 4_096;
2249
+ const STDOUT_EXCERPT_BYTES = 1_024;
2248
2250
 
2249
2251
  // Splits the user-supplied --bazel-flags string on whitespace.
2250
2252
  // Empty / undefined returns []. No shell parsing — quoted args with embedded
@@ -2265,11 +2267,22 @@ function buildBazelModShowVisibleReposArgv(opts) {
2265
2267
  startup.push(`--output_base=${opts.bazelOutputBase}`);
2266
2268
  }
2267
2269
  const userFlags = splitBazelFlags(opts.bazelFlags);
2268
- return [...startup, 'mod', 'show_repo', '--all_visible_repos', '--output=streamed_jsonproto', ...userFlags];
2270
+ return [...startup, 'mod', 'dump_repo_mapping', '', '--output=json', ...userFlags];
2269
2271
  }
2270
- function buildBazelArgv(queryStr, opts) {
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];
2282
+ }
2283
+ function buildBazelArgv(queryStr, opts, output = 'build') {
2271
2284
  // Startup flags MUST precede the `query` subcommand.
2272
- // Bazel argv shape: <startup> query <queryFlags> <invocationFlags> <queryStr> --output=build <userFlags>
2285
+ // Bazel argv shape: <startup> query <queryFlags> <invocationFlags> <queryStr> --output=<output> <userFlags>
2273
2286
  const startup = [];
2274
2287
  if (opts.bazelRc) {
2275
2288
  startup.push(`--bazelrc=${opts.bazelRc}`);
@@ -2280,7 +2293,7 @@ function buildBazelArgv(queryStr, opts) {
2280
2293
  // Keep query output stable and avoid updating Bazel lockfiles while extracting.
2281
2294
  const queryFlags = ['--lockfile_mode=off', '--noshow_progress'];
2282
2295
  const userFlags = splitBazelFlags(opts.bazelFlags);
2283
- return [...startup, 'query', ...queryFlags, ...opts.invocationFlags, queryStr, '--output=build', ...userFlags];
2296
+ return [...startup, 'query', ...queryFlags, ...opts.invocationFlags, queryStr, `--output=${output}`, ...userFlags];
2284
2297
  }
2285
2298
  function stringField(value) {
2286
2299
  return typeof value === 'string' ? value : '';
@@ -2288,6 +2301,46 @@ function stringField(value) {
2288
2301
  function numericExitCode(value) {
2289
2302
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
2290
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
+ }
2291
2344
  function normalizeSpawnError(error) {
2292
2345
  const e = error;
2293
2346
  return {
@@ -2303,11 +2356,12 @@ function normalizeSpawnError(error) {
2303
2356
  * and fails on non-zero exit. Rejected spawn calls are normalized into a
2304
2357
  * BazelQueryResult so retry/skip handling can inspect stderr.
2305
2358
  */
2306
- async function runBazelQuery(queryStr, opts) {
2307
- const argv = buildBazelArgv(queryStr, opts);
2359
+ async function runBazelQuery(queryStr, opts, output) {
2360
+ const argv = buildBazelArgv(queryStr, opts, output);
2308
2361
  if (opts.verbose) {
2309
2362
  logger.logger.log('[VERBOSE] Executing:', opts.bin, ', args:', argv);
2310
2363
  }
2364
+ const startedAt = Date.now();
2311
2365
  const {
2312
2366
  spinner
2313
2367
  } = constants.default;
@@ -2342,19 +2396,30 @@ async function runBazelQuery(queryStr, opts) {
2342
2396
  } else {
2343
2397
  spinner.failAndStop(`bazel query failed (${truncated}).`);
2344
2398
  }
2399
+ if (result) {
2400
+ logBazelTrace({
2401
+ argv,
2402
+ durationMs: Date.now() - startedAt,
2403
+ opts,
2404
+ result,
2405
+ step: `bazel query ${truncated}`
2406
+ });
2407
+ }
2345
2408
  }
2346
2409
  }
2347
2410
 
2348
2411
  /**
2349
2412
  * Bzlmod-native visible repository enumeration. This is only a candidate
2350
2413
  * source; callers must still validate each returned apparent repo name with a
2351
- * semantic query for generated JVM Maven rules.
2414
+ * semantic query for generated ecosystem rules.
2352
2415
  */
2353
2416
  async function runBazelModShowVisibleRepos(opts) {
2354
2417
  const argv = buildBazelModShowVisibleReposArgv(opts);
2355
2418
  if (opts.verbose) {
2356
2419
  logger.logger.log('[VERBOSE] Executing:', opts.bin, ', args:', argv);
2357
2420
  }
2421
+ const startedAt = Date.now();
2422
+ let result;
2358
2423
  try {
2359
2424
  const output = await spawn.spawn(opts.bin, argv, {
2360
2425
  cwd: opts.cwd,
@@ -2368,14 +2433,65 @@ async function runBazelModShowVisibleRepos(opts) {
2368
2433
  stderr,
2369
2434
  stdout
2370
2435
  } = output;
2371
- 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 = {
2372
2480
  code,
2373
2481
  stdout,
2374
2482
  stderr
2375
2483
  };
2376
2484
  } catch (e) {
2377
- return normalizeSpawnError(e);
2485
+ result = normalizeSpawnError(e);
2378
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;
2379
2495
  }
2380
2496
 
2381
2497
  /**
@@ -2394,13 +2510,31 @@ function buildProbeFor(opts) {
2394
2510
  };
2395
2511
  }
2396
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
+
2397
2531
  // Maximum size (bytes) we will read for any single Bazel workspace file.
2398
2532
  // Prevents DoS via maliciously large MODULE.bazel / WORKSPACE / .bzl files.
2399
- const MAX_WORKSPACE_FILE_BYTES = 5 * 1024 * 1024;
2533
+ const MAX_WORKSPACE_FILE_BYTES$1 = 5 * 1024 * 1024;
2400
2534
 
2401
2535
  // Maximum candidate count we will return (deduped) before truncating.
2402
2536
  // Real repos have <20; this is a hard ceiling against pathological inputs.
2403
- const MAX_CANDIDATES = 256;
2537
+ const MAX_CANDIDATES$1 = 256;
2404
2538
 
2405
2539
  // Regex strategy: anchored, bounded character classes, no nested quantifiers.
2406
2540
  // Match `use_repo(maven, "X", "Y", ...)` with a bounded arg-list window to
@@ -2421,13 +2555,13 @@ const MAVEN_COORDINATES_MARKER_RE = /\bmaven_coordinates\s*=/;
2421
2555
 
2422
2556
  // Reads file contents, refusing files that exceed MAX_WORKSPACE_FILE_BYTES.
2423
2557
  // Returns null when the file is missing, oversized, or unreadable.
2424
- function safeReadFile(file) {
2558
+ function safeReadFile$1(file) {
2425
2559
  if (!fs$1.existsSync(file)) {
2426
2560
  return null;
2427
2561
  }
2428
2562
  try {
2429
2563
  const stat = fs$1.statSync(file);
2430
- if (stat.size > MAX_WORKSPACE_FILE_BYTES) {
2564
+ if (stat.size > MAX_WORKSPACE_FILE_BYTES$1) {
2431
2565
  return null;
2432
2566
  }
2433
2567
  return fs$1.readFileSync(file, 'utf8');
@@ -2439,7 +2573,7 @@ function safeReadFile(file) {
2439
2573
  // Walks workspace root for legacy Starlark sources we can scan: WORKSPACE
2440
2574
  // (and WORKSPACE.bazel) plus top-level .bzl files. Non-recursive by design;
2441
2575
  // Phase 1 explicitly avoids static Starlark parsing at depth.
2442
- function listLegacyStarlarkFiles(cwd) {
2576
+ function listLegacyStarlarkFiles$1(cwd) {
2443
2577
  const files = [];
2444
2578
  const candidates = ['WORKSPACE', 'WORKSPACE.bazel'];
2445
2579
  for (const c of candidates) {
@@ -2469,7 +2603,7 @@ function uniqueSorted(items) {
2469
2603
  if (!seen.has(item)) {
2470
2604
  seen.add(item);
2471
2605
  out.push(item);
2472
- if (out.length >= MAX_CANDIDATES) {
2606
+ if (out.length >= MAX_CANDIDATES$1) {
2473
2607
  break;
2474
2608
  }
2475
2609
  }
@@ -2493,14 +2627,29 @@ function apparentNameFromJsonValue(value) {
2493
2627
  }
2494
2628
  return undefined;
2495
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
+ }
2496
2645
  function normalizeRepoName(name) {
2497
2646
  const repo = name.startsWith('@') ? name.slice(1) : name;
2498
2647
  return BAZEL_REPO_NAME_RE.test(repo) ? repo : undefined;
2499
2648
  }
2500
2649
 
2501
- // Parse `bazel mod show_repo --all_visible_repos --output=streamed_jsonproto`
2502
- // output. Bazel's JSON proto field casing may vary by formatter; accept both
2503
- // 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.
2504
2653
  function parseVisibleRepoCandidates(output) {
2505
2654
  const candidates = [];
2506
2655
  for (const line of output.split(/\r?\n/)) {
@@ -2510,6 +2659,7 @@ function parseVisibleRepoCandidates(output) {
2510
2659
  }
2511
2660
  try {
2512
2661
  const parsed = JSON.parse(trimmed);
2662
+ candidates.push(...apparentNamesFromRepoMapping(parsed));
2513
2663
  const apparentName = apparentNameFromJsonValue(parsed);
2514
2664
  if (apparentName) {
2515
2665
  const repo = normalizeRepoName(apparentName);
@@ -2531,7 +2681,7 @@ function parseMavenRepoCandidates(cwd, verbose) {
2531
2681
 
2532
2682
  // Bzlmod path: parse MODULE.bazel for use_repo(maven, ...).
2533
2683
  const moduleBazel = path.join(cwd, 'MODULE.bazel');
2534
- const moduleContent = safeReadFile(moduleBazel);
2684
+ const moduleContent = safeReadFile$1(moduleBazel);
2535
2685
  if (moduleContent) {
2536
2686
  const bzlmodHits = [];
2537
2687
  for (const m of moduleContent.matchAll(USE_REPO_RE)) {
@@ -2549,12 +2699,12 @@ function parseMavenRepoCandidates(cwd, verbose) {
2549
2699
  }
2550
2700
 
2551
2701
  // Legacy path: scan WORKSPACE + top-level .bzl files for maven_install(name=...).
2552
- const legacyFiles = listLegacyStarlarkFiles(cwd);
2702
+ const legacyFiles = listLegacyStarlarkFiles$1(cwd);
2553
2703
  if (verbose) {
2554
2704
  logger.logger.log('[VERBOSE] discovery: legacy files considered:', legacyFiles.length ? legacyFiles : '(none)');
2555
2705
  }
2556
2706
  for (const file of legacyFiles) {
2557
- const content = safeReadFile(file);
2707
+ const content = safeReadFile$1(file);
2558
2708
  if (!content) {
2559
2709
  continue;
2560
2710
  }
@@ -3025,8 +3175,18 @@ async function extractBazelToMaven(opts) {
3025
3175
  });
3026
3176
  }
3027
3177
  if (!allArtifacts.length) {
3028
- process.exitCode = 1;
3029
- 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`);
3030
3190
  return {
3031
3191
  artifactCount: 0,
3032
3192
  manifestPath,
@@ -3040,7 +3200,6 @@ async function extractBazelToMaven(opts) {
3040
3200
  ok: true
3041
3201
  };
3042
3202
  } catch (e) {
3043
- process.exitCode = 1;
3044
3203
  // Always surface the error message; users should not have to
3045
3204
  // re-run a multi-minute bazel build with --verbose just to see whether
3046
3205
  // the failure was a missing dependency, permission error, or network blip.
@@ -3681,24 +3840,23 @@ async function generateAutoManifest({
3681
3840
  if (!sockJson?.defaults?.manifest?.bazel?.disabled && detected.bazel) {
3682
3841
  const bazelConfig = sockJson?.defaults?.manifest?.bazel;
3683
3842
  logger.logger.log('Detected a Bazel workspace, extracting Maven dependencies via bazel query...');
3684
- const bazelResult = await extractBazelToMaven({
3843
+ const mavenResult = await extractBazelToMaven({
3685
3844
  bazelFlags: bazelConfig?.bazelFlags,
3686
3845
  bazelOutputBase: bazelConfig?.bazelOutputBase,
3687
3846
  bazelRc: bazelConfig?.bazelRc,
3688
3847
  bin: bazelConfig?.bazel ?? bazelConfig?.bin,
3689
3848
  cwd,
3690
- // Auto-manifest writes into a sibling directory instead of the repo root
3691
- // so scan discovery can pick it up without colliding with a checked-in
3692
- // rules_jvm_external lockfile or repo-root gitignore patterns.
3693
3849
  out: bazelConfig?.out ?? cwd,
3694
3850
  outLayout: 'flat',
3695
3851
  verbose: Boolean(bazelConfig?.verbose) || verbose
3696
3852
  });
3697
- if (!bazelResult.ok) {
3698
- 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');
3699
3855
  }
3700
- if (bazelResult.manifestPath) {
3701
- 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.');
3702
3860
  }
3703
3861
  }
3704
3862
  return {
@@ -7302,149 +7460,1087 @@ async function run$G(argv, importMeta, context) {
7302
7460
  await spawnPromise;
7303
7461
  }
7304
7462
 
7305
- const config$e = {
7306
- commandName: 'bazel',
7307
- description: '[beta] Bazel JVM SBOM support generate manifest files (`maven_install.json`) for a Bazel/Maven project',
7308
- hidden: false,
7309
- flags: {
7310
- ...flags.commonFlags,
7311
- bazel: {
7312
- type: 'string',
7313
- description: 'Path to bazel/bazelisk binary; default: $(which bazelisk) || $(which bazel)'
7314
- },
7315
- bazelFlags: {
7316
- type: 'string',
7317
- description: 'Flags forwarded to every bazel invocation (single quoted string)'
7318
- },
7319
- bazelOutputBase: {
7320
- type: 'string',
7321
- description: 'Bazel --output_base for read-only-cache CI environments'
7322
- },
7323
- bazelRc: {
7324
- type: 'string',
7325
- description: 'Path to additional .bazelrc fragments forwarded to bazel'
7326
- },
7327
- out: {
7328
- type: 'string',
7329
- description: 'Output directory for generated manifests; default: ./.socket/bazel-manifests/'
7330
- },
7331
- verbose: {
7332
- type: 'boolean',
7333
- description: 'Stream bazel stdout/stderr'
7334
- }
7335
- },
7336
- help: (command, config) => `
7337
- Usage
7338
- $ ${command} [options] [CWD=.]
7339
-
7340
- Options
7341
- ${utils.getFlagListOutput(config.flags)}
7342
-
7343
- [beta] Generates Bazel JVM SBOM manifests (\`maven_install.json\`-shaped)
7344
- by running \`bazel query\` against discovered Maven repos. Output is
7345
- consumed by \`socket scan create\`'s server-side parser.
7346
-
7347
- Note: this command generates Maven dependency manifests for Bazel JVM
7348
- workspaces. It does not run reachability analysis.
7349
-
7350
- To generate AND upload in one step, use \`socket scan create --auto-manifest\`
7351
- instead — it detects Bazel workspaces, runs the same extraction, and uploads
7352
- the result. This subcommand is for generation only.
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;
7353
7466
 
7354
- Examples
7355
- $ ${command} .
7356
- $ ${command} --bazel=/usr/local/bin/bazelisk .
7357
- `
7358
- };
7359
- const cmdManifestBazel = {
7360
- description: config$e.description,
7361
- hidden: config$e.hidden,
7362
- run: run$F
7363
- };
7364
- async function run$F(argv, importMeta, {
7365
- parentName
7366
- }) {
7367
- const cli = utils.meowOrExit({
7368
- argv,
7369
- config: config$e,
7370
- importMeta,
7371
- parentName
7372
- });
7373
- const {
7374
- json = false,
7375
- markdown = false
7376
- } = cli.flags;
7377
- const dryRun = !!cli.flags['dryRun'];
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;
7378
7470
 
7379
- // TODO: Implement json/md further.
7380
- const outputKind = utils.getOutputKind(json, markdown);
7381
- let [cwd = '.'] = cli.input;
7382
- // Note: path.resolve vs .join:
7383
- // If given path is absolute then cwd should not affect it.
7384
- cwd = path.resolve(process.cwd(), cwd);
7385
- const sockJson = utils.readOrDefaultSocketJson(cwd);
7386
- require$$9.debugFn('inspect', `override: ${constants.SOCKET_JSON} bazel`, sockJson?.defaults?.manifest?.bazel);
7387
- let {
7388
- bazel,
7389
- bazelFlags,
7390
- bazelOutputBase,
7391
- bazelRc,
7392
- out,
7393
- verbose
7394
- } = cli.flags;
7471
+ // Regex strategy: anchored, bounded character classes, no nested quantifiers.
7395
7472
 
7396
- // Set defaults for any flag/arg that is not given. Check socket.json first.
7397
- if (!bazel) {
7398
- const defaultBazel = sockJson.defaults?.manifest?.bazel?.bazel ?? sockJson.defaults?.manifest?.bazel?.bin;
7399
- if (defaultBazel) {
7400
- bazel = defaultBazel;
7401
- logger.logger.info(`Using default --bazel from ${constants.SOCKET_JSON}:`, bazel);
7402
- }
7403
- // Otherwise leave undefined; resolveBazelBinary performs the PATH
7404
- // lookup for bazelisk/bazel.
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]);
7405
7501
  }
7406
- if (!bazelFlags) {
7407
- if (sockJson.defaults?.manifest?.bazel?.bazelFlags) {
7408
- bazelFlags = sockJson.defaults?.manifest?.bazel?.bazelFlags;
7409
- logger.logger.info(`Using default --bazel-flags from ${constants.SOCKET_JSON}:`, bazelFlags);
7410
- } else {
7411
- bazelFlags = '';
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;
7412
7507
  }
7413
- }
7414
- if (!bazelOutputBase) {
7415
- if (sockJson.defaults?.manifest?.bazel?.bazelOutputBase) {
7416
- bazelOutputBase = sockJson.defaults?.manifest?.bazel?.bazelOutputBase;
7417
- logger.logger.info(`Using default --bazel-output-base from ${constants.SOCKET_JSON}:`, bazelOutputBase);
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;
7418
7513
  }
7514
+ candidates.push(info);
7419
7515
  }
7420
- if (!bazelRc) {
7421
- if (sockJson.defaults?.manifest?.bazel?.bazelRc) {
7422
- bazelRc = sockJson.defaults?.manifest?.bazel?.bazelRc;
7423
- logger.logger.info(`Using default --bazel-rc from ${constants.SOCKET_JSON}:`, bazelRc);
7424
- }
7516
+ if (verbose) {
7517
+ logger.logger.log('[VERBOSE] discovery: bazel mod show_extension pip.parse hits:', candidates.length, 'use_repo:', Array.from(useRepoNames));
7425
7518
  }
7426
- if (!out) {
7427
- if (sockJson.defaults?.manifest?.bazel?.out) {
7428
- out = sockJson.defaults?.manifest?.bazel?.out;
7429
- logger.logger.info(`Using default --out from ${constants.SOCKET_JSON}:`, out);
7430
- } else {
7431
- out = path.join(cwd, '.socket', 'bazel-manifests');
7432
- }
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;
7433
7527
  }
7434
- if (verbose === undefined) {
7435
- if (sockJson.defaults?.manifest?.bazel?.verbose !== undefined) {
7436
- verbose = sockJson.defaults?.manifest?.bazel?.verbose;
7437
- logger.logger.info(`Using default --verbose from ${constants.SOCKET_JSON}:`, verbose);
7438
- } else {
7439
- verbose = false;
7528
+ try {
7529
+ const stat = fs$1.statSync(file);
7530
+ if (stat.size > MAX_WORKSPACE_FILE_BYTES) {
7531
+ return null;
7440
7532
  }
7533
+ return fs$1.readFileSync(file, 'utf8');
7534
+ } catch {
7535
+ return null;
7441
7536
  }
7442
- if (verbose) {
7443
- logger.logger.group('- ', parentName, config$e.commandName, ':');
7444
- logger.logger.group('- flags:', cli.flags);
7445
- logger.logger.groupEnd();
7446
- logger.logger.log('- input:', cli.input);
7447
- logger.logger.groupEnd();
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
8410
+ $ ${command} .
8411
+ $ ${command} --ecosystem pypi .
8412
+ $ ${command} --ecosystem maven --ecosystem pypi .
8413
+ $ ${command} --bazel=/usr/local/bin/bazelisk .
8414
+ `
8415
+ };
8416
+ const cmdManifestBazel = {
8417
+ description: config$e.description,
8418
+ hidden: config$e.hidden,
8419
+ run: run$F
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
+ }
8457
+ async function run$F(argv, importMeta, {
8458
+ parentName
8459
+ }) {
8460
+ const cli = utils.meowOrExit({
8461
+ argv,
8462
+ config: config$e,
8463
+ importMeta,
8464
+ parentName
8465
+ });
8466
+ const {
8467
+ json = false,
8468
+ markdown = false
8469
+ } = cli.flags;
8470
+ const dryRun = !!cli.flags['dryRun'];
8471
+
8472
+ // TODO: Implement json/md further.
8473
+ const outputKind = utils.getOutputKind(json, markdown);
8474
+ let [cwd = '.'] = cli.input;
8475
+ // Note: path.resolve vs .join:
8476
+ // If given path is absolute then cwd should not affect it.
8477
+ cwd = path.resolve(process.cwd(), cwd);
8478
+ const sockJson = utils.readOrDefaultSocketJson(cwd);
8479
+ require$$9.debugFn('inspect', `override: ${constants.SOCKET_JSON} bazel`, sockJson?.defaults?.manifest?.bazel);
8480
+ const {
8481
+ ecosystem
8482
+ } = cli.flags;
8483
+ let {
8484
+ bazel,
8485
+ bazelFlags,
8486
+ bazelOutputBase,
8487
+ bazelRc,
8488
+ out,
8489
+ verbose
8490
+ } = cli.flags;
8491
+
8492
+ // Set defaults for any flag/arg that is not given. Check socket.json first.
8493
+ if (!bazel) {
8494
+ const defaultBazel = sockJson.defaults?.manifest?.bazel?.bazel ?? sockJson.defaults?.manifest?.bazel?.bin;
8495
+ if (defaultBazel) {
8496
+ bazel = defaultBazel;
8497
+ logger.logger.info(`Using default --bazel from ${constants.SOCKET_JSON}:`, bazel);
8498
+ }
8499
+ // Otherwise leave undefined; resolveBazelBinary performs the PATH
8500
+ // lookup for bazelisk/bazel.
8501
+ }
8502
+ if (!bazelFlags) {
8503
+ if (sockJson.defaults?.manifest?.bazel?.bazelFlags) {
8504
+ bazelFlags = sockJson.defaults?.manifest?.bazel?.bazelFlags;
8505
+ logger.logger.info(`Using default --bazel-flags from ${constants.SOCKET_JSON}:`, bazelFlags);
8506
+ } else {
8507
+ bazelFlags = '';
8508
+ }
8509
+ }
8510
+ if (!bazelOutputBase) {
8511
+ if (sockJson.defaults?.manifest?.bazel?.bazelOutputBase) {
8512
+ bazelOutputBase = sockJson.defaults?.manifest?.bazel?.bazelOutputBase;
8513
+ logger.logger.info(`Using default --bazel-output-base from ${constants.SOCKET_JSON}:`, bazelOutputBase);
8514
+ }
8515
+ }
8516
+ if (!bazelRc) {
8517
+ if (sockJson.defaults?.manifest?.bazel?.bazelRc) {
8518
+ bazelRc = sockJson.defaults?.manifest?.bazel?.bazelRc;
8519
+ logger.logger.info(`Using default --bazel-rc from ${constants.SOCKET_JSON}:`, bazelRc);
8520
+ }
8521
+ }
8522
+ if (!out) {
8523
+ if (sockJson.defaults?.manifest?.bazel?.out) {
8524
+ out = sockJson.defaults?.manifest?.bazel?.out;
8525
+ logger.logger.info(`Using default --out from ${constants.SOCKET_JSON}:`, out);
8526
+ } else {
8527
+ out = path.join(cwd, '.socket', 'bazel-manifests');
8528
+ }
8529
+ }
8530
+ if (verbose === undefined) {
8531
+ if (sockJson.defaults?.manifest?.bazel?.verbose !== undefined) {
8532
+ verbose = sockJson.defaults?.manifest?.bazel?.verbose;
8533
+ logger.logger.info(`Using default --verbose from ${constants.SOCKET_JSON}:`, verbose);
8534
+ } else {
8535
+ verbose = false;
8536
+ }
8537
+ }
8538
+ if (verbose) {
8539
+ logger.logger.group('- ', parentName, config$e.commandName, ':');
8540
+ logger.logger.group('- flags:', cli.flags);
8541
+ logger.logger.groupEnd();
8542
+ logger.logger.log('- input:', cli.input);
8543
+ logger.logger.groupEnd();
7448
8544
  }
7449
8545
  const wasValidInput = utils.checkCommandInput(outputKind, {
7450
8546
  nook: true,
@@ -7466,15 +8562,56 @@ async function run$F(argv, importMeta, {
7466
8562
  logger.logger.log(constants.default.DRY_RUN_BAILING_NOW);
7467
8563
  return;
7468
8564
  }
7469
- await extractBazelToMaven({
7470
- bazelFlags: bazelFlags,
7471
- bazelOutputBase: bazelOutputBase,
7472
- bazelRc: bazelRc,
7473
- bin: bazel,
7474
- cwd,
7475
- out: out,
7476
- verbose: Boolean(verbose)
7477
- });
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);
7478
8615
  }
7479
8616
 
7480
8617
  const config$d = {
@@ -17482,5 +18619,5 @@ process.on('unhandledRejection', async (reason, promise) => {
17482
18619
  // eslint-disable-next-line n/no-process-exit
17483
18620
  process.exit(1);
17484
18621
  });
17485
- //# debugId=d1f4c235-f351-4ff4-9652-79299095458b
18622
+ //# debugId=932c44e2-d146-411e-8818-31f6bf237a5b
17486
18623
  //# sourceMappingURL=cli.js.map