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.
@@ -0,0 +1,8 @@
1
+ """system-diff: draws a system from its code, and what each change does to it.
2
+
3
+ from system_diff import config, extract, diff, graph
4
+ cfg = config.load(root=Path("."))
5
+ bp = extract.extract(DirSource(Path(".")), cfg)
6
+ svg = graph.system_svg(bp, cfg)
7
+ """
8
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
system_diff/agent.py ADDED
@@ -0,0 +1,197 @@
1
+ """Claude Code: `system-diff hook`, which its Stop hook runs after every turn, and `system-diff setup claude`, which wires
2
+ system-diff in: the hook, a short skill telling the agent when to show the system diff, and the MCP server.
3
+
4
+ The hook never blocks and never fails a turn: it says in one line what changed in the system since the checkpoint, or
5
+ nothing when nothing structural did. Everything setup changes is printed, and `--undo` takes it back.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional
16
+
17
+ from .compare import compare
18
+ from .sources import git
19
+
20
+ NAME = "system-diff"
21
+ SKILL_MARK = "<!-- written by system-diff setup claude -->"
22
+ SKILL = f"""---
23
+ name: system-diff
24
+ description: Shows the person what a code change does to the system's structure (parts, endpoints, connections, settings, tables, outside systems). Use after changing code that may change the structure, before asking them to review, or when they ask what changed.
25
+ ---
26
+ {SKILL_MARK}
27
+
28
+ # Showing the system diff
29
+
30
+ After a change that may touch the system's structure:
31
+
32
+ 1. Call the `system_diff` MCP tool, or run `system-diff diff --base checkpoint --head . --out .git/system-diff/latest`.
33
+ 2. Tell the person, in two or three plain sentences built only from its plan, what changed in the system and why it
34
+ matters: an endpoint added or removed, a new connection between parts, a new outside system, changed stored data,
35
+ or code changed without tests.
36
+ 3. Point them at the picture: the path the tool returns, or `system-diff watch` for a page that redraws as you work.
37
+ 4. When they say they have seen it, call the `checkpoint` tool (or run `system-diff checkpoint`), so the next diff
38
+ shows only what is new to them.
39
+
40
+ Never mention a part, endpoint, table or system that is not in the plan. For a picture of something else (a process,
41
+ a sequence of calls), use the `draw` tool.
42
+ """
43
+
44
+
45
+ def _is_repo(path: Path) -> bool:
46
+ try:
47
+ return git("rev-parse", "--is-inside-work-tree", cwd=path).strip() == "true"
48
+ except (OSError, subprocess.CalledProcessError):
49
+ return False
50
+
51
+
52
+ def hook_message(root: Path) -> Optional[str]:
53
+ """One line on what changed in the system since the checkpoint; None when nothing structural did."""
54
+ c = compare(root, "checkpoint", ".")
55
+ if not c.diff["changes"]:
56
+ return None
57
+ return f"System diff since {c.base}: {c.diff['headline']} (`system-diff watch` shows the picture.)"
58
+
59
+
60
+ def run_hook(stdin_text: str, emit: Callable[[str], None] = print) -> int:
61
+ """What the Stop hook runs. Always exits 0: a hook that fails must not get in the agent's way."""
62
+ try:
63
+ payload = json.loads(stdin_text) if stdin_text.strip() else {}
64
+ except ValueError:
65
+ payload = {}
66
+ cwd = Path(payload.get("cwd") or os.getcwd())
67
+ if not _is_repo(cwd):
68
+ return 0
69
+ try:
70
+ root = Path(git("rev-parse", "--show-toplevel", cwd=cwd).strip())
71
+ message = hook_message(root)
72
+ except (OSError, subprocess.CalledProcessError, ValueError, KeyError):
73
+ return 0
74
+ if message:
75
+ emit(json.dumps({"systemMessage": message}))
76
+ return 0
77
+
78
+
79
+ # ── setup ────────────────────────────────────────────────────────────────────
80
+
81
+ SCOPES = ("user", "project", "local")
82
+
83
+
84
+ def hook_command() -> str:
85
+ """How Claude Code should run system-diff: by name when it is on PATH, else this Python by its full path."""
86
+ if shutil.which(NAME):
87
+ return f"{NAME} hook"
88
+ exe = sys.executable
89
+ return f'"{exe}" -m system_diff hook' if " " in exe else f"{exe} -m system_diff hook"
90
+
91
+
92
+ def _paths(scope: str, root: Path) -> tuple:
93
+ home = Path.home() / ".claude"
94
+ settings = {"user": home / "settings.json", "project": root / ".claude" / "settings.json",
95
+ "local": root / ".claude" / "settings.local.json"}[scope]
96
+ skills = home / "skills" if scope == "user" else root / ".claude" / "skills"
97
+ return settings, skills / NAME / "SKILL.md"
98
+
99
+
100
+ def _ours(entry: Dict[str, Any]) -> bool:
101
+ return any("system-diff hook" in h.get("command", "") or "system_diff hook" in h.get("command", "")
102
+ for h in entry.get("hooks", []))
103
+
104
+
105
+ def _load(path: Path) -> Dict[str, Any]:
106
+ if not path.is_file():
107
+ return {}
108
+ try:
109
+ data = json.loads(path.read_text(encoding="utf-8") or "{}")
110
+ except ValueError as exc:
111
+ raise SystemExit(f"{path} is not valid JSON ({exc}); fix it, then run setup again") from exc
112
+ if not isinstance(data, dict):
113
+ raise SystemExit(f"{path} is not a JSON object; fix it, then run setup again")
114
+ return data
115
+
116
+
117
+ def _save(path: Path, data: Dict[str, Any]) -> None:
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
120
+ f.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
121
+
122
+
123
+ def _claude(args: List[str]) -> subprocess.CompletedProcess:
124
+ return subprocess.run(["claude", *args], capture_output=True, text=True)
125
+
126
+
127
+ def setup_claude(root: Path, scope: str = "user", undo: bool = False, out: Callable[[str], None] = print) -> int:
128
+ """Add (or with `undo`, remove) the Stop hook, the skill and the MCP server, printing each change."""
129
+ if scope not in SCOPES:
130
+ raise SystemExit(f"--scope is one of {', '.join(SCOPES)}")
131
+ settings_path, skill_path = _paths(scope, root)
132
+ settings = _load(settings_path)
133
+ stop = settings.setdefault("hooks", {}).setdefault("Stop", [])
134
+ ours = [e for e in stop if _ours(e)]
135
+ has_claude = shutil.which("claude") is not None
136
+ if undo:
137
+ if ours:
138
+ settings["hooks"]["Stop"] = [e for e in stop if not _ours(e)]
139
+ if not settings["hooks"]["Stop"]:
140
+ del settings["hooks"]["Stop"]
141
+ if not settings["hooks"]:
142
+ del settings["hooks"]
143
+ _save(settings_path, settings)
144
+ out(f"removed the Stop hook from {settings_path}")
145
+ if skill_path.is_file() and SKILL_MARK in skill_path.read_text(encoding="utf-8"):
146
+ skill_path.unlink()
147
+ if not any(skill_path.parent.iterdir()):
148
+ skill_path.parent.rmdir()
149
+ out(f"removed the skill {skill_path}")
150
+ if has_claude and _claude(["mcp", "get", NAME]).returncode == 0:
151
+ got = _claude(["mcp", "remove", NAME, "--scope", scope])
152
+ out(f"removed the MCP server ({scope} scope)" if got.returncode == 0 else
153
+ f"could not remove the MCP server: {(got.stderr or got.stdout).strip()[:200]}")
154
+ out("system-diff is no longer wired into Claude Code" + ("" if scope == "user" else f" ({scope} scope)"))
155
+ return 0
156
+
157
+ if ours:
158
+ out(f"the Stop hook is already in {settings_path}")
159
+ else:
160
+ # A timeout (seconds) so a stuck git can never hold up the end of a turn; Claude Code's default is 600.
161
+ stop.append({"hooks": [{"type": "command", "command": hook_command(), "timeout": 30}]})
162
+ _save(settings_path, settings)
163
+ out(f"added a Stop hook to {settings_path}: after each turn, one line on what changed in the system")
164
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
165
+ if skill_path.is_file() and skill_path.read_text(encoding="utf-8") == SKILL:
166
+ out(f"the skill is already at {skill_path}")
167
+ elif skill_path.is_file() and SKILL_MARK not in skill_path.read_text(encoding="utf-8"):
168
+ out(f"left {skill_path} alone: someone else wrote it")
169
+ else:
170
+ with open(skill_path, "w", encoding="utf-8", newline="\n") as f:
171
+ f.write(SKILL)
172
+ out(f"wrote the skill {skill_path}: when and how to show the system diff")
173
+ if not has_claude:
174
+ out("Claude Code's `claude` command is not on PATH, so the MCP server was not registered; "
175
+ "run setup again where it is")
176
+ return 0
177
+ try:
178
+ import mcp # noqa: F401 (the extra the server needs)
179
+ except ImportError:
180
+ out('the MCP server needs its extra: pip install "system-diff[mcp]", then run setup again')
181
+ return 0
182
+ if _claude(["mcp", "get", NAME]).returncode == 0:
183
+ out("the MCP server is already registered")
184
+ else:
185
+ server = hook_command()[: -len(" hook")]
186
+ got = _claude(["mcp", "add", "--scope", scope, "--transport", "stdio", NAME, "--", *_split(server), "mcp"])
187
+ out(f"registered the MCP server ({scope} scope): system_map, system_diff, draw, checkpoint"
188
+ if got.returncode == 0 else f"could not register the MCP server: {(got.stderr or got.stdout).strip()[:200]}")
189
+ return 0
190
+
191
+
192
+ def _split(command: str) -> List[str]:
193
+ """`"C:/Program Files/py/python.exe" -m system_diff` -> its words, the quoted path as one."""
194
+ if command.startswith('"'):
195
+ exe, rest = command[1:].split('"', 1)
196
+ return [exe, *rest.split()]
197
+ return command.split()
system_diff/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """system-diff: the system's structure at a git ref, the typed diff between two refs, and pictures of both.
2
+
3
+ system-diff map [--ref REF | --dir DIR] [--out DIR] -> system.json, system.svg
4
+ system-diff diff --base A [--head B|.] [--out DIR] [--narrate] -> diff.json, diff.svg, plan.txt, headline.txt
5
+ system-diff readme [--json system.json] [--stale PNGS] -> the README's generated system map
6
+ system-diff draw SPEC.json... -> a hand-made picture beside each spec
7
+ system-diff png [--host HOST] FILE.svg... -> FILE.png beside each SVG
8
+ system-diff summarize --diff diff.json -> the plain-words summary alone
9
+ system-diff checkpoint [--ref REF] -> mark what you have seen
10
+ system-diff watch [--base checkpoint] [--port 8765] -> a live page of the diff since then
11
+ system-diff setup claude [--scope user|project|local] [--undo] -> the hook, a skill and the MCP server
12
+ system-diff mcp -> the MCP server (stdio)
13
+
14
+ Deterministic: everything is read from git objects (no checkout) or a directory, with the standard library only.
15
+ `--head .` is the working tree, uncommitted and untracked files included; `--base checkpoint` is what you last marked
16
+ as seen (HEAD until you mark something).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import List, Optional
25
+
26
+ from . import __version__, compare, config, graph, narrate, pictures, png, readme
27
+ from .extract import extract
28
+ from .sources import DirSource, GitSource, repo_root
29
+
30
+
31
+ def _write(path: Path, text: str, check: bool) -> bool:
32
+ """Write (or, with --check, only compare); True when the file is or would be different."""
33
+ if check:
34
+ return not path.is_file() or path.read_text(encoding="utf-8") != text
35
+ pictures.write_if_changed(path, text)
36
+ return False
37
+
38
+
39
+ def _map(args, root: Path) -> int:
40
+ if args.dir:
41
+ on_disk = config.load(root=Path(args.dir), path=args.config)
42
+ source = DirSource(Path(args.dir), config.segment_rx(on_disk["code"]["skip"]))
43
+ else:
44
+ source = GitSource(args.ref or "HEAD", root)
45
+ cfg = config.load(source, root, args.config)
46
+ bp = extract(source, cfg)
47
+ out = Path(args.out)
48
+ if not args.check:
49
+ out.mkdir(parents=True, exist_ok=True)
50
+ stale = _write(out / "system.json", json.dumps(bp, indent=1, ensure_ascii=False) + "\n", args.check)
51
+ stale |= _write(out / "system.svg", graph.system_svg(bp, cfg), args.check)
52
+ if args.check:
53
+ print(f"system map {'is out of date' if stale else 'is current'}: {out}/system.svg")
54
+ return 1 if stale else 0
55
+ print(f"system map of {bp['ref']}: {len(bp['components'])} parts, {len(bp['externals'])} outside systems, "
56
+ f"{len(bp['routes'])} routes, {len(bp['tables'])} tables -> {out}/system.svg")
57
+ return 0
58
+
59
+
60
+ def _diff(args, root: Path) -> int:
61
+ c = compare.compare(root, args.base, args.head, args.config)
62
+ d = c.diff
63
+ out = Path(args.out)
64
+ out.mkdir(parents=True, exist_ok=True)
65
+ (out / "diff.json").write_text(json.dumps(d, indent=1, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n")
66
+ (out / "plan.txt").write_text(d["plan"] + "\n", encoding="utf-8", newline="\n")
67
+ (out / "headline.txt").write_text(d["headline"] + "\n", encoding="utf-8", newline="\n")
68
+ svg = graph.diff_svg(d, c.before, c.after, c.cfg)
69
+ if svg:
70
+ pictures.write_if_changed(out / "diff.svg", svg)
71
+ elif (out / "diff.svg").is_file():
72
+ (out / "diff.svg").unlink() # an older run's picture must not pass for this one
73
+ story = narrate.narrate(d, d["commits"], root, c.cfg) if args.narrate else None
74
+ (out / "narrative.md").write_text((story or "") + "\n", encoding="utf-8", newline="\n")
75
+ print(d["headline"])
76
+ return 0
77
+
78
+
79
+ def _checkpoint(args, root: Path) -> int:
80
+ sha = compare.checkpoint(root, args.ref)
81
+ print(f"checkpoint: {args.ref or 'the working tree'} ({sha[:9]}) is what you have seen; "
82
+ "`system-diff diff --base checkpoint --head .` now shows only what comes after it")
83
+ return 0
84
+
85
+
86
+ def _readme(args, root: Path) -> int:
87
+ cfg = config.load(root=root, path=args.config)
88
+ dormant = json.loads(Path(args.json).read_text(encoding="utf-8")).get("dormant", {}) if args.json else {}
89
+ path = Path(args.readme or (root / cfg["readme"]["file"]))
90
+ stale = [x for x in args.stale.split(",") if x]
91
+ if args.check:
92
+ r = cfg["readme"]
93
+ text = path.read_text(encoding="utf-8")
94
+ if r["begin"] not in text or r["end"] not in text:
95
+ print(f"{path} has no system map section")
96
+ return 1
97
+ start, end = text.index(r["begin"]), text.index(r["end"]) + len(r["end"])
98
+ current = text[start:end] == readme.readme_section(dormant, cfg, stale)
99
+ print("README system map " + ("is current" if current else "is out of date"))
100
+ return 0 if current else 1
101
+ changed = readme.update_readme(path, dormant, cfg, stale)
102
+ print("README system map " + ("updated" if changed else "already current"))
103
+ return 0
104
+
105
+
106
+ def _summarize(args, root: Path) -> int:
107
+ cfg = config.load(root=root, path=args.config)
108
+ d = json.loads(Path(args.diff).read_text(encoding="utf-8"))
109
+ story = narrate.narrate(d, d.get("commits", ""), root, cfg)
110
+ print("BEGIN SUMMARY")
111
+ print(story or "")
112
+ print("END SUMMARY")
113
+ return 0 if story else 1
114
+
115
+
116
+ def parser() -> argparse.ArgumentParser:
117
+ ap = argparse.ArgumentParser(prog="system-diff", description=__doc__.split("\n")[0],
118
+ formatter_class=argparse.RawDescriptionHelpFormatter,
119
+ epilog="\n".join(__doc__.split("\n")[2:]))
120
+ ap.add_argument("--version", action="version", version=f"system-diff {__version__}")
121
+ ap.add_argument("--repo", help="the repository (default: the one holding the current directory)")
122
+ ap.add_argument("--config", help="a TOML or JSON config (default: system-diff.toml, or pyproject.toml's "
123
+ "[tool.system-diff])")
124
+ sub = ap.add_subparsers(dest="cmd", required=True)
125
+ m = sub.add_parser("map", help="the system as it is: system.json and system.svg")
126
+ m.add_argument("--ref", help="a commit, branch or tag (default HEAD)")
127
+ m.add_argument("--dir", help="a directory instead of a commit, e.g. . for the working tree")
128
+ m.add_argument("--out", default=".")
129
+ m.add_argument("--check", action="store_true", help="write nothing; exit 1 if the files are out of date")
130
+ d = sub.add_parser("diff", help="what a change does to the system")
131
+ d.add_argument("--base", required=True, help="a commit, or checkpoint (what you last marked as seen)")
132
+ d.add_argument("--head", default="HEAD", help="a commit, or . for the working tree (default HEAD)")
133
+ d.add_argument("--out", default=".")
134
+ d.add_argument("--narrate", action="store_true", help="add a plain-words summary from your LLM (narrative.md)")
135
+ r = sub.add_parser("readme", help="rewrite the README's system-map section")
136
+ r.add_argument("--json", default="", help="system.json, for the outside systems that are not drawn")
137
+ r.add_argument("--readme", default="")
138
+ r.add_argument("--stale", default="", help="comma-separated pictures that could not be redrawn")
139
+ r.add_argument("--check", action="store_true", help="write nothing; exit 1 if the section is out of date")
140
+ dr = sub.add_parser("draw", help="draw hand-made pictures from JSON specs")
141
+ dr.add_argument("specs", nargs="+")
142
+ p = sub.add_parser("png", help="SVG to PNG with headless Chromium")
143
+ p.add_argument("--host", help="an SSH host with Docker, when this machine has neither Docker nor Playwright")
144
+ p.add_argument("svgs", nargs="+")
145
+ s = sub.add_parser("summarize", help="the plain-words summary of a diff.json")
146
+ s.add_argument("--diff", required=True)
147
+ c = sub.add_parser("checkpoint", help="mark what you have seen: later diffs from `checkpoint` start here")
148
+ c.add_argument("--ref", help="a commit to mark instead of the working tree")
149
+ w = sub.add_parser("watch", help="a live local page of the diff since the checkpoint")
150
+ w.add_argument("--base", default="checkpoint")
151
+ w.add_argument("--port", type=int, default=8765)
152
+ w.add_argument("--interval", type=float, default=2.0, help="seconds between looks at the files")
153
+ sub.add_parser("mcp", help="run as an MCP server over stdio (needs the [mcp] extra)")
154
+ sub.add_parser("hook", help="what Claude Code's Stop hook runs: one line on what changed since the checkpoint")
155
+ st = sub.add_parser("setup", help="wire system-diff into an AI coding tool")
156
+ st.add_argument("tool", choices=["claude"])
157
+ st.add_argument("--scope", default="user", choices=["user", "project", "local"],
158
+ help="user: every project (default); project: this repository, shared; local: this repository, you")
159
+ st.add_argument("--undo", action="store_true", help="remove what setup added")
160
+ return ap
161
+
162
+
163
+ def main(argv: Optional[List[str]] = None) -> int:
164
+ args = parser().parse_args(argv)
165
+ root = repo_root(Path(args.repo) if args.repo else None)
166
+ if args.cmd == "map":
167
+ return _map(args, root)
168
+ if args.cmd == "diff":
169
+ return _diff(args, root)
170
+ if args.cmd == "readme":
171
+ return _readme(args, root)
172
+ if args.cmd == "summarize":
173
+ return _summarize(args, root)
174
+ if args.cmd == "checkpoint":
175
+ return _checkpoint(args, root)
176
+ if args.cmd == "watch":
177
+ from . import watch
178
+ watch.serve(root, args.base, args.port, args.config, args.interval)
179
+ return 0
180
+ if args.cmd == "hook":
181
+ from . import agent
182
+ return agent.run_hook(sys.stdin.read() if not sys.stdin.isatty() else "")
183
+ if args.cmd == "setup":
184
+ from . import agent
185
+ return agent.setup_claude(root, args.scope, args.undo)
186
+ if args.cmd == "mcp":
187
+ try:
188
+ from . import mcp_server
189
+ except ImportError:
190
+ print('system-diff: the MCP server needs its extra: pip install "system-diff[mcp]"', file=sys.stderr)
191
+ return 1
192
+ mcp_server.main()
193
+ return 0
194
+ if args.cmd == "draw":
195
+ try:
196
+ for out in pictures.draw_files([Path(x) for x in args.specs]):
197
+ print(f"drew {out}")
198
+ except ValueError as exc:
199
+ print(f"system-diff: {exc}", file=sys.stderr)
200
+ return 1
201
+ return 0
202
+ if args.cmd == "png":
203
+ try:
204
+ for out in png.render([Path(x) for x in args.svgs], host=args.host):
205
+ print(f"rendered {out}")
206
+ except (RuntimeError, ValueError) as exc:
207
+ print(f"system-diff: {exc}", file=sys.stderr)
208
+ return 1
209
+ return 0
210
+ return 2
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())
system_diff/compare.py ADDED
@@ -0,0 +1,105 @@
1
+ """What changed in the system between two points, where a point is a commit, the working tree, or the CHECKPOINT: the
2
+ last state a person marked as seen (`system-diff checkpoint`, or "Mark as seen" on the live page).
3
+
4
+ The working tree is read as a SNAPSHOT: a commit made with a temporary index, so untracked files count (ignored ones do
5
+ not) and nothing the person uses changes: not their index, not HEAD, not a branch. A checkpoint is such a snapshot kept
6
+ under refs/system-diff/checkpoint, so it lives in this clone only and is never pushed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional
16
+
17
+ from . import config
18
+ from .diff import diff, numstat
19
+ from .extract import Code, extract
20
+ from .sources import GitSource, git
21
+
22
+ CHECKPOINT_REF = "refs/system-diff/checkpoint"
23
+ WORKTREE = (".", "worktree")
24
+ _IDENTITY = {"GIT_AUTHOR_NAME": "system-diff", "GIT_AUTHOR_EMAIL": "system-diff@localhost",
25
+ "GIT_COMMITTER_NAME": "system-diff", "GIT_COMMITTER_EMAIL": "system-diff@localhost"}
26
+
27
+
28
+ def _git_env(root: Path, env: Dict[str, str], *args: str) -> str:
29
+ return subprocess.run(["git", *args], cwd=root, env=env, check=True, capture_output=True, text=True,
30
+ encoding="utf-8", errors="replace").stdout.strip()
31
+
32
+
33
+ def _head(root: Path) -> Optional[str]:
34
+ try:
35
+ return git("rev-parse", "--verify", "-q", "HEAD", cwd=root).strip() or None
36
+ except subprocess.CalledProcessError:
37
+ return None # a repository with no commit yet
38
+
39
+
40
+ def worktree_tree(root: Path) -> str:
41
+ """The working tree's git tree id: cheap, and the same until a file changes."""
42
+ with tempfile.TemporaryDirectory() as tmp:
43
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(tmp) / "index")}
44
+ if _head(root):
45
+ _git_env(root, env, "read-tree", "HEAD")
46
+ _git_env(root, env, "add", "-A")
47
+ return _git_env(root, env, "write-tree")
48
+
49
+
50
+ def snapshot(root: Path) -> str:
51
+ """The working tree as a commit (parent HEAD), made without touching the index, HEAD or any branch."""
52
+ tree = worktree_tree(root)
53
+ head = _head(root)
54
+ env = {**os.environ, **_IDENTITY}
55
+ return _git_env(root, env, "commit-tree", tree, *(["-p", head] if head else []), "-m", "system-diff snapshot")
56
+
57
+
58
+ def checkpoint(root: Path, ref: Optional[str] = None) -> str:
59
+ """Mark `ref` (default: the working tree as it is now) as seen. Returns the commit it points at."""
60
+ sha = git("rev-parse", "--verify", f"{ref}^{{commit}}", cwd=root).strip() if ref else snapshot(root)
61
+ git("update-ref", CHECKPOINT_REF, sha, cwd=root)
62
+ return sha
63
+
64
+
65
+ def checkpoint_sha(root: Path) -> Optional[str]:
66
+ try:
67
+ return git("rev-parse", "--verify", "-q", CHECKPOINT_REF, cwd=root).strip() or None
68
+ except subprocess.CalledProcessError:
69
+ return None
70
+
71
+
72
+ def resolve(root: Path, point: str) -> tuple:
73
+ """(commit, label) for a point: `checkpoint` (else HEAD, when nothing was marked yet), `.` for the working tree,
74
+ or any commit."""
75
+ if point == "checkpoint":
76
+ sha = checkpoint_sha(root)
77
+ return (sha, "checkpoint") if sha else (_head(root) or snapshot(root), "HEAD (no checkpoint yet)")
78
+ if point in WORKTREE:
79
+ return snapshot(root), "working tree"
80
+ return git("rev-parse", "--verify", f"{point}^{{commit}}", cwd=root).strip(), point
81
+
82
+
83
+ @dataclass
84
+ class Comparison:
85
+ diff: Dict[str, Any]
86
+ before: Dict[str, Any]
87
+ after: Dict[str, Any]
88
+ cfg: Dict[str, Any]
89
+ base: str
90
+ head: str
91
+
92
+
93
+ def compare(root: Path, base: str = "checkpoint", head: str = ".", config_path: Optional[str] = None) -> Comparison:
94
+ """The typed diff from `base` to `head`, both read with the head's config so a part keeps one name."""
95
+ (a_sha, a_label), (b_sha, b_label) = resolve(root, base), resolve(root, head)
96
+ head_src = GitSource(b_sha, root)
97
+ cfg = config.load(head_src, root, config_path)
98
+ a, b = extract(GitSource(a_sha, root), cfg), extract(head_src, cfg)
99
+ for side, point, label in ((a, base, a_label), (b, head, b_label)):
100
+ if point == "checkpoint" or point in WORKTREE:
101
+ side["ref"] = label # a snapshot's commit id means nothing to a person
102
+ d = diff(a, b, numstat(root, a_sha, b_sha, cfg), cfg.get("deploy", []), Code(cfg))
103
+ tip = "HEAD" if head in WORKTREE else b_sha
104
+ d["commits"] = git("log", "--format=%s%n%n%b", f"{a_sha}..{tip}", cwd=root)[:6000] if a_sha != b_sha else ""
105
+ return Comparison(d, a, b, cfg, a_label, b_label)
system_diff/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """What a project tells system-diff about itself: where its code is, what its parts are called, how to draw them.
2
+
3
+ Read from the first of: the file given with --config (TOML or JSON); `system-diff.toml` at the repository root (in the
4
+ commit being read, else on disk); `[tool.system-diff]` in pyproject.toml. Every key is optional. With none at all, each
5
+ directory of code is a part named by its path, which is a fair first picture of most repositories.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import tomllib
12
+ from copy import deepcopy
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Iterable, Optional
15
+
16
+ FILE = "system-diff.toml"
17
+
18
+ DEFAULTS: Dict[str, Any] = {
19
+ "code": {
20
+ "roots": [""], # path prefixes that hold code; "" is the whole repository
21
+ "extensions": [".py", ".ts", ".tsx", ".js", ".jsx"],
22
+ "skip": ["node_modules", "__pycache__", "dist", "build", ".venv*", "venv", ".git"],
23
+ "aliases": {}, # import prefix -> repository path, e.g. {"@/": "web/src/"}
24
+ "python_paths": ["", "src/"], # where `import a.b` is looked up
25
+ "group": {"default": 2}, # a part nobody declared is named by its first N path segments
26
+ "strip": None, # suffixes dropped from such a name; None: the code extensions
27
+ },
28
+ "components": [], # [{id, label, kind, layer, container, paths, hub, quiet}]
29
+ "externals": [], # [{id, label, shape, env}]: an outside system, known by the settings that reach it
30
+ "api": [], # [{prefix, component}]: who serves a URL the front end calls
31
+ "containers": {}, # container name -> label
32
+ "internal_env": {}, # setting -> container it points at
33
+ "migrations": {}, # {glob, schemas: [[from, to, schema]], store}
34
+ "tests": [], # globs of test files
35
+ "deploy": [], # globs of files whose change changes what is deployed
36
+ "compose": "docker-compose.yml",
37
+ "config_files": ["docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml", ".env.*example"],
38
+ "picture": {
39
+ "columns": [["ui", "What people use"], ["interface", "Entry points"], ["logic", "Logic"],
40
+ ["data", "Data access"], ["store", "Stored data"], ["external", "Outside the system"]],
41
+ "ui_containers": ["web"], # parts deployed here are what people use
42
+ "main_containers": [], # containers not named on their parts' boxes
43
+ "legend": {"new": "new", "legacy": "legacy, still running", "undeclared": "not named in system-diff.toml"},
44
+ "hub_note": "used across the code",
45
+ "min_flow": 1, # import sites before a connection is drawn
46
+ "top_flows": 2, # connections drawn from each part, strongest first
47
+ "max_ext_edges": 1, # users drawn per outside system; its box counts the rest
48
+ },
49
+ "readme": {
50
+ "file": "README.md",
51
+ "image": "docs/diagrams/system.png",
52
+ "begin": "<!-- system-map:begin -->",
53
+ "end": "<!-- system-map:end -->",
54
+ "stale": ("> ⚠️ **Older than the code:** {pictures}. The last run could not redraw {it}; the next run that "
55
+ "can render does, and removes this note. The `.svg` beside each picture is current and opens in a "
56
+ "browser.\n\n"),
57
+ "how": ("Regenerated by `system-diff` from the code: the parts and their columns from `system-diff.toml`, the "
58
+ "connections from imports, API calls and the settings each part reads, the outside systems from the "
59
+ "settings a deploy configuration sets, and the tables from the migrations. Parts used everywhere are "
60
+ "drawn without the arrows into them, and each part keeps only its strongest connections; "
61
+ "`system-diff map` writes all of them to `system.json`.{dormant}"),
62
+ "dormant": "\n\nRead by the code but set by no deploy configuration, so not drawn: {names}.",
63
+ },
64
+ "narrate": {
65
+ "settings": [], # more Claude Code settings files, highest precedence last
66
+ },
67
+ }
68
+
69
+
70
+ def merge(base: Dict[str, Any], over: Dict[str, Any]) -> Dict[str, Any]:
71
+ """`over` on top of `base`: tables merge key by key, anything else is replaced."""
72
+ out = deepcopy(base)
73
+ for k, v in (over or {}).items():
74
+ out[k] = merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else deepcopy(v)
75
+ return out
76
+
77
+
78
+ def parse(text: str, name: str) -> Dict[str, Any]:
79
+ if not text.strip():
80
+ return {}
81
+ if name.endswith(".json"):
82
+ return json.loads(text)
83
+ data = tomllib.loads(text)
84
+ return data.get("tool", {}).get("system-diff", {}) if name.endswith("pyproject.toml") else data
85
+
86
+
87
+ def load(source=None, root: Optional[Path] = None, path: Optional[str] = None) -> Dict[str, Any]:
88
+ """The project's settings over the defaults. `source` is what is being read (a commit or a directory): its own
89
+ system-diff.toml wins over the one on disk, so an old commit is described by the file it had."""
90
+ if path:
91
+ p = Path(path)
92
+ return merge(DEFAULTS, parse(p.read_text(encoding="utf-8"), p.name))
93
+ for name in (FILE, "pyproject.toml"):
94
+ text = source.read(name) if source is not None and name in source.files else ""
95
+ if not text and root is not None and (root / name).is_file():
96
+ text = (root / name).read_text(encoding="utf-8")
97
+ got = parse(text, name) if text else {}
98
+ if got:
99
+ return merge(DEFAULTS, got)
100
+ return deepcopy(DEFAULTS)
101
+
102
+
103
+ def segment_rx(names: Iterable[str]) -> re.Pattern:
104
+ """A path containing a directory named like one of `names` (`*` within a name)."""
105
+ alts = "|".join(re.escape(n).replace(r"\*", "[^/]*") for n in names)
106
+ return re.compile(rf"(^|/)({alts})(/|$)") if alts else re.compile(r"(?!)")