ckgraphify 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.
- ckgraphify-0.1.0.dist-info/METADATA +608 -0
- ckgraphify-0.1.0.dist-info/RECORD +42 -0
- ckgraphify-0.1.0.dist-info/WHEEL +5 -0
- ckgraphify-0.1.0.dist-info/entry_points.txt +2 -0
- ckgraphify-0.1.0.dist-info/licenses/LICENSE +21 -0
- ckgraphify-0.1.0.dist-info/top_level.txt +2 -0
- graphify/__init__.py +28 -0
- graphify/__main__.py +2885 -0
- graphify/analyze.py +575 -0
- graphify/benchmark.py +152 -0
- graphify/bridge_mtop.py +517 -0
- graphify/build.py +353 -0
- graphify/business_map.py +1420 -0
- graphify/cache.py +244 -0
- graphify/callflow_html.py +2014 -0
- graphify/cluster.py +221 -0
- graphify/dedup.py +355 -0
- graphify/detect.py +877 -0
- graphify/export.py +1264 -0
- graphify/extract.py +6094 -0
- graphify/global_graph.py +155 -0
- graphify/google_workspace.py +223 -0
- graphify/graph_main_backend.py +1443 -0
- graphify/graph_main_frontend.py +1227 -0
- graphify/graph_main_html.py +597 -0
- graphify/graph_main_merge.py +493 -0
- graphify/graph_main_trace.py +322 -0
- graphify/hooks.py +282 -0
- graphify/ingest.py +331 -0
- graphify/llm.py +1076 -0
- graphify/manifest.py +4 -0
- graphify/report.py +196 -0
- graphify/security.py +242 -0
- graphify/serve.py +670 -0
- graphify/transcribe.py +184 -0
- graphify/tree_html.py +580 -0
- graphify/validate.py +72 -0
- graphify/watch.py +705 -0
- graphify/wiki.py +255 -0
- skill/__init__.py +1 -0
- skill/skill-codex.md +147 -0
- skill/skill.md +151 -0
graphify/cache.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# per-file extraction cache - skip unchanged files on re-run
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Output directory name — override with GRAPHIFY_OUT env var for worktrees or
|
|
11
|
+
# shared-output setups. Accepts a relative name ("graphify-out-feature") or an
|
|
12
|
+
# absolute path ("/shared/graphify-out").
|
|
13
|
+
_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _body_content(content: bytes) -> bytes:
|
|
17
|
+
"""Strip YAML frontmatter from Markdown content, returning only the body."""
|
|
18
|
+
text = content.decode(errors="replace")
|
|
19
|
+
if text.startswith("---"):
|
|
20
|
+
end = text.find("\n---", 3)
|
|
21
|
+
if end != -1:
|
|
22
|
+
return text[end + 4:].encode()
|
|
23
|
+
return content
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_path(path: Path) -> Path:
|
|
27
|
+
"""Normalize path for consistent cache keys across Windows path spellings."""
|
|
28
|
+
import sys
|
|
29
|
+
if sys.platform != "win32":
|
|
30
|
+
return path
|
|
31
|
+
s = str(path)
|
|
32
|
+
if s.startswith("\\\\?\\"):
|
|
33
|
+
s = s[4:] # strip extended-length prefix \\?\
|
|
34
|
+
return Path(os.path.normcase(s))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def file_hash(path: Path, root: Path = Path(".")) -> str:
|
|
38
|
+
"""SHA256 of file contents + path relative to root.
|
|
39
|
+
|
|
40
|
+
Using a relative path (not absolute) makes cache entries portable across
|
|
41
|
+
machines and checkout directories, so shared caches and CI work correctly.
|
|
42
|
+
Falls back to the resolved absolute path if the file is outside root.
|
|
43
|
+
|
|
44
|
+
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
|
|
45
|
+
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
|
|
46
|
+
"""
|
|
47
|
+
p = _normalize_path(Path(path))
|
|
48
|
+
root = _normalize_path(Path(root))
|
|
49
|
+
if not p.is_file():
|
|
50
|
+
raise IsADirectoryError(f"file_hash requires a file, got: {p}")
|
|
51
|
+
raw = p.read_bytes()
|
|
52
|
+
content = _body_content(raw) if p.suffix.lower() == ".md" else raw
|
|
53
|
+
h = hashlib.sha256()
|
|
54
|
+
h.update(content)
|
|
55
|
+
h.update(b"\x00")
|
|
56
|
+
try:
|
|
57
|
+
rel = p.resolve().relative_to(Path(root).resolve())
|
|
58
|
+
h.update(rel.as_posix().lower().encode())
|
|
59
|
+
except ValueError:
|
|
60
|
+
h.update(p.resolve().as_posix().lower().encode())
|
|
61
|
+
return h.hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path:
|
|
65
|
+
"""Returns graphify-out/cache/{kind}/ - creates it if needed.
|
|
66
|
+
|
|
67
|
+
kind is "ast" or "semantic". Separate subdirectories prevent semantic cache
|
|
68
|
+
entries from overwriting AST cache entries for the same source_file (#582).
|
|
69
|
+
"""
|
|
70
|
+
_out = Path(_GRAPHIFY_OUT)
|
|
71
|
+
base = _out if _out.is_absolute() else Path(root).resolve() / _out
|
|
72
|
+
d = base / "cache" / kind
|
|
73
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
return d
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | None:
|
|
78
|
+
"""Return cached extraction for this file if hash matches, else None.
|
|
79
|
+
|
|
80
|
+
Cache key: SHA256 of file contents.
|
|
81
|
+
Cache value: stored as graphify-out/cache/{kind}/{hash}.json
|
|
82
|
+
|
|
83
|
+
For kind="ast", also checks the legacy flat cache/ directory so users
|
|
84
|
+
upgrading from pre-0.5.3 don't lose their existing AST cache entries.
|
|
85
|
+
Returns None if no cache entry or file has changed.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
h = file_hash(path, root)
|
|
89
|
+
except OSError:
|
|
90
|
+
return None
|
|
91
|
+
entry = cache_dir(root, kind) / f"{h}.json"
|
|
92
|
+
if entry.exists():
|
|
93
|
+
try:
|
|
94
|
+
return json.loads(entry.read_text(encoding="utf-8"))
|
|
95
|
+
except (json.JSONDecodeError, OSError):
|
|
96
|
+
return None
|
|
97
|
+
# Migration fallback: check legacy flat cache/ dir for AST entries
|
|
98
|
+
if kind == "ast":
|
|
99
|
+
legacy = Path(root).resolve() / _GRAPHIFY_OUT / "cache" / f"{h}.json"
|
|
100
|
+
if legacy.exists():
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(legacy.read_text(encoding="utf-8"))
|
|
103
|
+
except (json.JSONDecodeError, OSError):
|
|
104
|
+
return None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast") -> None:
|
|
109
|
+
"""Save extraction result for this file.
|
|
110
|
+
|
|
111
|
+
Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents.
|
|
112
|
+
result should be a dict with 'nodes' and 'edges' lists.
|
|
113
|
+
|
|
114
|
+
No-ops if `path` is not a regular file. Subagent-produced semantic fragments
|
|
115
|
+
occasionally carry a directory path in `source_file`; skipping them prevents
|
|
116
|
+
IsADirectoryError from aborting the whole batch.
|
|
117
|
+
"""
|
|
118
|
+
p = Path(path)
|
|
119
|
+
if not p.is_file():
|
|
120
|
+
return
|
|
121
|
+
h = file_hash(p, root)
|
|
122
|
+
target_dir = cache_dir(root, kind)
|
|
123
|
+
entry = target_dir / f"{h}.json"
|
|
124
|
+
fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp")
|
|
125
|
+
try:
|
|
126
|
+
os.write(fd, json.dumps(result).encode())
|
|
127
|
+
os.close(fd)
|
|
128
|
+
try:
|
|
129
|
+
os.replace(tmp_path, entry)
|
|
130
|
+
except PermissionError:
|
|
131
|
+
# Windows: os.replace can fail with WinError 5 if the target is
|
|
132
|
+
# briefly locked. Fall back to copy-then-delete.
|
|
133
|
+
import shutil
|
|
134
|
+
shutil.copy2(tmp_path, entry)
|
|
135
|
+
os.unlink(tmp_path)
|
|
136
|
+
except Exception:
|
|
137
|
+
try:
|
|
138
|
+
os.close(fd)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
os.unlink(tmp_path)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cached_files(root: Path = Path(".")) -> set[str]:
|
|
149
|
+
"""Return set of file hashes that have a valid cache entry (any kind)."""
|
|
150
|
+
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
|
|
151
|
+
hashes: set[str] = set()
|
|
152
|
+
# Legacy flat entries
|
|
153
|
+
if base.is_dir():
|
|
154
|
+
hashes.update(p.stem for p in base.glob("*.json"))
|
|
155
|
+
# Namespaced entries
|
|
156
|
+
for kind in ("ast", "semantic"):
|
|
157
|
+
d = base / kind
|
|
158
|
+
if d.is_dir():
|
|
159
|
+
hashes.update(p.stem for p in d.glob("*.json"))
|
|
160
|
+
return hashes
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def clear_cache(root: Path = Path(".")) -> None:
|
|
164
|
+
"""Delete all cache entries (ast/, semantic/, and legacy flat entries)."""
|
|
165
|
+
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
|
|
166
|
+
# Legacy flat entries
|
|
167
|
+
if base.is_dir():
|
|
168
|
+
for f in base.glob("*.json"):
|
|
169
|
+
f.unlink()
|
|
170
|
+
# Namespaced entries
|
|
171
|
+
for kind in ("ast", "semantic"):
|
|
172
|
+
d = base / kind
|
|
173
|
+
if d.is_dir():
|
|
174
|
+
for f in d.glob("*.json"):
|
|
175
|
+
f.unlink()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_semantic_cache(
|
|
179
|
+
files: list[str],
|
|
180
|
+
root: Path = Path("."),
|
|
181
|
+
) -> tuple[list[dict], list[dict], list[dict], list[str]]:
|
|
182
|
+
"""Check semantic extraction cache for a list of absolute file paths.
|
|
183
|
+
|
|
184
|
+
Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files).
|
|
185
|
+
Uncached files need Claude extraction; cached files are merged directly.
|
|
186
|
+
"""
|
|
187
|
+
cached_nodes: list[dict] = []
|
|
188
|
+
cached_edges: list[dict] = []
|
|
189
|
+
cached_hyperedges: list[dict] = []
|
|
190
|
+
uncached: list[str] = []
|
|
191
|
+
|
|
192
|
+
for fpath in files:
|
|
193
|
+
p = Path(fpath)
|
|
194
|
+
if not p.is_absolute():
|
|
195
|
+
p = Path(root) / p
|
|
196
|
+
result = load_cached(p, root, kind="semantic")
|
|
197
|
+
if result is not None:
|
|
198
|
+
cached_nodes.extend(result.get("nodes", []))
|
|
199
|
+
cached_edges.extend(result.get("edges", []))
|
|
200
|
+
cached_hyperedges.extend(result.get("hyperedges", []))
|
|
201
|
+
else:
|
|
202
|
+
uncached.append(fpath)
|
|
203
|
+
|
|
204
|
+
return cached_nodes, cached_edges, cached_hyperedges, uncached
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def save_semantic_cache(
|
|
208
|
+
nodes: list[dict],
|
|
209
|
+
edges: list[dict],
|
|
210
|
+
hyperedges: list[dict] | None = None,
|
|
211
|
+
root: Path = Path("."),
|
|
212
|
+
) -> int:
|
|
213
|
+
"""Save semantic extraction results to cache, keyed by source_file.
|
|
214
|
+
|
|
215
|
+
Groups nodes and edges by source_file, then saves one cache entry per file
|
|
216
|
+
under cache/semantic/ (separate from AST entries in cache/ast/) to prevent
|
|
217
|
+
hash-key collisions (#582).
|
|
218
|
+
Returns the number of files cached.
|
|
219
|
+
"""
|
|
220
|
+
from collections import defaultdict
|
|
221
|
+
|
|
222
|
+
by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": [], "hyperedges": []})
|
|
223
|
+
for n in nodes:
|
|
224
|
+
src = n.get("source_file", "")
|
|
225
|
+
if src:
|
|
226
|
+
by_file[src]["nodes"].append(n)
|
|
227
|
+
for e in edges:
|
|
228
|
+
src = e.get("source_file", "")
|
|
229
|
+
if src:
|
|
230
|
+
by_file[src]["edges"].append(e)
|
|
231
|
+
for h in (hyperedges or []):
|
|
232
|
+
src = h.get("source_file", "")
|
|
233
|
+
if src:
|
|
234
|
+
by_file[src]["hyperedges"].append(h)
|
|
235
|
+
|
|
236
|
+
saved = 0
|
|
237
|
+
for fpath, result in by_file.items():
|
|
238
|
+
p = Path(fpath)
|
|
239
|
+
if not p.is_absolute():
|
|
240
|
+
p = Path(root) / p
|
|
241
|
+
if p.is_file():
|
|
242
|
+
save_cached(p, result, root, kind="semantic")
|
|
243
|
+
saved += 1
|
|
244
|
+
return saved
|