graphcoding 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,6 @@
1
+ """GraphCoding — your repo's living knowledge graph.
2
+
3
+ Query before you touch code. Design in the graph. Sync as you go.
4
+ Drift is caught by tooling, not memory.
5
+ """
6
+ __version__ = "0.1.0"
graphcoding/cli.py ADDED
@@ -0,0 +1,382 @@
1
+ """graphcoding — the CLI.
2
+
3
+ Commands map 1:1 to the GraphCoding loop:
4
+
5
+ QUERY query, show, status
6
+ DESIGN plan, link, mark-delete
7
+ CODE (your editor / your agent)
8
+ SYNC sync
9
+ VERIFY drift
10
+
11
+ Plus lifecycle: init, scan, hooks.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+
21
+ from . import __version__
22
+ from .drift import blocking_count, compute_drift, format_report
23
+ from .scan import scan_repo, trackable
24
+ from .store import (DEFAULT_CONFIG, EDGE_TYPES, GRAPH_DIR, NODE_TYPES, Graph,
25
+ Node, config_path, find_root, load_config)
26
+ from .sync import sync as run_sync
27
+ from . import hooks as hooks_mod
28
+
29
+
30
+ def _root_or_die(args) -> str:
31
+ root = find_root(getattr(args, "root", None))
32
+ if not root:
33
+ sys.exit("no .graphcoding/ found — run `graphcoding init` at your repo root")
34
+ return root
35
+
36
+
37
+ def _print_node(g: Graph, node: Node, verbose: bool = True) -> None:
38
+ print(f"{node.name}")
39
+ print(f" type: {node.type} status: {node.status}"
40
+ + (f" language: {node.language}" if node.language else ""))
41
+ if node.summary:
42
+ print(f" summary: {node.summary}")
43
+ if verbose:
44
+ outgoing = sorted(node.edges, key=lambda e: (e["type"], e["to"]))
45
+ if outgoing:
46
+ print(" outgoing:")
47
+ for e in outgoing:
48
+ missing = "" if e["to"] in g.nodes else " (planned/missing)"
49
+ print(f" -[{e['type']}]-> {e['to']}{missing}")
50
+ incoming = g.incoming(node.name)
51
+ if incoming:
52
+ print(" recorded incoming edges (blast radius — these break if you change it):")
53
+ for src, etype in incoming:
54
+ print(f" <-[{etype}]- {src}")
55
+ if all(etype in ("IMPORTS", "CONTAINS") for _, etype in incoming):
56
+ print(" (scanner-visible edges only; runtime/cross-boundary callers"
57
+ " may exist unrecorded — record with: graphcoding link <src> CALLS"
58
+ f" {node.name})")
59
+ elif not node.name.endswith((".md", ".json")):
60
+ print(" recorded incoming edges: none — likely safe to change, but the"
61
+ " graph only knows recorded edges; verify runtime refs once, then"
62
+ " record them")
63
+
64
+
65
+ # ---------------------------------------------------------------- commands --
66
+ def cmd_init(args) -> None:
67
+ root = os.path.abspath(args.root or os.getcwd())
68
+ gdir = os.path.join(root, GRAPH_DIR)
69
+ os.makedirs(gdir, exist_ok=True)
70
+ cfgp = config_path(root)
71
+ if not os.path.exists(cfgp):
72
+ with open(cfgp, "w", encoding="utf-8") as f:
73
+ json.dump(DEFAULT_CONFIG, f, indent=2)
74
+ f.write("\n")
75
+ g = Graph.load(root)
76
+ g.save()
77
+ print(f"initialized {os.path.relpath(gdir, os.getcwd())}/ (config.json + graph.jsonl)")
78
+ if not args.no_scan:
79
+ cfg = load_config(root)
80
+ stats = scan_repo(root, cfg, g)
81
+ g.save()
82
+ print(f"scanned: {stats['files']} files -> {stats['added']} nodes added, "
83
+ f"{stats['updated']} updated")
84
+ if args.hooks:
85
+ for p in hooks_mod.install(root):
86
+ print(f"installed hook: {os.path.relpath(p, root)}")
87
+ print("next: `graphcoding status` · commit .graphcoding/ with your code")
88
+
89
+
90
+ def cmd_scan(args) -> None:
91
+ root = _root_or_die(args)
92
+ cfg = load_config(root)
93
+ g = Graph.load(root)
94
+ stats = scan_repo(root, cfg, g)
95
+ g.save()
96
+ print(f"scanned {stats['files']} files: {stats['added']} added, "
97
+ f"{stats['updated']} updated — graph now {len(g.nodes)} nodes")
98
+
99
+
100
+ def cmd_plan(args) -> None:
101
+ root = _root_or_die(args)
102
+ g = Graph.load(root)
103
+ node = Node(name=args.name, type=args.type, status="planned",
104
+ summary=args.summary or "")
105
+ for spec in args.edge or []:
106
+ try:
107
+ etype, target = spec.split(":", 1)
108
+ except ValueError:
109
+ sys.exit(f"bad --edge '{spec}' (want TYPE:target, e.g. IMPORTS:src/db.py)")
110
+ etype = etype.upper()
111
+ if etype not in EDGE_TYPES:
112
+ sys.exit(f"unknown edge type {etype} (one of {', '.join(EDGE_TYPES)})")
113
+ node.add_edge(target, etype)
114
+ existing = g.nodes.get(args.name)
115
+ if existing and existing.status != "planned" and not args.force:
116
+ sys.exit(f"{args.name} already exists with status={existing.status}; "
117
+ "use --force to re-plan it")
118
+ if existing:
119
+ existing.status = "planned"
120
+ if args.summary:
121
+ existing.summary = args.summary
122
+ for e in node.edges:
123
+ existing.add_edge(e["to"], e["type"])
124
+ else:
125
+ g.nodes[node.name] = node
126
+ g.save()
127
+ print(f"planned: {args.name}" + (f" — {args.summary}" if args.summary else ""))
128
+
129
+
130
+ def cmd_link(args) -> None:
131
+ root = _root_or_die(args)
132
+ g = Graph.load(root)
133
+ src = g.nodes.get(args.source)
134
+ if not src:
135
+ sys.exit(f"unknown source node {args.source} (plan or scan it first)")
136
+ etype = args.type.upper()
137
+ if etype not in EDGE_TYPES:
138
+ sys.exit(f"unknown edge type {etype} (one of {', '.join(EDGE_TYPES)})")
139
+ added = src.add_edge(args.target, etype)
140
+ g.save()
141
+ note = "" if args.target in g.nodes else " (target not in graph yet — planned work)"
142
+ print(("linked" if added else "already linked")
143
+ + f": {args.source} -[{etype}]-> {args.target}{note}")
144
+
145
+
146
+ def cmd_mark_delete(args) -> None:
147
+ root = _root_or_die(args)
148
+ g = Graph.load(root)
149
+ node = g.nodes.get(args.name)
150
+ if not node:
151
+ sys.exit(f"unknown node {args.name}")
152
+ incoming = g.incoming(args.name)
153
+ if incoming and not args.force:
154
+ print(f"{args.name} has {len(incoming)} incoming edge(s):")
155
+ for s, t in incoming:
156
+ print(f" <-[{t}]- {s}")
157
+ sys.exit("refusing to mark for deletion — update the callers first, "
158
+ "or pass --force if they are part of the same removal")
159
+ node.status = "to-be-deleted"
160
+ g.save()
161
+ print(f"marked to-be-deleted: {args.name} "
162
+ "(delete the file, then `graphcoding sync`)")
163
+
164
+
165
+ def cmd_sync(args) -> None:
166
+ root = _root_or_die(args)
167
+ cfg = load_config(root)
168
+ g = Graph.load(root)
169
+ res = run_sync(root, cfg, g, staged=args.staged, commit=args.commit,
170
+ files=args.files)
171
+ if not args.quiet:
172
+ print(f"sync: {len(res['upserted'])} upserted, {len(res['removed'])} removed")
173
+ for p in res["upserted"][:15]:
174
+ print(f" ~ {p}")
175
+ for p in res["removed"][:15]:
176
+ print(f" - {p}")
177
+ for p in res["skipped"]:
178
+ print(f" ! {p} marked deleted in git but still on disk — skipped")
179
+ planned = g.with_status("planned")
180
+ if planned:
181
+ print(f"still planned: {len(planned)} node(s) — `graphcoding status`")
182
+
183
+
184
+ def cmd_drift(args) -> None:
185
+ root = _root_or_die(args)
186
+ cfg = load_config(root)
187
+ g = Graph.load(root)
188
+ report = compute_drift(root, cfg, g)
189
+ scope = None
190
+ if args.staged:
191
+ try:
192
+ out = subprocess.run(["git", "-C", root, "diff", "--cached", "--name-only"],
193
+ capture_output=True, text=True, check=True).stdout
194
+ scope = {p for p in out.splitlines() if p.strip() and trackable(p, cfg)}
195
+ except (subprocess.CalledProcessError, FileNotFoundError):
196
+ scope = set()
197
+ if not scope:
198
+ sys.exit(0) # nothing staged that we track
199
+ n = blocking_count(report, scope)
200
+ if not (args.quiet and n == 0):
201
+ print(format_report(report, scope))
202
+ sys.exit(1 if n else 0)
203
+
204
+
205
+ def cmd_status(args) -> None:
206
+ root = _root_or_die(args)
207
+ cfg = load_config(root)
208
+ g = Graph.load(root)
209
+ by_status: dict[str, int] = {}
210
+ for n in g.nodes.values():
211
+ by_status[n.status] = by_status.get(n.status, 0) + 1
212
+ edges = sum(len(n.edges) for n in g.nodes.values())
213
+ print(f"graph: {len(g.nodes)} nodes, {edges} edges "
214
+ f"({', '.join(f'{k}={v}' for k, v in sorted(by_status.items()))})")
215
+ planned = g.with_status("planned")
216
+ if planned:
217
+ print(f"\nplanned — left to build ({len(planned)}):")
218
+ for n in planned:
219
+ print(f" ? {n.name}" + (f" — {n.summary}" if n.summary else ""))
220
+ doomed = g.with_status("to-be-deleted")
221
+ if doomed:
222
+ print(f"\nto-be-deleted — left to remove ({len(doomed)}):")
223
+ for n in doomed:
224
+ print(f" ! {n.name}")
225
+ broken = []
226
+ for n in g.nodes.values():
227
+ for e in n.edges:
228
+ if e["to"] not in g.nodes:
229
+ broken.append((n.name, e["type"], e["to"]))
230
+ if broken:
231
+ print(f"\ndangling edges — dependencies not wired yet ({len(broken)}):")
232
+ for s, t, d in sorted(broken)[:20]:
233
+ print(f" {s} -[{t}]-> {d}")
234
+ rep = compute_drift(root, cfg, g)
235
+ n_block = blocking_count(rep)
236
+ print(f"\ndrift: {'NONE' if not n_block else f'{n_block} blocking — run `graphcoding drift`'}")
237
+
238
+
239
+ def cmd_summary(args) -> None:
240
+ root = _root_or_die(args)
241
+ g = Graph.load(root)
242
+ node = g.nodes.get(args.name)
243
+ if not node:
244
+ sys.exit(f"unknown node {args.name} (scan or plan it first)")
245
+ node.summary = args.text
246
+ if node.status == "needs-analysis":
247
+ node.status = "ok"
248
+ g.save()
249
+ print(f"summary set: {args.name} — {args.text}")
250
+
251
+
252
+ def cmd_health(args) -> None:
253
+ from .health import compute_health, format_health
254
+ root = _root_or_die(args)
255
+ cfg = load_config(root)
256
+ g = Graph.load(root)
257
+ print(format_health(compute_health(root, cfg, g)))
258
+
259
+
260
+ def cmd_query(args) -> None:
261
+ root = _root_or_die(args)
262
+ g = Graph.load(root)
263
+ results = g.search(args.terms, limit=args.limit)
264
+ if not results:
265
+ print("no matches")
266
+ return
267
+ for score, node in results:
268
+ line = f"{node.name} [{node.type}/{node.status}]"
269
+ if node.summary:
270
+ line += f" — {node.summary}"
271
+ print(line)
272
+
273
+
274
+ def cmd_show(args) -> None:
275
+ root = _root_or_die(args)
276
+ g = Graph.load(root)
277
+ node = g.nodes.get(args.name)
278
+ if not node:
279
+ hits = g.search([args.name], limit=5)
280
+ if len(hits) == 1:
281
+ node = hits[0][1]
282
+ elif hits:
283
+ print(f"no exact node '{args.name}'; close matches:")
284
+ for _, n in hits:
285
+ print(f" {n.name}")
286
+ sys.exit(1)
287
+ else:
288
+ sys.exit(f"no node named or matching '{args.name}'")
289
+ _print_node(g, node)
290
+
291
+
292
+ def cmd_hooks(args) -> None:
293
+ root = _root_or_die(args)
294
+ for p in hooks_mod.install(root):
295
+ print(f"installed: {os.path.relpath(p, root)}")
296
+
297
+
298
+ # ------------------------------------------------------------------ parser --
299
+ def build_parser() -> argparse.ArgumentParser:
300
+ p = argparse.ArgumentParser(
301
+ prog="graphcoding",
302
+ description="GraphCoding — your repo's living knowledge graph. "
303
+ "Query before you touch code; the graph is the design contract.")
304
+ p.add_argument("--version", action="version", version=f"graphcoding {__version__}")
305
+ p.add_argument("--root", help="repo root (default: walk up from cwd)")
306
+ sub = p.add_subparsers(dest="command", required=True)
307
+
308
+ s = sub.add_parser("init", help="create .graphcoding/ and scan the repo")
309
+ s.add_argument("--no-scan", action="store_true", help="skip the initial scan")
310
+ s.add_argument("--hooks", action="store_true", help="also install git hooks")
311
+ s.set_defaults(func=cmd_init)
312
+
313
+ s = sub.add_parser("scan", help="(re)scan the whole repo into the graph")
314
+ s.set_defaults(func=cmd_scan)
315
+
316
+ s = sub.add_parser("plan", help="declare a node you intend to build (DESIGN)")
317
+ s.add_argument("name", help="repo-relative path, or path::Symbol")
318
+ s.add_argument("--summary", "-s", help="one line: what it will do")
319
+ s.add_argument("--type", "-t", default="CodeFile", choices=NODE_TYPES)
320
+ s.add_argument("--edge", "-e", action="append",
321
+ help="TYPE:target (repeatable), e.g. -e IMPORTS:src/db.py")
322
+ s.add_argument("--force", action="store_true")
323
+ s.set_defaults(func=cmd_plan)
324
+
325
+ s = sub.add_parser("link", help="add an edge between nodes")
326
+ s.add_argument("source")
327
+ s.add_argument("type", help="|".join(EDGE_TYPES))
328
+ s.add_argument("target")
329
+ s.set_defaults(func=cmd_link)
330
+
331
+ s = sub.add_parser("mark-delete", help="mark a node's file for removal")
332
+ s.add_argument("name")
333
+ s.add_argument("--force", action="store_true",
334
+ help="mark even with incoming edges")
335
+ s.set_defaults(func=cmd_mark_delete)
336
+
337
+ s = sub.add_parser("sync", help="reconcile the graph with changed files (SYNC)")
338
+ s.add_argument("--staged", action="store_true", help="sync staged changes")
339
+ s.add_argument("--commit", nargs="?", const="HEAD",
340
+ help="sync files changed in a commit (default HEAD)")
341
+ s.add_argument("--files", nargs="*", help="sync explicit files")
342
+ s.add_argument("--quiet", action="store_true")
343
+ s.set_defaults(func=cmd_sync)
344
+
345
+ s = sub.add_parser("drift", help="working tree vs graph; exit 1 on drift (VERIFY)")
346
+ s.add_argument("--staged", action="store_true",
347
+ help="only gate on files staged for commit")
348
+ s.add_argument("--quiet", action="store_true", help="print nothing when clean")
349
+ s.set_defaults(func=cmd_drift)
350
+
351
+ s = sub.add_parser("status", help="planned work, dangling edges, drift summary")
352
+ s.set_defaults(func=cmd_status)
353
+
354
+ s = sub.add_parser("health", help="memory quality: coverage, stale summaries, orphans")
355
+ s.set_defaults(func=cmd_health)
356
+
357
+ s = sub.add_parser("summary", help="set/replace a node's one-line intent")
358
+ s.add_argument("name")
359
+ s.add_argument("text")
360
+ s.set_defaults(func=cmd_summary)
361
+
362
+ s = sub.add_parser("query", help="search nodes by name + summary (QUERY)")
363
+ s.add_argument("terms", nargs="+")
364
+ s.add_argument("--limit", type=int, default=20)
365
+ s.set_defaults(func=cmd_query)
366
+
367
+ s = sub.add_parser("show", help="one node: summary, edges, blast radius (QUERY)")
368
+ s.add_argument("name")
369
+ s.set_defaults(func=cmd_show)
370
+
371
+ s = sub.add_parser("hooks", help="install pre-commit gate + post-commit sync")
372
+ s.set_defaults(func=cmd_hooks)
373
+ return p
374
+
375
+
376
+ def main(argv: list[str] | None = None) -> None:
377
+ args = build_parser().parse_args(argv)
378
+ args.func(args)
379
+
380
+
381
+ if __name__ == "__main__":
382
+ main()
graphcoding/drift.py ADDED
@@ -0,0 +1,83 @@
1
+ """Drift detection — compare the working tree against the graph.
2
+
3
+ Four findings:
4
+ missing_node file on disk with no graph node (blocking)
5
+ ghost_node graph node whose file is gone (blocking)
6
+ not_deleted marked to-be-deleted but still on disk (blocking)
7
+ unbuilt_planned planned node with no file yet (informational —
8
+ design ahead of code is the point)
9
+
10
+ Exit code 1 when any blocking drift exists, so hooks and CI can gate on it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ from .scan import tracked_files
17
+ from .store import Graph
18
+
19
+
20
+ def compute_drift(root: str, cfg: dict, graph: Graph) -> dict:
21
+ disk = set(tracked_files(root, cfg))
22
+ file_nodes = graph.file_nodes()
23
+ missing = sorted(disk - set(file_nodes))
24
+ ghosts, unbuilt, not_deleted = [], [], []
25
+ for path, node in sorted(file_nodes.items()):
26
+ on_disk = path in disk or os.path.exists(os.path.join(root, path))
27
+ if node.status == "planned":
28
+ if not on_disk:
29
+ unbuilt.append(path)
30
+ # planned + on disk = built but not synced -> caught as stale status
31
+ elif node.status == "to-be-deleted":
32
+ if on_disk:
33
+ not_deleted.append(path)
34
+ else:
35
+ ghosts.append(path) # deleted on disk, node awaiting removal
36
+ elif not on_disk:
37
+ ghosts.append(path)
38
+ built_not_synced = [p for p, n in sorted(file_nodes.items())
39
+ if n.status == "planned"
40
+ and (p in disk or os.path.exists(os.path.join(root, p)))]
41
+ return {
42
+ "disk": len(disk),
43
+ "nodes": len(file_nodes),
44
+ "missing_node": missing,
45
+ "ghost_node": ghosts,
46
+ "not_deleted": not_deleted,
47
+ "built_not_synced": built_not_synced,
48
+ "unbuilt_planned": unbuilt,
49
+ }
50
+
51
+
52
+ def blocking_count(report: dict, scope: set[str] | None = None) -> int:
53
+ items = (report["missing_node"] + report["ghost_node"]
54
+ + report["not_deleted"] + report["built_not_synced"])
55
+ if scope is not None:
56
+ items = [p for p in items if p in scope]
57
+ return len(items)
58
+
59
+
60
+ def format_report(report: dict, scope: set[str] | None = None) -> str:
61
+ def rows(key: str, mark: str) -> list[str]:
62
+ items = report[key]
63
+ if scope is not None:
64
+ items = [p for p in items if p in scope]
65
+ lines = [f"{key}: {len(items)}"]
66
+ lines += [f" {mark} {p}" for p in items[:25]]
67
+ if len(items) > 25:
68
+ lines.append(f" … and {len(items) - 25} more")
69
+ return lines
70
+
71
+ out = ["=== graphcoding drift report ==="]
72
+ out.append(f"disk tracked files: {report['disk']} | graph file nodes: {report['nodes']}")
73
+ out += rows("missing_node", "+")
74
+ out += rows("ghost_node", "-")
75
+ out += rows("not_deleted", "!")
76
+ out += rows("built_not_synced", "~")
77
+ out += rows("unbuilt_planned", "?")
78
+ n = blocking_count(report, scope)
79
+ out.append("")
80
+ out.append(f"DRIFT={'YES' if n else 'NONE'} ({n} blocking issue{'s' if n != 1 else ''})")
81
+ if n:
82
+ out.append("fix: graphcoding sync (then re-run: graphcoding drift)")
83
+ return "\n".join(out)
graphcoding/health.py ADDED
@@ -0,0 +1,97 @@
1
+ """Memory-quality report — the decay counter.
2
+
3
+ The drift gate holds the floor (structure can't rot). This module measures the
4
+ upper floors, which CAN rot: summaries, intent edges, lifecycle hygiene.
5
+ `graphcoding health` names what's decaying so neglect is visible long before
6
+ it hurts. Informational — always exits 0; quality is steered, not gated.
7
+
8
+ Checks:
9
+ * summary coverage nodes with no summary at all
10
+ * stale-summary suspects stored summary no longer matches what the file's
11
+ own docstring/first-comment says (computed live —
12
+ nothing extra is stored, so no graph-diff noise)
13
+ * needs-analysis backlog machine-guessed summaries awaiting a mind
14
+ * orphans nodes with no edges either way — unmapped territory
15
+ * intent-edge richness hand-recorded CALLS/REFERENCES/DEPENDS_ON count;
16
+ the graph's irreplaceable layer
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from .scan import scan_file
21
+ from .store import Graph
22
+
23
+ INTENT_EDGES = ("CALLS", "REFERENCES", "DEPENDS_ON", "RELATED_TO",
24
+ "INHERITS", "IMPLEMENTS")
25
+
26
+
27
+ def compute_health(root: str, cfg: dict, graph: Graph) -> dict:
28
+ files = graph.file_nodes()
29
+ incoming_counts: dict[str, int] = {}
30
+ for n in graph.nodes.values():
31
+ for e in n.edges:
32
+ incoming_counts[e["to"]] = incoming_counts.get(e["to"], 0) + 1
33
+
34
+ no_summary, stale_suspects, needs_analysis, orphans = [], [], [], []
35
+ intent_edges = 0
36
+ for path, node in sorted(files.items()):
37
+ if node.status == "planned":
38
+ continue
39
+ if not node.summary:
40
+ no_summary.append(path)
41
+ else:
42
+ # live re-extraction; a differing fresh auto-summary means the
43
+ # file's self-description moved while the stored summary didn't
44
+ fresh, _ = scan_file(root, path, {**cfg, "scan_symbols": False})
45
+ if fresh.summary and fresh.summary != node.summary \
46
+ and len(fresh.summary) >= len(node.summary):
47
+ stale_suspects.append((path, node.summary, fresh.summary))
48
+ if node.status == "needs-analysis":
49
+ needs_analysis.append(path)
50
+ if not node.edges and not incoming_counts.get(path):
51
+ orphans.append(path)
52
+ intent_edges += sum(1 for e in node.edges if e["type"] in INTENT_EDGES)
53
+
54
+ total = len([n for n in files.values() if n.status != "planned"])
55
+ return {
56
+ "total": total,
57
+ "no_summary": no_summary,
58
+ "stale_suspects": stale_suspects,
59
+ "needs_analysis": needs_analysis,
60
+ "orphans": orphans,
61
+ "intent_edges": intent_edges,
62
+ }
63
+
64
+
65
+ def format_health(h: dict) -> str:
66
+ total = max(h["total"], 1)
67
+ covered = h["total"] - len(h["no_summary"])
68
+ out = ["=== graphcoding health — memory quality ==="]
69
+ out.append(f"nodes: {h['total']} summary coverage: {covered}/{h['total']} "
70
+ f"({100 * covered // total}%) hand-recorded intent edges: {h['intent_edges']}")
71
+
72
+ def block(title: str, items: list, render) -> None:
73
+ if not items:
74
+ return
75
+ out.append(f"\n{title} ({len(items)}):")
76
+ for it in items[:15]:
77
+ out.append(render(it))
78
+ if len(items) > 15:
79
+ out.append(f" … and {len(items) - 15} more")
80
+
81
+ block("no summary — invisible to query and to agents", h["no_summary"],
82
+ lambda p: f" · {p}")
83
+ block("stale-summary suspects — file's self-description moved, graph didn't",
84
+ h["stale_suspects"],
85
+ lambda s: f" ~ {s[0]}\n graph: {s[1][:70]}\n file: {s[2][:70]}")
86
+ block("needs-analysis — machine guesses awaiting a mind", h["needs_analysis"],
87
+ lambda p: f" ? {p}")
88
+ block("orphans — no edges either way; unmapped or genuinely dead",
89
+ h["orphans"], lambda p: f" ø {p}")
90
+
91
+ if h["intent_edges"] == 0 and h["total"] > 5:
92
+ out.append("\nzero hand-recorded edges: the graph is still only a scan. "
93
+ "Record what no scanner sees (HTTP calls, queues, cron→table): "
94
+ "graphcoding link <src> CALLS <dst>")
95
+ if not (h["no_summary"] or h["stale_suspects"] or h["needs_analysis"]):
96
+ out.append("\nmemory is healthy: every node carries current, human-grade intent.")
97
+ return "\n".join(out)
graphcoding/hooks.py ADDED
@@ -0,0 +1,65 @@
1
+ """Git hook installer — the enforcement layer.
2
+
3
+ pre-commit : blocks the commit when a file being committed drifts from the
4
+ graph. Scoped to staged files so teammates' un-graphed WIP never
5
+ deadlocks your commit.
6
+ post-commit: syncs the graph with what was just committed (belt + braces; the
7
+ pre-commit gate should already have forced a sync).
8
+
9
+ Existing hooks are preserved: ours is appended behind a marker block.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import stat
15
+
16
+ MARKER_BEGIN = "# >>> graphcoding hook >>>"
17
+ MARKER_END = "# <<< graphcoding hook <<<"
18
+
19
+ PRE_COMMIT = f"""{MARKER_BEGIN}
20
+ # Block the commit if any STAGED file drifts from .graphcoding/graph.jsonl
21
+ if command -v graphcoding >/dev/null 2>&1; then
22
+ graphcoding drift --staged || {{
23
+ echo ""
24
+ echo "[graphcoding] staged files drift from the graph."
25
+ echo "[graphcoding] run: graphcoding sync --staged && git add .graphcoding/graph.jsonl"
26
+ exit 1
27
+ }}
28
+ fi
29
+ {MARKER_END}"""
30
+
31
+ POST_COMMIT = f"""{MARKER_BEGIN}
32
+ # Keep the graph in step with what was just committed
33
+ if command -v graphcoding >/dev/null 2>&1; then
34
+ graphcoding sync --commit HEAD --quiet || true
35
+ fi
36
+ {MARKER_END}"""
37
+
38
+
39
+ def _install_one(hooks_dir: str, name: str, body: str) -> str:
40
+ path = os.path.join(hooks_dir, name)
41
+ if os.path.exists(path):
42
+ with open(path, encoding="utf-8") as f:
43
+ content = f.read()
44
+ if MARKER_BEGIN in content:
45
+ head, _, rest = content.partition(MARKER_BEGIN)
46
+ _, _, tail = rest.partition(MARKER_END)
47
+ content = head.rstrip("\n") + "\n" + body + tail
48
+ else:
49
+ content = content.rstrip("\n") + "\n\n" + body + "\n"
50
+ else:
51
+ content = "#!/bin/sh\n\n" + body + "\n"
52
+ with open(path, "w", encoding="utf-8") as f:
53
+ f.write(content)
54
+ os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
55
+ return path
56
+
57
+
58
+ def install(root: str) -> list[str]:
59
+ hooks_dir = os.path.join(root, ".git", "hooks")
60
+ if not os.path.isdir(hooks_dir):
61
+ raise SystemExit("not a git repository (no .git/hooks) — run inside a git repo")
62
+ return [
63
+ _install_one(hooks_dir, "pre-commit", PRE_COMMIT),
64
+ _install_one(hooks_dir, "post-commit", POST_COMMIT),
65
+ ]