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/__init__.py +8 -0
- system_diff/__main__.py +5 -0
- system_diff/agent.py +197 -0
- system_diff/cli.py +214 -0
- system_diff/compare.py +105 -0
- system_diff/config.py +106 -0
- system_diff/diff.py +171 -0
- system_diff/extract.py +279 -0
- system_diff/graph.py +209 -0
- system_diff/mcp_server.py +206 -0
- system_diff/narrate.py +141 -0
- system_diff/pictures.py +550 -0
- system_diff/png.py +121 -0
- system_diff/readme.py +39 -0
- system_diff/sources.py +76 -0
- system_diff/watch.py +177 -0
- system_diff-0.1.0.dist-info/METADATA +131 -0
- system_diff-0.1.0.dist-info/RECORD +21 -0
- system_diff-0.1.0.dist-info/WHEEL +4 -0
- system_diff-0.1.0.dist-info/entry_points.txt +3 -0
- system_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
system_diff/__init__.py
ADDED
|
@@ -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"
|
system_diff/__main__.py
ADDED
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"(?!)")
|