system-diff 0.1.0__py3-none-any.whl

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.
system_diff/png.py ADDED
@@ -0,0 +1,121 @@
1
+ """SVG -> PNG, each FILE.svg -> FILE.png beside it, at twice the size for sharp text.
2
+
3
+ Only for places that show a PNG and not an SVG with its styles (some PR pages, some previews, some proxies); GitHub shows
4
+ the SVG itself. Headless Chromium draws them, in this order: Docker here (the pinned image below only for its Chromium
5
+ and puppeteer), else Playwright if it is installed (`pip install system-diff[png]`, then `playwright install chromium`),
6
+ else Docker on HOST over SSH (`--host`).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tarfile
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ IMAGE = os.getenv("SYSTEM_DIFF_RENDER_IMAGE", "minlag/mermaid-cli:11.4.2")
21
+
22
+ SHOT_JS = r"""// Every /data/N.svg -> /data/N.png: the SVG's own width and height, at device scale 2.
23
+ const fs = require("fs");
24
+ const puppeteer = require("/home/mermaidcli/node_modules/puppeteer");
25
+ const config = JSON.parse(fs.readFileSync("/puppeteer-config.json", "utf8"));
26
+ (async () => {
27
+ const browser = await puppeteer.launch(config);
28
+ const page = await browser.newPage();
29
+ for (const f of fs.readdirSync("/data").filter((x) => x.endsWith(".svg"))) {
30
+ const head = fs.readFileSync(`/data/${f}`, "utf8").slice(0, 400);
31
+ const width = Math.ceil(+(head.match(/\bwidth="([\d.]+)"/) || [0, 1200])[1]);
32
+ const height = Math.ceil(+(head.match(/\bheight="([\d.]+)"/) || [0, 800])[1]);
33
+ await page.setViewport({ width, height, deviceScaleFactor: 2 });
34
+ await page.goto(`file:///data/${f}`, { waitUntil: "load" });
35
+ await page.screenshot({ path: `/data/${f.replace(/\.svg$/, ".png")}`, clip: { x: 0, y: 0, width, height } });
36
+ }
37
+ await browser.close();
38
+ })().catch((e) => { console.error(`render failed: ${e.message}`); process.exit(1); });
39
+ """
40
+ IN_CONTAINER = "cd /data && HOME=/tmp node shot.js"
41
+
42
+
43
+ def _docker_here() -> bool:
44
+ if not shutil.which("docker"):
45
+ return False
46
+ return subprocess.run(["docker", "info"], capture_output=True).returncode == 0
47
+
48
+
49
+ def _playwright(stage: Path, names: List[str]) -> bool:
50
+ try:
51
+ from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
52
+ except ImportError:
53
+ return False
54
+ import re
55
+ with sync_playwright() as p:
56
+ browser = p.chromium.launch()
57
+ for name in names:
58
+ svg = stage / name
59
+ head = svg.read_text(encoding="utf-8")[:400]
60
+ width = int(float((re.search(r'\bwidth="([\d.]+)"', head) or [0, 1200])[1]) + 0.999)
61
+ height = int(float((re.search(r'\bheight="([\d.]+)"', head) or [0, 800])[1]) + 0.999)
62
+ page = browser.new_page(viewport={"width": width, "height": height}, device_scale_factor=2)
63
+ page.goto(svg.resolve().as_uri())
64
+ page.screenshot(path=str(svg.with_suffix(".png")), clip={"x": 0, "y": 0, "width": width, "height": height})
65
+ page.close()
66
+ browser.close()
67
+ return True
68
+
69
+
70
+ def _over_ssh(stage: Path, host: str) -> None:
71
+ buf = io.BytesIO()
72
+ with tarfile.open(fileobj=buf, mode="w") as tar:
73
+ for f in sorted(stage.iterdir()):
74
+ tar.add(f, arcname=f.name)
75
+ remote = ("set -e; d=$(mktemp -d $HOME/.system-diff-render.XXXXXX); trap 'rm -rf $d' EXIT; tar -x -C $d; "
76
+ f"chmod 777 $d; docker run --rm -v $d:/data --entrypoint sh {IMAGE} -c '{IN_CONTAINER}' >&2; "
77
+ "cd $d && tar -cf - *.png")
78
+ got = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", host, remote],
79
+ input=buf.getvalue(), capture_output=True)
80
+ if got.returncode != 0:
81
+ raise RuntimeError(f"drawing on {host} failed: {got.stderr.decode(errors='replace')[-800:]}")
82
+ with tarfile.open(fileobj=io.BytesIO(got.stdout)) as tar:
83
+ for m in tar.getmembers(): # only flat PNGs come back; nothing a remote sends may land elsewhere
84
+ name = Path(m.name).name
85
+ if m.isfile() and name.endswith(".png"):
86
+ (stage / name).write_bytes(tar.extractfile(m).read())
87
+
88
+
89
+ def render(svgs: List[Path], host: Optional[str] = None) -> List[Path]:
90
+ """Draw each SVG as a PNG beside it. Raises RuntimeError naming what is missing when nothing can draw."""
91
+ for f in svgs:
92
+ if f.suffix != ".svg" or not f.is_file():
93
+ raise ValueError(f"not an .svg file: {f}")
94
+ stage = Path(tempfile.mkdtemp(prefix="system-diff-render."))
95
+ try:
96
+ names = []
97
+ for i, f in enumerate(svgs):
98
+ shutil.copyfile(f, stage / f"{i}.svg")
99
+ names.append(f"{i}.svg")
100
+ (stage / "shot.js").write_text(SHOT_JS, encoding="utf-8", newline="\n")
101
+ if _docker_here():
102
+ user = ["-u", f"{os.getuid()}:{os.getgid()}"] if sys.platform.startswith("linux") else []
103
+ done = subprocess.run(["docker", "run", "--rm", *user, "-v", f"{stage}:/data", "--entrypoint", "sh", IMAGE,
104
+ "-c", IN_CONTAINER])
105
+ if done.returncode != 0:
106
+ raise RuntimeError("Chromium could not draw the pictures (output above)")
107
+ elif not _playwright(stage, names):
108
+ if not host:
109
+ raise RuntimeError("nothing here can draw PNGs: no Docker, no Playwright "
110
+ "(`pip install system-diff[png]`), and no --host with Docker")
111
+ _over_ssh(stage, host)
112
+ out = []
113
+ for i, f in enumerate(svgs):
114
+ png = stage / f"{i}.png"
115
+ if not png.is_file():
116
+ raise RuntimeError(f"no picture came back for {f}")
117
+ shutil.copyfile(png, f.with_suffix(".png"))
118
+ out.append(f.with_suffix(".png"))
119
+ return out
120
+ finally:
121
+ shutil.rmtree(stage, ignore_errors=True)
system_diff/readme.py ADDED
@@ -0,0 +1,39 @@
1
+ """The README's generated system-map section: the picture, how it is made, and a warning when the picture is stale."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Iterable
6
+
7
+ from .pictures import write_if_changed
8
+
9
+
10
+ def readme_section(dormant: Dict[str, dict], cfg: Dict[str, Any], stale: Iterable[str] = ()) -> str:
11
+ """The generated block. `stale`: pictures that could not be redrawn this time, named, so no one mistakes an old
12
+ picture for the current system; the SVGs beside them are always current."""
13
+ r = cfg["readme"]
14
+ stale = list(stale)
15
+ tag = ""
16
+ if stale:
17
+ tag = r["stale"].format(pictures=", ".join(f"`{x}`" for x in stale), it="it" if len(stale) == 1 else "them")
18
+ note = ""
19
+ if dormant:
20
+ names = ", ".join(f"{v['label']} ({', '.join(v['used_by'])})" for v in dormant.values())
21
+ note = r["dormant"].format(names=names)
22
+ return (
23
+ f"{r['begin']}\n"
24
+ f"{tag}"
25
+ f"![System map, generated from the code]({r['image']})\n\n"
26
+ "<details><summary>How this map is made</summary>\n\n"
27
+ f"{r['how'].format(dormant=note)}\n\n</details>\n"
28
+ f"{r['end']}"
29
+ )
30
+
31
+
32
+ def update_readme(readme: Path, dormant: Dict[str, dict], cfg: Dict[str, Any], stale: Iterable[str] = ()) -> bool:
33
+ """Rewrite the section between the README's markers. True when the file changed."""
34
+ r = cfg["readme"]
35
+ text = readme.read_text(encoding="utf-8")
36
+ if r["begin"] not in text or r["end"] not in text:
37
+ raise SystemExit(f"{readme} has no {r['begin']} … {r['end']} section: add the two lines where the map goes")
38
+ start, end = text.index(r["begin"]), text.index(r["end"]) + len(r["end"])
39
+ return write_if_changed(readme, text[:start] + readme_section(dormant, cfg, stale) + text[end:])
system_diff/sources.py ADDED
@@ -0,0 +1,76 @@
1
+ """Where the files come from: a commit, read straight from git's object store (no checkout), or a directory."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Dict, Iterable, Optional
9
+
10
+
11
+ def git(*args: str, cwd: Path) -> str:
12
+ return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True,
13
+ encoding="utf-8", errors="replace").stdout
14
+
15
+
16
+ def repo_root(start: Optional[Path] = None) -> Path:
17
+ """The repository holding `start` (default: the current directory), or `start` itself outside one."""
18
+ here = Path(start or Path.cwd()).resolve()
19
+ try:
20
+ return Path(git("rev-parse", "--show-toplevel", cwd=here).strip())
21
+ except (OSError, subprocess.CalledProcessError):
22
+ return here
23
+
24
+
25
+ class GitSource:
26
+ """The files of a commit."""
27
+
28
+ def __init__(self, ref: str, root: Path):
29
+ self.root, self.ref = Path(root), ref
30
+ self.label = git("rev-parse", "--short", ref, cwd=self.root).strip()
31
+ self.files = [f for f in git("ls-tree", "-r", "-z", "--name-only", ref, cwd=self.root).split("\0") if f]
32
+ self._cache: Dict[str, str] = {}
33
+
34
+ def read_many(self, paths: Iterable[str]) -> Dict[str, str]:
35
+ paths = list(paths)
36
+ want = [p for p in paths if p not in self._cache]
37
+ if want:
38
+ out = subprocess.run(["git", "cat-file", "--batch"], cwd=self.root, check=True, capture_output=True,
39
+ input="".join(f"{self.ref}:{p}\n" for p in want).encode()).stdout
40
+ i = 0
41
+ for p in want:
42
+ header_end = out.index(b"\n", i)
43
+ parts = out[i:header_end].split()
44
+ i = header_end + 1
45
+ if len(parts) < 3 or parts[1] == b"missing":
46
+ self._cache[p] = ""
47
+ continue
48
+ size = int(parts[2])
49
+ self._cache[p] = out[i:i + size].decode("utf-8", errors="replace")
50
+ i += size + 1
51
+ return {p: self._cache.get(p, "") for p in paths}
52
+
53
+ def read(self, path: str) -> str:
54
+ return self.read_many([path])[path]
55
+
56
+
57
+ class DirSource:
58
+ """The files of a directory: the working tree, or a test fixture."""
59
+
60
+ def __init__(self, directory: Path, skip: Optional[re.Pattern] = None, label: str = "working tree"):
61
+ self.dir, self.label = Path(directory), label
62
+ files = []
63
+ for top, dirs, names in os.walk(self.dir):
64
+ rel = os.path.relpath(top, self.dir).replace(os.sep, "/")
65
+ dirs[:] = sorted(x for x in dirs if x != ".git" and not (skip and skip.search(f"{rel}/{x}/")))
66
+ files += [(f"{rel}/{n}" if rel != "." else n) for n in names]
67
+ self.files = sorted(files)
68
+
69
+ def read_many(self, paths: Iterable[str]) -> Dict[str, str]:
70
+ return {p: self.read(p) for p in paths}
71
+
72
+ def read(self, path: str) -> str:
73
+ try:
74
+ return (self.dir / path).read_text(encoding="utf-8", errors="replace")
75
+ except OSError:
76
+ return ""
system_diff/watch.py ADDED
@@ -0,0 +1,177 @@
1
+ """`system-diff watch`: a live page of what changed in the system since the checkpoint, for a person to keep beside the
2
+ editor while they, or an AI coding agent, change the code. It redraws when a file changes; "Mark as seen" moves the
3
+ checkpoint, so the page then shows only what is new to them.
4
+
5
+ Local only: the server listens on 127.0.0.1, and "Mark as seen" needs the page's own token, so no other web page open in
6
+ the same browser can press it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import secrets
12
+ import subprocess
13
+ import threading
14
+ import time
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Optional
18
+
19
+ from . import graph
20
+ from .compare import checkpoint, checkpoint_sha, compare, worktree_tree
21
+
22
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8"><title>System diff</title>
23
+ <meta name="viewport" content="width=device-width, initial-scale=1">
24
+ <style>
25
+ :root { --ink:#161c24; --muted:#667385; --line:#e1e5eb; --bg:#f8f9fb; --work:#1f5fbf; }
26
+ * { box-sizing: border-box; }
27
+ body { margin:0; font:14px/1.45 'Segoe UI', FreeSans, 'Helvetica Neue', Arial, sans-serif; color:var(--ink); background:var(--bg); }
28
+ header { position:sticky; top:0; display:flex; flex-wrap:wrap; gap:12px; align-items:center; padding:12px 20px;
29
+ background:#fff; border-bottom:1px solid var(--line); }
30
+ h1 { font-size:15px; margin:0; } .since { color:var(--muted); font-size:12.5px; }
31
+ button { margin-left:auto; border:1px solid var(--work); background:var(--work); color:#fff; border-radius:8px;
32
+ padding:6px 14px; font:inherit; font-weight:600; cursor:pointer; } button:disabled { opacity:.5; cursor:default; }
33
+ main { padding:16px 20px; display:grid; gap:14px; }
34
+ .headline { font-size:16px; font-weight:600; } .error { color:#c4321c; white-space:pre-wrap; }
35
+ .picture { overflow:auto; background:#fff; border:1px solid var(--line); border-radius:10px; padding:8px; }
36
+ .picture svg { display:block; max-width:none; }
37
+ pre { margin:0; padding:12px 14px; background:#fff; border:1px solid var(--line); border-radius:10px; overflow:auto;
38
+ font:12.5px/1.5 Consolas, Inconsolata, 'DejaVu Sans Mono', monospace; }
39
+ .quiet { color:var(--muted); }
40
+ </style></head><body>
41
+ <header><h1>System diff</h1><span class="since" id="since"></span>
42
+ <button id="seen" type="button" title="Move the checkpoint here: the page then shows only what is new">Mark as seen</button></header>
43
+ <main><div class="headline" id="headline">Reading the code…</div><div class="error" id="error"></div>
44
+ <div class="picture" id="picture" hidden></div><pre id="plan" hidden></pre></main>
45
+ <script>
46
+ const TOKEN = "__TOKEN__";
47
+ let version = -1;
48
+ function show(s) {
49
+ version = s.version;
50
+ document.getElementById("since").textContent = "since " + s.base + " · updated " + s.at;
51
+ document.getElementById("headline").textContent = s.headline;
52
+ document.getElementById("error").textContent = s.error || "";
53
+ const pic = document.getElementById("picture"), plan = document.getElementById("plan");
54
+ pic.hidden = !s.svg; pic.innerHTML = s.svg || "";
55
+ plan.hidden = !s.changes; plan.textContent = s.plan;
56
+ document.getElementById("seen").disabled = !s.changes;
57
+ }
58
+ async function poll() {
59
+ try {
60
+ const r = await fetch("state?since=" + version, { cache: "no-store" });
61
+ if (r.status === 200) show(await r.json());
62
+ } catch (e) { document.getElementById("error").textContent = "The watcher stopped: start system-diff watch again."; }
63
+ setTimeout(poll, 1500);
64
+ }
65
+ document.getElementById("seen").onclick = async () => {
66
+ const r = await fetch("seen", { method: "POST", headers: { "X-Token": TOKEN } });
67
+ if (r.status === 200) show(await r.json());
68
+ };
69
+ poll();
70
+ </script></body></html>
71
+ """
72
+
73
+
74
+ class Watcher:
75
+ """The current diff, recomputed in the background whenever the working tree or the checkpoint moves."""
76
+
77
+ def __init__(self, root: Path, base: str = "checkpoint", config_path: Optional[str] = None, interval: float = 2.0):
78
+ self.root, self.base, self.config_path, self.interval = root, base, config_path, interval
79
+ self.token = secrets.token_urlsafe(16)
80
+ self.lock = threading.Lock()
81
+ self.state: Dict[str, Any] = {"version": 0, "headline": "Reading the code…", "plan": "", "svg": "",
82
+ "changes": 0, "base": base, "at": "", "error": ""}
83
+ self.seen_key: Optional[tuple] = None
84
+ self.stop = threading.Event()
85
+ # One look at the files at a time: otherwise a slow look that started before "Mark as seen" could finish
86
+ # after it and put the old diff back on the page.
87
+ self.busy = threading.Lock()
88
+
89
+ def key(self) -> tuple:
90
+ return worktree_tree(self.root), checkpoint_sha(self.root)
91
+
92
+ def refresh(self) -> None:
93
+ try:
94
+ c = compare(self.root, self.base, ".", self.config_path)
95
+ svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg) or ""
96
+ new = {"headline": c.diff["headline"], "plan": c.diff["plan"], "svg": svg,
97
+ "changes": len(c.diff["changes"]), "base": c.base, "error": ""}
98
+ except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
99
+ new = {"error": f"{type(exc).__name__}: {exc}"}
100
+ with self.lock:
101
+ self.state.update(new, version=self.state["version"] + 1, at=time.strftime("%H:%M:%S"))
102
+
103
+ def tick(self, force: bool = False) -> None:
104
+ with self.busy:
105
+ try:
106
+ key = self.key()
107
+ except (OSError, subprocess.CalledProcessError) as exc:
108
+ with self.lock:
109
+ self.state.update(error=f"git: {exc}", version=self.state["version"] + 1)
110
+ return
111
+ if force or key != self.seen_key:
112
+ self.seen_key = key
113
+ self.refresh()
114
+
115
+ def loop(self) -> None:
116
+ while not self.stop.is_set():
117
+ self.tick()
118
+ self.stop.wait(self.interval)
119
+
120
+ def mark_seen(self) -> None:
121
+ with self.busy:
122
+ checkpoint(self.root)
123
+ self.tick(force=True) # the page's answer is the diff from the new checkpoint, never an older look
124
+
125
+ def snapshot_state(self) -> Dict[str, Any]:
126
+ with self.lock:
127
+ return dict(self.state)
128
+
129
+
130
+ def handler(w: Watcher):
131
+ class Handler(BaseHTTPRequestHandler):
132
+ def log_message(self, *a): # quiet: the terminal shows the URL, not every poll
133
+ pass
134
+
135
+ def _send(self, code: int, body: bytes, kind: str) -> None:
136
+ self.send_response(code)
137
+ self.send_header("Content-Type", kind)
138
+ self.send_header("Content-Length", str(len(body)))
139
+ self.send_header("Cache-Control", "no-store")
140
+ self.end_headers()
141
+ self.wfile.write(body)
142
+
143
+ def do_GET(self):
144
+ if self.path in ("/", "/index.html"):
145
+ return self._send(200, PAGE.replace("__TOKEN__", w.token).encode(), "text/html; charset=utf-8")
146
+ if self.path.startswith("/state"):
147
+ return self._send(200, json.dumps(w.snapshot_state()).encode(), "application/json")
148
+ return self._send(404, b"not found", "text/plain")
149
+
150
+ def do_POST(self):
151
+ if self.path != "/seen" or self.headers.get("X-Token") != w.token:
152
+ return self._send(403, b"forbidden", "text/plain")
153
+ w.mark_seen()
154
+ return self._send(200, json.dumps(w.snapshot_state()).encode(), "application/json")
155
+
156
+ return Handler
157
+
158
+
159
+ def serve(root: Path, base: str = "checkpoint", port: int = 8765, config_path: Optional[str] = None,
160
+ interval: float = 2.0, ready=None) -> None:
161
+ """Run until interrupted. `ready(url)` is called once the server listens (tests use port 0)."""
162
+ w = Watcher(root, base, config_path, interval)
163
+ server = ThreadingHTTPServer(("127.0.0.1", port), handler(w))
164
+ thread = threading.Thread(target=w.loop, daemon=True)
165
+ thread.start()
166
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
167
+ if ready:
168
+ ready(url, server, w)
169
+ else:
170
+ print(f"system-diff watch: {url} (Ctrl-C to stop)", flush=True)
171
+ try:
172
+ server.serve_forever()
173
+ except KeyboardInterrupt:
174
+ pass
175
+ finally:
176
+ w.stop.set()
177
+ server.server_close()
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: system-diff
3
+ Version: 0.1.0
4
+ Summary: Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs.
5
+ Project-URL: Homepage, https://github.com/simplifiedmachine/system-diff
6
+ Project-URL: Documentation, https://github.com/simplifiedmachine/system-diff/wiki
7
+ Project-URL: Issues, https://github.com/simplifiedmachine/system-diff/issues
8
+ Author: simplifiedmachine
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: architecture,code-review,code-visualization,developer-tools,diagrams,pull-requests,system-design
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Software Development :: Documentation
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.11
21
+ Provides-Extra: dev
22
+ Requires-Dist: mcp>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp>=1.10; extra == 'mcp'
26
+ Provides-Extra: png
27
+ Requires-Dist: playwright>=1.40; extra == 'png'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # system-diff
31
+
32
+ Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or
33
+ removes. No dependencies; SVG pictures for PRs and READMEs.
34
+
35
+ <!-- system-map:begin -->
36
+ ![System map, generated from the code](https://raw.githubusercontent.com/simplifiedmachine/system-diff/main/docs/diagrams/system.svg)
37
+
38
+ <details><summary>How this map is made</summary>
39
+
40
+ Regenerated by `system-diff` from the code: the parts and their columns from `system-diff.toml`, the connections from imports, API calls and the settings each part reads, the outside systems from the settings a deploy configuration sets, and the tables from the migrations. Parts used everywhere are drawn without the arrows into them, and each part keeps only its strongest connections; `system-diff map` writes all of them to `system.json`.
41
+
42
+ Read by the code but set by no deploy configuration, so not drawn: Headless Chromium (Docker or Playwright) (png).
43
+
44
+ </details>
45
+ <!-- system-map:end -->
46
+
47
+ ## What it does
48
+
49
+ - **`map`**: reads a commit straight from git (no checkout) or a folder, and writes `system.json` (everything it
50
+ found) and `system.svg` (the picture): a column per role, from what people use through entry points, logic and data
51
+ access to stored data and outside systems.
52
+ - **`diff`**: the typed difference between two commits, or a commit and your working tree: parts, endpoints,
53
+ connections, outside systems, tables, settings and deployment files added, changed or removed. One sentence
54
+ (`headline.txt`), the exact list (`plan.txt`, like `terraform plan`), and a picture of only what changed, with the
55
+ parts it touches beside it (`diff.svg`). `--narrate` adds a plain-words summary from your own LLM.
56
+ - **`readme`**: keeps a generated system map in your README, between two markers.
57
+ - **`draw`**: hand-made pictures in the same look, from small JSON specs: maps, stages of a process, sequences.
58
+
59
+ Deterministic: the same code gives the same picture, byte for byte, and a part that did not change keeps its place.
60
+
61
+ ## Install
62
+
63
+ ```sh
64
+ pipx install system-diff
65
+ ```
66
+
67
+ Python 3.11 or newer, nothing else. `pip install "system-diff[png]"` adds PNG output through Playwright; `system-diff
68
+ png` also uses Docker when it is there.
69
+
70
+ ## Use
71
+
72
+ ```sh
73
+ system-diff map --dir . --out docs/diagrams # the system as it is now
74
+ system-diff diff --base origin/main --out out # what this branch does to it
75
+ system-diff diff --base HEAD --head . --out out # what your uncommitted work does to it
76
+ system-diff readme --json docs/diagrams/system.json # refresh the map in README.md
77
+ ```
78
+
79
+ With no config, every folder of code is a part named by its path. A `system-diff.toml` at the repository root names the
80
+ parts and says what is what:
81
+
82
+ ```toml
83
+ tests = ["tests/**"]
84
+
85
+ [code]
86
+ roots = ["app/", "web/src/"]
87
+
88
+ [[components]]
89
+ id = "api"
90
+ label = "API"
91
+ kind = "interface" # ui | interface | logic | data: the column it is drawn in
92
+ paths = ["app/api/**"]
93
+
94
+ [[externals]]
95
+ id = "postgres"
96
+ label = "PostgreSQL"
97
+ shape = "db"
98
+ env = ["DATABASE_URL"] # the settings that reach it
99
+ ```
100
+
101
+ Every key, and how the code is read: [the wiki](https://github.com/simplifiedmachine/system-diff/wiki).
102
+
103
+ ## In pull requests
104
+
105
+ This repository runs system-diff on itself: the map above is regenerated from its own code, CI fails when it is out of
106
+ date, and every pull request gets its system diff as a comment (`.github/workflows/system-diff.yml`, which you can
107
+ copy).
108
+
109
+ ## With AI coding agents
110
+
111
+ When an agent changes the code with you, the question is what it did to the system. system-diff answers it as you go:
112
+
113
+ ```sh
114
+ pipx install system-diff
115
+ pipx inject system-diff mcp # the MCP server's one dependency
116
+ system-diff setup claude # the hook, a skill and the MCP server, in one step (--scope project, --undo)
117
+ system-diff watch # http://127.0.0.1:8765/ : the system diff since you last marked it seen
118
+ ```
119
+
120
+ - **After every Claude Code turn**, one line says what changed in the system since your checkpoint ("Adds 1 endpoint
121
+ (DELETE /api/orders/{id}); changes 1 table (public.orders)."), or nothing when nothing structural did.
122
+ - **The live page** redraws as files change. Press **Mark as seen** once you have understood it: the next diff then
123
+ shows only what is new to you. `system-diff checkpoint` does the same from the terminal.
124
+ - **The MCP server** gives the agent `system_diff` (the exact plan to explain from, and the picture), `system_map`,
125
+ `draw` (pictures of processes and sequences in the same look) and `checkpoint`.
126
+ - The working tree is read as a snapshot (untracked files included) without touching your index, HEAD or branches,
127
+ and the checkpoint lives under `refs/system-diff/`, in your clone only.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,21 @@
1
+ system_diff/__init__.py,sha256=QegtiHEdVJe5UC9fwHjqYi5Q8zbvDRbAViIa6MEHz9I,289
2
+ system_diff/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ system_diff/agent.py,sha256=mYN3nlxyx4U5vpNtm0UbSZSMym6Zcea7pbz00YcXKC4,9037
4
+ system_diff/cli.py,sha256=3_VPJg4n_R56oy2OZANu3bPPMhrzlB-QDpHjogwiNcI,10958
5
+ system_diff/compare.py,sha256=hf1VOJPVwC1BK4gUAlv98wX07X44fX2eawRS0piDhDU,4578
6
+ system_diff/config.py,sha256=psPCQJLQDiNQ1FIsl6-PKP77hYu51__fRGJgru4oU1A,5961
7
+ system_diff/diff.py,sha256=gcUDrMjjQbq8uloGYs_8O5RnZ_yqqCDjdsNWmuBcqvQ,9020
8
+ system_diff/extract.py,sha256=yUYkyUwhpmhp3sXBySTjWHuHtxvPUeqnv1MuxCfIOIw,13034
9
+ system_diff/graph.py,sha256=OPrMYiWcth_9Yts08hyzG-u0oT8ubkc4V5T59tsH7is,11086
10
+ system_diff/mcp_server.py,sha256=ISLUqwYehOoGsMjQhcakieQc-1-NywKLP9fav0_XMo4,8954
11
+ system_diff/narrate.py,sha256=wRq2oRPt0TZyEdsd-rQcQY0EpiHaFpyKDM3svqMioCA,7691
12
+ system_diff/pictures.py,sha256=G4a8DmvkOttuW962vcGm6CnsbeOTIN_WLrl6ZK0lQGQ,28479
13
+ system_diff/png.py,sha256=HCxc23roQNxHdcsULmMQnvpqLA1Ejkkc1k9lR_Swa14,5775
14
+ system_diff/readme.py,sha256=3qOXsxgaBE8aIbbuK2YDrRy9J3NjbwdzfR0EB3xqgAY,1823
15
+ system_diff/sources.py,sha256=ZtFE89tv7Cscb3qKO95PjbY0OUZm30hj9IeBxCvutxU,3085
16
+ system_diff/watch.py,sha256=VDW4L1HQ6q0i5BMpwfy9qOGE1PW8YBEe9dSYqCBrTog,8303
17
+ system_diff-0.1.0.dist-info/METADATA,sha256=krlsARSg-d2WIEGUVQAlR6KwWZCTbt1lKlGvcyREN-c,6094
18
+ system_diff-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
19
+ system_diff-0.1.0.dist-info/entry_points.txt,sha256=8fw6tkuFfZI2L7ScWUXVxj28LK9p6UiImnnB8E1om1E,84
20
+ system_diff-0.1.0.dist-info/licenses/LICENSE,sha256=QHQ-0t-7_4_K7j92CTlQScHiJiDZ91DcpV6WIOIFYQE,1074
21
+ system_diff-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sysdiff = system_diff.cli:main
3
+ system-diff = system_diff.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 simplifiedmachine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.