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.
- graphcoding/__init__.py +6 -0
- graphcoding/cli.py +382 -0
- graphcoding/drift.py +83 -0
- graphcoding/health.py +97 -0
- graphcoding/hooks.py +65 -0
- graphcoding/scan.py +254 -0
- graphcoding/store.py +202 -0
- graphcoding/sync.py +86 -0
- graphcoding-0.1.0.dist-info/METADATA +199 -0
- graphcoding-0.1.0.dist-info/RECORD +14 -0
- graphcoding-0.1.0.dist-info/WHEEL +5 -0
- graphcoding-0.1.0.dist-info/entry_points.txt +2 -0
- graphcoding-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphcoding-0.1.0.dist-info/top_level.txt +1 -0
graphcoding/__init__.py
ADDED
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
|
+
]
|