@mcptoolshop/venvkit 0.2.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/dist/doctorLite.d.ts +75 -0
  4. package/dist/doctorLite.d.ts.map +1 -0
  5. package/dist/doctorLite.js +705 -0
  6. package/dist/doctorLite.js.map +1 -0
  7. package/dist/doctorLite.test.d.ts +2 -0
  8. package/dist/doctorLite.test.d.ts.map +1 -0
  9. package/dist/doctorLite.test.js +268 -0
  10. package/dist/doctorLite.test.js.map +1 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +6 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/integration.test.d.ts +2 -0
  16. package/dist/integration.test.d.ts.map +1 -0
  17. package/dist/integration.test.js +245 -0
  18. package/dist/integration.test.js.map +1 -0
  19. package/dist/mapRender.d.ts +105 -0
  20. package/dist/mapRender.d.ts.map +1 -0
  21. package/dist/mapRender.js +718 -0
  22. package/dist/mapRender.js.map +1 -0
  23. package/dist/mapRender.test.d.ts +2 -0
  24. package/dist/mapRender.test.d.ts.map +1 -0
  25. package/dist/mapRender.test.js +571 -0
  26. package/dist/mapRender.test.js.map +1 -0
  27. package/dist/map_cli.d.ts +3 -0
  28. package/dist/map_cli.d.ts.map +1 -0
  29. package/dist/map_cli.js +278 -0
  30. package/dist/map_cli.js.map +1 -0
  31. package/dist/map_cli.test.d.ts +2 -0
  32. package/dist/map_cli.test.d.ts.map +1 -0
  33. package/dist/map_cli.test.js +276 -0
  34. package/dist/map_cli.test.js.map +1 -0
  35. package/dist/runLog.d.ts +71 -0
  36. package/dist/runLog.d.ts.map +1 -0
  37. package/dist/runLog.js +98 -0
  38. package/dist/runLog.js.map +1 -0
  39. package/dist/runLog.test.d.ts +2 -0
  40. package/dist/runLog.test.d.ts.map +1 -0
  41. package/dist/runLog.test.js +327 -0
  42. package/dist/runLog.test.js.map +1 -0
  43. package/dist/scanEnvPaths.d.ts +18 -0
  44. package/dist/scanEnvPaths.d.ts.map +1 -0
  45. package/dist/scanEnvPaths.js +174 -0
  46. package/dist/scanEnvPaths.js.map +1 -0
  47. package/dist/scanEnvPaths.test.d.ts +2 -0
  48. package/dist/scanEnvPaths.test.d.ts.map +1 -0
  49. package/dist/scanEnvPaths.test.js +250 -0
  50. package/dist/scanEnvPaths.test.js.map +1 -0
  51. package/dist/taskCluster.d.ts +62 -0
  52. package/dist/taskCluster.d.ts.map +1 -0
  53. package/dist/taskCluster.js +180 -0
  54. package/dist/taskCluster.js.map +1 -0
  55. package/dist/taskCluster.test.d.ts +2 -0
  56. package/dist/taskCluster.test.d.ts.map +1 -0
  57. package/dist/taskCluster.test.js +375 -0
  58. package/dist/taskCluster.test.js.map +1 -0
  59. package/dist/vitest.config.d.ts +3 -0
  60. package/dist/vitest.config.d.ts.map +1 -0
  61. package/dist/vitest.config.js +8 -0
  62. package/dist/vitest.config.js.map +1 -0
  63. package/package.json +58 -0
