This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.unmaintained-graph: init

+792
+26
tools/unmaintained-graph/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, ... }: 5 + let 6 + d3 = pkgs.fetchurl { 7 + url = "https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"; 8 + hash = "sha256-8glLv2FBs1lyLE/kVOtsSw8OQswQzHr5IfwVj864ZTk="; 9 + }; 10 + in 11 + { 12 + # nix itself is deliberately not a runtime input: use the caller's nix, 13 + # which matches the flake's lock file and the local store. 14 + packages.unmaintained-graph = 15 + (pkgs.writeShellApplication { 16 + name = "unmaintained-graph"; 17 + runtimeInputs = [ pkgs.python3 ]; 18 + text = '' 19 + export UNMAINTAINED_GRAPH_TEMPLATE=${./graph.html} 20 + export UNMAINTAINED_GRAPH_D3=${d3} 21 + exec python3 ${./unmaintained_graph.py} "$@" 22 + ''; 23 + }).overrideAttrs 24 + { passthru.usage = builtins.readFile ./usage.kdl; }; 25 + }; 26 + }
+497
tools/unmaintained-graph/graph.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Unmaintained packages</title> 6 + <style> 7 + :root { 8 + --bg: #0d1117; 9 + --panel: #161b22; 10 + --fg: #c9d1d9; 11 + --dim: #8b949e; 12 + --line: #30363d; 13 + --red: #f85149; 14 + --blue: #58a6ff; 15 + --green: #3fb950; 16 + --yellow: #d29922; 17 + } 18 + * { 19 + box-sizing: border-box; 20 + } 21 + body { 22 + margin: 0; 23 + font: 24 + 13px/1.4 ui-monospace, 25 + SFMono-Regular, 26 + Menlo, 27 + monospace; 28 + background: var(--bg); 29 + color: var(--fg); 30 + display: grid; 31 + grid-template-columns: 340px 1fr 380px; 32 + grid-template-rows: auto 1fr; 33 + height: 100vh; 34 + } 35 + header { 36 + grid-column: 1 / -1; 37 + display: flex; 38 + gap: 12px; 39 + align-items: center; 40 + padding: 8px 12px; 41 + background: var(--panel); 42 + border-bottom: 1px solid var(--line); 43 + } 44 + header h1 { 45 + font-size: 14px; 46 + margin: 0 12px 0 0; 47 + } 48 + input, 49 + select, 50 + button { 51 + font: inherit; 52 + background: var(--bg); 53 + color: var(--fg); 54 + border: 1px solid var(--line); 55 + border-radius: 4px; 56 + padding: 3px 6px; 57 + } 58 + button { 59 + cursor: pointer; 60 + } 61 + button:hover { 62 + border-color: var(--blue); 63 + } 64 + #list { 65 + overflow: auto; 66 + border-right: 1px solid var(--line); 67 + } 68 + #list .row { 69 + display: flex; 70 + justify-content: space-between; 71 + gap: 8px; 72 + padding: 3px 10px; 73 + cursor: pointer; 74 + white-space: nowrap; 75 + } 76 + #list .row:hover, 77 + #list .row.sel { 78 + background: #1f2630; 79 + } 80 + #list .row .n { 81 + overflow: hidden; 82 + text-overflow: ellipsis; 83 + } 84 + #list .row .c { 85 + color: var(--dim); 86 + } 87 + #list .meta { 88 + padding: 6px 10px; 89 + color: var(--dim); 90 + border-bottom: 1px solid var(--line); 91 + } 92 + .inst { 93 + color: var(--yellow); 94 + } 95 + #graph { 96 + position: relative; 97 + overflow: hidden; 98 + } 99 + #graph svg { 100 + width: 100%; 101 + height: 100%; 102 + display: block; 103 + } 104 + #toolbar { 105 + position: absolute; 106 + top: 8px; 107 + left: 8px; 108 + display: flex; 109 + gap: 6px; 110 + flex-wrap: wrap; 111 + } 112 + #legend { 113 + position: absolute; 114 + bottom: 8px; 115 + left: 8px; 116 + color: var(--dim); 117 + background: #161b22cc; 118 + padding: 6px 8px; 119 + border-radius: 4px; 120 + } 121 + #legend span { 122 + margin-right: 10px; 123 + } 124 + .dot { 125 + display: inline-block; 126 + width: 9px; 127 + height: 9px; 128 + border-radius: 50%; 129 + margin-right: 4px; 130 + vertical-align: middle; 131 + } 132 + #details { 133 + overflow: auto; 134 + border-left: 1px solid var(--line); 135 + padding: 10px 12px; 136 + } 137 + #details h2 { 138 + font-size: 14px; 139 + margin: 0 0 6px; 140 + word-break: break-all; 141 + } 142 + #details .k { 143 + color: var(--dim); 144 + } 145 + #details ul { 146 + margin: 4px 0 10px; 147 + padding-left: 16px; 148 + } 149 + #details li { 150 + cursor: pointer; 151 + word-break: break-all; 152 + } 153 + #details li:hover { 154 + color: var(--blue); 155 + } 156 + a { 157 + color: var(--blue); 158 + } 159 + .u { 160 + color: var(--red); 161 + } 162 + text { 163 + fill: var(--fg); 164 + font-size: 10px; 165 + pointer-events: none; 166 + paint-order: stroke; 167 + stroke: var(--bg); 168 + stroke-width: 3px; 169 + } 170 + .link { 171 + stroke: #3d444d; 172 + stroke-width: 1.2; 173 + } 174 + .link.via { 175 + stroke: var(--red); 176 + stroke-dasharray: 4 3; 177 + opacity: 0.7; 178 + } 179 + .node circle { 180 + stroke: var(--bg); 181 + stroke-width: 1.5; 182 + cursor: pointer; 183 + } 184 + .node.focus circle { 185 + stroke: #fff; 186 + stroke-width: 2.5; 187 + } 188 + .node.inst circle { 189 + stroke: var(--yellow); 190 + stroke-width: 2.5; 191 + } 192 + </style> 193 + </head> 194 + <body> 195 + <header> 196 + <h1 id="title">Unmaintained packages</h1> 197 + <label 198 + >machine 199 + <select id="machine"></select 200 + ></label> 201 + <input id="search" placeholder="filter list…" size="24" /> 202 + <label 203 + ><input type="checkbox" id="onlyInst" /> directly installed only</label 204 + > 205 + <span id="summary" class="k"></span> 206 + </header> 207 + <div id="list"></div> 208 + <div id="graph"> 209 + <div id="toolbar"> 210 + <button 211 + id="bExpand" 212 + title="add packages that use the selected node (or double-click a node)" 213 + > 214 + expand users 215 + </button> 216 + <button 217 + id="bPath" 218 + title="all shortest chains from the system toplevel down to the selected node" 219 + > 220 + chains to system 221 + </button> 222 + <button 223 + id="bUnm" 224 + title="nearest unmaintained packages that (transitively) use the selected node; adds the 30 biggest, the side panel lists all" 225 + > 226 + unmaintained users 227 + </button> 228 + <button id="bReset">reset</button> 229 + </div> 230 + <div id="legend"> 231 + <span 232 + ><i class="dot" style="background: var(--red)"></i>unmaintained</span 233 + > 234 + <span><i class="dot" style="background: var(--dim)"></i>other</span> 235 + <span 236 + ><i class="dot" style="background: var(--green)"></i>system-path</span 237 + > 238 + <span 239 + ><i class="dot" style="background: var(--blue)"></i>system 240 + toplevel</span 241 + > 242 + <span 243 + ><i class="dot" style="border: 2px solid var(--yellow)"></i>directly 244 + installed</span 245 + > 246 + <span 247 + >arrow: user → used · dashed: via maintained packages · node size: 248 + #dependents</span 249 + > 250 + </div> 251 + <svg></svg> 252 + </div> 253 + <div id="details"> 254 + <p class="k"> 255 + Pick a package on the left. The graph shows every shortest chain through 256 + which the system (blue, top) pulls it in. Click nodes to inspect, 257 + double-click to expand who uses them. 258 + </p> 259 + </div> 260 + 261 + <script> 262 + /*D3*/ 263 + </script> 264 + <script> 265 + const D = /*DATA*/; 266 + const N = D.names.length, M = D.machines.length; 267 + const isU = new Uint8Array(N); for (const k in D.udep) isU[+k] = 1; 268 + const rootSet = new Set(D.roots); 269 + const children = Array.from({ length: N }, () => []); 270 + D.parents.forEach((ps, c) => { for (const p of ps) children[p].push(c); }); 271 + const direct = new Map(Object.entries(D.direct).map(([k, v]) => [+k, v])); 272 + const nameCount = new Map(); for (const n of D.names) nameCount.set(n, (nameCount.get(n) || 0) + 1); 273 + const label = i => nameCount.get(D.names[i]) > 1 ? `${D.names[i]} (${D.hash[i]})` : D.names[i]; 274 + 275 + const $ = s => document.querySelector(s); 276 + const msel = $("#machine"); 277 + msel.innerHTML = `<option value="-1">all (${M})</option>` + D.machines.map((m, i) => `<option value="${i}">${m}</option>`).join(""); 278 + let mi = -1; 279 + const bitOf = i => 1 << i; 280 + const inMachine = i => mi < 0 || (D.mask[i] & bitOf(mi)); 281 + const depOf = i => mi < 0 ? D.dep[i] : (isU[i] ? D.udep[i][mi] : D.dep[i]); 282 + const instOn = i => direct.get(i) || 0; 283 + const isInst = i => mi < 0 ? instOn(i) !== 0 : (instOn(i) & bitOf(mi)) !== 0; 284 + const machinesOf = bits => D.machines.filter((_, k) => bits & bitOf(k)); 285 + const parentsOf = i => D.parents[i].filter(inMachine); 286 + // D.file[i] = [path shown, link or null]; the generator links nixpkgs files to GitHub when it knows the revision 287 + const fileLink = i => { 288 + const f = D.file[i]; if (!f) return ""; 289 + return f[1] ? `<a target="_blank" href="${f[1]}">${f[0]}</a>` : f[0]; 290 + }; 291 + document.title = $("#title").textContent = D.title; 292 + 293 + // ---------- list ---------- 294 + let selected = -1; 295 + $("#list").addEventListener("click", e => { const r = e.target.closest(".row"); if (r) focusNode(+r.dataset.i); }); 296 + $("#search").addEventListener("input", renderList); 297 + $("#onlyInst").addEventListener("change", renderList); 298 + msel.addEventListener("change", () => { mi = +msel.value; renderList(); if (selected >= 0) focusNode(selected); }); 299 + 300 + // one list row per package name; variants = all drvs with that name (different deps/stages) 301 + const variants = new Map(); 302 + for (const k in D.udep) { const i = +k, n = D.names[i]; if (!variants.has(n)) variants.set(n, []); variants.get(n).push(i); } 303 + function renderList() { 304 + const q = $("#search").value.toLowerCase(), oi = $("#onlyInst").checked; 305 + const rows = []; 306 + for (const [n, vs] of variants) { 307 + if (q && !n.toLowerCase().includes(q)) continue; 308 + const v = vs.filter(inMachine); if (!v.length) continue; 309 + if (oi && !v.some(isInst)) continue; 310 + v.sort((a, b) => depOf(b) - depOf(a)); 311 + rows.push({ n, best: v[0], count: v.length, dep: depOf(v[0]), inst: v.some(isInst) }); 312 + } 313 + rows.sort((a, b) => b.dep - a.dep); 314 + $("#summary").textContent = `${rows.length} unmaintained packages` + (mi < 0 ? " across all machines" : ` on ${D.machines[mi]}`); 315 + const shown = rows.slice(0, 800); 316 + const sel = selected >= 0 ? D.names[selected] : null; 317 + $("#list").innerHTML = `<div class="meta">sorted by #dependents (best variant)${rows.length > shown.length ? ` · showing ${shown.length} of ${rows.length}, filter to narrow` : ""}</div>` + 318 + shown.map(r => `<div class="row${r.n === sel ? " sel" : ""}" data-i="${r.best}"><span class="n">${r.inst ? '<span class="inst">●</span> ' : ""}${r.n}${r.count > 1 ? ` <span class="k">×${r.count}</span>` : ""}</span><span class="c">${r.dep}</span></div>`).join(""); 319 + } 320 + const svg = d3.select("#graph svg"), g = svg.append("g"); 321 + svg.append("defs").append("marker").attr("id", "arrow").attr("viewBox", "0 -4 8 8").attr("refX", 8).attr("refY", 0) 322 + .attr("markerWidth", 7).attr("markerHeight", 7).attr("orient", "auto").append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#3d444d"); 323 + const zoom = d3.zoom().scaleExtent([0.05, 4]).on("zoom", e => g.attr("transform", e.transform)); 324 + svg.call(zoom).on("dblclick.zoom", null); 325 + const gl = g.append("g"), gn = g.append("g"); 326 + let vis = new Map(); // node id -> {id, level, x, y} 327 + let via = []; // [{source, target, steps}] dashed shortcuts 328 + let focusId = -1; 329 + const sim = d3.forceSimulation() 330 + .force("link", d3.forceLink().id(d => d.id).distance(90).strength(0.3)) 331 + .force("charge", d3.forceManyBody().strength(-450)) 332 + .force("collide", d3.forceCollide(d => radius(d.id) + 10)) 333 + .force("y", d3.forceY(d => -d.level * 140).strength(0.6)) 334 + .force("x", d3.forceX(0).strength(0.01)) 335 + .on("tick", tick); 336 + 337 + const radius = i => 4 + 2.2 * Math.log10(Math.max(0, depOf(i)) + 1); 338 + const color = i => rootSet.has(i) ? "var(--blue)" : D.names[i] === "system-path" ? "var(--green)" : isU[i] ? "var(--red)" : "var(--dim)"; 339 + 340 + function add(i, level) { 341 + if (!vis.has(i)) { const near = [...vis.values()].find(n => n.level === level - 1); vis.set(i, { id: i, level, x: (near?.x || 0) + (Math.random() - .5) * 80, y: -level * 110 }); } 342 + } 343 + function expand(i, limit = 40) { 344 + const lv = vis.get(i).level; 345 + const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a))); 346 + ps.slice(0, limit).forEach(p => add(p, lv + 1)); 347 + return ps.length; 348 + } 349 + function bfsUp(start, stop) { // breadth-first over users until stop(i) holds on each branch 350 + const prev = new Map([[start, -1]]), q = [start], hits = []; 351 + for (let h = 0; h < q.length; h++) { 352 + const i = q[h]; 353 + if (i !== start && stop(i)) { hits.push(i); continue; } 354 + for (const p of parentsOf(i)) if (!prev.has(p)) { prev.set(p, i); q.push(p); } 355 + } 356 + return { prev, hits }; 357 + } 358 + // distance (in "uses" steps) from i up to every node that transitively uses it 359 + function distUp(i) { 360 + const d = new Map([[i, 0]]), q = [i]; 361 + for (let h = 0; h < q.length; h++) { const v = q[h]; for (const p of parentsOf(v)) if (!d.has(p)) { d.set(p, d.get(v) + 1); q.push(p); } } 362 + return d; 363 + } 364 + // how far the selected machine's system (or the nearest one, for "all") is above i 365 + function systemDistance(i, d = distUp(i)) { 366 + const reach = (mi < 0 ? D.roots : [D.roots[mi]]).filter(r => d.has(r)); 367 + if (!reach.length) return null; 368 + const L = Math.min(...reach.map(r => d.get(r))); 369 + return { L, roots: reach.filter(r => d.get(r) === L) }; 370 + } 371 + // add every node on a shortest chain system -> ... -> i (walk down from the roots along edges that get one step closer to i) 372 + function chainsToSystem(i, cap = 150) { 373 + const d = distUp(i), s = systemDistance(i, d); 374 + if (!s) return null; 375 + const on = new Set(s.roots), q = [...s.roots]; 376 + for (let h = 0; h < q.length && on.size < cap; h++) { 377 + const v = q[h]; 378 + for (const c of children[v]) if (inMachine(c) && d.get(c) === d.get(v) - 1 && !on.has(c)) { on.add(c); q.push(c); } 379 + } 380 + const base = vis.get(i)?.level ?? 0; 381 + for (const v of on) add(v, base + d.get(v)); 382 + return { ...s, nodes: on.size, capped: on.size >= cap }; 383 + } 384 + function unmaintainedUsers(i, limit = 30) { // adds the `limit` biggest; the details panel lists all 385 + const { prev, hits } = bfsUp(i, j => isU[j]); 386 + const lv = vis.get(i).level; 387 + hits.sort((a, b) => depOf(b) - depOf(a)); 388 + for (const h of hits.slice(0, limit)) { 389 + let steps = 0; for (let j = h; j !== i; j = prev.get(j)) steps++; 390 + add(h, lv + 1); 391 + if (steps > 1) via.push({ source: h, target: i, steps }); 392 + } 393 + return hits; 394 + } 395 + 396 + function draw() { 397 + const nodes = [...vis.values()]; 398 + const links = []; 399 + for (const n of nodes) for (const p of D.parents[n.id]) if (vis.has(p) && inMachine(p)) links.push({ source: p, target: n.id }); 400 + const vlinks = via.filter(v => vis.has(v.source) && vis.has(v.target)).map(v => ({ ...v, via: true })); 401 + const all = links.concat(vlinks); 402 + const ls = gl.selectAll("line").data(all, d => `${d.source.id ?? d.source}-${d.target.id ?? d.target}-${!!d.via}`); 403 + ls.exit().remove(); 404 + ls.enter().append("line").attr("class", d => d.via ? "link via" : "link").attr("marker-end", "url(#arrow)") 405 + .append("title").text(d => d.via ? `uses via ${d.steps - 1} maintained package(s)` : "uses"); 406 + const ns = gn.selectAll("g.node").data(nodes, d => d.id); 407 + ns.exit().remove(); 408 + const ne = ns.enter().append("g").attr("class", "node") 409 + .call(d3.drag().on("start", (e, d) => { if (!e.active) sim.alphaTarget(.3).restart(); d.fx = d.x; d.fy = d.y; }) 410 + .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; }) 411 + .on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = d.fy = null; })) 412 + .on("click", (e, d) => { e.stopPropagation(); showDetails(d.id); }) 413 + .on("dblclick", (e, d) => { e.stopPropagation(); expand(d.id); draw(); showDetails(d.id); }); 414 + ne.append("circle"); 415 + ne.append("text").attr("dy", -9).attr("text-anchor", "middle"); 416 + ne.append("title"); 417 + const nm = ne.merge(ns); 418 + nm.attr("class", d => `node${d.id === focusId ? " focus" : ""}${isInst(d.id) ? " inst" : ""}`); 419 + nm.select("circle").attr("r", d => radius(d.id)).attr("fill", d => color(d.id)); 420 + nm.select("text").text(d => label(d.id)); 421 + nm.select("title").text(d => `${label(d.id)}\n${depOf(d.id)} dependents · ${parentsOf(d.id).length} direct users`); 422 + sim.nodes(nodes); 423 + sim.force("link").links(all); 424 + sim.alpha(0.8).restart(); 425 + fitSoon(); 426 + } 427 + function tick() { 428 + gl.selectAll("line").each(function (d) { 429 + const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y, len = Math.hypot(dx, dy) || 1, r = radius(d.target.id) + 2; 430 + this.setAttribute("x1", d.source.x); this.setAttribute("y1", d.source.y); 431 + this.setAttribute("x2", d.target.x - dx / len * r); this.setAttribute("y2", d.target.y - dy / len * r); 432 + }); 433 + gn.selectAll("g.node").attr("transform", d => `translate(${d.x},${d.y})`); 434 + } 435 + function fit() { 436 + const ns = sim.nodes().filter(n => isFinite(n.x)); if (!ns.length) return; 437 + const { width, height } = svg.node().getBoundingClientRect(), pad = 60; 438 + const [x0, x1] = d3.extent(ns, n => n.x), [y0, y1] = d3.extent(ns, n => n.y); 439 + const k = Math.min(1.5, (width - 2 * pad) / Math.max(1, x1 - x0), (height - 2 * pad) / Math.max(1, y1 - y0)); 440 + svg.transition().duration(400).call(zoom.transform, d3.zoomIdentity.translate(width / 2 - k * (x0 + x1) / 2, height / 2 - k * (y0 + y1) / 2).scale(k)); 441 + } 442 + let fitTimer; const fitSoon = () => { clearTimeout(fitTimer); fitTimer = setTimeout(fit, 700); }; 443 + 444 + function focusNode(i) { 445 + selected = focusId = i; 446 + vis = new Map(); via = []; 447 + gl.selectAll("*").remove(); gn.selectAll("*").remove(); 448 + add(i, 0); 449 + if (!chainsToSystem(i)) expand(i); 450 + draw(); showDetails(i); renderList(); 451 + history.replaceState(null, "", "#" + D.hash[i]); 452 + } 453 + 454 + let detailId = -1; 455 + function showDetails(i) { 456 + detailId = i; 457 + const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a))); 458 + const ms = machinesOf(D.mask[i]), inst = machinesOf(instOn(i)); 459 + const li = j => `<li data-i="${j}" class="${isU[j] ? "u" : ""}">${label(j)} <span class="k">(${depOf(j)})</span></li>`; 460 + $("#details").innerHTML = ` 461 + <h2 class="${isU[i] ? "u" : ""}">${label(i)}</h2> 462 + <div>${isU[i] ? '<span class="u">no maintainer</span>' : '<span class="k">not flagged</span>'}</div> 463 + ${D.file[i] ? `<div><span class="k">file</span> ${fileLink(i)}</div>` : ""} 464 + <div><span class="k">drv</span> ${D.hash[i]}…-${D.names[i]}.drv</div> 465 + <div><span class="k">dependents</span> ${depOf(i)} ${mi < 0 ? "(max over machines)" : "on " + D.machines[mi]}</div> 466 + <div><span class="k">distance to system</span> ${(s => s ? `${s.L} step(s)${mi < 0 ? ` (nearest: ${s.roots.map(r => D.machines[D.roots.indexOf(r)]).join(" ")})` : ""}` : "not reachable")(systemDistance(i))}</div> 467 + ${isU[i] ? `<div><span class="k">per machine</span> ${D.machines.map((m, k) => D.udep[i][k] >= 0 ? `${m}:${D.udep[i][k]}` : "").filter(Boolean).join(" ")}</div>` : ""} 468 + <div><span class="k">in ${ms.length} machine(s)</span> ${ms.join(" ")}</div> 469 + <div><span class="k">directly installed on</span> ${inst.length ? `<span class="inst">${inst.join(" ")}</span>` : "—"}</div> 470 + <p><button id="dFocus">focus here</button> <button id="dExpand">expand users</button></p> 471 + <div class="k">used directly by ${ps.length} (click to add to graph, red = unmaintained):</div> 472 + <ul id="dParents">${ps.slice(0, 300).map(li).join("")}${ps.length > 300 ? `<li class="k">… ${ps.length - 300} more</li>` : ""}</ul> 473 + ${(variants.get(D.names[i]) || []).length > 1 ? `<div class="k">other variants of ${D.names[i]} (separate derivations, e.g. bootstrap stages):</div><ul id="dVar">${variants.get(D.names[i]).filter(j => j !== i && inMachine(j)).sort((a, b) => depOf(b) - depOf(a)).map(li).join("")}</ul>` : ""} 474 + <div id="dUnm"></div>`; 475 + $("#dFocus").onclick = () => focusNode(i); 476 + $("#dExpand").onclick = () => { if (!vis.has(i)) add(i, 0); expand(i, 1e9); draw(); }; 477 + $("#dParents").onclick = e => { const l = e.target.closest("li[data-i]"); if (!l) return; const j = +l.dataset.i; if (!vis.has(i)) add(i, 0); add(j, vis.get(i).level + 1); draw(); }; 478 + if ($("#dVar")) $("#dVar").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); }; 479 + if (isU[i]) { 480 + const { hits } = bfsUp(i, j => isU[j]); 481 + hits.sort((a, b) => depOf(b) - depOf(a)); 482 + $("#dUnm").innerHTML = `<div class="k">nearest unmaintained users (${hits.length}):</div><ul>${hits.slice(0, 200).map(li).join("")}</ul>`; 483 + $("#dUnm").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); }; 484 + } 485 + } 486 + const needSel = f => () => { if (detailId < 0) return; if (!vis.has(detailId)) add(detailId, 0); f(detailId); draw(); }; 487 + $("#bExpand").onclick = needSel(i => expand(i)); 488 + $("#bPath").onclick = needSel(i => chainsToSystem(i)); 489 + $("#bUnm").onclick = needSel(unmaintainedUsers); 490 + $("#bReset").onclick = () => { if (focusId >= 0) focusNode(focusId); }; 491 + 492 + renderList(); 493 + const h = location.hash.slice(1); const hi = h ? D.hash.indexOf(h) : -1; 494 + if (hi >= 0) focusNode(hi); 495 + </script> 496 + </body> 497 + </html>
+251
tools/unmaintained-graph/unmaintained_graph.py
··· 1 + """Render an interactive HTML graph of the packages without maintainers in 2 + the system closures of a flake's NixOS/nix-darwin machines. 3 + 4 + Each machine is evaluated with nixpkgs' `maintainerless` problem switched to 5 + "warn" (via extendModules, so the flake itself is not changed and no 6 + derivation changes). The warnings name the unmaintained packages; the graph 7 + comes from `nix derivation show -r` on the system derivation, so build 8 + dependencies are included. 9 + """ 10 + 11 + import argparse 12 + import concurrent.futures 13 + import json 14 + import os 15 + import re 16 + import subprocess 17 + import sys 18 + from collections import defaultdict 19 + from pathlib import Path 20 + 21 + WARNING = re.compile( 22 + r"evaluation warning: Package '([^']+)' in (/nix/store/[^:\s]+):\d+ " 23 + r"has the following problem: maintainerless" 24 + ) 25 + MAX_MACHINES = 31 # the page stores machine membership in 32-bit masks 26 + 27 + EVAL_EXPR = """ 28 + let 29 + flake = builtins.getFlake %(flake)s; 30 + base = flake.%(kind)sConfigurations.${%(machine)s}; 31 + sys = base.extendModules { 32 + modules = [ 33 + { nixpkgs.config.problems.matchers = [ { kind = "maintainerless"; handler = "warn"; } ]; } 34 + ]; 35 + }; 36 + top = if %(kind_str)s == "nixos" then sys.config.system.build.toplevel else sys.system; 37 + in 38 + { 39 + drv = top.drvPath; 40 + # warnings point into the source nixpkgs was evaluated from, which pkgs.path may only be a copy of 41 + nixpkgs = sys.pkgs.hello.meta.position; 42 + rev = sys.config.system.nixos.revision or sys.config.system.nixpkgsRevision or ""; 43 + } 44 + """ 45 + 46 + 47 + def log(msg): 48 + print(msg, file=sys.stderr, flush=True) 49 + 50 + 51 + def nix_str(s): 52 + return json.dumps(s) # a JSON string literal is a valid Nix string for these inputs 53 + 54 + 55 + def nix_eval_json(expr): 56 + return subprocess.run( 57 + ["nix", "eval", "--impure", "--json", "--expr", expr], 58 + capture_output=True, 59 + text=True, 60 + ) 61 + 62 + 63 + def discover(flake): 64 + expr = ( 65 + f"let f = builtins.getFlake {nix_str(flake)}; in {{" 66 + " nixos = builtins.attrNames (f.nixosConfigurations or { });" 67 + " darwin = builtins.attrNames (f.darwinConfigurations or { }); }" 68 + ) 69 + proc = nix_eval_json(expr) 70 + if proc.returncode != 0: 71 + sys.exit(f"could not list machines of {flake}:\n{proc.stderr[-2000:]}") 72 + found = json.loads(proc.stdout) 73 + return [(m, "nixos") for m in found["nixos"]] + [(m, "darwin") for m in found["darwin"]] 74 + 75 + 76 + def evaluate(flake, machine, kind): 77 + expr = EVAL_EXPR % { 78 + "flake": nix_str(flake), 79 + "kind": kind, 80 + "kind_str": nix_str(kind), 81 + "machine": nix_str(machine), 82 + } 83 + proc = nix_eval_json(expr) 84 + if proc.returncode != 0: 85 + lines = proc.stderr.strip().splitlines() 86 + raise RuntimeError(lines[-1] if lines else "evaluation failed") 87 + result = json.loads(proc.stdout) 88 + result["warned"] = sorted(set(WARNING.findall(proc.stderr))) 89 + return result 90 + 91 + 92 + def derivation_graph(drv): 93 + """{drv basename: (name, [input drv basenames])} for the whole build closure of drv.""" 94 + proc = subprocess.run(["nix", "derivation", "show", "-r", drv], capture_output=True, text=True) 95 + if proc.returncode != 0: 96 + raise RuntimeError(proc.stderr.strip()[-500:]) 97 + data = json.loads(proc.stdout) 98 + data = data.get("derivations", data) # newer nix nests them 99 + graph = {} 100 + for path, d in data.items(): 101 + inputs = d.get("inputDrvs") or d.get("inputs", {}).get("drvs") or {} 102 + graph[os.path.basename(path)] = (d["name"], [os.path.basename(p) for p in inputs]) 103 + return graph 104 + 105 + 106 + def dependents_counts(children): 107 + """How many nodes transitively depend on each node (bitset DP in topological order).""" 108 + n = len(children) 109 + indeg = [0] * n 110 + for cs in children: 111 + for c in cs: 112 + indeg[c] += 1 113 + order = [i for i in range(n) if indeg[i] == 0] 114 + for i in order: # grows while iterating 115 + for c in children[i]: 116 + indeg[c] -= 1 117 + if indeg[c] == 0: 118 + order.append(c) 119 + anc = [0] * n # bitset of strict ancestors 120 + for i in order: 121 + a = anc[i] | (1 << i) 122 + for c in children[i]: 123 + anc[c] |= a 124 + return [bin(a).count("1") for a in anc] 125 + 126 + 127 + def file_entry(path, sources): 128 + """[path to show, GitHub link or None] for the file a warning points at.""" 129 + for src, rev in sources.items(): 130 + if path.startswith(src + "/"): 131 + rel = path[len(src) + 1 :] 132 + link = re.fullmatch(r"[0-9a-f]{7,40}", rev or "") 133 + return [rel, f"https://github.com/NixOS/nixpkgs/blob/{rev}/{rel}" if link else None] 134 + m = re.match(r"/nix/store/[^/]+/(.*)", path) 135 + return [(m.group(1) if m else path) + " (not nixpkgs)", None] 136 + 137 + 138 + def build_data(title, results, graphs): 139 + mach = sorted(results) 140 + # "<nixpkgs>/pkgs/by-name/he/hello/package.nix:42" -> "<nixpkgs>" 141 + sources = {r["nixpkgs"].rsplit("/pkgs/", 1)[0]: r["rev"] for r in results.values()} 142 + warned = {name: path for m in mach for name, path in results[m]["warned"]} 143 + 144 + key2i, names, keys, parents, mask = {}, [], [], [], [] 145 + direct = defaultdict(int) # node -> machines where it is a direct input of system-path 146 + dep = defaultdict(dict) # node -> {machine index: dependents} 147 + roots = [] 148 + 149 + def nid(key, name): 150 + i = key2i.get(key) 151 + if i is None: 152 + i = key2i[key] = len(names) 153 + names.append(name) 154 + keys.append(key) 155 + parents.append(set()) 156 + mask.append(0) 157 + return i 158 + 159 + for mi, m in enumerate(mach): 160 + g = graphs[m] 161 + local = list(g) 162 + loc = {k: j for j, k in enumerate(local)} 163 + gid = [nid(k, g[k][0]) for k in local] 164 + children = [[loc[c] for c in g[k][1] if c in loc] for k in local] 165 + for j, cs in enumerate(children): 166 + mask[gid[j]] |= 1 << mi 167 + for c in cs: 168 + parents[gid[c]].add(gid[j]) 169 + if g[local[j]][0] == "system-path": 170 + for c in cs: 171 + direct[gid[c]] |= 1 << mi 172 + for j, count in enumerate(dependents_counts(children)): 173 + dep[gid[j]][mi] = count 174 + roots.append(key2i[os.path.basename(results[m]["drv"])]) 175 + 176 + unmaintained = [i for i, n in enumerate(names) if n in warned] 177 + data = { 178 + "title": title, 179 + "machines": mach, 180 + "roots": roots, 181 + "names": names, 182 + "hash": [k[:8] for k in keys], 183 + "mask": mask, 184 + "direct": {str(i): v for i, v in direct.items()}, 185 + "dep": [max(dep[i].values()) for i in range(len(names))], 186 + "udep": {str(i): [dep[i].get(k, -1) for k in range(len(mach))] for i in unmaintained}, 187 + "file": {str(i): file_entry(warned[names[i]], sources) for i in unmaintained}, 188 + "parents": [sorted(p) for p in parents], 189 + } 190 + return data, len({names[i] for i in unmaintained}) 191 + 192 + 193 + def main(): 194 + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 195 + ap.add_argument("--flake", default=".", help="flake to inspect (default: .)") 196 + ap.add_argument( 197 + "-m", 198 + "--machine", 199 + action="append", 200 + default=[], 201 + help="only this machine (repeatable; default: all nixos/darwin configurations)", 202 + ) 203 + ap.add_argument("-o", "--output", default="unmaintained-graph.html", help="output HTML file") 204 + ap.add_argument("-j", "--jobs", type=int, default=4, help="parallel evaluations (default: 4)") 205 + ap.add_argument("--title", help="page title (default: derived from the flake)") 206 + args = ap.parse_args() 207 + 208 + flake = args.flake 209 + if os.path.exists(flake): 210 + flake = str(Path(flake).resolve()) 211 + machines = discover(flake) 212 + if args.machine: 213 + known = {m for m, _ in machines} 214 + missing = [m for m in args.machine if m not in known] 215 + if missing: 216 + sys.exit(f"unknown machine(s): {', '.join(missing)}") 217 + machines = [(m, k) for m, k in machines if m in args.machine] 218 + if not machines: 219 + sys.exit("no nixosConfigurations/darwinConfigurations found") 220 + if len(machines) > MAX_MACHINES: 221 + sys.exit(f"{len(machines)} machines, at most {MAX_MACHINES} supported; pick some with --machine") 222 + 223 + results = {} 224 + with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool: 225 + futures = {pool.submit(evaluate, flake, m, k): m for m, k in machines} 226 + for fut in concurrent.futures.as_completed(futures): 227 + m = futures[fut] 228 + try: 229 + results[m] = fut.result() 230 + except Exception as e: # report and keep going with the other machines 231 + log(f"[skip] {m}: {e}") 232 + continue 233 + log(f"[{len(results)}/{len(machines)}] {m}: {len(results[m]['warned'])} unmaintained") 234 + if not results: 235 + sys.exit("every evaluation failed") 236 + 237 + with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool: 238 + graphs = dict(zip(results, pool.map(lambda m: derivation_graph(results[m]["drv"]), results))) 239 + 240 + title = args.title or f"Unmaintained packages in {Path(flake).name if os.path.isabs(flake) else flake}" 241 + data, count = build_data(title, results, graphs) 242 + 243 + template = Path(os.environ["UNMAINTAINED_GRAPH_TEMPLATE"]).read_text() 244 + d3 = Path(os.environ["UNMAINTAINED_GRAPH_D3"]).read_text() 245 + payload = json.dumps(data, separators=(",", ":")).replace("</", "<\\/") 246 + Path(args.output).write_text(template.replace("/*D3*/", d3, 1).replace("/*DATA*/", payload, 1)) 247 + log(f"wrote {args.output}: {count} unmaintained packages across {len(results)} machine(s)") 248 + 249 + 250 + if __name__ == "__main__": 251 + main()
+18
tools/unmaintained-graph/usage.kdl
··· 1 + bin "unmaintained-graph" 2 + about "Render an interactive HTML graph of the packages without maintainers in the system closures (build dependencies included) of a flake's NixOS/nix-darwin machines" 3 + 4 + flag "--flake" help="Flake to inspect (default: .)" { 5 + arg "<flakeref>" 6 + } 7 + flag "-m --machine" help="Only this machine; repeatable (default: all nixosConfigurations and darwinConfigurations)" var=#true { 8 + arg "<name>" 9 + } 10 + flag "-o --output" help="Output HTML file (default: unmaintained-graph.html)" { 11 + arg "<file>" 12 + } 13 + flag "-j --jobs" help="Parallel evaluations (default: 4)" { 14 + arg "<n>" 15 + } 16 + flag "--title" help="Page title (default: derived from the flake)" { 17 + arg "<title>" 18 + }