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.
@@ -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]]