@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,718 @@
1
+ // mapRender.ts
2
+ //
3
+ // Build a "venv ecosystem map" from DoctorLite reports.
4
+ // Outputs:
5
+ // - Canonical Graph JSON (v1)
6
+ // - Mermaid diagram (derived)
7
+ // - Insights (what's broken + blast radius + cleanup plan)
8
+ //
9
+ // "Special" bits:
10
+ // 1) Blast-radius analysis: detects base interpreters that infect many envs (shared issues).
11
+ // 2) Shadow/leak detection: calls out PYTHONPATH + user-site leakage as ecosystem-level problems.
12
+ // 3) Mermaid subgraphs per base interpreter + severity styling + "hot edge" labels for dominant issues.
13
+ // 4) Deterministic node IDs via sha256 so maps are stable across runs.
14
+ import * as os from "node:os";
15
+ import { createHash } from "node:crypto";
16
+ import { clusterRuns, isFlaky, isEnvDependentFlaky, getFailingEnvs } from "./taskCluster.js";
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+ function sha256Hex(input) {
21
+ return createHash("sha256").update(input).digest("hex");
22
+ }
23
+ function stableId(prefix, input) {
24
+ return `${prefix}:${sha256Hex(input).slice(0, 16)}`;
25
+ }
26
+ function normPath(p) {
27
+ if (!p)
28
+ return "";
29
+ return os.platform() === "win32" ? p.toLowerCase() : p;
30
+ }
31
+ function pickArch(facts) {
32
+ // facts.bits is numeric; we can convert to x86_64-ish label for the map
33
+ const bits = facts?.bits;
34
+ if (bits === 64)
35
+ return "x86_64";
36
+ if (bits === 32)
37
+ return "x86";
38
+ return undefined;
39
+ }
40
+ function statusFromReport(r) {
41
+ return r.status ?? "unknown";
42
+ }
43
+ function issuesFromFindings(findings) {
44
+ return findings.map((f) => ({
45
+ code: f.code,
46
+ severity: f.severity,
47
+ message: f.what,
48
+ }));
49
+ }
50
+ function shouldIncludeReport(r, opts) {
51
+ const minScore = opts.filter?.minScore;
52
+ if (typeof minScore === "number" && (r.score ?? 0) < minScore)
53
+ return false;
54
+ const under = opts.filter?.pathsUnder;
55
+ if (under) {
56
+ const p = normPath(r.pythonPath);
57
+ if (!p.startsWith(normPath(under)))
58
+ return false;
59
+ }
60
+ const codes = opts.filter?.codes;
61
+ if (codes && codes.length > 0) {
62
+ const set = new Set(r.findings.map((f) => f.code));
63
+ if (!codes.some((c) => set.has(c)))
64
+ return false;
65
+ }
66
+ // tags aren't in DoctorLiteReport by default; keep hook for future
67
+ return true;
68
+ }
69
+ function hintForIssue(code) {
70
+ switch (code) {
71
+ case "SSL_BROKEN":
72
+ return "Fix base Python / OpenSSL; this blocks installs and HTTPS.";
73
+ case "DLL_LOAD_FAIL":
74
+ case "ABI_MISMATCH":
75
+ return "Native deps mismatch; recreate venv with compatible Python + wheels.";
76
+ case "USER_SITE_LEAK":
77
+ case "PYTHONPATH_INJECTED":
78
+ return "Path leakage; disable user-site, remove PYTHONPATH, prefer editable installs.";
79
+ case "PIP_CHECK_FAIL":
80
+ return "Dependency conflicts; pip check then reinstall or recreate venv.";
81
+ case "PYVENV_CFG_INVALID":
82
+ return "Stale pyvenv.cfg; venv likely moved—recreate it.";
83
+ default:
84
+ return "Investigate and apply the doctor fix plan; recreating the venv is often fastest.";
85
+ }
86
+ }
87
+ function emojiForIssue(code) {
88
+ switch (code) {
89
+ case "SSL_BROKEN":
90
+ return "🔒";
91
+ case "CERT_STORE_FAIL":
92
+ return "🪪";
93
+ case "DLL_LOAD_FAIL":
94
+ return "🧩";
95
+ case "ABI_MISMATCH":
96
+ return "⚙️";
97
+ case "USER_SITE_LEAK":
98
+ return "🕳️";
99
+ case "PYTHONPATH_INJECTED":
100
+ return "🧵";
101
+ case "PIP_CHECK_FAIL":
102
+ return "🧨";
103
+ case "PIP_MISSING":
104
+ return "📦";
105
+ case "ARCH_MISMATCH":
106
+ return "🏗️";
107
+ case "PYVENV_CFG_INVALID":
108
+ return "🧱";
109
+ default:
110
+ return "❗";
111
+ }
112
+ }
113
+ function safeMermaidId(nodeId) {
114
+ // Mermaid IDs must be simple. Use deterministic hash.
115
+ return `n_${sha256Hex(nodeId).slice(0, 10)}`;
116
+ }
117
+ function guessEnvLabel(pythonPath) {
118
+ const parts = pythonPath.split(/[\\/]+/).filter(Boolean);
119
+ return parts.length ? parts[Math.max(0, parts.length - 2)] ?? pythonPath : pythonPath;
120
+ }
121
+ function mermaidLabel(node) {
122
+ const py = node.python?.version ? `py${node.python.version}` : "";
123
+ const score = typeof node.health?.score === "number" ? `score ${node.health.score}` : "";
124
+ const caps = (node.caps?.features?.length ? node.caps.features.slice(0, 3).join(",") : "") || "";
125
+ const line2 = [py, score, caps].filter(Boolean).join(" • ");
126
+ const lines = [node.label, line2].filter(Boolean);
127
+ // Mermaid supports <br/> inside quoted labels
128
+ return lines.join("<br/>");
129
+ }
130
+ function mermaidClass(status) {
131
+ if (status === "good")
132
+ return "good";
133
+ if (status === "warn")
134
+ return "warn";
135
+ if (status === "bad")
136
+ return "bad";
137
+ return "unknown";
138
+ }
139
+ function countTopIssues(reports, maxTop) {
140
+ const counts = new Map();
141
+ for (const r of reports) {
142
+ for (const f of r.findings) {
143
+ if (f.severity === "info")
144
+ continue;
145
+ counts.set(f.code, (counts.get(f.code) ?? 0) + 1);
146
+ }
147
+ }
148
+ return [...counts.entries()]
149
+ .sort((a, b) => b[1] - a[1])
150
+ .slice(0, maxTop)
151
+ .map(([code, count]) => ({ code, count, hint: hintForIssue(code) }));
152
+ }
153
+ function aggregateInsights(graph, reports, clusters = []) {
154
+ const insights = [];
155
+ const top = graph.summary.topIssues;
156
+ if (top.length > 0) {
157
+ const t0 = top[0];
158
+ if (t0) {
159
+ insights.push({
160
+ severity: t0.count >= 3 ? "high" : "medium",
161
+ text: `Top recurring issue: ${t0.code} (${t0.count}). ${t0.hint}`,
162
+ meta: { code: t0.code, count: t0.count },
163
+ });
164
+ }
165
+ }
166
+ // Blast radius: base -> many bad/warn envs or shared bad issue codes
167
+ const baseNodes = graph.nodes.filter((n) => n.type === "base");
168
+ for (const base of baseNodes) {
169
+ const childrenEdges = graph.edges.filter((e) => e.type === "USES_BASE" && e.from === base.id);
170
+ const childIds = childrenEdges.map((e) => e.to);
171
+ const childNodes = graph.nodes.filter((n) => childIds.includes(n.id));
172
+ const badCount = childNodes.filter((n) => n.health?.status === "bad").length;
173
+ if (childNodes.length >= 3 && badCount >= Math.ceil(childNodes.length / 2)) {
174
+ insights.push({
175
+ severity: "high",
176
+ text: `Base interpreter "${base.label}" has a large blast radius: ${badCount}/${childNodes.length} attached envs are bad. Fix the base first, then recreate envs.`,
177
+ meta: { baseId: base.id, total: childNodes.length, bad: badCount },
178
+ });
179
+ }
180
+ }
181
+ // Ecosystem hygiene: PYTHONPATH + user site leaks
182
+ const leakCount = reports.reduce((n, r) => n + (r.findings.some((f) => f.code === "USER_SITE_LEAK") ? 1 : 0), 0);
183
+ const pyPathCount = reports.reduce((n, r) => n + (r.findings.some((f) => f.code === "PYTHONPATH_INJECTED") ? 1 : 0), 0);
184
+ if (leakCount >= 2 || pyPathCount >= 2) {
185
+ insights.push({
186
+ severity: "high",
187
+ text: `Ecosystem hygiene problem: USER_SITE_LEAK in ${leakCount} env(s), PYTHONPATH_INJECTED in ${pyPathCount} env(s). This causes "works here, fails there." Lock these down (PYTHONNOUSERSITE=1, remove PYTHONPATH).`,
188
+ meta: { leakCount, pyPathCount },
189
+ });
190
+ }
191
+ // "Nuclear option" recommendation when too many unique issues show up
192
+ const uniqueIssues = new Set();
193
+ for (const r of reports)
194
+ for (const f of r.findings)
195
+ if (f.severity !== "info")
196
+ uniqueIssues.add(f.code);
197
+ if (reports.length >= 5 && uniqueIssues.size >= 8) {
198
+ insights.push({
199
+ severity: "medium",
200
+ text: `High entropy detected: ${uniqueIssues.size} different issue types across ${reports.length} envs. Consider standardizing on 2–3 "golden envs" (data, web, ml) and routing tasks into them.`,
201
+ meta: { envs: reports.length, uniqueIssues: uniqueIssues.size },
202
+ });
203
+ }
204
+ // --- Flaky task detection ---
205
+ const flakyTasks = clusters.filter(isFlaky).slice(0, 3);
206
+ for (const c of flakyTasks) {
207
+ const failingEnvs = getFailingEnvs(c, 2);
208
+ const envHint = failingEnvs.length > 0
209
+ ? ` Failures concentrated in: ${failingEnvs.map((e) => guessEnvLabel(e.pythonPath)).join(", ")}.`
210
+ : "";
211
+ insights.push({
212
+ severity: "high",
213
+ text: `Flaky task detected: "${c.sig.name}" (success ${(c.successRate * 100).toFixed(0)}%, ${c.runs} runs). Dominant failure: ${c.dominantFailure ?? "unknown"}.${envHint}`,
214
+ meta: {
215
+ taskSig: c.sig.sigId,
216
+ runs: c.runs,
217
+ successRate: c.successRate,
218
+ dominantFailure: c.dominantFailure,
219
+ },
220
+ });
221
+ }
222
+ // --- Env-dependent flake detection ---
223
+ const envFlakyTasks = clusters.filter(isEnvDependentFlaky).slice(0, 2);
224
+ for (const c of envFlakyTasks) {
225
+ if (flakyTasks.includes(c))
226
+ continue; // already reported
227
+ const failingEnvs = getFailingEnvs(c, 2);
228
+ insights.push({
229
+ severity: "high",
230
+ text: `Env-dependent flake: "${c.sig.name}" succeeds on some envs but fails on others. Problem envs: ${failingEnvs.map((e) => guessEnvLabel(e.pythonPath)).join(", ")}.`,
231
+ meta: { taskSig: c.sig.sigId, failingEnvs: failingEnvs.map((e) => e.pythonPath) },
232
+ });
233
+ }
234
+ // --- Failure bottleneck envs ---
235
+ const failByEnv = new Map();
236
+ for (const e of graph.edges.filter((e) => e.type === "FAILED_RUN")) {
237
+ failByEnv.set(e.to, (failByEnv.get(e.to) ?? 0) + (e.weight ?? 1));
238
+ }
239
+ const worstEnvs = [...failByEnv.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2);
240
+ for (const [envId, failCount] of worstEnvs) {
241
+ if (failCount < 3)
242
+ continue; // only report significant hotspots
243
+ const env = graph.nodes.find((n) => n.id === envId);
244
+ if (!env)
245
+ continue;
246
+ insights.push({
247
+ severity: "high",
248
+ text: `Failure hotspot: env "${env.label}" is associated with ${failCount} failing runs. Consider rebuilding it or isolating it from routing.`,
249
+ meta: { envId, failCount },
250
+ });
251
+ }
252
+ // --- Contagion: most failures share a common issue ---
253
+ if (clusters.length > 0) {
254
+ const failureCodeCounts = new Map();
255
+ for (const c of clusters) {
256
+ for (const [code, count] of Object.entries(c.failureCounts)) {
257
+ failureCodeCounts.set(code, (failureCodeCounts.get(code) ?? 0) + count);
258
+ }
259
+ }
260
+ const totalFailures = graph.summary.runsFailed;
261
+ const topFailureCode = [...failureCodeCounts.entries()].sort((a, b) => b[1] - a[1])[0];
262
+ if (topFailureCode && totalFailures > 0 && topFailureCode[1] / totalFailures >= 0.5) {
263
+ insights.push({
264
+ severity: "high",
265
+ text: `Most failures (${topFailureCode[1]}/${totalFailures}) share the same root cause: ${topFailureCode[0]}. ${hintForIssue(topFailureCode[0])}`,
266
+ meta: { code: topFailureCode[0], count: topFailureCode[1], totalFailures },
267
+ });
268
+ }
269
+ }
270
+ if (insights.length === 0) {
271
+ insights.push({ severity: "low", text: "No major systemic risks detected. Keep env count small and prefer reproducible installs." });
272
+ }
273
+ return insights;
274
+ }
275
+ function dominantIssue(findings) {
276
+ // Pick the highest-penalty non-info code as "dominant"
277
+ const nonInfo = findings.filter((f) => f.severity !== "info");
278
+ if (nonInfo.length === 0)
279
+ return null;
280
+ nonInfo.sort((a, b) => (b.penalty ?? 0) - (a.penalty ?? 0));
281
+ return nonInfo[0]?.code ?? null;
282
+ }
283
+ function deriveBaseKey(r) {
284
+ // Best-effort base grouping:
285
+ // - If it's a venv, facts.base_prefix is the base interpreter prefix directory.
286
+ // - Otherwise, group by prefix/executable directory.
287
+ const f = r.facts;
288
+ const basePrefix = typeof f?.base_prefix === "string" && f.base_prefix.length ? f.base_prefix : "";
289
+ if (basePrefix)
290
+ return basePrefix;
291
+ const prefix = typeof f?.prefix === "string" ? f.prefix : "";
292
+ if (prefix)
293
+ return prefix;
294
+ // Fall back to executable path parent
295
+ return r.pythonPath;
296
+ }
297
+ function derivePyVersion(r) {
298
+ const vi = r.facts?.version_info;
299
+ if (Array.isArray(vi) && vi.length >= 2)
300
+ return `${vi[0]}.${vi[1]}`;
301
+ // fall back to parsing "version" if present
302
+ const v = r.facts?.version;
303
+ const m = v?.match(/(\d+)\.(\d+)\.(\d+)/);
304
+ if (m)
305
+ return `${m[1]}.${m[2]}`;
306
+ return undefined;
307
+ }
308
+ function deriveImpl() {
309
+ // DoctorLite facts don't include impl explicitly; default CPython
310
+ return "CPython";
311
+ }
312
+ function nodeLabelForBase(baseKey, pyVersion, arch) {
313
+ // Keep it readable: show the directory/prefix, not entire story.
314
+ return `Base: ${pyVersion ?? "py?"} ${arch ?? ""}\n${baseKey}`;
315
+ }
316
+ function summarizeCounts(nodes) {
317
+ const venvs = nodes.filter((n) => n.type === "venv");
318
+ const healthy = venvs.filter((n) => n.health?.status === "good").length;
319
+ const warning = venvs.filter((n) => n.health?.status === "warn").length;
320
+ const broken = venvs.filter((n) => n.health?.status === "bad").length;
321
+ return { healthy, warning, broken };
322
+ }
323
+ export function mapRender(reports, runs = [], options = {}) {
324
+ const opts = {
325
+ format: options.format ?? "both",
326
+ focus: options.focus ?? "all",
327
+ filter: options.filter ?? {},
328
+ includeBaseSubgraphs: options.includeBaseSubgraphs ?? true,
329
+ includeHotEdgeLabels: options.includeHotEdgeLabels ?? true,
330
+ maxTopIssues: options.maxTopIssues ?? 10,
331
+ taskMode: options.taskMode ?? "clustered",
332
+ };
333
+ const included = reports.filter((r) => shouldIncludeReport(r, opts));
334
+ // Build nodes/edges
335
+ const nodes = [];
336
+ const edges = [];
337
+ const baseIdByKey = new Map();
338
+ const envIdByPyPath = new Map();
339
+ // Helper: create/get base node
340
+ const getOrCreateBaseNode = (baseKey, pyVersion, arch) => {
341
+ const keyNorm = normPath(baseKey);
342
+ let id = baseIdByKey.get(keyNorm);
343
+ if (!id) {
344
+ id = stableId("base", keyNorm);
345
+ baseIdByKey.set(keyNorm, id);
346
+ nodes.push({
347
+ id,
348
+ type: "base",
349
+ label: nodeLabelForBase(baseKey, pyVersion, arch).replace(/\n/g, " • "),
350
+ path: baseKey,
351
+ python: { version: pyVersion, impl: deriveImpl(), arch },
352
+ health: { status: "unknown" },
353
+ fingerprints: { python: `sha256:${sha256Hex(keyNorm).slice(0, 24)}` },
354
+ lastSeenAt: nowIso(),
355
+ });
356
+ }
357
+ return id;
358
+ };
359
+ // Create env nodes + base edges
360
+ for (const r of included) {
361
+ const pyPath = normPath(r.pythonPath);
362
+ const envId = stableId("env", pyPath);
363
+ envIdByPyPath.set(pyPath, envId);
364
+ const pyVersion = derivePyVersion(r);
365
+ const arch = pickArch(r.facts);
366
+ const dominant = dominantIssue(r.findings);
367
+ const envFingerprint = `sha256:${sha256Hex(pyPath + "|" + String(pyVersion) + "|" + (dominant ?? "")).slice(0, 24)}`;
368
+ nodes.push({
369
+ id: envId,
370
+ type: "venv",
371
+ label: (() => {
372
+ // Friendly: show just last folder name for venv-ish paths
373
+ const parts = r.pythonPath.split(/[\\/]+/).filter(Boolean);
374
+ const guess = parts.length ? parts[Math.max(0, parts.length - 2)] : r.pythonPath;
375
+ return guess ?? r.pythonPath;
376
+ })(),
377
+ path: r.pythonPath,
378
+ python: { version: pyVersion, impl: deriveImpl(), arch },
379
+ health: {
380
+ status: statusFromReport(r),
381
+ score: r.score,
382
+ issues: issuesFromFindings(r.findings),
383
+ },
384
+ caps: {
385
+ // DoctorLite doesn't enumerate packages; we still include signals as "features"
386
+ features: [
387
+ r.findings.some((f) => f.code === "SSL_BROKEN") ? "ssl:broken" : "ssl:ok",
388
+ r.findings.some((f) => f.code === "USER_SITE_LEAK") ? "usersite:leak" : "usersite:clean",
389
+ r.findings.some((f) => f.code === "PYTHONPATH_INJECTED") ? "pythonpath:set" : "pythonpath:clean",
390
+ ],
391
+ tags: [],
392
+ },
393
+ fingerprints: { env: envFingerprint },
394
+ lastSeenAt: r.ranAt ?? nowIso(),
395
+ });
396
+ // Base grouping
397
+ const baseKey = deriveBaseKey(r);
398
+ const baseId = getOrCreateBaseNode(baseKey, pyVersion, arch);
399
+ edges.push({
400
+ id: stableId("e", `${baseId}->${envId}`),
401
+ from: baseId,
402
+ to: envId,
403
+ type: "USES_BASE",
404
+ weight: 1,
405
+ meta: { dominantIssue: dominant },
406
+ });
407
+ }
408
+ // Improve base health by aggregating child env health (so the map reflects blast radius)
409
+ for (const base of nodes.filter((n) => n.type === "base")) {
410
+ const childEdges = edges.filter((e) => e.type === "USES_BASE" && e.from === base.id);
411
+ const childNodes = nodes.filter((n) => childEdges.some((e) => e.to === n.id));
412
+ if (childNodes.length === 0)
413
+ continue;
414
+ // Base score is median of child scores (more stable than min), but status is worst-case
415
+ const scores = childNodes
416
+ .map((n) => n.health?.score)
417
+ .filter((s) => typeof s === "number")
418
+ .sort((a, b) => a - b);
419
+ const median = scores.length ? scores[Math.floor(scores.length / 2)] : undefined;
420
+ const worst = childNodes.some((n) => n.health?.status === "bad")
421
+ ? "bad"
422
+ : childNodes.some((n) => n.health?.status === "warn")
423
+ ? "warn"
424
+ : childNodes.some((n) => n.health?.status === "good")
425
+ ? "good"
426
+ : "unknown";
427
+ base.health = { status: worst, score: median };
428
+ }
429
+ // --- Task nodes + edges from run logs ---
430
+ let runsPassed = 0;
431
+ let runsFailed = 0;
432
+ let clusters = [];
433
+ // Build node index for quick lookups
434
+ const nodeIndex = new Map();
435
+ for (const n of nodes)
436
+ nodeIndex.set(n.id, n);
437
+ if (opts.taskMode !== "none" && runs.length > 0) {
438
+ // Count totals regardless of mode
439
+ for (const r of runs) {
440
+ if (r.outcome.ok)
441
+ runsPassed++;
442
+ else
443
+ runsFailed++;
444
+ }
445
+ if (opts.taskMode === "clustered") {
446
+ // Clustered mode: one task node per signature
447
+ clusters = clusterRuns(runs);
448
+ for (const c of clusters) {
449
+ const taskId = `task:${c.sig.sigId}`;
450
+ const flaky = isFlaky(c);
451
+ const envFlaky = isEnvDependentFlaky(c);
452
+ nodes.push({
453
+ id: taskId,
454
+ type: "task",
455
+ label: c.sig.name,
456
+ health: {
457
+ status: c.fail === 0 ? "good" : c.ok === 0 ? "bad" : "warn",
458
+ score: Math.round(100 * c.successRate),
459
+ issues: c.dominantFailure
460
+ ? [
461
+ {
462
+ code: c.dominantFailure,
463
+ severity: c.fail > 0 ? "warn" : "info",
464
+ message: `dominant failure (${c.failureCounts[c.dominantFailure]})`,
465
+ },
466
+ ]
467
+ : [],
468
+ },
469
+ caps: {
470
+ features: [
471
+ `runs:${c.runs}`,
472
+ `ok:${c.ok}`,
473
+ `fail:${c.fail}`,
474
+ flaky ? "flaky:true" : "flaky:false",
475
+ envFlaky ? "env-flaky:true" : "env-flaky:false",
476
+ ],
477
+ tags: [],
478
+ },
479
+ lastSeenAt: c.lastAt,
480
+ });
481
+ nodeIndex.set(taskId, nodes[nodes.length - 1]);
482
+ // Build weighted edges to envs
483
+ for (const [pyPath, count] of Object.entries(c.envCounts)) {
484
+ const envPy = normPath(pyPath);
485
+ const envId = envIdByPyPath.get(envPy) ?? stableId("env", envPy);
486
+ // Ensure env node exists even if missing from reports
487
+ if (!nodeIndex.has(envId)) {
488
+ const label = guessEnvLabel(pyPath);
489
+ const newEnvNode = {
490
+ id: envId,
491
+ type: "venv",
492
+ label,
493
+ path: pyPath,
494
+ health: { status: "unknown" },
495
+ lastSeenAt: c.lastAt,
496
+ };
497
+ nodes.push(newEnvNode);
498
+ nodeIndex.set(envId, newEnvNode);
499
+ envIdByPyPath.set(envPy, envId);
500
+ }
501
+ edges.push({
502
+ id: stableId("e", `${taskId}->${envId}:route`),
503
+ from: taskId,
504
+ to: envId,
505
+ type: "ROUTES_TASK_TO",
506
+ label: `x${count}`,
507
+ weight: count,
508
+ meta: { taskSig: c.sig.sigId },
509
+ });
510
+ const failCount = c.envFailCounts[pyPath] ?? 0;
511
+ if (failCount > 0) {
512
+ const dom = c.dominantFailure ?? "RUN_FAILED";
513
+ edges.push({
514
+ id: stableId("e", `${taskId}->${envId}:fail`),
515
+ from: taskId,
516
+ to: envId,
517
+ type: "FAILED_RUN",
518
+ label: `${emojiForIssue(dom)} ${dom} x${failCount}`,
519
+ weight: failCount,
520
+ meta: { taskSig: c.sig.sigId, dominantIssue: dom },
521
+ });
522
+ }
523
+ }
524
+ }
525
+ }
526
+ else {
527
+ // "runs" mode: one task node per run (original behavior)
528
+ for (const run of runs) {
529
+ const taskKey = `${run.task.name}|${run.task.command}|${run.at}|${run.selected.pythonPath}`;
530
+ const taskId = stableId("task", run.runId || taskKey);
531
+ nodes.push({
532
+ id: taskId,
533
+ type: "task",
534
+ label: run.task.name,
535
+ path: run.cwd,
536
+ health: {
537
+ status: run.outcome.ok ? "good" : "bad",
538
+ score: run.outcome.ok ? 100 : 40,
539
+ issues: run.outcome.ok
540
+ ? []
541
+ : [
542
+ {
543
+ code: run.outcome.errorClass ?? "RUN_FAILED",
544
+ severity: "bad",
545
+ message: run.outcome.stderrSnippet ?? "Task failed",
546
+ },
547
+ ],
548
+ },
549
+ caps: {
550
+ features: [
551
+ run.task.requirements?.packages?.length
552
+ ? `pkgs:${run.task.requirements.packages.slice(0, 3).join(",")}`
553
+ : "pkgs:none",
554
+ ],
555
+ tags: run.task.requirements?.tags ?? [],
556
+ },
557
+ lastSeenAt: run.at,
558
+ });
559
+ nodeIndex.set(taskId, nodes[nodes.length - 1]);
560
+ const envPy = normPath(run.selected.pythonPath);
561
+ const envId = envIdByPyPath.get(envPy) ?? stableId("env", envPy);
562
+ if (!nodeIndex.has(envId)) {
563
+ const newEnvNode = {
564
+ id: envId,
565
+ type: "venv",
566
+ label: guessEnvLabel(run.selected.pythonPath),
567
+ path: run.selected.pythonPath,
568
+ health: { status: run.selected.status ?? "unknown", score: run.selected.score },
569
+ lastSeenAt: run.at,
570
+ };
571
+ nodes.push(newEnvNode);
572
+ nodeIndex.set(envId, newEnvNode);
573
+ envIdByPyPath.set(envPy, envId);
574
+ }
575
+ edges.push({
576
+ id: stableId("e", `${taskId}->${envId}:route`),
577
+ from: taskId,
578
+ to: envId,
579
+ type: "ROUTES_TASK_TO",
580
+ label: "routes",
581
+ weight: 1,
582
+ meta: { runId: run.runId, command: run.task.command },
583
+ });
584
+ if (!run.outcome.ok) {
585
+ const dom = run.doctor?.dominantIssue ?? run.outcome.errorClass ?? "RUN_FAILED";
586
+ edges.push({
587
+ id: stableId("e", `${taskId}->${envId}:fail`),
588
+ from: taskId,
589
+ to: envId,
590
+ type: "FAILED_RUN",
591
+ label: `${emojiForIssue(dom)} ${dom}`,
592
+ weight: 2,
593
+ meta: { runId: run.runId, exitCode: run.outcome.exitCode, dominantIssue: dom },
594
+ });
595
+ }
596
+ }
597
+ }
598
+ }
599
+ // Summary + insights
600
+ const { healthy, warning, broken } = summarizeCounts(nodes);
601
+ const topIssues = countTopIssues(included, opts.maxTopIssues);
602
+ const graph = {
603
+ version: "1.0",
604
+ generatedAt: nowIso(),
605
+ host: {
606
+ os: os.platform(),
607
+ arch: os.arch(),
608
+ hostname: os.hostname(),
609
+ },
610
+ summary: {
611
+ envCount: nodes.filter((n) => n.type === "venv").length,
612
+ baseCount: nodes.filter((n) => n.type === "base").length,
613
+ taskCount: nodes.filter((n) => n.type === "task").length,
614
+ healthy,
615
+ warning,
616
+ broken,
617
+ runsPassed,
618
+ runsFailed,
619
+ topIssues,
620
+ },
621
+ nodes,
622
+ edges,
623
+ };
624
+ const insights = aggregateInsights(graph, included, clusters);
625
+ let mermaid;
626
+ if (opts.format === "mermaid" || opts.format === "both") {
627
+ mermaid = renderMermaid(graph, {
628
+ includeBaseSubgraphs: opts.includeBaseSubgraphs,
629
+ includeHotEdgeLabels: opts.includeHotEdgeLabels,
630
+ });
631
+ }
632
+ return { graph, mermaid, insights };
633
+ }
634
+ export function renderMermaid(graph, opts = {}) {
635
+ const includeSubgraphs = opts.includeBaseSubgraphs ?? true;
636
+ const hotEdges = opts.includeHotEdgeLabels ?? true;
637
+ const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
638
+ const baseNodes = graph.nodes.filter((n) => n.type === "base");
639
+ const idMap = new Map();
640
+ for (const n of graph.nodes)
641
+ idMap.set(n.id, safeMermaidId(n.id));
642
+ const lines = [];
643
+ lines.push("graph TD");
644
+ // Nodes
645
+ for (const n of graph.nodes) {
646
+ const mid = idMap.get(n.id);
647
+ const label = mermaidLabel(n);
648
+ const cls = n.type === "base"
649
+ ? "base"
650
+ : n.type === "task"
651
+ ? "task"
652
+ : mermaidClass(n.health?.status ?? "unknown");
653
+ lines.push(` ${mid}["${escapeMermaid(label)}"]:::${cls}`);
654
+ }
655
+ // Edges
656
+ const usesBase = graph.edges.filter((e) => e.type === "USES_BASE");
657
+ for (const e of usesBase) {
658
+ const from = idMap.get(e.from);
659
+ const to = idMap.get(e.to);
660
+ let label = "USES_BASE";
661
+ if (hotEdges) {
662
+ const dom = e.meta?.dominantIssue;
663
+ if (dom)
664
+ label = `${emojiForIssue(dom)} ${dom}`;
665
+ }
666
+ lines.push(` ${from} -->|${escapeMermaid(label)}| ${to}`);
667
+ }
668
+ // Optional: cluster venvs under base interpreters
669
+ if (includeSubgraphs && baseNodes.length > 0) {
670
+ // Mermaid subgraphs need nodes referenced inside; we'll emit them as comments + group edges do the real wiring.
671
+ // For readability, we add subgraph blocks listing the venv nodes.
672
+ for (const base of baseNodes) {
673
+ const baseMid = idMap.get(base.id);
674
+ const attached = usesBase
675
+ .filter((e) => e.from === base.id)
676
+ .map((e) => nodeById.get(e.to))
677
+ .filter((n) => Boolean(n))
678
+ .filter((n) => n.type === "venv");
679
+ if (attached.length === 0)
680
+ continue;
681
+ lines.push(` subgraph ${baseMid}_cluster["${escapeMermaid(base.label)}"]`);
682
+ for (const v of attached) {
683
+ const vmid = idMap.get(v.id);
684
+ // Reference node within subgraph (no redefinition; mermaid tolerates this as a bare identifier)
685
+ lines.push(` ${vmid}`);
686
+ }
687
+ lines.push(" end");
688
+ }
689
+ }
690
+ // Task edges: ROUTES_TASK_TO + FAILED_RUN
691
+ const taskEdges = graph.edges.filter((e) => e.type === "ROUTES_TASK_TO" || e.type === "FAILED_RUN");
692
+ for (const e of taskEdges) {
693
+ const from = idMap.get(e.from);
694
+ const to = idMap.get(e.to);
695
+ if (!from || !to)
696
+ continue;
697
+ const label = e.label ?? e.type;
698
+ const style = e.type === "FAILED_RUN" ? " -.->|" : " -->|";
699
+ lines.push(` ${from}${style}${escapeMermaid(label)}| ${to}`);
700
+ }
701
+ // Styles
702
+ lines.push("");
703
+ lines.push(" classDef good fill:#eaffea,stroke:#2b8a3e,stroke-width:1px;");
704
+ lines.push(" classDef warn fill:#fff4d6,stroke:#b7791f,stroke-width:1px;");
705
+ lines.push(" classDef bad fill:#ffe3e3,stroke:#c92a2a,stroke-width:1px;");
706
+ lines.push(" classDef unknown fill:#f1f3f5,stroke:#868e96,stroke-width:1px;");
707
+ lines.push(" classDef base fill:#e7f5ff,stroke:#1c7ed6,stroke-width:1px;");
708
+ lines.push(" classDef task fill:#f8f0fc,stroke:#862e9c,stroke-width:1px;");
709
+ // "Legend" node (tiny special touch)
710
+ lines.push("");
711
+ lines.push(' legend["Legend<br/>green=good • yellow=warn • red=bad • purple=task<br/>edge label = dominant failure"]:::unknown');
712
+ return lines.join("\n");
713
+ }
714
+ function escapeMermaid(s) {
715
+ // Keep Mermaid happy inside quotes/edge labels
716
+ return s.replace(/"/g, '\\"').replace(/\|/g, "\\|");
717
+ }
718
+ //# sourceMappingURL=mapRender.js.map