@paretools/python 0.8.5 → 0.10.0

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/formatters.d.ts +52 -5
  5. package/dist/lib/formatters.d.ts.map +1 -1
  6. package/dist/lib/formatters.js +287 -37
  7. package/dist/lib/formatters.js.map +1 -1
  8. package/dist/lib/parsers.d.ts +30 -15
  9. package/dist/lib/parsers.d.ts.map +1 -1
  10. package/dist/lib/parsers.js +506 -51
  11. package/dist/lib/parsers.js.map +1 -1
  12. package/dist/lib/python-runner.d.ts +2 -0
  13. package/dist/lib/python-runner.d.ts.map +1 -1
  14. package/dist/lib/python-runner.js +12 -8
  15. package/dist/lib/python-runner.js.map +1 -1
  16. package/dist/schemas/index.d.ts +354 -69
  17. package/dist/schemas/index.d.ts.map +1 -1
  18. package/dist/schemas/index.js +266 -32
  19. package/dist/schemas/index.js.map +1 -1
  20. package/dist/tools/black.d.ts.map +1 -1
  21. package/dist/tools/black.js +45 -10
  22. package/dist/tools/black.js.map +1 -1
  23. package/dist/tools/conda.d.ts.map +1 -1
  24. package/dist/tools/conda.js +73 -14
  25. package/dist/tools/conda.js.map +1 -1
  26. package/dist/tools/mypy.d.ts.map +1 -1
  27. package/dist/tools/mypy.js +168 -13
  28. package/dist/tools/mypy.js.map +1 -1
  29. package/dist/tools/pip-audit.d.ts.map +1 -1
  30. package/dist/tools/pip-audit.js +75 -15
  31. package/dist/tools/pip-audit.js.map +1 -1
  32. package/dist/tools/pip-install.d.ts.map +1 -1
  33. package/dist/tools/pip-install.js +88 -14
  34. package/dist/tools/pip-install.js.map +1 -1
  35. package/dist/tools/pip-list.d.ts.map +1 -1
  36. package/dist/tools/pip-list.js +58 -13
  37. package/dist/tools/pip-list.js.map +1 -1
  38. package/dist/tools/pip-show.d.ts.map +1 -1
  39. package/dist/tools/pip-show.js +46 -12
  40. package/dist/tools/pip-show.js.map +1 -1
  41. package/dist/tools/poetry.d.ts.map +1 -1
  42. package/dist/tools/poetry.js +75 -14
  43. package/dist/tools/poetry.js.map +1 -1
  44. package/dist/tools/pyenv.d.ts.map +1 -1
  45. package/dist/tools/pyenv.js +84 -19
  46. package/dist/tools/pyenv.js.map +1 -1
  47. package/dist/tools/pytest.d.ts.map +1 -1
  48. package/dist/tools/pytest.js +77 -11
  49. package/dist/tools/pytest.js.map +1 -1
  50. package/dist/tools/ruff-format.d.ts.map +1 -1
  51. package/dist/tools/ruff-format.js +84 -9
  52. package/dist/tools/ruff-format.js.map +1 -1
  53. package/dist/tools/ruff.d.ts.map +1 -1
  54. package/dist/tools/ruff.js +87 -11
  55. package/dist/tools/ruff.js.map +1 -1
  56. package/dist/tools/uv-install.d.ts.map +1 -1
  57. package/dist/tools/uv-install.js +79 -14
  58. package/dist/tools/uv-install.js.map +1 -1
  59. package/dist/tools/uv-run.d.ts.map +1 -1
  60. package/dist/tools/uv-run.js +81 -13
  61. package/dist/tools/uv-run.js.map +1 -1
  62. package/package.json +2 -2
@@ -1,8 +1,13 @@
1
- /** Parses `pip install` output into structured data with installed packages and satisfaction status. */
1
+ /** Parses `pip install` output into structured data with installed packages and satisfaction status.
2
+ * Handles both normal installs ("Successfully installed ...") and dry-run output ("Would install ..."). */
2
3
  export function parsePipInstall(stdout, stderr, exitCode) {
3
4
  const output = stdout + "\n" + stderr;
5
+ const lines = output.split("\n");
4
6
  const alreadySatisfied = output.includes("already satisfied");
7
+ const dryRun = output.includes("Would install") || output.includes("--dry-run");
5
8
  const installed = [];
9
+ const warnings = [];
10
+ // Normal mode: "Successfully installed pkg-1.0.0 pkg2-2.0.0"
6
11
  const installMatch = output.match(/Successfully installed (.+)/);
7
12
  if (installMatch) {
8
13
  const packages = installMatch[1].trim().split(/\s+/);
@@ -16,16 +21,73 @@ export function parsePipInstall(stdout, stderr, exitCode) {
16
21
  }
17
22
  }
18
23
  }