@@ -0,0 +1,705 @@
1
+ // venvkit/doctorLite.ts
2
+ // Doctor-lite runner: fast, deterministic environment checks for a given Python interpreter.
3
+ // - Runs ~2–4 subprocesses (facts, pip, imports, optional https probe)
4
+ // - Produces findings (what/why/fix), score (0–100), status (good/warn/bad/unknown)
5
+ // - Designed to feed router + venv map
6
+ import { spawn } from "node:child_process";
7
+ import * as fs from "node:fs/promises";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+ function mergeEnv(base, overrides) {
14
+ return { ...base, ...(overrides ?? {}) };
15
+ }
16
+ /**
17
+ * Run a subprocess with timeout, capturing stdout/stderr.
18
+ */
19
+ export async function runCmd(cmd, args, opts) {
20
+ return new Promise((resolve) => {
21
+ const child = spawn(cmd, args, {
22
+ env: opts.env,
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ windowsHide: true,
25
+ });
26
+ let stdout = "";
27
+ let stderr = "";
28
+ let timedOut = false;
29
+ child.stdout.setEncoding("utf8");
30
+ child.stderr.setEncoding("utf8");
31
+ child.stdout.on("data", (d) => (stdout += d));
32
+ child.stderr.on("data", (d) => (stderr += d));
33
+ const timer = setTimeout(() => {
34
+ timedOut = true;
35
+ try {
36
+ child.kill();
37
+ }
38
+ catch { }
39
+ }, opts.timeoutMs);
40
+ child.on("close", (code) => {
41
+ clearTimeout(timer);
42
+ resolve({
43
+ ok: !timedOut && code === 0,
44
+ exitCode: code,
45
+ stdout,
46
+ stderr,
47
+ timedOut,
48
+ });
49
+ });
50
+ child.on("error", (err) => {
51
+ clearTimeout(timer);
52
+ resolve({
53
+ ok: false,
54
+ exitCode: null,
55
+ stdout,
56
+ stderr: stderr + String(err),
57
+ timedOut,
58
+ });
59
+ });
60
+ });
61
+ }
62
+ function jsonOrNull(s) {
63
+ try {
64
+ return JSON.parse(s);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function classifyImportFailure(text) {
71
+ const t = text.toLowerCase();
72
+ // Windows
73
+ if (t.includes("dll load failed"))
74
+ return "DLL_LOAD_FAIL";
75
+ if (t.includes("winerror 193") || t.includes("not a valid win32 application"))
76
+ return "ABI_MISMATCH";
77
+ if (t.includes("winerror 126") || t.includes("the specified module could not be found"))
78
+ return "DLL_LOAD_FAIL";
79
+ if (t.includes("winerror 127") || t.includes("procedure could not be found"))
80
+ return "ABI_MISMATCH";
81
+ // Linux
82
+ if (t.includes("cannot open shared object file"))
83
+ return "DLL_LOAD_FAIL";
84
+ if (t.includes("glibc_") || t.includes("undefined symbol"))
85
+ return "ABI_MISMATCH";
86
+ // macOS - general
87
+ if (t.includes("library not loaded"))
88
+ return "DLL_LOAD_FAIL";
89
+ if (t.includes("symbol not found"))
90
+ return "ABI_MISMATCH";
91
+ if (t.includes("incompatible architecture") || t.includes("mach-o"))
92
+ return "ABI_MISMATCH";
93
+ // macOS ARM-specific patterns
94
+ if (t.includes("mach-o file, but is an incompatible architecture"))
95
+ return "ABI_MISMATCH";
96
+ if (t.includes("bad cpu type in executable"))
97
+ return "ABI_MISMATCH";
98
+ return "IMPORT_FAIL";
99
+ }
100
+ function clampScoreForHardFails(score, findings) {
101
+ const codes = new Set(findings.map((f) => f.code));
102
+ if (codes.has("PYTHON_EXEC_MISSING"))
103
+ return 0;
104
+ if (codes.has("ARCH_MISMATCH"))
105
+ return Math.min(score, 15);
106
+ if (codes.has("DLL_LOAD_FAIL") || codes.has("ABI_MISMATCH"))
107
+ return Math.min(score, 25);
108
+ if (codes.has("SUBPROCESS_BROKEN"))
109
+ return Math.min(score, 25);
110
+ return score;
111
+ }
112
+ function computeStatus(score, findings) {
113
+ if (findings.some((f) => f.severity === "bad"))
114
+ return "bad";
115
+ if (score >= 85)
116
+ return "good";
117
+ if (score >= 60)
118
+ return "warn";
119
+ return "bad";
120
+ }
121
+ function scoreReport(findings) {
122
+ const penalty = findings.reduce((sum, f) => sum + (f.penalty ?? 0), 0);
123
+ let score = Math.max(0, 100 - penalty);
124
+ score = clampScoreForHardFails(score, findings);
125
+ const status = computeStatus(score, findings);
126
+ return { score, status };
127
+ }
128
+ function baseFixRecreateVenv(pythonPath) {
129
+ return [
130
+ `If this is a venv, consider recreating it:`,
131
+ ` 1) delete the venv directory`,
132
+ ` 2) recreate: "${pythonPath}" -m venv .venv`,
133
+ ` 3) upgrade tooling: ".venv/bin/python" -m pip install -U pip setuptools wheel`,
134
+ ];
135
+ }
136
+ function mkFinding(partial) {
137
+ return {
138
+ ...partial,
139
+ fix: partial.fix ??
140
+ [
141
+ {
142
+ title: "Safe",
143
+ steps: baseFixRecreateVenv("<python>"),
144
+ },
145
+ ],
146
+ };
147
+ }
148
+ /**
149
+ * Validate pyvenv.cfg for a venv interpreter.
150
+ * Checks that the file exists and contains a valid 'home' line.
151
+ */
152
+ async function validatePyvenvCfg(pythonPath) {
153
+ // pythonPath: <venv>/Scripts/python.exe OR <venv>/bin/python
154
+ const dir = path.dirname(pythonPath);
155
+ // Scripts/.. or bin/..
156
+ const venvRoot = path.resolve(dir, "..");
157
+ const cfgPath = path.join(venvRoot, "pyvenv.cfg");
158
+ try {
159
+ const txt = await fs.readFile(cfgPath, "utf8");
160
+ const homeLine = txt.split(/\r?\n/).find((l) => l.toLowerCase().startsWith("home"));
161
+ if (!homeLine)
162
+ return { ok: false, cfgPath, reason: "Missing 'home =' line" };
163
+ const home = homeLine.split("=").slice(1).join("=").trim();
164
+ if (!home)
165
+ return { ok: false, cfgPath, reason: "Empty 'home' value" };
166
+ // Best-effort existence check (don't hard fail portable layouts)
167
+ try {
168
+ await fs.stat(home);
169
+ }
170
+ catch {
171
+ return { ok: false, cfgPath, reason: `home path does not exist: ${home}`, home };
172
+ }
173
+ return { ok: true, cfgPath, home };
174
+ }
175
+ catch (e) {
176
+ return { ok: false, cfgPath, reason: `Cannot read pyvenv.cfg: ${String(e)}` };
177
+ }
178
+ }
179
+ export async function doctorLite(opts, runner = runCmd) {
180
+ const timeoutMs = opts.timeoutMs ?? 6000;
181
+ // Controlled env for python probes (avoid false positives)
182
+ const controlledEnv = mergeEnv(process.env, {
183
+ ...opts.env,
184
+ // prevent user site leakage during probes (we detect it explicitly from facts)
185
+ PYTHONNOUSERSITE: "1",
186
+ // keep empty to avoid hidden path injections
187
+ PYTHONPATH: "",
188
+ PIP_DISABLE_PIP_VERSION_CHECK: "1",
189
+ });
190
+ const findings = [];
191
+ // 0) Quick existence/subprocess check
192
+ const hello = await runner(opts.pythonPath, ["-c", "print('ok')"], { env: controlledEnv, timeoutMs });
193
+ if (!hello.ok) {
194
+ findings.push(mkFinding({
195
+ code: "PYTHON_EXEC_MISSING",
196
+ severity: "bad",
197
+ penalty: 100,
198
+ what: "Python interpreter cannot be executed",
199
+ why: hello.timedOut ? "Subprocess timed out or was killed" : "Spawn failed or exited non-zero",
200
+ fix: [
201
+ {
202
+ title: "Quick",
203
+ steps: [
204
+ `Verify the path exists and is executable: ${opts.pythonPath}`,
205
+ `If this is a venv, ensure it wasn't deleted or moved.`,
206
+ `If managed by an IDE/tool, reselect the interpreter.`,
207
+ ],
208
+ },
209
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
210
+ ],
211
+ evidence: { stderr: hello.stderr, exitCode: hello.exitCode, timedOut: hello.timedOut },
212
+ }));
213
+ const { score, status } = scoreReport(findings);
214
+ return {
215
+ pythonPath: opts.pythonPath,
216
+ ranAt: nowIso(),
217
+ status,
218
+ score,
219
+ summary: "Python could not be executed.",
220
+ findings,
221
+ };
222
+ }
223
+ // 1) Facts (always)
224
+ const factsScript = "import json, sys, platform, site, struct;" +
225
+ "print(json.dumps({" +
226
+ "'version': sys.version," +
227
+ "'version_info': list(sys.version_info)," +
228
+ "'executable': sys.executable," +
229
+ "'prefix': sys.prefix," +
230
+ "'base_prefix': getattr(sys,'base_prefix',None)," +
231
+ "'bits': struct.calcsize('P')*8," +
232
+ "'machine': platform.machine()," +
233
+ "'os': platform.system().lower()," +
234
+ "'py_path': sys.path[:15]," +
235
+ "'enable_user_site': getattr(site,'ENABLE_USER_SITE',None)," +
236
+ "'user_site': site.getusersitepackages()" +
237
+ "}))";
238
+ const factsRes = await runner(opts.pythonPath, ["-c", factsScript], { env: controlledEnv, timeoutMs });
239
+ const facts = factsRes.ok ? jsonOrNull(factsRes.stdout.trim()) : null;
240
+ if (!facts) {
241
+ findings.push(mkFinding({
242
+ code: "SUBPROCESS_BROKEN",
243
+ severity: "bad",
244
+ penalty: 40,
245
+ what: "Python subprocess returned unexpected output",
246
+ why: "Could not parse JSON facts; environment may be unstable or output was intercepted",
247
+ fix: [
248
+ {
249
+ title: "Quick",
250
+ steps: [
251
+ `Try running directly:`,
252
+ ` "${opts.pythonPath}" -c "import sys; print(sys.version)"`,
253
+ `If this prints, but JSON fails, check for sitecustomize/usercustomize that prints to stdout.`,
254
+ ],
255
+ },
256
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
257
+ ],
258
+ evidence: { stdout: factsRes.stdout, stderr: factsRes.stderr },
259
+ }));
260
+ }
261
+ else {
262
+ // a) venv wiring - explicit null handling
263
+ const basePrefix = facts.base_prefix ?? "";
264
+ const isVenv = basePrefix.length > 0 && facts.prefix !== basePrefix;
265
+ if (!isVenv) {
266
+ findings.push(mkFinding({
267
+ code: "NOT_A_VENV",
268
+ severity: "info",
269
+ penalty: 10,
270
+ what: "Interpreter is not a virtual environment",
271
+ why: "sys.prefix equals sys.base_prefix",
272
+ fix: [
273
+ {
274
+ title: "Quick",
275
+ steps: [
276
+ `If you expected a venv, you are likely using the base interpreter.`,
277
+ `Create and select a venv:`,
278
+ ` "${opts.pythonPath}" -m venv .venv`,
279
+ ` ".venv\\Scripts\\python" -m pip install -U pip setuptools wheel`,
280
+ ],
281
+ },
282
+ ],
283
+ evidence: { prefix: facts.prefix, base_prefix: facts.base_prefix },
284
+ }));
285
+ }
286
+ else {
287
+ // Validate pyvenv.cfg for venvs
288
+ const cfg = await validatePyvenvCfg(opts.pythonPath);
289
+ if (!cfg.ok) {
290
+ findings.push(mkFinding({
291
+ code: "PYVENV_CFG_INVALID",
292
+ severity: "warn",
293
+ penalty: 25,
294
+ what: "pyvenv.cfg appears invalid or stale",
295
+ why: cfg.reason ?? "pyvenv.cfg validation failed",
296
+ fix: [
297
+ {
298
+ title: "Quick",
299
+ steps: [
300
+ "Recreate the venv (recommended).",
301
+ `Delete venv folder and run: "${opts.pythonPath}" -m venv .venv`,
302
+ ],
303
+ },
304
+ ],
305
+ evidence: { cfgPath: cfg.cfgPath, home: cfg.home, reason: cfg.reason },
306
+ }));
307
+ }
308
+ }
309
+ // b) PYTHONPATH injected (based on parent env)
310
+ const rawPyPath = process.env.PYTHONPATH;
311
+ if (rawPyPath && rawPyPath.trim() !== "") {
312
+ findings.push(mkFinding({
313
+ code: "PYTHONPATH_INJECTED",
314
+ severity: "warn",
315
+ penalty: 15,
316
+ what: "PYTHONPATH is set in the host environment",
317
+ why: "It can shadow venv packages and cause non-reproducible imports",
318
+ fix: [
319
+ {
320
+ title: "Safe",
321
+ steps: [
322
+ `Remove PYTHONPATH from your shell/profile/IDE settings.`,
323
+ `If you need local code, use editable installs: pip install -e .`,
324
+ ],
325
+ },
326
+ ],
327
+ evidence: { PYTHONPATH: rawPyPath },
328
+ }));
329
+ }
330
+ // c) arch mismatch
331
+ if (opts.requireX64 === true && facts.bits !== 64) {
332
+ findings.push(mkFinding({
333
+ code: "ARCH_MISMATCH",
334
+ severity: "bad",
335
+ penalty: 80,
336
+ what: "Python architecture mismatch",
337
+ why: `Task requires 64-bit, but interpreter is ${facts.bits}-bit`,
338
+ fix: [
339
+ {
340
+ title: "Quick",
341
+ steps: [
342
+ `Install/select a 64-bit Python interpreter.`,
343
+ `Recreate the venv with the correct interpreter.`,
344
+ ],
345
+ },
346
+ ],
347
+ evidence: { bits: facts.bits, machine: facts.machine },
348
+ }));
349
+ }
350
+ // d) user site enabled/leaking
351
+ if (facts.enable_user_site === true) {
352
+ findings.push(mkFinding({
353
+ code: "USER_SITE_ENABLED",
354
+ severity: "warn",
355
+ penalty: 10,
356
+ what: "User site-packages are enabled",
357
+ why: "This can allow --user installs to leak into your environment",
358
+ fix: [
359
+ {
360
+ title: "Quick",
361
+ steps: [
362
+ `Prefer running with PYTHONNOUSERSITE=1 for tooling.`,
363
+ `Avoid pip install --user for development environments.`,
364
+ ],
365
+ },
366
+ ],
367
+ evidence: { enable_user_site: facts.enable_user_site, user_site: facts.user_site },
368
+ }));
369
+ }
370
+ const inSysPath = Array.isArray(facts.py_path) && facts.user_site && facts.py_path.includes(facts.user_site);
371
+ if (inSysPath) {
372
+ findings.push(mkFinding({
373
+ code: "USER_SITE_LEAK",
374
+ severity: "warn",
375
+ penalty: 20,
376
+ what: "User site-packages appear on sys.path",
377
+ why: "Packages installed with --user may shadow venv packages",
378
+ fix: [
379
+ {
380
+ title: "Safe",
381
+ steps: [
382
+ `Remove user-site leakage:`,
383
+ ` - unset PYTHONPATH / remove .pth hacks`,
384
+ ` - run with PYTHONNOUSERSITE=1`,
385
+ ` - recreate the venv`,
386
+ ],
387
+ },
388
+ ],
389
+ evidence: { user_site: facts.user_site, sys_path_top: facts.py_path },
390
+ }));
391
+ }
392
+ }
393
+ // 2) pip sanity (always try)
394
+ const pipVer = await runner(opts.pythonPath, ["-m", "pip", "--version"], { env: controlledEnv, timeoutMs });
395
+ if (!pipVer.ok) {
396
+ findings.push(mkFinding({
397
+ code: "PIP_MISSING",
398
+ severity: "warn",
399
+ penalty: 25,
400
+ what: "pip is missing or not runnable",
401
+ why: "python -m pip failed",
402
+ fix: [
403
+ {
404
+ title: "Quick",
405
+ steps: [
406
+ `Try: "${opts.pythonPath}" -m ensurepip --upgrade`,
407
+ `Then: "${opts.pythonPath}" -m pip install -U pip setuptools wheel`,
408
+ ],
409
+ },
410
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
411
+ ],
412
+ evidence: { stderr: pipVer.stderr, exitCode: pipVer.exitCode },
413
+ }));
414
+ }
415
+ else {
416
+ // detect mismatch in "pip X from ... (python 3.Y)"
417
+ const m = pipVer.stdout.match(/\(python\s+(\d+\.\d+)\)/i);
418
+ const pyVersion = facts?.version_info?.length ? `${facts.version_info[0]}.${facts.version_info[1]}` : null;
419
+ if (m && pyVersion && m[1] !== pyVersion) {
420
+ findings.push(mkFinding({
421
+ code: "PIP_POINTS_TO_OTHER_PYTHON",
422
+ severity: "warn",
423
+ penalty: 30,
424
+ what: "pip appears associated with a different Python version",
425
+ why: `pip reports python ${m[1]} but interpreter is ${pyVersion}`,
426
+ fix: [
427
+ {
428
+ title: "Safe",
429
+ steps: [
430
+ `Upgrade pip via the interpreter to rebind it:`,
431
+ ` "${opts.pythonPath}" -m pip install -U pip setuptools wheel`,
432
+ `If it persists, recreate the venv.`,
433
+ ],
434
+ },
435
+ ],
436
+ evidence: { pipVersion: pipVer.stdout.trim(), interpreter: opts.pythonPath },
437
+ }));
438
+ }
439
+ }
440
+ if (opts.strict) {
441
+ const pipCheck = await runner(opts.pythonPath, ["-m", "pip", "check"], { env: controlledEnv, timeoutMs });
442
+ if (!pipCheck.ok) {
443
+ findings.push(mkFinding({
444
+ code: "PIP_CHECK_FAIL",
445
+ severity: "warn",
446
+ penalty: 25,
447
+ what: "Installed packages have broken requirements",
448
+ why: "pip check reported conflicts or missing requirements",
449
+ fix: [
450
+ {
451
+ title: "Quick",
452
+ steps: [
453
+ `Run: "${opts.pythonPath}" -m pip check`,
454
+ `Then reinstall the reported packages (or recreate the venv).`,
455
+ ],
456
+ },
457
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
458
+ ],
459
+ evidence: { stdout: pipCheck.stdout, stderr: pipCheck.stderr, exitCode: pipCheck.exitCode },
460
+ }));
461
+ }
462
+ }
463
+ // 3) Import tests (always include ssl; add required modules)
464
+ const modules = Array.from(new Set(["ssl", "ctypes", "sqlite3", ...(opts.requiredModules ?? [])].filter(Boolean)));
465
+ const importScript = "import json, importlib, traceback;" +
466
+ `mods=${JSON.stringify(modules)};` +
467
+ "out=[];" +
468
+ "for m in mods:" +
469
+ " try:" +
470
+ " importlib.import_module(m);" +
471
+ " out.append({'module':m,'ok':True});" +
472
+ " except Exception as e:" +
473
+ " out.append({'module':m,'ok':False,'err':repr(e),'tb':traceback.format_exc()});" +
474
+ "print(json.dumps({'imports':out}))";
475
+ const importsRes = await runner(opts.pythonPath, ["-c", importScript], { env: controlledEnv, timeoutMs });
476
+ const importsJson = importsRes.ok
477
+ ? jsonOrNull(importsRes.stdout.trim())
478
+ : null;
479
+ if (!importsJson) {
480
+ findings.push(mkFinding({
481
+ code: "SUBPROCESS_BROKEN",
482
+ severity: "bad",
483
+ penalty: 40,
484
+ what: "Import probe failed",
485
+ why: "Could not parse import probe output; environment may be unstable",
486
+ fix: [{ title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) }],
487
+ evidence: { stdout: importsRes.stdout, stderr: importsRes.stderr },
488
+ }));
489
+ }
490
+ else {
491
+ for (const r of importsJson.imports) {
492
+ if (r.ok)
493
+ continue;
494
+ const text = `${r.err ?? ""}\n${r.tb ?? ""}`.trim();
495
+ // Special case: ssl fail => SSL_BROKEN
496
+ if (r.module === "ssl") {
497
+ findings.push(mkFinding({
498
+ code: "SSL_BROKEN",
499
+ severity: "bad",
500
+ penalty: 40,
501
+ what: "SSL module cannot be imported",
502
+ why: "OpenSSL libraries are missing, incompatible, or blocked",
503
+ fix: [
504
+ {
505
+ title: "Quick",
506
+ steps: [
507
+ `If on Windows: reinstall Python from python.org (matching x64/x86)`,
508
+ `Recreate the venv after reinstalling.`,
509
+ ],
510
+ },
511
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
512
+ ],
513
+ evidence: { module: r.module, err: r.err, tb: r.tb },
514
+ }));
515
+ continue;
516
+ }
517
+ const cls = classifyImportFailure(text);
518
+ if (cls === "DLL_LOAD_FAIL") {
519
+ findings.push(mkFinding({
520
+ code: "DLL_LOAD_FAIL",
521
+ severity: "bad",
522
+ penalty: 55,
523
+ what: `Native extension failed to load while importing ${r.module}`,
524
+ why: "A required shared library/DLL is missing or not found in the loader path",
525
+ fix: [
526
+ {
527
+ title: "Quick",
528
+ steps: [
529
+ `Reinstall the failing package (and its deps):`,
530
+ ` "${opts.pythonPath}" -m pip install --force-reinstall --no-cache-dir ${r.module}`,
531
+ `If that doesn't work, recreate the venv.`,
532
+ ],
533
+ },
534
+ {
535
+ title: "Safe",
536
+ steps: [
537
+ ...baseFixRecreateVenv(opts.pythonPath),
538
+ `If it's numpy/torch/scipy/etc., ensure you're using wheels for your platform (x64 vs arm64).`,
539
+ ],
540
+ },
541
+ ],
542
+ evidence: { module: r.module, err: r.err, tb: r.tb },
543
+ }));
544
+ }
545
+ else if (cls === "ABI_MISMATCH") {
546
+ findings.push(mkFinding({
547
+ code: "ABI_MISMATCH",
548
+ severity: "bad",
549
+ penalty: 55,
550
+ what: `Binary/ABI mismatch while importing ${r.module}`,
551
+ why: "Compiled wheels are incompatible with your Python version or architecture",
552
+ fix: [
553
+ {
554
+ title: "Quick",
555
+ steps: [
556
+ `Verify python arch/version matches the package wheels.`,
557
+ `Recreate the venv using a compatible Python version (common: torch pins).`,
558
+ ],
559
+ },
560
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
561
+ ],
562
+ evidence: { module: r.module, err: r.err, tb: r.tb },
563
+ }));
564
+ }
565
+ else {
566
+ // IMPORT_FAIL: severity depends on whether it was required
567
+ const isRequired = (opts.requiredModules ?? []).includes(r.module) || ["ctypes", "sqlite3"].includes(r.module);
568
+ findings.push(mkFinding({
569
+ code: "IMPORT_FAIL",
570
+ severity: isRequired ? "bad" : "warn",
571
+ penalty: isRequired ? 35 : 20,
572
+ what: `Import failed: ${r.module}`,
573
+ why: "Package missing, incompatible, or shadowed on sys.path",
574
+ fix: [
575
+ {
576
+ title: "Quick",
577
+ steps: [
578
+ `Install/repair the module:`,
579
+ ` "${opts.pythonPath}" -m pip install -U ${r.module}`,
580
+ `If installed, check for shadowing (PYTHONPATH / user-site leakage).`,
581
+ ],
582
+ },
583
+ { title: "Safe", steps: baseFixRecreateVenv(opts.pythonPath) },
584
+ ],
585
+ evidence: { module: r.module, err: r.err, tb: r.tb },
586
+ }));
587
+ }
588
+ }
589
+ }
590
+ // 4) Optional HTTPS probe (only if requested and ssl isn't broken)
591
+ if (opts.httpsProbe && !findings.some((f) => f.code === "SSL_BROKEN")) {
592
+ const httpsScript = "import json, urllib.request, traceback;" +
593
+ "u='https://pypi.org/simple/';" +
594
+ "try:" +
595
+ " with urllib.request.urlopen(u, timeout=5) as r:" +
596
+ " print(json.dumps({'ok':True,'status': getattr(r,'status',200)}))" +
597
+ "except Exception as e:" +
598
+ " print(json.dumps({'ok':False,'err':repr(e),'tb':traceback.format_exc()}))";
599
+ const httpsRes = await runner(opts.pythonPath, ["-c", httpsScript], { env: controlledEnv, timeoutMs });
600
+ const httpsJson = httpsRes.ok
601
+ ? jsonOrNull(httpsRes.stdout.trim())
602
+ : null;
603
+ if (!httpsJson || httpsJson.ok !== true) {
604
+ const errText = `${httpsJson?.err ?? ""}\n${httpsJson?.tb ?? ""}\n${httpsRes.stderr ?? ""}`.toLowerCase();
605
+ const certish = errText.includes("certificate_verify_failed") ||
606
+ errText.includes("unable to get local issuer certificate") ||
607
+ errText.includes("self signed certificate");
608
+ findings.push(mkFinding({
609
+ code: "CERT_STORE_FAIL",
610
+ severity: "warn",
611
+ penalty: 25,
612
+ what: "HTTPS probe failed",
613
+ why: certish
614
+ ? "Certificate verification failed (often corporate MITM or missing CA roots)"
615
+ : "Network/TLS connection failed",
616
+ fix: [
617
+ {
618
+ title: "Quick",
619
+ steps: [
620
+ certish
621
+ ? `If you're on a corporate network, install your org's root CA into the OS trust store (or configure a cert bundle).`
622
+ : `Check connectivity/DNS/proxy settings.`,
623
+ `If this blocks installs, use an internal index or wheelhouse.`,
624
+ ],
625
+ },
626
+ ],
627
+ evidence: {
628
+ certish,
629
+ stdout: httpsRes.stdout,
630
+ stderr: httpsRes.stderr,
631
+ err: httpsJson?.err,
632
+ tb: httpsJson?.tb,
633
+ },
634
+ }));
635
+ }
636
+ }
637
+ // 5) Strict multi-version scan (optional, heavier)
638
+ if (opts.strict) {
639
+ const multiScript = "import json, sys;" +
640
+ "try:" +
641
+ " import importlib.metadata as md" +
642
+ "except Exception:" +
643
+ " import importlib_metadata as md" +
644
+ "dists={};" +
645
+ "for dist in md.distributions():" +
646
+ " name=(dist.metadata.get('Name','') or '').lower();" +
647
+ " if not name: continue;" +
648
+ " loc=str(dist.locate_file(''));" +
649
+ " ver=getattr(dist,'version','?');" +
650
+ " dists.setdefault(name, []).append({'version':ver,'location':loc});" +
651
+ "multi={k:v for k,v in dists.items() if len({x['location'] for x in v})>1};" +
652
+ "print(json.dumps({'multi':multi, 'sys_path_top': sys.path[:15]}))";
653
+ const multiRes = await runner(opts.pythonPath, ["-c", multiScript], { env: controlledEnv, timeoutMs });
654
+ const multiJson = multiRes.ok
655
+ ? jsonOrNull(multiRes.stdout.trim())
656
+ : null;
657
+ if (multiJson && multiJson.multi && Object.keys(multiJson.multi).length > 0) {
658
+ findings.push(mkFinding({
659
+ code: "MULTI_VERSION_ON_PATH",
660
+ severity: "warn",
661
+ penalty: 20,
662
+ what: "Multiple installations of the same package detected",
663
+ why: "Duplicate distributions can shadow each other and cause unpredictable behavior",
664
+ fix: [
665
+ {
666
+ title: "Safe",
667
+ steps: [
668
+ `Best fix is to recreate the venv.`,
669
+ `If you must repair in place, uninstall until one remains, then reinstall:`,
670
+ ` "${opts.pythonPath}" -m pip uninstall <pkg> (repeat)`,
671
+ ` "${opts.pythonPath}" -m pip install <pkg>`,
672
+ ],
673
+ },
674
+ ],
675
+ evidence: { multi: multiJson.multi, sys_path_top: multiJson.sys_path_top },
676
+ }));
677
+ }
678
+ }
679
+ const { score, status } = scoreReport(findings);
680
+ const top = findings
681
+ .filter((f) => f.severity !== "info")
682
+ .sort((a, b) => (b.penalty ?? 0) - (a.penalty ?? 0))[0];
683
+ const summary = top?.what ??
684
+ (status === "good"
685
+ ? "Environment looks healthy."
686
+ : status === "warn"
687
+ ? "Environment has warnings."
688
+ : "Environment is unhealthy.");
689
+ return {
690
+ pythonPath: opts.pythonPath,
691
+ ranAt: nowIso(),
692
+ status,
693
+ score,
694
+ summary,
695
+ facts: facts ?? undefined,
696
+ findings,
697
+ };
698
+ }
699
+ // Convenience: make a stable "env id" for mapping/logging
700
+ export function envIdFromPythonPath(pythonPath) {
701
+ // Keep it simple and deterministic. You can upgrade to a hash later.
702
+ const norm = os.platform() === "win32" ? pythonPath.toLowerCase() : pythonPath;
703
+ return `py:${norm}`;
704
+ }
705
+ //# sourceMappingURL=doctorLite.js.map