graphkeeper-cli 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.
- graphkeeper/__init__.py +71 -0
- graphkeeper/build.py +48 -0
- graphkeeper/cli.py +167 -0
- graphkeeper/formatters.py +125 -0
- graphkeeper/git.py +207 -0
- graphkeeper/graphify.py +176 -0
- graphkeeper/py.typed +0 -0
- graphkeeper/query.py +114 -0
- graphkeeper/store.py +121 -0
- graphkeeper/types.py +149 -0
- graphkeeper_cli-0.1.0.dist-info/METADATA +264 -0
- graphkeeper_cli-0.1.0.dist-info/RECORD +15 -0
- graphkeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- graphkeeper_cli-0.1.0.dist-info/entry_points.txt +2 -0
- graphkeeper_cli-0.1.0.dist-info/licenses/LICENSE +191 -0
graphkeeper/graphify.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional enrichment from graphify (https://github.com/Graphify-Labs/graphify,
|
|
3
|
+
PyPI package `graphifyy`)'s symbol/import/call-graph extraction.
|
|
4
|
+
|
|
5
|
+
Ported from src/graphify.ts. Every `graphify` invocation uses an argv list
|
|
6
|
+
passed directly to the OS (`subprocess.run`, `shell=False`), never a shell
|
|
7
|
+
string, for the same reason as `git.py`. Detection and extraction failures
|
|
8
|
+
never raise -- every failure mode is reported back via `skipped_reason` so
|
|
9
|
+
`build()` can proceed in co-change-only mode, matching the TypeScript
|
|
10
|
+
source's "never throws" contract.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import PurePosixPath
|
|
19
|
+
|
|
20
|
+
from .store import GRAPHIFY_RAW_DIR_NAME, PathSafetyError, assert_path_inside, ensure_graphkeeper_subdir
|
|
21
|
+
from .types import GraphifyEnrichment
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_S = 5 * 60
|
|
24
|
+
|
|
25
|
+
_VERSION_RE = re.compile(r"graphify\s+([\w.-]+)", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_graphify() -> dict:
|
|
29
|
+
"""Detects whether graphify is installed and on PATH, by running
|
|
30
|
+
`graphify --version`. Returns `{"installed": bool, "version": str |
|
|
31
|
+
None}`."""
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["graphify", "--version"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=10,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
41
|
+
return {"installed": False, "version": None}
|
|
42
|
+
|
|
43
|
+
if result.returncode != 0:
|
|
44
|
+
return {"installed": False, "version": None}
|
|
45
|
+
|
|
46
|
+
match = _VERSION_RE.search(result.stdout or "")
|
|
47
|
+
if match:
|
|
48
|
+
version = match.group(1)
|
|
49
|
+
else:
|
|
50
|
+
version = (result.stdout or "").strip() or "unknown"
|
|
51
|
+
return {"installed": True, "version": version}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_graphify_enrichment(repo_root: str) -> GraphifyEnrichment:
|
|
55
|
+
"""Runs `graphify extract <repo_root> --code-only --no-cluster --out
|
|
56
|
+
<dir>` (graphify's own headless, no-LLM, no-API-key CLI path for local
|
|
57
|
+
AST-based symbol/import/call-graph extraction) and merges its
|
|
58
|
+
`graph.json` output into a GraphifyEnrichment. Never raises: any failure
|
|
59
|
+
is reported back via `skipped_reason` so `build()` can proceed in
|
|
60
|
+
co-change-only mode.
|
|
61
|
+
|
|
62
|
+
`--out` is pointed at a directory inside the caller-managed
|
|
63
|
+
`.graphkeeper/` tree, so graphify's own output never lands outside it.
|
|
64
|
+
"""
|
|
65
|
+
detected = detect_graphify()
|
|
66
|
+
if not detected["installed"]:
|
|
67
|
+
return GraphifyEnrichment(
|
|
68
|
+
enriched=False,
|
|
69
|
+
version=None,
|
|
70
|
+
skipped_reason=(
|
|
71
|
+
"graphify was not found on PATH. Install it with `uv tool install graphifyy` "
|
|
72
|
+
"(or `pipx install graphifyy`) for symbol/call-graph enrichment; GraphKeeper "
|
|
73
|
+
"works fine without it, in co-change-only mode."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
raw_dir = ensure_graphkeeper_subdir(repo_root, GRAPHIFY_RAW_DIR_NAME)
|
|
79
|
+
except PathSafetyError as err:
|
|
80
|
+
return GraphifyEnrichment(
|
|
81
|
+
enriched=False,
|
|
82
|
+
version=detected["version"],
|
|
83
|
+
skipped_reason=f"Could not prepare a safe output directory for graphify: {err}",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
extract_result = subprocess.run(
|
|
88
|
+
["graphify", "extract", repo_root, "--code-only", "--no-cluster", "--out", raw_dir],
|
|
89
|
+
cwd=repo_root,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=DEFAULT_TIMEOUT_S,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
except (FileNotFoundError, OSError) as err:
|
|
96
|
+
return GraphifyEnrichment(
|
|
97
|
+
enriched=False,
|
|
98
|
+
version=detected["version"],
|
|
99
|
+
skipped_reason=f"graphify extract failed to run: {err}",
|
|
100
|
+
)
|
|
101
|
+
except subprocess.TimeoutExpired as err:
|
|
102
|
+
return GraphifyEnrichment(
|
|
103
|
+
enriched=False,
|
|
104
|
+
version=detected["version"],
|
|
105
|
+
skipped_reason=f"graphify extract failed to run: {err}",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if extract_result.returncode != 0:
|
|
109
|
+
stderr_tail = " ".join((extract_result.stderr or "").strip().split("\n")[-3:]).strip()
|
|
110
|
+
suffix = f": {stderr_tail}" if stderr_tail else ""
|
|
111
|
+
return GraphifyEnrichment(
|
|
112
|
+
enriched=False,
|
|
113
|
+
version=detected["version"],
|
|
114
|
+
skipped_reason=f"graphify extract exited with status {extract_result.returncode}{suffix}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
graph_json_path = os.path.join(raw_dir, "graphify-out", "graph.json")
|
|
118
|
+
try:
|
|
119
|
+
assert_path_inside(graph_json_path, repo_root)
|
|
120
|
+
except PathSafetyError as err:
|
|
121
|
+
return GraphifyEnrichment(
|
|
122
|
+
enriched=False,
|
|
123
|
+
version=detected["version"],
|
|
124
|
+
skipped_reason=f"graphify's output path failed a safety check: {err}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not os.path.exists(graph_json_path):
|
|
128
|
+
return GraphifyEnrichment(
|
|
129
|
+
enriched=False,
|
|
130
|
+
version=detected["version"],
|
|
131
|
+
skipped_reason=f'graphify extract completed but did not produce a graph.json at "{graph_json_path}".',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
with open(graph_json_path, "r", encoding="utf-8") as fh:
|
|
136
|
+
raw = fh.read()
|
|
137
|
+
parsed = json.loads(raw)
|
|
138
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
139
|
+
return GraphifyEnrichment(
|
|
140
|
+
enriched=False,
|
|
141
|
+
version=detected["version"],
|
|
142
|
+
skipped_reason=f"Could not parse graphify's graph.json: {err}",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
nodes = parsed.get("nodes") if isinstance(parsed, dict) else None
|
|
146
|
+
edges = parsed.get("edges") if isinstance(parsed, dict) else None
|
|
147
|
+
if not isinstance(nodes, list) or not isinstance(edges, list):
|
|
148
|
+
return GraphifyEnrichment(
|
|
149
|
+
enriched=False,
|
|
150
|
+
version=detected["version"],
|
|
151
|
+
skipped_reason="graphify's graph.json did not have the expected {nodes, edges} shape.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# graphify reports `source_file` relative to its own --out directory,
|
|
155
|
+
# not the scanned target. Because that --out directory lives nested
|
|
156
|
+
# inside .graphkeeper/ (so graphify's own output never escapes it, per
|
|
157
|
+
# this project's path-safety guard), those paths come back as
|
|
158
|
+
# "../../src/a.py" instead of "src/a.py". Rewrite them relative to
|
|
159
|
+
# repo_root so they line up with the co-change paths GraphKeeper mines
|
|
160
|
+
# from git log.
|
|
161
|
+
def rewrite_source_file(item: dict) -> dict:
|
|
162
|
+
source_file = item.get("source_file")
|
|
163
|
+
if not source_file:
|
|
164
|
+
return item
|
|
165
|
+
absolute = os.path.normpath(os.path.join(raw_dir, source_file))
|
|
166
|
+
rewritten = os.path.relpath(absolute, repo_root)
|
|
167
|
+
rewritten = str(PurePosixPath(*rewritten.split(os.sep)))
|
|
168
|
+
return {**item, "source_file": rewritten}
|
|
169
|
+
|
|
170
|
+
return GraphifyEnrichment(
|
|
171
|
+
enriched=True,
|
|
172
|
+
version=detected["version"],
|
|
173
|
+
skipped_reason=None,
|
|
174
|
+
nodes=[rewrite_source_file(n) for n in nodes],
|
|
175
|
+
edges=[rewrite_source_file(e) for e in edges],
|
|
176
|
+
)
|
graphkeeper/py.typed
ADDED
|
File without changes
|
graphkeeper/query.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query logic against a built GraphKeeper store: co-change lookups and
|
|
3
|
+
graphify-backed call-graph lookups.
|
|
4
|
+
|
|
5
|
+
Ported from src/query.ts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .types import CallsQueryResult, CoChangeQueryResult, CoChangeResultRow, GraphKeeperStore, GraphifyNode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_file_arg(store: GraphKeeperStore, file: str) -> str:
|
|
17
|
+
"""Normalizes a user-supplied file argument to the repo-relative form
|
|
18
|
+
GraphKeeper stores paths in."""
|
|
19
|
+
normalized = file
|
|
20
|
+
if os.path.isabs(normalized):
|
|
21
|
+
rel = os.path.relpath(normalized, store.repo_path)
|
|
22
|
+
if not rel.startswith(".."):
|
|
23
|
+
normalized = rel
|
|
24
|
+
normalized = re.sub(r"^\./", "", normalized)
|
|
25
|
+
return normalized.replace(os.sep, "/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def query_co_change(store: GraphKeeperStore, file: str, limit: Optional[int] = None) -> CoChangeQueryResult:
|
|
29
|
+
"""Finds files that historically changed alongside `file`, ranked by
|
|
30
|
+
co-change frequency (how many commits touched both)."""
|
|
31
|
+
target = normalize_file_arg(store, file)
|
|
32
|
+
results: List[CoChangeResultRow] = []
|
|
33
|
+
for edge in store.co_change:
|
|
34
|
+
if edge.a == target:
|
|
35
|
+
results.append(CoChangeResultRow(file=edge.b, count=edge.count))
|
|
36
|
+
elif edge.b == target:
|
|
37
|
+
results.append(CoChangeResultRow(file=edge.a, count=edge.count))
|
|
38
|
+
results.sort(key=lambda r: r.count, reverse=True)
|
|
39
|
+
limited = results[:limit] if limit and limit > 0 else results
|
|
40
|
+
return CoChangeQueryResult(file=target, results=limited)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize_symbol(s: str) -> str:
|
|
44
|
+
return re.sub(r"\(\)\s*$", "", s.strip()).lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_graphify_node(nodes: List[GraphifyNode], symbol: str) -> Optional[GraphifyNode]:
|
|
48
|
+
"""Finds a graphify node whose label best matches `symbol` (exact match
|
|
49
|
+
preferred, then case-insensitive)."""
|
|
50
|
+
for n in nodes:
|
|
51
|
+
if n.get("label") == symbol or n.get("label") == f"{symbol}()":
|
|
52
|
+
return n
|
|
53
|
+
target = _normalize_symbol(symbol)
|
|
54
|
+
for n in nodes:
|
|
55
|
+
if _normalize_symbol(str(n.get("label", ""))) == target:
|
|
56
|
+
return n
|
|
57
|
+
for n in nodes:
|
|
58
|
+
node_id = n.get("id", "")
|
|
59
|
+
if node_id == symbol or str(node_id).endswith(f"_{symbol}"):
|
|
60
|
+
return n
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def query_calls(store: GraphKeeperStore, symbol: str) -> CallsQueryResult:
|
|
65
|
+
"""Finds callers/callees of `symbol` using graphify's `calls` edges.
|
|
66
|
+
Only meaningful when the build included graphify enrichment; otherwise
|
|
67
|
+
reports why (never a silent empty result or a crash)."""
|
|
68
|
+
if not store.graphify.enriched:
|
|
69
|
+
return CallsQueryResult(
|
|
70
|
+
symbol=symbol,
|
|
71
|
+
available=False,
|
|
72
|
+
unavailable_reason=(
|
|
73
|
+
store.graphify.skipped_reason
|
|
74
|
+
or "This build did not include graphify enrichment, so call-graph queries aren't "
|
|
75
|
+
"available. Install graphify (`uv tool install graphifyy`) and re-run "
|
|
76
|
+
"`graphkeeper build` to enable them."
|
|
77
|
+
),
|
|
78
|
+
node=None,
|
|
79
|
+
calls=[],
|
|
80
|
+
called_by=[],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
nodes_by_id = {n.get("id"): n for n in store.graphify.nodes}
|
|
84
|
+
node = find_graphify_node(store.graphify.nodes, symbol)
|
|
85
|
+
|
|
86
|
+
if not node:
|
|
87
|
+
return CallsQueryResult(
|
|
88
|
+
symbol=symbol,
|
|
89
|
+
available=True,
|
|
90
|
+
unavailable_reason=None,
|
|
91
|
+
node=None,
|
|
92
|
+
calls=[],
|
|
93
|
+
called_by=[],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
calls = [
|
|
97
|
+
{"node": nodes_by_id.get(e.get("target")), "edge": e}
|
|
98
|
+
for e in store.graphify.edges
|
|
99
|
+
if e.get("relation") == "calls" and e.get("source") == node.get("id")
|
|
100
|
+
]
|
|
101
|
+
called_by = [
|
|
102
|
+
{"node": nodes_by_id.get(e.get("source")), "edge": e}
|
|
103
|
+
for e in store.graphify.edges
|
|
104
|
+
if e.get("relation") == "calls" and e.get("target") == node.get("id")
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
return CallsQueryResult(
|
|
108
|
+
symbol=symbol,
|
|
109
|
+
available=True,
|
|
110
|
+
unavailable_reason=None,
|
|
111
|
+
node=node,
|
|
112
|
+
calls=calls,
|
|
113
|
+
called_by=called_by,
|
|
114
|
+
)
|
graphkeeper/store.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path-safety-checked local storage for the GraphKeeper store.
|
|
3
|
+
|
|
4
|
+
Ported from src/store.ts. Every write and every directory GraphKeeper
|
|
5
|
+
creates is verified, after resolving symlinks, to be nested inside the
|
|
6
|
+
target repo's own `.graphkeeper/` directory -- so a maliciously crafted
|
|
7
|
+
repo (e.g. `.graphkeeper` itself replaced with a symlink to `/etc`, or a
|
|
8
|
+
target path containing `..`) can never redirect GraphKeeper's writes
|
|
9
|
+
somewhere unexpected.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .types import GraphKeeperStore
|
|
18
|
+
|
|
19
|
+
GRAPHKEEPER_DIR_NAME = ".graphkeeper"
|
|
20
|
+
GRAPH_FILE_NAME = "graph.json"
|
|
21
|
+
GRAPHIFY_RAW_DIR_NAME = "graphify-raw"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PathSafetyError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_repo_root(target_path: str) -> str:
|
|
29
|
+
"""Resolves `target_path` to a real, existing directory. Follows
|
|
30
|
+
symlinks (`os.path.realpath`) so a malicious repo cannot point
|
|
31
|
+
GraphKeeper's notion of "repo root" somewhere unexpected via a crafted
|
|
32
|
+
symlink at the entry point."""
|
|
33
|
+
resolved = os.path.abspath(target_path)
|
|
34
|
+
if not os.path.exists(resolved):
|
|
35
|
+
raise PathSafetyError(f'Path "{target_path}" does not exist.')
|
|
36
|
+
real = os.path.realpath(resolved)
|
|
37
|
+
if not os.path.isdir(real):
|
|
38
|
+
raise PathSafetyError(f'Path "{target_path}" is not a directory.')
|
|
39
|
+
return real
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_path_inside(child_path: str, parent_path: str) -> None:
|
|
43
|
+
"""Confirms that `child_path`, once symlinks are resolved, is the same
|
|
44
|
+
as or nested inside `parent_path`. This is the core guard against a
|
|
45
|
+
crafted repo causing GraphKeeper to read or write outside the intended
|
|
46
|
+
output directory."""
|
|
47
|
+
real_parent = os.path.realpath(parent_path)
|
|
48
|
+
|
|
49
|
+
# child_path may not exist yet (e.g. before mkdir); resolve its nearest
|
|
50
|
+
# existing ancestor and re-attach the remaining, not-yet-created
|
|
51
|
+
# segments, mirroring src/store.ts's own walk-up loop.
|
|
52
|
+
to_resolve = os.path.abspath(child_path)
|
|
53
|
+
remainder = []
|
|
54
|
+
while not os.path.exists(to_resolve):
|
|
55
|
+
remainder.insert(0, os.path.basename(to_resolve))
|
|
56
|
+
parent_of_to_resolve = os.path.dirname(to_resolve)
|
|
57
|
+
if parent_of_to_resolve == to_resolve:
|
|
58
|
+
# Reached filesystem root without finding an existing ancestor.
|
|
59
|
+
break
|
|
60
|
+
to_resolve = parent_of_to_resolve
|
|
61
|
+
|
|
62
|
+
real_existing_ancestor = os.path.realpath(to_resolve) if os.path.exists(to_resolve) else to_resolve
|
|
63
|
+
real_child = os.path.join(real_existing_ancestor, *remainder) if remainder else real_existing_ancestor
|
|
64
|
+
|
|
65
|
+
is_inside = real_child == real_parent or real_child.startswith(real_parent + os.sep)
|
|
66
|
+
if not is_inside:
|
|
67
|
+
raise PathSafetyError(
|
|
68
|
+
f"Refusing to write outside the target repo's {GRAPHKEEPER_DIR_NAME}/ directory "
|
|
69
|
+
f'(resolved "{child_path}" -> "{real_child}", expected it under "{real_parent}").'
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ensure_graphkeeper_subdir(repo_root: str, *subdir_segments: str) -> str:
|
|
74
|
+
"""Creates (or reuses) `.graphkeeper/<subdir...>` under `repo_root`,
|
|
75
|
+
verifying containment at every step."""
|
|
76
|
+
gk_dir = os.path.join(repo_root, GRAPHKEEPER_DIR_NAME)
|
|
77
|
+
os.makedirs(gk_dir, exist_ok=True)
|
|
78
|
+
assert_path_inside(gk_dir, repo_root)
|
|
79
|
+
|
|
80
|
+
directory = gk_dir
|
|
81
|
+
for segment in subdir_segments:
|
|
82
|
+
directory = os.path.join(directory, segment)
|
|
83
|
+
os.makedirs(directory, exist_ok=True)
|
|
84
|
+
assert_path_inside(directory, repo_root)
|
|
85
|
+
return directory
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def graph_file_path(repo_root: str) -> str:
|
|
89
|
+
"""Path to the merged GraphKeeper store file, `.graphkeeper/graph.json`,
|
|
90
|
+
under `repo_root`."""
|
|
91
|
+
return os.path.join(repo_root, GRAPHKEEPER_DIR_NAME, GRAPH_FILE_NAME)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_store(repo_root: str, store: GraphKeeperStore) -> str:
|
|
95
|
+
"""Writes the store atomically (write to temp file, then rename) inside
|
|
96
|
+
`.graphkeeper/`."""
|
|
97
|
+
gk_dir = ensure_graphkeeper_subdir(repo_root)
|
|
98
|
+
final_path = os.path.join(gk_dir, GRAPH_FILE_NAME)
|
|
99
|
+
tmp_path = f"{final_path}.tmp-{os.getpid()}"
|
|
100
|
+
assert_path_inside(tmp_path, repo_root)
|
|
101
|
+
with open(tmp_path, "w", encoding="utf-8") as fh:
|
|
102
|
+
json.dump(store.to_dict(), fh, indent=2)
|
|
103
|
+
os.replace(tmp_path, final_path)
|
|
104
|
+
return final_path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_store(repo_root: str, override_path: Optional[str] = None) -> GraphKeeperStore:
|
|
108
|
+
"""Reads and parses `.graphkeeper/graph.json`, or a caller-supplied path
|
|
109
|
+
override."""
|
|
110
|
+
target = os.path.abspath(override_path) if override_path else graph_file_path(repo_root)
|
|
111
|
+
if not os.path.exists(target):
|
|
112
|
+
raise PathSafetyError(f'No graph found at "{target}". Run "graphkeeper build" first.')
|
|
113
|
+
with open(target, "r", encoding="utf-8") as fh:
|
|
114
|
+
raw = fh.read()
|
|
115
|
+
try:
|
|
116
|
+
parsed = json.loads(raw)
|
|
117
|
+
except json.JSONDecodeError as err:
|
|
118
|
+
raise PathSafetyError(f'"{target}" does not contain a valid GraphKeeper store.') from err
|
|
119
|
+
if not isinstance(parsed, dict):
|
|
120
|
+
raise PathSafetyError(f'"{target}" does not contain a valid GraphKeeper store.')
|
|
121
|
+
return GraphKeeperStore.from_dict(parsed)
|
graphkeeper/types.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared types for GraphKeeper's local knowledge-graph store.
|
|
3
|
+
|
|
4
|
+
The store is a single JSON file, `.graphkeeper/graph.json`, containing:
|
|
5
|
+
- co-change data mined from `git log` (GraphKeeper's own contribution)
|
|
6
|
+
- optionally, symbol/call-graph data merged in from graphify, if graphify
|
|
7
|
+
is installed and enrichment succeeded (see graphkeeper/graphify.py)
|
|
8
|
+
|
|
9
|
+
Ported from src/types.ts. Field names here are snake_case (Python
|
|
10
|
+
convention); `GraphKeeperStore.to_dict()` / `.from_dict()` translate to and
|
|
11
|
+
from the camelCase JSON schema the npm CLI also reads and writes, so a
|
|
12
|
+
`.graphkeeper/graph.json` built by either distribution can be read by the
|
|
13
|
+
other.
|
|
14
|
+
|
|
15
|
+
graphify node/edge shapes are kept as plain `Dict[str, Any]` rather than a
|
|
16
|
+
fixed dataclass, matching the TypeScript source's own `[key: string]:
|
|
17
|
+
unknown` index signature -- graphify's own `graph.json` output can carry
|
|
18
|
+
fields this project doesn't know about in advance, and round-tripping them
|
|
19
|
+
through a fixed schema would silently drop them.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
GraphifyNode = Dict[str, Any]
|
|
27
|
+
GraphifyEdge = Dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CoChangeEdge:
|
|
32
|
+
"""One unordered pair of files that changed together in one or more commits."""
|
|
33
|
+
|
|
34
|
+
a: str
|
|
35
|
+
b: str
|
|
36
|
+
count: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class GraphifyEnrichment:
|
|
41
|
+
"""Result of attempting graphify enrichment during a build."""
|
|
42
|
+
|
|
43
|
+
enriched: bool
|
|
44
|
+
version: Optional[str]
|
|
45
|
+
skipped_reason: Optional[str]
|
|
46
|
+
nodes: List[GraphifyNode] = field(default_factory=list)
|
|
47
|
+
edges: List[GraphifyEdge] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class GraphKeeperStore:
|
|
52
|
+
"""The full on-disk store written to `.graphkeeper/graph.json`."""
|
|
53
|
+
|
|
54
|
+
version: int
|
|
55
|
+
generated_at: str
|
|
56
|
+
repo_path: str
|
|
57
|
+
commits_analyzed: int
|
|
58
|
+
commits_skipped: int
|
|
59
|
+
co_change: List[CoChangeEdge]
|
|
60
|
+
file_commit_counts: Dict[str, int]
|
|
61
|
+
graphify: GraphifyEnrichment
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"version": self.version,
|
|
66
|
+
"generatedAt": self.generated_at,
|
|
67
|
+
"repoPath": self.repo_path,
|
|
68
|
+
"commitsAnalyzed": self.commits_analyzed,
|
|
69
|
+
"commitsSkipped": self.commits_skipped,
|
|
70
|
+
"coChange": [{"a": e.a, "b": e.b, "count": e.count} for e in self.co_change],
|
|
71
|
+
"fileCommitCounts": self.file_commit_counts,
|
|
72
|
+
"graphify": {
|
|
73
|
+
"enriched": self.graphify.enriched,
|
|
74
|
+
"version": self.graphify.version,
|
|
75
|
+
"skippedReason": self.graphify.skipped_reason,
|
|
76
|
+
"nodes": self.graphify.nodes,
|
|
77
|
+
"edges": self.graphify.edges,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def from_dict(data: Dict[str, Any]) -> "GraphKeeperStore":
|
|
83
|
+
graphify_raw = data.get("graphify") or {}
|
|
84
|
+
graphify = GraphifyEnrichment(
|
|
85
|
+
enriched=bool(graphify_raw.get("enriched", False)),
|
|
86
|
+
version=graphify_raw.get("version"),
|
|
87
|
+
skipped_reason=graphify_raw.get("skippedReason"),
|
|
88
|
+
nodes=list(graphify_raw.get("nodes") or []),
|
|
89
|
+
edges=list(graphify_raw.get("edges") or []),
|
|
90
|
+
)
|
|
91
|
+
co_change = [
|
|
92
|
+
CoChangeEdge(a=e["a"], b=e["b"], count=e["count"]) for e in (data.get("coChange") or [])
|
|
93
|
+
]
|
|
94
|
+
return GraphKeeperStore(
|
|
95
|
+
version=data.get("version", 1),
|
|
96
|
+
generated_at=data.get("generatedAt", ""),
|
|
97
|
+
repo_path=data.get("repoPath", ""),
|
|
98
|
+
commits_analyzed=data.get("commitsAnalyzed", 0),
|
|
99
|
+
commits_skipped=data.get("commitsSkipped", 0),
|
|
100
|
+
co_change=co_change,
|
|
101
|
+
file_commit_counts=dict(data.get("fileCommitCounts") or {}),
|
|
102
|
+
graphify=graphify,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class BuildOptions:
|
|
108
|
+
"""Options for `build()`. Mirrors src/types.ts's `BuildOptions`."""
|
|
109
|
+
|
|
110
|
+
max_files_per_commit: Optional[int] = None
|
|
111
|
+
"""Skip commits touching more than this many files (default: 100)."""
|
|
112
|
+
|
|
113
|
+
skip_graphify: bool = False
|
|
114
|
+
"""Skip graphify enrichment even if graphify is installed."""
|
|
115
|
+
|
|
116
|
+
graphify_timeout_ms: Optional[int] = None
|
|
117
|
+
"""Present for parity with the TypeScript `BuildOptions` type. Not
|
|
118
|
+
currently wired to a real timeout override in either the TypeScript
|
|
119
|
+
source or this port -- `graphify.py`'s enrichment timeout is a fixed
|
|
120
|
+
constant, same as upstream. Kept here rather than silently dropped, so a
|
|
121
|
+
future fix lands in one obvious place."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class BuildResult:
|
|
126
|
+
store: GraphKeeperStore
|
|
127
|
+
output_path: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class CoChangeResultRow:
|
|
132
|
+
file: str
|
|
133
|
+
count: int
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class CoChangeQueryResult:
|
|
138
|
+
file: str
|
|
139
|
+
results: List[CoChangeResultRow]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class CallsQueryResult:
|
|
144
|
+
symbol: str
|
|
145
|
+
available: bool
|
|
146
|
+
unavailable_reason: Optional[str]
|
|
147
|
+
node: Optional[GraphifyNode]
|
|
148
|
+
calls: List[Dict[str, Any]]
|
|
149
|
+
called_by: List[Dict[str, Any]]
|