feat: sync 12 new skills into catalog (total 31 skills), update manifest and README

This commit is contained in:
2026-08-27 11:47:43 +07:00
parent e1490f65ca
commit ab57d1ea81
87 changed files with 15489 additions and 68 deletions

View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Animate how a codebase's architecture grew, across its git history.
Walks the git history of a directory, re-runs one of the bundled importers at
each sampled commit (the tree is pulled with ``git archive`` — the working copy
is never touched), lays each out and exports a PNG frame, then assembles a
single self-contained HTML player (frames embedded as base64, play / step
controls, no external files or CDNs). Open it in any browser to watch the
modules and edges appear over time.
python3 timelapse.py skills/drawio-skill/scripts --importer pyimports
# -> architecture-evolution.html
The importer is any of the bundled graph extractors (pyimports, jsimports,
goimports, rustimports, pyclasses, tfimports, k8simports, composeimports,
sqlerd); it is run against the archived directory with the same positional
"path" argument they all take, so point the path at the project/package/infra
root the importer expects. Extra importer flags pass through via
``--importer-args`` (e.g. ``--importer-args "--group"``).
Commits that touched the directory are sampled evenly (always keeping the first
and last) down to ``--max-frames``; a commit where the importer finds nothing
(the path did not exist yet) is skipped. Needs git, the importer's requirements,
Graphviz (autolayout) and the draw.io CLI — the same tools the importers use.
Usage: python3 timelapse.py <dir> [--importer NAME] [--importer-args STR]
[--max-frames N] [-o out.html] [--direction TB|LR] [--keep-frames]
"""
import argparse
import base64
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
IMPORTERS = {"pyimports", "jsimports", "goimports", "rustimports", "pyclasses",
"tfimports", "k8simports", "composeimports", "sqlerd"}
def git(root, *args):
"""Run a git command in `root`; return (returncode, stdout_bytes)."""
p = subprocess.run(["git", "-C", root, *args], capture_output=True)
return p.returncode, p.stdout
def sample_indices(total, n):
"""Evenly-spaced indices across range(total), always incl. first and last.
For total <= n every index is kept; otherwise n indices are picked so the
first (0) and last (total-1) are always present and the rest are spread
uniformly between them.
"""
if total <= 0:
return []
if total <= n or n <= 1:
return list(range(total))
return sorted({round(i * (total - 1) / (n - 1)) for i in range(n)})
def history(root, subpath):
"""Chronological [(hash, iso_date, subject), ...] of commits touching subpath."""
code, out = git(root, "log", "--format=%H%x09%aI%x09%s", "--", subpath or ".")
if code != 0:
return []
rows = []
for line in out.decode("utf-8", "replace").splitlines():
parts = line.split("\t", 2)
if len(parts) == 3:
rows.append(tuple(parts))
return list(reversed(rows)) # oldest first
def extract_tree(root, commit, subpath, dest):
"""Extract subpath of `commit` into dest via git archive. False if absent."""
code, tar_bytes = git(root, "archive", commit, subpath or ".")
if code != 0 or not tar_bytes:
return False
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf:
tf.extractall(dest) # git-authored tar; trusted
return True
def build_frame(importer, importer_args, work_path, direction, tmp):
"""Importer -> autolayout -> PNG for one commit. Returns (png_bytes, n, e) or None."""
graph_json = os.path.join(tmp, "graph.json")
imp = subprocess.run(
[sys.executable, os.path.join(HERE, importer + ".py"), work_path,
"-o", graph_json, *importer_args],
capture_output=True)
if imp.returncode != 0 or not os.path.exists(graph_json):
return None
with open(graph_json, encoding="utf-8") as f:
graph = json.loads(f.read())
if not graph.get("nodes"):
return None
graph["direction"] = direction
with open(graph_json, "w", encoding="utf-8") as f:
f.write(json.dumps(graph))
drawio = os.path.join(tmp, "frame.drawio")
lay = subprocess.run(
[sys.executable, os.path.join(HERE, "autolayout.py"), graph_json, "-o", drawio],
capture_output=True)
if lay.returncode != 0 or not os.path.exists(drawio):
return None
png = os.path.join(tmp, "frame.png")
exp = subprocess.run(["drawio", "-x", "-f", "png", "--width", "1600",
"-o", png, drawio], capture_output=True)
if exp.returncode != 0 or not os.path.exists(png):
return None
with open(png, "rb") as f:
png_data = f.read()
return png_data, len(graph["nodes"]), len(graph["edges"])
def build_html(frames, title):
"""Self-contained HTML player for the frame list."""
data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(),
"hash": h[:9], "date": d[:10], "subj": s, "n": n, "e": e}
for png, h, d, s, n, e in frames]
peak = max((f["n"] for f in data), default=1) or 1
payload = json.dumps(data).replace("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
padding:12px 4px 6px;color:#556;font-size:13px}}
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
.cap b{{color:inherit;font-weight:600}}.cap .subj{{color:#1a1a1a}}
@media(prefers-color-scheme:dark){{.cap .subj{{color:#e8e8e8}}}}
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
button:hover{{border-color:#0d99ff}}
input[type=range]{{flex:1;accent-color:#0d99ff}}
</style></head><body>
<header><h1>{title}</h1></header>
<main>
<div id="stage"><img id="img" alt="architecture frame"></div>
<div class="cap">
<span><b id="idx"></b></span>
<span><b id="hash"></b> · <span id="date"></span></span>
<span class="subj" id="subj"></span>
<span id="counts"></span>
</div>
<div class="barwrap"><div class="bar" id="bar"></div></div>
<div class="ctl">
<button id="prev">‹ Prev</button>
<button id="play">▶ Play</button>
<button id="next">Next ›</button>
<input type="range" id="scrub" min="0" value="0">
</div>
</main>
<script>
const F={payload},PEAK={peak};
let i=0,timer=null;
const $=id=>document.getElementById(id);
$("scrub").max=F.length-1;
function show(k){{
i=(k+F.length)%F.length;const f=F[i];
$("img").src=f.img;$("idx").textContent=`Frame ${{i+1}} / ${{F.length}}`;
$("hash").textContent=f.hash;$("date").textContent=f.date;$("subj").textContent=f.subj;
$("counts").textContent=`${{f.n}} nodes · ${{f.e}} edges`;
$("bar").style.width=(6+94*f.n/PEAK)+"%";$("scrub").value=i;
}}
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
$("prev").onclick=()=>{{stop();show(i-1);}};
$("next").onclick=()=>{{stop();show(i+1);}};
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
$("play").onclick=()=>{{
if(timer){{stop();return;}}
$("play").textContent="⏸ Pause";
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},900);
}};
show(0);
</script></body></html>"""
def main():
ap = argparse.ArgumentParser(description="Git-history architecture time-lapse -> HTML player.")
ap.add_argument("path", help="directory to visualize (inside a git repo)")
ap.add_argument("--importer", default="pyimports",
help="bundled importer to run at each commit (default pyimports)")
ap.add_argument("--importer-args", default="",
help="extra args passed to the importer, e.g. \"--group\"")
ap.add_argument("--max-frames", type=int, default=10, help="sample down to N commits")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("-o", "--output", default="architecture-evolution.html")
ap.add_argument("--keep-frames", action="store_true",
help="also write the PNG frames next to the HTML")
args = ap.parse_args()
importer = args.importer[:-3] if args.importer.endswith(".py") else args.importer
if importer not in IMPORTERS:
sys.exit(f"error: unknown importer {importer!r} (choose one of: "
+ ", ".join(sorted(IMPORTERS)) + ")")
if not os.path.isdir(args.path):
sys.exit(f"error: {args.path} is not a directory")
code, top = git(args.path, "rev-parse", "--show-toplevel")
if code != 0:
sys.exit(f"error: {args.path} is not inside a git repository")
root = top.decode().strip()
subpath = os.path.relpath(os.path.abspath(args.path), root)
if subpath == ".":
subpath = ""
commits = history(root, subpath)
if not commits:
sys.exit(f"error: no commits touch {args.path}")
picked = [commits[i] for i in sample_indices(len(commits), args.max_frames)]
imp_args = args.importer_args.split()
frames = []
for n, (h, date, subj) in enumerate(picked, 1):
sys.stderr.write(f"[{n}/{len(picked)}] {h[:9]} {subj[:50]}\n")
with tempfile.TemporaryDirectory() as tmp:
if not extract_tree(root, h, subpath, tmp):
continue
work = os.path.join(tmp, subpath) if subpath else tmp
frame = build_frame(importer, imp_args, work, args.direction, tmp)
if frame is None:
sys.stderr.write(" (importer found nothing — skipped)\n")
continue
png, nn, ee = frame
frames.append((png, h, date, subj, nn, ee))
if args.keep_frames:
fp = f"{os.path.splitext(args.output)[0]}-frame{len(frames):02d}.png"
with open(fp, "wb") as f:
f.write(png)
if not frames:
sys.exit("error: no frames produced (importer found nothing in any commit)")
title = f"Architecture evolution — {os.path.basename(os.path.abspath(args.path))}"
with open(args.output, "w", encoding="utf-8") as f:
f.write(build_html(frames, title))
sys.stderr.write(f"wrote {args.output} ({len(frames)} frames)\n")
if __name__ == "__main__":
main()