24
+ // Dry-run mode: "Would install pkg-1.0.0 pkg2-2.0.0"
25
+ if (installed.length === 0) {
26
+ const dryRunMatch = output.match(/Would install (.+)/);
27
+ if (dryRunMatch) {
28
+ const packages = dryRunMatch[1].trim().split(/\s+/);
29
+ for (const pkg of packages) {
30
+ const lastDash = pkg.lastIndexOf("-");
31
+ if (lastDash > 0) {
32
+ installed.push({
33
+ name: pkg.slice(0, lastDash),
34
+ version: pkg.slice(lastDash + 1),
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+ for (const line of lines) {
41
+ const trimmed = line.trim();
42
+ if (/^(WARNING|DEPRECATION):/i.test(trimmed)) {
43
+ warnings.push(trimmed);
44
+ }
45
+ }
19
46
  return {
20
47
  success: exitCode === 0,
21
48
  installed,
22
49
  alreadySatisfied,
50
+ warnings: warnings.length > 0 ? warnings : undefined,
51
+ dryRun,
23
52
  total: installed.length,
24
53
  };
25
54
  }
26
55
  const MYPY_RE = /^(.+?):(\d+)(?::(\d+))?: (error|warning|note): (.+?)(?:\s+\[([^\]]+)\])?$/;
27
- /** Parses mypy type-checker output into structured diagnostics with file locations and error codes. */
28
- export function parseMypyOutput(stdout, exitCode) {
56
+ /** Parses mypy JSON output (from `--output json`) into structured diagnostics. */
57
+ export function parseMypyJsonOutput(stdout, exitCode) {
58
+ let entries;
59
+ try {
60
+ entries = JSON.parse(stdout);
61
+ }
62
+ catch {
63
+ // Fall back to text parsing if JSON parsing fails (older mypy without --output json)
64
+ return parseMypyTextOutput(stdout, exitCode);
65
+ }
66
+ if (!Array.isArray(entries)) {
67
+ return parseMypyTextOutput(stdout, exitCode);
68
+ }
69
+ const diagnostics = entries.map((e) => ({
70
+ file: e.file,
71
+ line: e.line,
72
+ column: e.column > 0 ? e.column : undefined,
73
+ severity: e.severity,
74
+ message: e.message,
75
+ code: e.code || undefined,
76
+ }));
77
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
78
+ const warnings = diagnostics.filter((d) => d.severity === "warning").length;
79
+ const notes = diagnostics.filter((d) => d.severity === "note").length;
80
+ return {
81
+ success: exitCode === 0,
82
+ diagnostics,
83
+ total: diagnostics.length,
84
+ errors,
85
+ warnings,
86
+ notes,
87
+ };
88
+ }
89
+ /** Parses mypy text output into structured diagnostics (fallback for older mypy without JSON). */
90
+ export function parseMypyTextOutput(stdout, exitCode) {
29
91
  const lines = stdout.split("\n");
30
92
  const diagnostics = [];
31
93
  for (const line of lines) {
@@ -42,23 +104,30 @@ export function parseMypyOutput(stdout, exitCode) {
42
104
  }
43
105
  }
44
106
  const errors = diagnostics.filter((d) => d.severity === "error").length;
45
- const warnings = diagnostics.filter((d) => d.severity === "warning" || d.severity === "note").length;
107
+ const warnings = diagnostics.filter((d) => d.severity === "warning").length;
108
+ const notes = diagnostics.filter((d) => d.severity === "note").length;
46
109
  return {
47
110
  success: exitCode === 0,
48
111
  diagnostics,
49
112
  total: diagnostics.length,
50
113
  errors,
51
114
  warnings,
115
+ notes,
52
116
  };
53
117
  }
118
+ /** Parses mypy output: tries JSON first, falls back to text parsing.
119
+ * @deprecated Use parseMypyJsonOutput (which auto-falls back) instead. Kept for backward compat. */
120
+ export function parseMypyOutput(stdout, exitCode) {
121
+ return parseMypyTextOutput(stdout, exitCode);
122
+ }
54
123
  /** Parses `ruff check --output-format json` output into structured lint diagnostics with fixability info. */
55
- export function parseRuffJson(stdout) {
124
+ export function parseRuffJson(stdout, exitCode, stderr = "") {
56
125
  let entries;
57
126
  try {
58
127
  entries = JSON.parse(stdout);
59
128
  }
60
129
  catch {
61
- return { diagnostics: [], total: 0, fixable: 0 };
130
+ return { success: exitCode === 0, diagnostics: [], total: 0, fixable: 0 };
62
131
  }
63
132
  const diagnostics = entries.map((e) => ({
64
133
  file: e.filename,
@@ -69,34 +138,83 @@ export function parseRuffJson(stdout) {
69
138
  code: e.code,
70
139
  message: e.message,
71
140
  fixable: !!e.fix,
141
+ fixApplicability: extractFixApplicability(e.fix),
142
+ url: e.url || undefined,
72
143
  }));
73
144
  const fixable = diagnostics.filter((d) => d.fixable).length;
74
- return { diagnostics, total: diagnostics.length, fixable };
145
+ const fixedMatch = stderr.match(/\bFixed\s+(\d+)\s+(?:errors?|violations?)\b/i) ??
146
+ stderr.match(/\b(\d+)\s+fixed\b/i);
147
+ const fixedCount = fixedMatch ? parseInt(fixedMatch[1], 10) : undefined;
148
+ return {
149
+ success: exitCode === 0,
150
+ diagnostics,
151
+ total: diagnostics.length,
152
+ fixable,
153
+ fixedCount,
154
+ };
155
+ }
156
+ /** Extracts fix applicability from a ruff fix object. */
157
+ function extractFixApplicability(fix) {
158
+ if (!fix)
159
+ return undefined;
160
+ if (typeof fix === "object" && "applicability" in fix && typeof fix.applicability === "string") {
161
+ const val = fix.applicability.toLowerCase();
162
+ if (val === "safe" || val === "unsafe" || val === "display") {
163
+ return val;
164
+ }
165
+ }
166
+ return undefined;
75
167
  }
76
168
  /** Parses `pip-audit --format json` output into structured vulnerability data with fix versions. */
77
- export function parsePipAuditJson(stdout) {
169
+ export function parsePipAuditJson(stdout, exitCode) {
78
170
  let data;
79
171
  try {
80
172
  data = JSON.parse(stdout);
81
173
  }
82
174
  catch {
83
- return { vulnerabilities: [], total: 0 };
84
- }
85
- const vulnerabilities = (data.dependencies ?? []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
86
- name: dep.name,
87
- version: dep.version,
88
- id: v.id,
89
- description: v.description ?? "",
90
- fixVersions: v.fix_versions ?? [],
91
- })));
92
- return { vulnerabilities, total: vulnerabilities.length };
175
+ return { success: exitCode === 0, vulnerabilities: [], total: 0 };
176
+ }
177
+ const byPackage = [];
178
+ const skipped = [];
179
+ const vulnerabilities = (data.dependencies ?? []).flatMap((dep) => {
180
+ if (dep.skip_reason) {
181
+ skipped.push({ name: dep.name, reason: dep.skip_reason });
182
+ return [];
183
+ }
184
+ const packageVulns = (dep.vulns ?? []).map((v) => ({
185
+ name: dep.name,
186
+ version: dep.version,
187
+ id: v.id,
188
+ description: v.description ?? "",
189
+ fixVersions: v.fix_versions ?? [],
190
+ aliases: v.aliases && v.aliases.length > 0 ? v.aliases : undefined,
191
+ url: v.url || undefined,
192
+ severity: v.severity || undefined,
193
+ cvssScore: v.cvss_score != null ? v.cvss_score : undefined,
194
+ }));
195
+ if (packageVulns.length > 0) {
196
+ byPackage.push({
197
+ name: dep.name,
198
+ version: dep.version,
199
+ vulnerabilities: packageVulns,
200
+ });
201
+ }
202
+ return packageVulns;
203
+ });
204
+ return {
205
+ success: exitCode === 0,
206
+ vulnerabilities,
207
+ byPackage: byPackage.length > 0 ? byPackage : undefined,
208
+ skipped: skipped.length > 0 ? skipped : undefined,
209
+ total: vulnerabilities.length,
210
+ };
93
211
  }
94
212
  const PYTEST_DURATION_RE = /in ([\d.]+)s/;
95
213
  function extractCount(line, label) {
96
214
  const m = line.match(label);
97
215
  return m ? parseInt(m[1], 10) : 0;
98
216
  }
99
- /** Parses pytest output into structured test results with pass/fail/error/skip counts and failure details. */
217
+ /** Parses pytest output into structured test results with pass/fail/error/skip/warning counts and failure details. */
100
218
  export function parsePytestOutput(stdout, stderr, exitCode) {
101
219
  const output = stdout + "\n" + stderr;
102
220
  const lines = output.split("\n");
@@ -105,14 +223,16 @@ export function parsePytestOutput(stdout, stderr, exitCode) {
105
223
  let failed = 0;
106
224
  let errors = 0;
107
225
  let skipped = 0;
226
+ let warnings = 0;
108
227
  let duration = 0;
109
228
  for (const line of lines) {
110
229
  // Match lines containing pytest summary markers (== ... in Xs ==) or standalone counts
111
- if (/\d+ (?:passed|failed|error|skipped)/.test(line) || /in [\d.]+s/.test(line)) {
230
+ if (/\d+ (?:passed|failed|error|skipped|warning)/.test(line) || /in [\d.]+s/.test(line)) {
112
231
  passed = Math.max(passed, extractCount(line, /(\d+) passed/));
113
232
  failed = Math.max(failed, extractCount(line, /(\d+) failed/));
114
233
  errors = Math.max(errors, extractCount(line, /(\d+) errors?/));
115
234
  skipped = Math.max(skipped, extractCount(line, /(\d+) skipped/));
235
+ warnings = Math.max(warnings, extractCount(line, /(\d+) warnings?/));
116
236
  const durationMatch = line.match(PYTEST_DURATION_RE);
117
237
  if (durationMatch) {
118
238
  duration = parseFloat(durationMatch[1]);
@@ -128,6 +248,7 @@ export function parsePytestOutput(stdout, stderr, exitCode) {
128
248
  failed: 0,
129
249
  errors: 0,
130
250
  skipped: 0,
251
+ warnings,
131
252
  total: 0,
132
253
  duration,
133
254
  failures: [],
@@ -168,6 +289,7 @@ export function parsePytestOutput(stdout, stderr, exitCode) {
168
289
  failed,
169
290
  errors,
170
291
  skipped,
292
+ warnings,
171
293
  total,
172
294
  duration,
173
295
  failures,
@@ -176,7 +298,10 @@ export function parsePytestOutput(stdout, stderr, exitCode) {
176
298
  // Matches uv install output lines like: " + package==version" or "Installed N packages in Ns"
177
299
  const UV_INSTALLED_PKG_RE = /^\s*\+\s+(\S+)==(\S+)/;
178
300
  const UV_SUMMARY_RE = /Installed (\d+) packages? in ([\d.]+)/;
179
- /** Parses uv pip install output into structured data with installed packages. */
301
+ // Matches uv resolution conflict lines like: "`package>=1.0` and `package<1.0`"
302
+ const UV_CONFLICT_RE = /`([a-zA-Z0-9_-]+)\s*([^`]*)`/g;
303
+ /** Parses uv pip install output into structured data with installed packages.
304
+ * On failure, extracts resolution conflict information from stderr. */
180
305
  export function parseUvInstall(stdout, stderr, exitCode) {
181
306
  const output = stdout + "\n" + stderr;
182
307
  const lines = output.split("\n");
@@ -192,19 +317,90 @@ export function parseUvInstall(stdout, stderr, exitCode) {
192
317
  duration = parseFloat(summaryMatch[2]);
193
318
  }
194
319
  }
320
+ // On failure, extract resolution error details from stderr
321
+ let error;
322
+ let resolutionConflicts;
323
+ if (exitCode !== 0) {
324
+ const stderrTrimmed = stderr.trim();
325
+ if (stderrTrimmed) {
326
+ error = stderrTrimmed;
327
+ }
328
+ // Check for resolution/version conflict errors
329
+ if (stderr.includes("version solving failed") ||
330
+ stderr.includes("Cannot install") ||
331
+ stderr.includes("conflict") ||
332
+ stderr.includes("incompatible")) {
333
+ const conflicts = [];
334
+ const seen = new Set();
335
+ for (const line of stderr.split("\n")) {
336
+ // Reset lastIndex for global regex
337
+ UV_CONFLICT_RE.lastIndex = 0;
338
+ let match;
339
+ while ((match = UV_CONFLICT_RE.exec(line)) !== null) {
340
+ const pkg = match[1];
341
+ const constraint = match[2].trim();
342
+ const key = `${pkg}:${constraint}`;
343
+ if (!seen.has(key) && constraint) {
344
+ seen.add(key);
345
+ conflicts.push({ package: pkg, constraint });
346
+ }
347
+ }
348
+ }
349
+ if (conflicts.length > 0) {
350
+ resolutionConflicts = conflicts;
351
+ }
352
+ }
353
+ }
354
+ const alreadySatisfied = exitCode === 0 &&
355
+ installed.length === 0 &&
356
+ /(already installed|already satisfied|audited \d+ packages|nothing to install)/i.test(output);
195
357
  return {
196
358
  success: exitCode === 0,
197
359
  installed,
198
360
  total: installed.length,
199
361
  duration,
362
+ alreadySatisfied,
363
+ error,
364
+ resolutionConflicts,
200
365
  };
201
366
  }
202
367
  /** Parses uv run output into structured result with exit code, stdout, stderr. */
203
- export function parseUvRun(stdout, stderr, exitCode, durationMs) {
368
+ export function parseUvRun(stdout, stderr, exitCode, durationMs, options) {
369
+ const uvDiagnosticLines = [];
370
+ const commandErrLines = [];
371
+ for (const line of stderr.split("\n")) {
372
+ const trimmed = line.trim();
373
+ if (/^(Resolved|Prepared|Installed|Uninstalled|Audited)\b/.test(trimmed) ||
374
+ /^Using (?:Python|CPython)\b/.test(trimmed) ||
375
+ /^Downloading\b/.test(trimmed)) {
376
+ if (trimmed)
377
+ uvDiagnosticLines.push(trimmed);
378
+ }
379
+ else {
380
+ commandErrLines.push(line);
381
+ }
382
+ }
383
+ let outputStdout = stdout;
384
+ let outputStderr = commandErrLines.join("\n");
385
+ let truncated = false;
386
+ const maxOutputChars = options?.maxOutputChars ?? 0;
387
+ if (maxOutputChars > 0) {
388
+ if (outputStdout.length > maxOutputChars) {
389
+ outputStdout = outputStdout.slice(0, maxOutputChars) + "…";
390
+ truncated = true;
391
+ }
392
+ if (outputStderr.length > maxOutputChars) {
393
+ outputStderr = outputStderr.slice(0, maxOutputChars) + "…";
394
+ truncated = true;
395
+ }
396
+ }
204
397
  return {
205
398
  exitCode,
206
- stdout,
207
- stderr,
399
+ stdout: outputStdout,
400
+ stderr: outputStderr,
401
+ commandStderr: outputStderr || undefined,
402
+ uvDiagnostics: uvDiagnosticLines.length > 0 ? uvDiagnosticLines : undefined,
403
+ truncated: truncated || undefined,
208
404
  success: exitCode === 0,
209
405
  duration: Math.round(durationMs) / 1000,
210
406
  };
@@ -218,6 +414,7 @@ export function parseBlackOutput(stdout, stderr, exitCode) {
218
414
  const output = stdout + "\n" + stderr;
219
415
  const lines = output.split("\n");
220
416
  const wouldReformat = [];
417
+ const diagnostics = [];
221
418
  let filesChanged = 0;
222
419
  let filesUnchanged = 0;
223
420
  for (const line of lines) {
@@ -229,6 +426,15 @@ export function parseBlackOutput(stdout, stderr, exitCode) {
229
426
  if (reformattedMatch) {
230
427
  wouldReformat.push(reformattedMatch[1].trim());
231
428
  }
429
+ const parseDiagMatch = line.match(/cannot format (.+?): Cannot parse: (\d+):(\d+): (.+)$/i);
430
+ if (parseDiagMatch) {
431
+ diagnostics.push({
432
+ file: parseDiagMatch[1],
433
+ line: parseInt(parseDiagMatch[2], 10),
434
+ column: parseInt(parseDiagMatch[3], 10),
435
+ message: parseDiagMatch[4],
436
+ });
437
+ }
232
438
  }
233
439
  // Parse summary line
234
440
  for (const line of lines) {
@@ -251,38 +457,69 @@ export function parseBlackOutput(stdout, stderr, exitCode) {
251
457
  const filesChecked = filesChanged + filesUnchanged;
252
458
  // In check mode, success means no files need reformatting
253
459
  // In format mode, success is always true (exitCode 0) unless black errors
254
- // exitCode 1 in check mode = files would be reformatted
255
- // exitCode 123 = internal error
460
+ // exitCode 1 in check mode = files would be reformatted ("check_failed")
461
+ // exitCode 123 = internal error (parse error or crash)
256
462
  const success = exitCode === 0;
463
+ let errorType;
464
+ if (exitCode === 1) {
465
+ errorType = "check_failed";
466
+ }
467
+ else if (exitCode === 123) {
468
+ errorType = "internal_error";
469
+ }
257
470
  return {
258
471
  filesChanged,
259
472
  filesUnchanged,
260
473
  filesChecked,
261
474
  success,
475
+ exitCode: exitCode !== 0 ? exitCode : undefined,
476
+ errorType,
477
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
262
478
  wouldReformat,
263
479
  };
264
480
  }
265
- /** Parses `pip list --format json` output into structured package list. */
266
- export function parsePipListJson(stdout) {
481
+ /** Parses `pip list --format json` output into structured package list.
482
+ * When outdated=true, also parses latestVersion and latestFiletype fields.
483
+ * On JSON parse failure, surfaces error with raw output for debugging. */
484
+ export function parsePipListJson(stdout, exitCode, outdated) {
267
485
  let entries;
268
486
  try {
269
487
  entries = JSON.parse(stdout);
270
488
  }
271
- catch {
272
- return { packages: [], total: 0 };
489
+ catch (err) {
490
+ const errorMessage = err instanceof Error ? err.message : "Failed to parse pip list JSON output";
491
+ // Truncate raw output to 500 chars to avoid bloating the response
492
+ const rawOutput = stdout.length > 500 ? stdout.slice(0, 500) + "..." : stdout;
493
+ return {
494
+ success: false,
495
+ packages: [],
496
+ total: 0,
497
+ error: errorMessage,
498
+ rawOutput: rawOutput || undefined,
499
+ };
273
500
  }
274
501
  if (!Array.isArray(entries)) {
275
- return { packages: [], total: 0 };
502
+ return {
503
+ success: false,
504
+ packages: [],
505
+ total: 0,
506
+ error: "pip list output is not a JSON array",
507
+ rawOutput: stdout.length > 500 ? stdout.slice(0, 500) + "..." : stdout,
508
+ };
276
509
  }
277
510
  const packages = entries.map((e) => ({
278
511
  name: e.name,
279
512
  version: e.version,
513
+ location: e.location || undefined,
514
+ editableProject: e.editable_project != null ? true : undefined,
515
+ ...(outdated && e.latest_version ? { latestVersion: e.latest_version } : {}),
516
+ ...(outdated && e.latest_filetype ? { latestFiletype: e.latest_filetype } : {}),
280
517
  }));
281
- return { packages, total: packages.length };
518
+ return { success: exitCode === 0, packages, total: packages.length };
282
519
  }
283
- /** Parses `pip show <package>` key-value output into structured package metadata. */
284
- export function parsePipShowOutput(stdout) {
285
- const lines = stdout.split("\n");
520
+ /** Parses a single block of `pip show` key-value output into a package info object. */
521
+ function parseSinglePipShowBlock(block) {
522
+ const lines = block.split("\n");
286
523
  const data = {};
287
524
  for (const line of lines) {
288
525
  const colonIdx = line.indexOf(": ");
@@ -295,15 +532,62 @@ export function parsePipShowOutput(stdout) {
295
532
  const requires = data["Requires"] && data["Requires"].trim()
296
533
  ? data["Requires"].split(",").map((r) => r.trim())
297
534
  : [];
535
+ const requiredBy = data["Required-by"] && data["Required-by"].trim()
536
+ ? data["Required-by"].split(",").map((r) => r.trim())
537
+ : [];
538
+ const classifiers = data["Classifier"] && data["Classifier"].trim()
539
+ ? data["Classifier"].split(",").map((c) => c.trim())
540
+ : [];
298
541
  return {
299
542
  name: data["Name"] || "",
300
543
  version: data["Version"] || "",
301
544
  summary: data["Summary"] || "",
302
545
  homepage: data["Home-page"] || undefined,
303
546
  author: data["Author"] || undefined,
547
+ authorEmail: data["Author-email"] || undefined,
304
548
  license: data["License"] || undefined,
305
549
  location: data["Location"] || undefined,
306
550
  requires,
551
+ requiredBy: requiredBy.length > 0 ? requiredBy : undefined,
552
+ metadataVersion: data["Metadata-Version"] || undefined,
553
+ classifiers: classifiers.length > 0 ? classifiers : undefined,
554
+ };
555
+ }
556
+ /** Parses `pip show <package>` key-value output into structured package metadata.
557
+ * Supports multiple packages separated by `---` delimiter. */
558
+ export function parsePipShowOutput(stdout, exitCode) {
559
+ // Split on `---` separator for multi-package output
560
+ const blocks = stdout
561
+ .split(/^---$/m)
562
+ .map((b) => b.trim())
563
+ .filter(Boolean);
564
+ const packages = blocks.map(parseSinglePipShowBlock);
565
+ // Use first package for backward-compatible top-level fields
566
+ const first = packages.length > 0
567
+ ? packages[0]
568
+ : {
569
+ name: "",
570
+ version: "",
571
+ summary: "",
572
+ requires: [],
573
+ };
574
+ const hasName = Boolean(first.name);
575
+ return {
576
+ success: exitCode === 0 && hasName,
577
+ packages,
578
+ // Backward-compat top-level fields from first package
579
+ name: first.name,
580
+ version: first.version,
581
+ summary: first.summary,
582
+ homepage: first.homepage,
583
+ author: first.author,
584
+ authorEmail: first.authorEmail,
585
+ license: first.license,
586
+ location: first.location,
587
+ requires: first.requires,
588
+ requiredBy: first.requiredBy,
589
+ metadataVersion: first.metadataVersion,
590
+ classifiers: first.classifiers,
307
591
  };
308
592
  }
309
593
  /** Parses `pyenv` command output into structured version management data. */
@@ -347,6 +631,16 @@ export function parsePyenvOutput(stdout, stderr, exitCode, action) {
347
631
  .trim();
348
632
  return { action, success: true, current: ver || undefined };
349
633
  }
634
+ case "installList": {
635
+ const availableVersions = [];
636
+ for (const line of stdout.split("\n")) {
637
+ const trimmed = line.trim();
638
+ if (trimmed && !trimmed.startsWith("Available versions:") && !trimmed.startsWith("--")) {
639
+ availableVersions.push(trimmed);
640
+ }
641
+ }
642
+ return { action, success: true, availableVersions };
643
+ }
350
644
  case "install": {
351
645
  // pyenv install outputs to stderr typically
352
646
  const installed = output.match(/Installed Python-(\S+)/)?.[1] || output.match(/Installing Python-(\S+)/)?.[1];
@@ -356,34 +650,73 @@ export function parsePyenvOutput(stdout, stderr, exitCode, action) {
356
650
  installed: installed || undefined,
357
651
  };
358
652
  }
653
+ case "uninstall": {
654
+ // pyenv uninstall -f <version>
655
+ // On success, output may contain "pyenv: remove <version>" or similar
656
+ const version = output.match(/remove\s+(\S+)/)?.[1] ||
657
+ output.match(/uninstall(?:ed)?\s+(\S+)/i)?.[1] ||
658
+ // If no match, try extracting from stderr which often has the version
659
+ stderr.match(/(\d+\.\d+\.\d+\S*)/)?.[1] ||
660
+ stdout.match(/(\d+\.\d+\.\d+\S*)/)?.[1];
661
+ return {
662
+ action,
663
+ success: true,
664
+ uninstalled: version || undefined,
665
+ };
666
+ }
359
667
  case "local": {
360
668
  return { action, success: true, localVersion: stdout.trim() || undefined };
361
669
  }
362
670
  case "global": {
363
671
  return { action, success: true, globalVersion: stdout.trim() || undefined };
364
672
  }
673
+ case "which": {
674
+ const commandPath = stdout.trim();
675
+ return { action, success: true, commandPath: commandPath || undefined };
676
+ }
677
+ case "rehash": {
678
+ return { action, success: true };
679
+ }
365
680
  }
366
681
  }
367
- const RUFF_FORMAT_FILE_RE = /^(?:Would reformat|reformatted): (.+)$/;
368
- /** Parses `ruff format` output into structured result with file counts. */
682
+ const RUFF_FORMAT_WOULD_RE = /^Would reformat: (.+)$/;
683
+ const RUFF_FORMAT_DID_RE = /^reformatted: (.+)$/;
684
+ /** Parses `ruff format` output into structured result with file counts.
685
+ * Distinguishes check mode ("Would reformat") from fix mode ("reformatted"). */
369
686
  export function parseRuffFormatOutput(stdout, stderr, exitCode) {
370
687
  // ruff format writes file-level output to stderr
371
688
  const output = stdout + "\n" + stderr;
372
689
  const lines = output.split("\n");
373
690
  const files = [];
691
+ let checkMode = false;
374
692
  for (const line of lines) {
375
- const match = line.match(RUFF_FORMAT_FILE_RE);
376
- if (match) {
377
- files.push(match[1].trim());
693
+ const wouldMatch = line.match(RUFF_FORMAT_WOULD_RE);
694
+ if (wouldMatch) {
695
+ files.push(wouldMatch[1].trim());
696
+ checkMode = true;
697
+ continue;
698
+ }
699
+ const didMatch = line.match(RUFF_FORMAT_DID_RE);
700
+ if (didMatch) {
701
+ files.push(didMatch[1].trim());
378
702
  }
379
703
  }
704
+ // Also detect check mode from summary line
705
+ if (output.includes("would be reformatted")) {
706
+ checkMode = true;
707
+ }
380
708
  // Parse summary line: "N files reformatted" or "N files would be reformatted" or "N files left unchanged"
381
709
  const reformattedMatch = output.match(/(\d+) files? (?:would be )?reformatted/);
382
710
  const filesChanged = reformattedMatch ? parseInt(reformattedMatch[1], 10) : files.length;
711
+ // Parse unchanged count: "N files left unchanged" or "N files would be left unchanged"
712
+ const unchangedMatch = output.match(/(\d+) files? (?:would be )?left unchanged/);
713
+ const filesUnchanged = unchangedMatch ? parseInt(unchangedMatch[1], 10) : 0;
383
714
  return {
384
715
  success: exitCode === 0,
385
716
  filesChanged: filesChanged || files.length,
717
+ filesUnchanged,
386
718
  files: files.length > 0 ? files : undefined,
719
+ checkMode,
387
720
  };
388
721
  }
389
722
  /** Parses `conda list --json` output into structured package list. */
@@ -392,11 +725,18 @@ export function parseCondaListJson(stdout, envName) {
392
725
  try {
393
726
  entries = JSON.parse(stdout);
394
727
  }
395
- catch {
396
- return { action: "list", packages: [], total: 0, environment: envName };
728
+ catch (err) {
729
+ const parseError = err instanceof Error ? err.message : "Failed to parse conda list JSON output";
730
+ return { action: "list", packages: [], total: 0, environment: envName, parseError };
397
731
  }
398
732
  if (!Array.isArray(entries)) {
399
- return { action: "list", packages: [], total: 0, environment: envName };
733
+ return {
734
+ action: "list",
735
+ packages: [],
736
+ total: 0,
737
+ environment: envName,
738
+ parseError: "conda list output is not a JSON array",
739
+ };
400
740
  }
401
741
  const packages = entries.map((e) => ({
402
742
  name: e.name,
@@ -412,7 +752,8 @@ export function parseCondaInfoJson(stdout) {
412
752
  try {
413
753
  data = JSON.parse(stdout);
414
754
  }
415
- catch {
755
+ catch (err) {
756
+ const parseError = err instanceof Error ? err.message : "Failed to parse conda info JSON output";
416
757
  return {
417
758
  action: "info",
418
759
  condaVersion: "",
@@ -422,6 +763,7 @@ export function parseCondaInfoJson(stdout) {
422
763
  channels: [],
423
764
  envsDirs: [],
424
765
  pkgsDirs: [],
766
+ parseError,
425
767
  };
426
768
  }
427
769
  return {
@@ -442,8 +784,9 @@ export function parseCondaEnvListJson(stdout, activePrefix) {
442
784
  try {
443
785
  data = JSON.parse(stdout);
444
786
  }
445
- catch {
446
- return { action: "env-list", environments: [], total: 0 };
787
+ catch (err) {
788
+ const parseError = err instanceof Error ? err.message : "Failed to parse conda env list JSON output";
789
+ return { action: "env-list", environments: [], total: 0, parseError };
447
790
  }
448
791
  const envs = (data.envs ?? []).map((envPath) => {
449
792
  // Extract name from the last path segment, or "base" for the root env
@@ -457,9 +800,104 @@ export function parseCondaEnvListJson(stdout, activePrefix) {
457
800
  });
458
801
  return { action: "env-list", environments: envs, total: envs.length };
459
802
  }
803
+ /** Parses `conda create/remove/update --json` output into structured mutation summaries. */
804
+ export function parseCondaMutationJson(stdout, stderr, action, environment, prefix) {
805
+ let data;
806
+ try {
807
+ data = stdout.trim() ? JSON.parse(stdout) : {};
808
+ }
809
+ catch (err) {
810
+ const parseError = err instanceof Error ? err.message : `Failed to parse conda ${action} JSON output`;
811
+ if (action === "create") {
812
+ return {
813
+ action,
814
+ success: false,
815
+ environment,
816
+ prefix,
817
+ totalAdded: 0,
818
+ error: stderr.trim() || undefined,
819
+ parseError,
820
+ };
821
+ }
822
+ if (action === "remove") {
823
+ return {
824
+ action,
825
+ success: false,
826
+ environment,
827
+ prefix,
828
+ totalRemoved: 0,
829
+ error: stderr.trim() || undefined,
830
+ parseError,
831
+ };
832
+ }
833
+ return {
834
+ action,
835
+ success: false,
836
+ environment,
837
+ prefix,
838
+ totalUpdated: 0,
839
+ error: stderr.trim() || undefined,
840
+ parseError,
841
+ };
842
+ }
843
+ const linked = (data.actions?.LINK ?? []).map((p) => ({
844
+ name: p.name ?? "",
845
+ version: p.version ?? undefined,
846
+ channel: p.channel ?? undefined,
847
+ buildString: p.build_string ?? undefined,
848
+ }));
849
+ const unlinked = (data.actions?.UNLINK ?? []).map((p) => ({
850
+ name: p.name ?? "",
851
+ version: p.version ?? undefined,
852
+ channel: p.channel ?? undefined,
853
+ buildString: p.build_string ?? undefined,
854
+ }));
855
+ const success = data.success !== false && !data.error;
856
+ const outPrefix = data.prefix ?? prefix;
857
+ const error = data.error ?? (success ? undefined : stderr.trim() || undefined);
858
+ if (action === "create") {
859
+ return {
860
+ action,
861
+ success,
862
+ environment,
863
+ prefix: outPrefix,
864
+ addedPackages: linked.length > 0 ? linked : undefined,
865
+ totalAdded: linked.length,
866
+ error,
867
+ };
868
+ }
869
+ if (action === "remove") {
870
+ return {
871
+ action,
872
+ success,
873
+ environment,
874
+ prefix: outPrefix,
875
+ removedPackages: unlinked.length > 0 ? unlinked : undefined,
876
+ totalRemoved: unlinked.length,
877
+ error,
878
+ };
879
+ }
880
+ const updatedPackages = linked.filter((l) => unlinked.some((u) => u.name === l.name));
881
+ const addedPackages = linked.filter((l) => !unlinked.some((u) => u.name === l.name));
882
+ const removedPackages = unlinked.filter((u) => !linked.some((l) => l.name === u.name));
883
+ return {
884
+ action,
885
+ success,
886
+ environment,
887
+ prefix: outPrefix,
888
+ updatedPackages: updatedPackages.length > 0 ? updatedPackages : undefined,
889
+ addedPackages: addedPackages.length > 0 ? addedPackages : undefined,
890
+ removedPackages: removedPackages.length > 0 ? removedPackages : undefined,
891
+ totalUpdated: updatedPackages.length + addedPackages.length + removedPackages.length,
892
+ error,
893
+ };
894
+ }
460
895
  // ─── poetry parsers ──────────────────────────────────────────────────────────
461
- /** Regex matching `poetry show --no-ansi` lines: "package-name version description" */
462
- const POETRY_SHOW_RE = /^(\S+)\s+(\S+)/;
896
+ /** Strict regex matching `poetry show --no-ansi` lines: "package-name version description"
897
+ * Anchored to start, requires package name to start with a letter/digit, and version
898
+ * to look like a version string (digit-prefixed). This avoids matching status lines,
899
+ * blank lines, or other non-package output. */
900
+ const POETRY_SHOW_RE = /^([a-zA-Z0-9][a-zA-Z0-9._-]*)\s+(\d[a-zA-Z0-9._-]*)(?:\s+(.+))?$/;
463
901
  /** Regex matching `poetry build` artifact lines: " - Built package-1.0.0.tar.gz" or " Built package-1.0.0-py3-none-any.whl" */
464
902
  const POETRY_BUILT_RE = /Built\s+(\S+)/;
465
903
  /** Regex matching `poetry add/remove` installed/removed lines with version e.g. " - Installing requests (2.31.0)" */
@@ -471,9 +909,17 @@ export function parsePoetryOutput(stdout, stderr, exitCode, action) {
471
909
  if (action === "show") {
472
910
  const packages = [];
473
911
  for (const line of lines) {
474
- const match = line.match(POETRY_SHOW_RE);
912
+ const trimmed = line.trim();
913
+ // Skip empty lines and lines that don't look like package entries
914
+ if (!trimmed)
915
+ continue;
916
+ const match = trimmed.match(POETRY_SHOW_RE);
475
917
  if (match) {
476
- packages.push({ name: match[1], version: match[2] });
918
+ packages.push({
919
+ name: match[1],
920
+ version: match[2],
921
+ description: match[3]?.trim() || undefined,
922
+ });
477
923
  }
478
924
  }
479
925
  return {
@@ -498,7 +944,16 @@ export function parsePoetryOutput(stdout, stderr, exitCode, action) {
498
944
  total: artifacts.length,
499
945
  };
500
946
  }
501
- // install, add, remove — parse installed/updated/removed packages
947
+ if (action === "check" || action === "lock" || action === "export") {
948
+ const messages = lines.map((l) => l.trim()).filter(Boolean);
949
+ return {
950
+ success: exitCode === 0,
951
+ action,
952
+ messages: messages.length > 0 ? messages : undefined,
953
+ total: messages.length,
954
+ };
955
+ }
956
+ // install, add, remove, update — parse installed/updated/removed packages
502
957
  const packages = [];
503
958
  for (const line of lines) {
504
959
  const match = line.match(POETRY_INSTALL_LINE_RE);