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,71 @@
1
+ """
2
+ GraphKeeper library API.
3
+
4
+ from graphkeeper import build, query_co_change
5
+
6
+ result = build(".")
7
+ print(f"{result.store.commits_analyzed} commit(s) analyzed")
8
+
9
+ co_change = query_co_change(result.store, "src/git.py")
10
+ for row in co_change.results:
11
+ print(row.count, row.file)
12
+
13
+ This is the Python port of the graphkeeper-cli npm package
14
+ (https://www.npmjs.com/package/graphkeeper-cli). Both distributions mine
15
+ `git log` for the same file-level co-change signal and, when graphify
16
+ (https://github.com/Graphify-Labs/graphify) is installed, enrich the same
17
+ store with its symbol/call-graph output; see
18
+ https://github.com/RudrenduPaul/GraphKeeper for the canonical
19
+ documentation and the original TypeScript source. `graphkeeper.cli` is a
20
+ thin argument-parsing wrapper over the same functions exported here, so
21
+ anything scriptable from the command line is also usable directly from
22
+ Python.
23
+ """
24
+ from .build import build
25
+ from .git import GitError, mine_co_change, unquote_git_path
26
+ from .graphify import detect_graphify, run_graphify_enrichment
27
+ from .query import find_graphify_node, normalize_file_arg, query_calls, query_co_change
28
+ from .store import PathSafetyError, graph_file_path, read_store, resolve_repo_root, write_store
29
+ from .types import (
30
+ BuildOptions,
31
+ BuildResult,
32
+ CallsQueryResult,
33
+ CoChangeEdge,
34
+ CoChangeQueryResult,
35
+ CoChangeResultRow,
36
+ GraphifyEnrichment,
37
+ GraphifyEdge,
38
+ GraphifyNode,
39
+ GraphKeeperStore,
40
+ )
41
+
42
+ __version__ = "0.1.0"
43
+
44
+ __all__ = [
45
+ "build",
46
+ "query_co_change",
47
+ "query_calls",
48
+ "find_graphify_node",
49
+ "normalize_file_arg",
50
+ "detect_graphify",
51
+ "run_graphify_enrichment",
52
+ "mine_co_change",
53
+ "unquote_git_path",
54
+ "GitError",
55
+ "resolve_repo_root",
56
+ "read_store",
57
+ "write_store",
58
+ "graph_file_path",
59
+ "PathSafetyError",
60
+ "GraphKeeperStore",
61
+ "CoChangeEdge",
62
+ "GraphifyNode",
63
+ "GraphifyEdge",
64
+ "GraphifyEnrichment",
65
+ "BuildOptions",
66
+ "BuildResult",
67
+ "CoChangeQueryResult",
68
+ "CoChangeResultRow",
69
+ "CallsQueryResult",
70
+ "__version__",
71
+ ]
graphkeeper/build.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Builds (or rebuilds) the GraphKeeper store for a repo.
3
+
4
+ Ported from src/build.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+ from .git import mine_co_change
12
+ from .graphify import run_graphify_enrichment
13
+ from .store import resolve_repo_root, write_store
14
+ from .types import BuildOptions, BuildResult, GraphifyEnrichment, GraphKeeperStore
15
+
16
+
17
+ def build(target_path: str, options: Optional[BuildOptions] = None) -> BuildResult:
18
+ """Builds (or rebuilds) the GraphKeeper store for a repo: mines `git
19
+ log` for file-level co-change, optionally enriches it with graphify's
20
+ symbol/call graph if graphify is installed, and writes the merged
21
+ result to `.graphkeeper/graph.json`."""
22
+ opts = options or BuildOptions()
23
+ repo_root = resolve_repo_root(target_path)
24
+
25
+ mined = mine_co_change(repo_root, opts.max_files_per_commit)
26
+
27
+ if opts.skip_graphify:
28
+ graphify = GraphifyEnrichment(
29
+ enriched=False,
30
+ version=None,
31
+ skipped_reason="graphify enrichment skipped via --no-graphify.",
32
+ )
33
+ else:
34
+ graphify = run_graphify_enrichment(repo_root)
35
+
36
+ store = GraphKeeperStore(
37
+ version=1,
38
+ generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
39
+ repo_path=repo_root,
40
+ commits_analyzed=mined.commits_analyzed,
41
+ commits_skipped=mined.commits_skipped,
42
+ co_change=mined.co_change,
43
+ file_commit_counts=mined.file_commit_counts,
44
+ graphify=graphify,
45
+ )
46
+
47
+ output_path = write_store(repo_root, store)
48
+ return BuildResult(store=store, output_path=output_path)
graphkeeper/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Argument-parsing wrapper over graphkeeper's build/query library functions.
4
+ Console entry point: `graphkeeper <command> [options]`, installed via the
5
+ `graphkeeper` console-script defined in python/pyproject.toml.
6
+
7
+ Ported from src/cli.ts (which uses `commander`); this port uses the stdlib
8
+ `argparse` to avoid a CLI-framework dependency. Subcommands, flags,
9
+ defaults, and exit codes are kept identical to the npm CLI's `--help`
10
+ output and behavior: `build [path]`, `query co-change <file>`, and
11
+ `query calls <symbol>`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from typing import List, NoReturn, Optional
19
+
20
+ from .build import build
21
+ from .formatters import (
22
+ format_build_json,
23
+ format_build_text,
24
+ format_calls_json,
25
+ format_calls_text,
26
+ format_co_change_json,
27
+ format_co_change_text,
28
+ parse_positive_int,
29
+ )
30
+ from .query import query_calls, query_co_change
31
+ from .store import read_store, resolve_repo_root
32
+ from .types import BuildOptions
33
+
34
+ VERSION = "0.1.0"
35
+
36
+
37
+ def _report_error(err: Exception, as_json: bool) -> NoReturn:
38
+ message = str(err)
39
+ if as_json:
40
+ sys.stderr.write(json.dumps({"error": message}, indent=2) + "\n")
41
+ else:
42
+ sys.stderr.write(f"Error: {message}\n")
43
+ sys.exit(2)
44
+
45
+
46
+ def build_parser() -> "tuple[argparse.ArgumentParser, argparse.ArgumentParser]":
47
+ parser = argparse.ArgumentParser(
48
+ prog="graphkeeper",
49
+ description=(
50
+ "Local-only CLI that mines git history for file-level co-change patterns and builds a "
51
+ "queryable knowledge graph for AI coding agents, with optional enrichment from graphify's "
52
+ "symbol/call-graph output when it is installed."
53
+ ),
54
+ )
55
+ parser.add_argument("-V", "--version", action="version", version=VERSION)
56
+
57
+ subparsers = parser.add_subparsers(dest="command")
58
+
59
+ build_cmd = subparsers.add_parser(
60
+ "build",
61
+ description="Mine git history for co-change and (if available) merge in graphify's symbol/call graph",
62
+ )
63
+ build_cmd.add_argument("path", nargs="?", default=".", help="path to the target repo")
64
+ build_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
65
+ build_cmd.add_argument(
66
+ "--max-files-per-commit",
67
+ default=None,
68
+ help="skip commits touching more than this many files (default: 100)",
69
+ )
70
+ build_cmd.add_argument(
71
+ "--no-graphify",
72
+ dest="graphify",
73
+ action="store_false",
74
+ default=True,
75
+ help="skip graphify enrichment even if graphify is installed",
76
+ )
77
+
78
+ query_cmd = subparsers.add_parser("query", description="Query the GraphKeeper store built by `graphkeeper build`")
79
+ query_subparsers = query_cmd.add_subparsers(dest="query_command")
80
+
81
+ co_change_cmd = query_subparsers.add_parser(
82
+ "co-change",
83
+ description="List files that historically change alongside <file>, ranked by co-change frequency",
84
+ )
85
+ co_change_cmd.add_argument("file", help="repo-relative file path, e.g. src/git.py")
86
+ co_change_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
87
+ co_change_cmd.add_argument("--limit", default=None, help="cap the number of results")
88
+ co_change_cmd.add_argument(
89
+ "--graph", default=None, help="path to a specific graph.json (default: <cwd>/.graphkeeper/graph.json)"
90
+ )
91
+
92
+ calls_cmd = query_subparsers.add_parser(
93
+ "calls",
94
+ description="Show callers/callees of <symbol> (requires graphify enrichment from `graphkeeper build`)",
95
+ )
96
+ calls_cmd.add_argument("symbol", help="symbol name, e.g. parse_config")
97
+ calls_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
98
+ calls_cmd.add_argument(
99
+ "--graph", default=None, help="path to a specific graph.json (default: <cwd>/.graphkeeper/graph.json)"
100
+ )
101
+
102
+ return parser, query_cmd
103
+
104
+
105
+ def _run_build(args: argparse.Namespace) -> int:
106
+ try:
107
+ max_files_per_commit = (
108
+ parse_positive_int(args.max_files_per_commit, "--max-files-per-commit")
109
+ if args.max_files_per_commit
110
+ else None
111
+ )
112
+ result = build(
113
+ args.path,
114
+ BuildOptions(max_files_per_commit=max_files_per_commit, skip_graphify=not args.graphify),
115
+ )
116
+ print(format_build_json(result) if args.json else format_build_text(result))
117
+ return 0
118
+ except Exception as err: # noqa: BLE001 -- mirrors src/cli.ts's catch-all reportError()
119
+ _report_error(err, bool(args.json))
120
+
121
+
122
+ def _run_query_co_change(args: argparse.Namespace) -> int:
123
+ try:
124
+ limit = parse_positive_int(args.limit, "--limit") if args.limit else None
125
+ repo_root = resolve_repo_root(".")
126
+ store = read_store(repo_root, args.graph)
127
+ result = query_co_change(store, args.file, limit)
128
+ print(format_co_change_json(result) if args.json else format_co_change_text(result))
129
+ return 0 if result.results else 1
130
+ except Exception as err: # noqa: BLE001
131
+ _report_error(err, bool(args.json))
132
+
133
+
134
+ def _run_query_calls(args: argparse.Namespace) -> int:
135
+ try:
136
+ repo_root = resolve_repo_root(".")
137
+ store = read_store(repo_root, args.graph)
138
+ result = query_calls(store, args.symbol)
139
+ print(format_calls_json(result) if args.json else format_calls_text(result))
140
+ return 0 if (result.available and result.node) else 1
141
+ except Exception as err: # noqa: BLE001
142
+ _report_error(err, bool(args.json))
143
+
144
+
145
+ def run_cli(argv: Optional[List[str]] = None) -> int:
146
+ parser, query_cmd = build_parser()
147
+ args = parser.parse_args(argv)
148
+
149
+ if args.command == "build":
150
+ return _run_build(args)
151
+ if args.command == "query":
152
+ if args.query_command == "co-change":
153
+ return _run_query_co_change(args)
154
+ if args.query_command == "calls":
155
+ return _run_query_calls(args)
156
+ query_cmd.print_help()
157
+ return 0
158
+ parser.print_help()
159
+ return 0
160
+
161
+
162
+ def main() -> None:
163
+ sys.exit(run_cli(sys.argv[1:]))
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
@@ -0,0 +1,125 @@
1
+ """
2
+ Human-readable and JSON output formatters, plus small CLI argument-parsing
3
+ helpers.
4
+
5
+ Ported from src/cli-lib.ts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from .types import BuildResult, CallsQueryResult, CoChangeQueryResult
12
+
13
+
14
+ def parse_positive_int(raw: str, flag_name: str) -> int:
15
+ stripped = raw.strip()
16
+ try:
17
+ n = int(stripped)
18
+ except ValueError:
19
+ n = None
20
+ if n is None or n <= 0 or str(n) != stripped:
21
+ raise ValueError(f'Invalid {flag_name} value "{raw}". Expected a positive integer.')
22
+ return n
23
+
24
+
25
+ def format_build_text(result: BuildResult) -> str:
26
+ store = result.store
27
+ lines = [f"GraphKeeper build complete: {store.repo_path}", ""]
28
+ skipped_note = (
29
+ f" ({store.commits_skipped} commit(s) skipped: too many files changed)"
30
+ if store.commits_skipped > 0
31
+ else ""
32
+ )
33
+ lines.append(
34
+ f"Co-change graph: {store.commits_analyzed} commit(s) analyzed, "
35
+ f"{len(store.co_change)} file pair(s) found{skipped_note}"
36
+ )
37
+ if store.graphify.enriched:
38
+ version = store.graphify.version or "unknown"
39
+ lines.append(
40
+ f"graphify enrichment: included (v{version}) -- "
41
+ f"{len(store.graphify.nodes)} node(s), {len(store.graphify.edges)} edge(s)"
42
+ )
43
+ else:
44
+ lines.append(f"graphify enrichment: skipped -- {store.graphify.skipped_reason or 'unknown reason'}")
45
+ lines.append("")
46
+ lines.append(f"Wrote {result.output_path}")
47
+ return "\n".join(lines)
48
+
49
+
50
+ def format_build_json(result: BuildResult) -> str:
51
+ store = result.store
52
+ payload = {
53
+ "repoPath": store.repo_path,
54
+ "outputPath": result.output_path,
55
+ "generatedAt": store.generated_at,
56
+ "commitsAnalyzed": store.commits_analyzed,
57
+ "commitsSkipped": store.commits_skipped,
58
+ "coChangePairs": len(store.co_change),
59
+ "graphify": {
60
+ "enriched": store.graphify.enriched,
61
+ "version": store.graphify.version,
62
+ "skippedReason": store.graphify.skipped_reason,
63
+ "nodes": len(store.graphify.nodes),
64
+ "edges": len(store.graphify.edges),
65
+ },
66
+ }
67
+ return json.dumps(payload, indent=2)
68
+
69
+
70
+ def format_co_change_text(result: CoChangeQueryResult) -> str:
71
+ lines = [f'Files that historically change alongside "{result.file}":', ""]
72
+ if not result.results:
73
+ lines.append(" (no co-change data found for this file -- check the path, or run `graphkeeper build` first)")
74
+ else:
75
+ for r in result.results:
76
+ lines.append(f" {str(r.count).rjust(4)} {r.file}")
77
+ return "\n".join(lines)
78
+
79
+
80
+ def format_co_change_json(result: CoChangeQueryResult) -> str:
81
+ payload = {"file": result.file, "results": [{"file": r.file, "count": r.count} for r in result.results]}
82
+ return json.dumps(payload, indent=2)
83
+
84
+
85
+ def format_calls_text(result: CallsQueryResult) -> str:
86
+ if not result.available:
87
+ return "\n".join(
88
+ [
89
+ f'Call-graph query for "{result.symbol}" is not available.',
90
+ "",
91
+ result.unavailable_reason or "graphify enrichment is required for this query type.",
92
+ ]
93
+ )
94
+ if not result.node:
95
+ return f'No symbol matching "{result.symbol}" was found in the graphify graph.'
96
+
97
+ label = result.node.get("label", result.symbol)
98
+ source_file = result.node.get("source_file") or "unknown location"
99
+ lines = [f"{label} ({source_file})", ""]
100
+ lines.append(f"Calls ({len(result.calls)}):")
101
+ for c in result.calls:
102
+ node = c.get("node")
103
+ edge = c.get("edge") or {}
104
+ target = node.get("label") if node else edge.get("target")
105
+ lines.append(f" --> {target}")
106
+ lines.append("")
107
+ lines.append(f"Called by ({len(result.called_by)}):")
108
+ for c in result.called_by:
109
+ node = c.get("node")
110
+ edge = c.get("edge") or {}
111
+ source = node.get("label") if node else edge.get("source")
112
+ lines.append(f" <-- {source}")
113
+ return "\n".join(lines)
114
+
115
+
116
+ def format_calls_json(result: CallsQueryResult) -> str:
117
+ payload = {
118
+ "symbol": result.symbol,
119
+ "available": result.available,
120
+ "unavailableReason": result.unavailable_reason,
121
+ "node": result.node,
122
+ "calls": result.calls,
123
+ "calledBy": result.called_by,
124
+ }
125
+ return json.dumps(payload, indent=2)
graphkeeper/git.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Mines `git log` history for file-level co-change: pairs of files that were
3
+ modified together in the same commit.
4
+
5
+ Ported from src/git.ts. Every `git` invocation uses an argv list passed
6
+ directly to the OS (`subprocess.run`, `shell=False`), never a shell string,
7
+ so commit messages, file paths, or repo paths can never be interpreted as
8
+ shell syntax.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from typing import Dict, List, Optional
15
+
16
+ from .types import CoChangeEdge
17
+
18
+ # Marker used to split `git log` output into per-commit chunks. Chosen to be
19
+ # extremely unlikely to appear in a commit message or file path.
20
+ COMMIT_MARKER = "@@GK-COMMIT@@"
21
+
22
+ DEFAULT_MAX_FILES_PER_COMMIT = 100
23
+
24
+
25
+ class GitError(Exception):
26
+ pass
27
+
28
+
29
+ def _run_git(repo_path: str, args: List[str]) -> "subprocess.CompletedProcess[str]":
30
+ try:
31
+ return subprocess.run(
32
+ ["git", "-C", repo_path, *args],
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ except FileNotFoundError as err:
38
+ raise GitError("git is not installed or not on PATH.") from err
39
+ except OSError as err:
40
+ raise GitError(f"Failed to run git: {err}") from err
41
+
42
+
43
+ def assert_is_git_repo(repo_path: str) -> None:
44
+ """Raises GitError if `repo_path` is not inside a git working tree."""
45
+ result = _run_git(repo_path, ["rev-parse", "--is-inside-work-tree"])
46
+ if result.returncode != 0:
47
+ raise GitError(f'"{repo_path}" is not a git repository. {result.stderr.strip()}'.strip())
48
+
49
+
50
+ def unquote_git_path(raw: str) -> str:
51
+ """Unquotes a git `--name-only` path entry. Git C-quotes paths
52
+ containing unusual bytes (non-ASCII when core.quotePath is on, quotes,
53
+ backslashes, control characters) by wrapping them in double quotes with
54
+ backslash/octal escapes. Plain paths are returned unchanged."""
55
+ if len(raw) < 2 or raw[0] != '"' or raw[-1] != '"':
56
+ return raw
57
+ inner = raw[1:-1]
58
+ # Octal escapes are individual *bytes* of a (possibly multi-byte UTF-8)
59
+ # sequence, so this must accumulate raw bytes and UTF-8-decode once at
60
+ # the end -- decoding each escaped byte independently would mangle any
61
+ # non-ASCII character spanning more than one byte.
62
+ byte_values: List[int] = []
63
+ i = 0
64
+ n = len(inner)
65
+ while i < n:
66
+ ch = inner[i]
67
+ if ch != "\\":
68
+ # core.quotePath=true escapes every byte >= 0x80, so an
69
+ # unescaped character here is always plain ASCII.
70
+ byte_values.append(ord(ch))
71
+ i += 1
72
+ continue
73
+ nxt = inner[i + 1] if i + 1 < n else None
74
+ if nxt is None:
75
+ byte_values.append(ord(ch))
76
+ i += 1
77
+ continue
78
+ if "0" <= nxt <= "7":
79
+ octal = inner[i + 1 : i + 4]
80
+ try:
81
+ code = int(octal, 8)
82
+ except ValueError:
83
+ code = ord(nxt)
84
+ byte_values.append(code)
85
+ i += 4
86
+ continue
87
+ if nxt == "n":
88
+ byte_values.append(0x0A)
89
+ elif nxt == "t":
90
+ byte_values.append(0x09)
91
+ elif nxt == "\\":
92
+ byte_values.append(0x5C)
93
+ elif nxt == '"':
94
+ byte_values.append(0x22)
95
+ else:
96
+ byte_values.append(ord(nxt))
97
+ i += 2
98
+ return bytes(byte_values).decode("utf-8")
99
+
100
+
101
+ def _has_no_nul_byte(p: str) -> bool:
102
+ """True if every character code in `p` is non-zero (i.e. no embedded
103
+ NUL bytes)."""
104
+ return "\0" not in p
105
+
106
+
107
+ def _is_safe_tracked_path(p: str) -> bool:
108
+ """Rejects path entries that could escape the repo tree or aren't real
109
+ tracked files."""
110
+ if not p:
111
+ return False
112
+ if not _has_no_nul_byte(p):
113
+ return False
114
+ if p.startswith("/") or p.startswith("~"):
115
+ return False
116
+ segments = p.split("/")
117
+ if any(s == ".." for s in segments):
118
+ return False
119
+ return True
120
+
121
+
122
+ @dataclass
123
+ class CoChangeMiningResult:
124
+ co_change: List[CoChangeEdge] = field(default_factory=list)
125
+ file_commit_counts: Dict[str, int] = field(default_factory=dict)
126
+ commits_analyzed: int = 0
127
+ commits_skipped: int = 0
128
+
129
+
130
+ def mine_co_change(repo_path: str, max_files_per_commit: Optional[int] = None) -> CoChangeMiningResult:
131
+ """Mines `git log` history for file-level co-change: pairs of files that
132
+ were modified together in the same commit. This is GraphKeeper's own
133
+ signal, distinct from (and complementary to) symbol/call-graph
134
+ extraction."""
135
+ effective_max = max_files_per_commit if max_files_per_commit is not None else DEFAULT_MAX_FILES_PER_COMMIT
136
+ assert_is_git_repo(repo_path)
137
+
138
+ result = _run_git(
139
+ repo_path,
140
+ [
141
+ "-c",
142
+ "core.quotePath=true",
143
+ "log",
144
+ "--no-merges",
145
+ f"--pretty=format:{COMMIT_MARKER}%H",
146
+ "--name-only",
147
+ ],
148
+ )
149
+ if result.returncode != 0:
150
+ raise GitError(f"git log failed: {result.stderr.strip()}")
151
+
152
+ stdout = result.stdout or ""
153
+ if stdout.strip() == "":
154
+ return CoChangeMiningResult()
155
+
156
+ # Keyed by (a, b) tuples -- each commit's own file list is sorted before
157
+ # pairing, so a < b lexicographically within every pair, making the
158
+ # tuple an unambiguous key across all commits (no JSON-stringify trick
159
+ # needed, unlike the TypeScript original, since Python tuples are
160
+ # natively hashable).
161
+ pair_counts: Dict[tuple, int] = {}
162
+ file_commit_counts: Dict[str, int] = {}
163
+ commits_analyzed = 0
164
+ commits_skipped = 0
165
+
166
+ chunks = stdout.split(COMMIT_MARKER)[1:]
167
+ for chunk in chunks:
168
+ newline_index = chunk.find("\n")
169
+ files_raw = "" if newline_index == -1 else chunk[newline_index + 1 :]
170
+ seen = []
171
+ seen_set = set()
172
+ for line in files_raw.split("\n"):
173
+ candidate = line.strip()
174
+ if not candidate:
175
+ continue
176
+ unquoted = unquote_git_path(candidate)
177
+ if not _is_safe_tracked_path(unquoted):
178
+ continue
179
+ if unquoted not in seen_set:
180
+ seen_set.add(unquoted)
181
+ seen.append(unquoted)
182
+ files = sorted(seen)
183
+
184
+ if len(files) == 0:
185
+ continue
186
+ if len(files) > effective_max:
187
+ commits_skipped += 1
188
+ continue
189
+
190
+ commits_analyzed += 1
191
+ for f in files:
192
+ file_commit_counts[f] = file_commit_counts.get(f, 0) + 1
193
+ for idx in range(len(files)):
194
+ for jdx in range(idx + 1, len(files)):
195
+ a, b = files[idx], files[jdx]
196
+ key = (a, b)
197
+ pair_counts[key] = pair_counts.get(key, 0) + 1
198
+
199
+ co_change = [CoChangeEdge(a=a, b=b, count=count) for (a, b), count in pair_counts.items()]
200
+ co_change.sort(key=lambda e: e.count, reverse=True)
201
+
202
+ return CoChangeMiningResult(
203
+ co_change=co_change,
204
+ file_commit_counts=file_commit_counts,
205
+ commits_analyzed=commits_analyzed,
206
+ commits_skipped=commits_skipped,
207
+ )