···11+"""Render an interactive HTML graph of the packages without maintainers in
22+the system closures of a flake's NixOS/nix-darwin machines.
33+44+Each machine is evaluated with nixpkgs' `maintainerless` problem switched to
55+"warn" (via extendModules, so the flake itself is not changed and no
66+derivation changes). The warnings name the unmaintained packages; the graph
77+comes from `nix derivation show -r` on the system derivation, so build
88+dependencies are included.
99+"""
1010+1111+import argparse
1212+import concurrent.futures
1313+import json
1414+import os
1515+import re
1616+import subprocess
1717+import sys
1818+from collections import defaultdict
1919+from pathlib import Path
2020+2121+WARNING = re.compile(
2222+ r"evaluation warning: Package '([^']+)' in (/nix/store/[^:\s]+):\d+ "
2323+ r"has the following problem: maintainerless"
2424+)
2525+MAX_MACHINES = 31 # the page stores machine membership in 32-bit masks
2626+2727+EVAL_EXPR = """
2828+let
2929+ flake = builtins.getFlake %(flake)s;
3030+ base = flake.%(kind)sConfigurations.${%(machine)s};
3131+ sys = base.extendModules {
3232+ modules = [
3333+ { nixpkgs.config.problems.matchers = [ { kind = "maintainerless"; handler = "warn"; } ]; }
3434+ ];
3535+ };
3636+ top = if %(kind_str)s == "nixos" then sys.config.system.build.toplevel else sys.system;
3737+in
3838+{
3939+ drv = top.drvPath;
4040+ # warnings point into the source nixpkgs was evaluated from, which pkgs.path may only be a copy of
4141+ nixpkgs = sys.pkgs.hello.meta.position;
4242+ rev = sys.config.system.nixos.revision or sys.config.system.nixpkgsRevision or "";
4343+}
4444+"""
4545+4646+4747+def log(msg):
4848+ print(msg, file=sys.stderr, flush=True)
4949+5050+5151+def nix_str(s):
5252+ return json.dumps(s) # a JSON string literal is a valid Nix string for these inputs
5353+5454+5555+def nix_eval_json(expr):
5656+ return subprocess.run(
5757+ ["nix", "eval", "--impure", "--json", "--expr", expr],
5858+ capture_output=True,
5959+ text=True,
6060+ )
6161+6262+6363+def discover(flake):
6464+ expr = (
6565+ f"let f = builtins.getFlake {nix_str(flake)}; in {{"
6666+ " nixos = builtins.attrNames (f.nixosConfigurations or { });"
6767+ " darwin = builtins.attrNames (f.darwinConfigurations or { }); }"
6868+ )
6969+ proc = nix_eval_json(expr)
7070+ if proc.returncode != 0:
7171+ sys.exit(f"could not list machines of {flake}:\n{proc.stderr[-2000:]}")
7272+ found = json.loads(proc.stdout)
7373+ return [(m, "nixos") for m in found["nixos"]] + [(m, "darwin") for m in found["darwin"]]
7474+7575+7676+def evaluate(flake, machine, kind):
7777+ expr = EVAL_EXPR % {
7878+ "flake": nix_str(flake),
7979+ "kind": kind,
8080+ "kind_str": nix_str(kind),
8181+ "machine": nix_str(machine),
8282+ }
8383+ proc = nix_eval_json(expr)
8484+ if proc.returncode != 0:
8585+ lines = proc.stderr.strip().splitlines()
8686+ raise RuntimeError(lines[-1] if lines else "evaluation failed")
8787+ result = json.loads(proc.stdout)
8888+ result["warned"] = sorted(set(WARNING.findall(proc.stderr)))
8989+ return result
9090+9191+9292+def derivation_graph(drv):
9393+ """{drv basename: (name, [input drv basenames])} for the whole build closure of drv."""
9494+ proc = subprocess.run(["nix", "derivation", "show", "-r", drv], capture_output=True, text=True)
9595+ if proc.returncode != 0:
9696+ raise RuntimeError(proc.stderr.strip()[-500:])
9797+ data = json.loads(proc.stdout)
9898+ data = data.get("derivations", data) # newer nix nests them
9999+ graph = {}
100100+ for path, d in data.items():
101101+ inputs = d.get("inputDrvs") or d.get("inputs", {}).get("drvs") or {}
102102+ graph[os.path.basename(path)] = (d["name"], [os.path.basename(p) for p in inputs])
103103+ return graph
104104+105105+106106+def dependents_counts(children):
107107+ """How many nodes transitively depend on each node (bitset DP in topological order)."""
108108+ n = len(children)
109109+ indeg = [0] * n
110110+ for cs in children:
111111+ for c in cs:
112112+ indeg[c] += 1
113113+ order = [i for i in range(n) if indeg[i] == 0]
114114+ for i in order: # grows while iterating
115115+ for c in children[i]:
116116+ indeg[c] -= 1
117117+ if indeg[c] == 0:
118118+ order.append(c)
119119+ anc = [0] * n # bitset of strict ancestors
120120+ for i in order:
121121+ a = anc[i] | (1 << i)
122122+ for c in children[i]:
123123+ anc[c] |= a
124124+ return [bin(a).count("1") for a in anc]
125125+126126+127127+def file_entry(path, sources):
128128+ """[path to show, GitHub link or None] for the file a warning points at."""
129129+ for src, rev in sources.items():
130130+ if path.startswith(src + "/"):
131131+ rel = path[len(src) + 1 :]
132132+ link = re.fullmatch(r"[0-9a-f]{7,40}", rev or "")
133133+ return [rel, f"https://github.com/NixOS/nixpkgs/blob/{rev}/{rel}" if link else None]
134134+ m = re.match(r"/nix/store/[^/]+/(.*)", path)
135135+ return [(m.group(1) if m else path) + " (not nixpkgs)", None]
136136+137137+138138+def build_data(title, results, graphs):
139139+ mach = sorted(results)
140140+ # "<nixpkgs>/pkgs/by-name/he/hello/package.nix:42" -> "<nixpkgs>"
141141+ sources = {r["nixpkgs"].rsplit("/pkgs/", 1)[0]: r["rev"] for r in results.values()}
142142+ warned = {name: path for m in mach for name, path in results[m]["warned"]}
143143+144144+ key2i, names, keys, parents, mask = {}, [], [], [], []
145145+ direct = defaultdict(int) # node -> machines where it is a direct input of system-path
146146+ dep = defaultdict(dict) # node -> {machine index: dependents}
147147+ roots = []
148148+149149+ def nid(key, name):
150150+ i = key2i.get(key)
151151+ if i is None:
152152+ i = key2i[key] = len(names)
153153+ names.append(name)
154154+ keys.append(key)
155155+ parents.append(set())
156156+ mask.append(0)
157157+ return i
158158+159159+ for mi, m in enumerate(mach):
160160+ g = graphs[m]
161161+ local = list(g)
162162+ loc = {k: j for j, k in enumerate(local)}
163163+ gid = [nid(k, g[k][0]) for k in local]
164164+ children = [[loc[c] for c in g[k][1] if c in loc] for k in local]
165165+ for j, cs in enumerate(children):
166166+ mask[gid[j]] |= 1 << mi
167167+ for c in cs:
168168+ parents[gid[c]].add(gid[j])
169169+ if g[local[j]][0] == "system-path":
170170+ for c in cs:
171171+ direct[gid[c]] |= 1 << mi
172172+ for j, count in enumerate(dependents_counts(children)):
173173+ dep[gid[j]][mi] = count
174174+ roots.append(key2i[os.path.basename(results[m]["drv"])])
175175+176176+ unmaintained = [i for i, n in enumerate(names) if n in warned]
177177+ data = {
178178+ "title": title,
179179+ "machines": mach,
180180+ "roots": roots,
181181+ "names": names,
182182+ "hash": [k[:8] for k in keys],
183183+ "mask": mask,
184184+ "direct": {str(i): v for i, v in direct.items()},
185185+ "dep": [max(dep[i].values()) for i in range(len(names))],
186186+ "udep": {str(i): [dep[i].get(k, -1) for k in range(len(mach))] for i in unmaintained},
187187+ "file": {str(i): file_entry(warned[names[i]], sources) for i in unmaintained},
188188+ "parents": [sorted(p) for p in parents],
189189+ }
190190+ return data, len({names[i] for i in unmaintained})
191191+192192+193193+def main():
194194+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
195195+ ap.add_argument("--flake", default=".", help="flake to inspect (default: .)")
196196+ ap.add_argument(
197197+ "-m",
198198+ "--machine",
199199+ action="append",
200200+ default=[],
201201+ help="only this machine (repeatable; default: all nixos/darwin configurations)",
202202+ )
203203+ ap.add_argument("-o", "--output", default="unmaintained-graph.html", help="output HTML file")
204204+ ap.add_argument("-j", "--jobs", type=int, default=4, help="parallel evaluations (default: 4)")
205205+ ap.add_argument("--title", help="page title (default: derived from the flake)")
206206+ args = ap.parse_args()
207207+208208+ flake = args.flake
209209+ if os.path.exists(flake):
210210+ flake = str(Path(flake).resolve())
211211+ machines = discover(flake)
212212+ if args.machine:
213213+ known = {m for m, _ in machines}
214214+ missing = [m for m in args.machine if m not in known]
215215+ if missing:
216216+ sys.exit(f"unknown machine(s): {', '.join(missing)}")
217217+ machines = [(m, k) for m, k in machines if m in args.machine]
218218+ if not machines:
219219+ sys.exit("no nixosConfigurations/darwinConfigurations found")
220220+ if len(machines) > MAX_MACHINES:
221221+ sys.exit(f"{len(machines)} machines, at most {MAX_MACHINES} supported; pick some with --machine")
222222+223223+ results = {}
224224+ with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool:
225225+ futures = {pool.submit(evaluate, flake, m, k): m for m, k in machines}
226226+ for fut in concurrent.futures.as_completed(futures):
227227+ m = futures[fut]
228228+ try:
229229+ results[m] = fut.result()
230230+ except Exception as e: # report and keep going with the other machines
231231+ log(f"[skip] {m}: {e}")
232232+ continue
233233+ log(f"[{len(results)}/{len(machines)}] {m}: {len(results[m]['warned'])} unmaintained")
234234+ if not results:
235235+ sys.exit("every evaluation failed")
236236+237237+ with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool:
238238+ graphs = dict(zip(results, pool.map(lambda m: derivation_graph(results[m]["drv"]), results)))
239239+240240+ title = args.title or f"Unmaintained packages in {Path(flake).name if os.path.isabs(flake) else flake}"
241241+ data, count = build_data(title, results, graphs)
242242+243243+ template = Path(os.environ["UNMAINTAINED_GRAPH_TEMPLATE"]).read_text()
244244+ d3 = Path(os.environ["UNMAINTAINED_GRAPH_D3"]).read_text()
245245+ payload = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
246246+ Path(args.output).write_text(template.replace("/*D3*/", d3, 1).replace("/*DATA*/", payload, 1))
247247+ log(f"wrote {args.output}: {count} unmaintained packages across {len(results)} machine(s)")
248248+249249+250250+if __name__ == "__main__":
251251+ main()