pycode-kg 0.16.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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Click command for Structural Importance Ranking (SIR)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from pycode_kg.analysis.centrality import (
|
|
12
|
+
StructuralImportanceRanker,
|
|
13
|
+
aggregate_module_scores,
|
|
14
|
+
)
|
|
15
|
+
from pycode_kg.cli.main import cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cli.command("centrality")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--db",
|
|
21
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
22
|
+
default=Path(".pycodekg/graph.sqlite"),
|
|
23
|
+
show_default=True,
|
|
24
|
+
help="Path to the PyCodeKG SQLite graph.",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--kind",
|
|
28
|
+
"kinds",
|
|
29
|
+
multiple=True,
|
|
30
|
+
type=click.Choice(["module", "class", "function", "method"], case_sensitive=False),
|
|
31
|
+
help="Restrict output to one or more node kinds.",
|
|
32
|
+
)
|
|
33
|
+
@click.option("--top", type=int, default=25, show_default=True, help="Maximum rows to show.")
|
|
34
|
+
@click.option(
|
|
35
|
+
"--group-by",
|
|
36
|
+
type=click.Choice(["node", "module"], case_sensitive=False),
|
|
37
|
+
default="node",
|
|
38
|
+
show_default=True,
|
|
39
|
+
help="Return node-level or module-level rankings.",
|
|
40
|
+
)
|
|
41
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
|
|
42
|
+
@click.option("--write-db", is_flag=True, help="Persist node-level scores into centrality_scores.")
|
|
43
|
+
def cmd_centrality(
|
|
44
|
+
db: Path,
|
|
45
|
+
kinds: tuple[str, ...],
|
|
46
|
+
top: int,
|
|
47
|
+
group_by: str,
|
|
48
|
+
as_json: bool,
|
|
49
|
+
write_db: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Compute Structural Importance Ranking over the resolved PyCodeKG graph."""
|
|
52
|
+
ranker = StructuralImportanceRanker(db)
|
|
53
|
+
records = ranker.compute(kinds=set(kinds) if kinds else None, top=None)
|
|
54
|
+
|
|
55
|
+
if write_db:
|
|
56
|
+
ranker.write_scores(records)
|
|
57
|
+
|
|
58
|
+
if group_by == "module":
|
|
59
|
+
payload = aggregate_module_scores(records)[:top]
|
|
60
|
+
if as_json:
|
|
61
|
+
click.echo(json.dumps(payload, indent=2))
|
|
62
|
+
return
|
|
63
|
+
_print_module_table(payload)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
node_payload = records[:top]
|
|
67
|
+
if as_json:
|
|
68
|
+
click.echo(json.dumps([_record_to_dict(r) for r in node_payload], indent=2))
|
|
69
|
+
return
|
|
70
|
+
_print_node_table(node_payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _record_to_dict(record) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"node_id": record.node_id,
|
|
76
|
+
"kind": record.kind,
|
|
77
|
+
"name": record.name,
|
|
78
|
+
"module_path": record.module_path,
|
|
79
|
+
"score": record.score,
|
|
80
|
+
"rank": record.rank,
|
|
81
|
+
"inbound_count": record.inbound_count,
|
|
82
|
+
"cross_module_inbound": record.cross_module_inbound,
|
|
83
|
+
"rel_breakdown": record.rel_breakdown,
|
|
84
|
+
"top_contributors": record.top_contributors,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_node_table(records) -> None:
|
|
89
|
+
headers = (
|
|
90
|
+
"Rank",
|
|
91
|
+
"Score",
|
|
92
|
+
"Kind",
|
|
93
|
+
"Name",
|
|
94
|
+
"Inbound",
|
|
95
|
+
"XMod",
|
|
96
|
+
"Module",
|
|
97
|
+
)
|
|
98
|
+
rows = [
|
|
99
|
+
(
|
|
100
|
+
r.rank,
|
|
101
|
+
f"{r.score:.6f}",
|
|
102
|
+
r.kind,
|
|
103
|
+
r.name,
|
|
104
|
+
r.inbound_count,
|
|
105
|
+
r.cross_module_inbound,
|
|
106
|
+
r.module_path or "",
|
|
107
|
+
)
|
|
108
|
+
for r in records
|
|
109
|
+
]
|
|
110
|
+
click.echo(click.format_filename(""))
|
|
111
|
+
click.echo(_format_table(headers, rows))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _print_module_table(payload) -> None:
|
|
115
|
+
headers = ("Rank", "Score", "Members", "Module")
|
|
116
|
+
rows = [
|
|
117
|
+
(row["rank"], f"{row['score']:.6f}", row["member_count"], row["module_path"])
|
|
118
|
+
for row in payload
|
|
119
|
+
]
|
|
120
|
+
click.echo(_format_table(headers, rows))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _format_table(headers, rows) -> str:
|
|
124
|
+
widths = [len(str(h)) for h in headers]
|
|
125
|
+
for row in rows:
|
|
126
|
+
for i, value in enumerate(row):
|
|
127
|
+
widths[i] = max(widths[i], len(str(value)))
|
|
128
|
+
fmt = " ".join(f"{{:{w}}}" for w in widths)
|
|
129
|
+
lines = [fmt.format(*headers), fmt.format(*["-" * w for w in widths])]
|
|
130
|
+
lines.extend(fmt.format(*[str(v) for v in row]) for row in rows)
|
|
131
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_explain.py
|
|
3
|
+
|
|
4
|
+
Click subcommand for explaining code nodes:
|
|
5
|
+
|
|
6
|
+
explain — get a natural-language explanation of a code node by its ID
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from pycode_kg.cli.main import cli
|
|
16
|
+
from pycode_kg.cli.options import lancedb_option, model_option, sqlite_option
|
|
17
|
+
from pycode_kg.kg import PyCodeKG
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command("explain")
|
|
21
|
+
@click.argument("node_id", metavar="NODE_ID")
|
|
22
|
+
@sqlite_option
|
|
23
|
+
@lancedb_option
|
|
24
|
+
@click.option(
|
|
25
|
+
"--table",
|
|
26
|
+
default="pycodekg_nodes",
|
|
27
|
+
show_default=True,
|
|
28
|
+
help="LanceDB table name.",
|
|
29
|
+
)
|
|
30
|
+
@model_option
|
|
31
|
+
@click.option(
|
|
32
|
+
"--out",
|
|
33
|
+
type=click.Path(),
|
|
34
|
+
default=None,
|
|
35
|
+
help="Output file path (default: stdout).",
|
|
36
|
+
)
|
|
37
|
+
def explain(
|
|
38
|
+
node_id: str,
|
|
39
|
+
sqlite: str,
|
|
40
|
+
lancedb: str,
|
|
41
|
+
table: str,
|
|
42
|
+
model: str,
|
|
43
|
+
out: str | None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Get a natural-language explanation of a code node.
|
|
46
|
+
|
|
47
|
+
NODE_ID is the stable identifier of a node, e.g.:
|
|
48
|
+
fn:src/pycode_kg/store.py:GraphStore.expand
|
|
49
|
+
"""
|
|
50
|
+
# explain-only does not need repo_root for source reading; use sqlite parent as placeholder
|
|
51
|
+
repo_root = Path(sqlite).parent
|
|
52
|
+
|
|
53
|
+
kg = PyCodeKG(
|
|
54
|
+
repo_root=repo_root,
|
|
55
|
+
db_path=Path(sqlite),
|
|
56
|
+
lancedb_dir=Path(lancedb),
|
|
57
|
+
model=model,
|
|
58
|
+
table=table,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
node = kg.node(node_id)
|
|
62
|
+
|
|
63
|
+
if node is None:
|
|
64
|
+
click.echo(f"[ERROR] Node not found: {node_id}", err=True)
|
|
65
|
+
kg.close()
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
|
|
68
|
+
markdown_output = _explain_node(kg, node_id, node)
|
|
69
|
+
|
|
70
|
+
if out:
|
|
71
|
+
Path(out).write_text(markdown_output)
|
|
72
|
+
click.echo(f"[OK] Explanation written to {out}")
|
|
73
|
+
else:
|
|
74
|
+
click.echo(markdown_output)
|
|
75
|
+
|
|
76
|
+
kg.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _explain_node(kg: PyCodeKG, node_id: str, node: dict) -> str:
|
|
80
|
+
"""Generate markdown explanation for a node.
|
|
81
|
+
|
|
82
|
+
:param kg: PyCodeKG instance
|
|
83
|
+
:param node_id: Node identifier
|
|
84
|
+
:param node: Node dict from kg.node()
|
|
85
|
+
:return: Markdown-formatted explanation
|
|
86
|
+
"""
|
|
87
|
+
out: list[str] = []
|
|
88
|
+
|
|
89
|
+
# Header with kind and name
|
|
90
|
+
kind = node.get("kind", "unknown")
|
|
91
|
+
name = node.get("qualname") or node.get("name", "unknown")
|
|
92
|
+
out.append(f"# {kind.capitalize()}: `{name}`\n")
|
|
93
|
+
|
|
94
|
+
# Metadata
|
|
95
|
+
out.append("## Metadata\n")
|
|
96
|
+
if node.get("module_path"):
|
|
97
|
+
out.append(f"- **Module**: `{node['module_path']}`")
|
|
98
|
+
if node.get("lineno") is not None:
|
|
99
|
+
out.append(
|
|
100
|
+
f"- **Location**: line {node['lineno']}"
|
|
101
|
+
+ (f"–{node['end_lineno']}" if node.get("end_lineno") else "")
|
|
102
|
+
)
|
|
103
|
+
out.append(f"- **ID**: `{node_id}`")
|
|
104
|
+
out.append("")
|
|
105
|
+
|
|
106
|
+
# Docstring
|
|
107
|
+
docstring = node.get("docstring", "").strip()
|
|
108
|
+
if docstring:
|
|
109
|
+
out.append("## Documentation\n")
|
|
110
|
+
out.append(docstring)
|
|
111
|
+
out.append("")
|
|
112
|
+
|
|
113
|
+
# Get callers
|
|
114
|
+
try:
|
|
115
|
+
caller_list = kg.callers(node_id, rel="CALLS")
|
|
116
|
+
if caller_list:
|
|
117
|
+
out.append("## Called By (Callers)\n")
|
|
118
|
+
out.append(f"This {kind} is called by **{len(caller_list)}** other function(s):\n")
|
|
119
|
+
for caller in caller_list[:10]:
|
|
120
|
+
caller_name = caller.get("qualname") or caller.get("name", "unknown")
|
|
121
|
+
caller_module = caller.get("module_path", "")
|
|
122
|
+
out.append(f"- `{caller_name}` ({caller_module})")
|
|
123
|
+
if len(caller_list) > 10:
|
|
124
|
+
out.append(f"- ... and {len(caller_list) - 10} more")
|
|
125
|
+
out.append("")
|
|
126
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Get callees (what this function calls)
|
|
130
|
+
try:
|
|
131
|
+
if hasattr(kg, "_store") and kg._store is not None:
|
|
132
|
+
edges = kg._store.edges_from(node_id, rel="CALLS", limit=50)
|
|
133
|
+
if edges:
|
|
134
|
+
out.append("## Calls (Callees)\n")
|
|
135
|
+
out.append(f"This {kind} calls **{len(edges)}** other function(s):\n")
|
|
136
|
+
callees = set()
|
|
137
|
+
for edge in edges:
|
|
138
|
+
dst_id = edge.get("dst")
|
|
139
|
+
if dst_id is not None:
|
|
140
|
+
dst_node = kg.node(dst_id)
|
|
141
|
+
if dst_node:
|
|
142
|
+
dst_name = dst_node.get("qualname") or dst_node.get("name", "unknown")
|
|
143
|
+
callees.add(f"- `{dst_name}`")
|
|
144
|
+
for callee in sorted(list(callees))[:10]:
|
|
145
|
+
out.append(callee)
|
|
146
|
+
if len(callees) > 10:
|
|
147
|
+
out.append(f"- ... and {len(callees) - 10} more")
|
|
148
|
+
out.append("")
|
|
149
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Role assessment
|
|
153
|
+
out.append("## Role in Codebase\n")
|
|
154
|
+
try:
|
|
155
|
+
caller_count = len(kg.callers(node_id, rel="CALLS"))
|
|
156
|
+
if caller_count > 50:
|
|
157
|
+
out.append(
|
|
158
|
+
f"**High-value function**: Called {caller_count} times. "
|
|
159
|
+
"This is likely a core API or bottleneck. Changes here may have wide impact."
|
|
160
|
+
)
|
|
161
|
+
elif caller_count > 10:
|
|
162
|
+
out.append(
|
|
163
|
+
f"**Important function**: Called {caller_count} times. "
|
|
164
|
+
"Part of the essential infrastructure."
|
|
165
|
+
)
|
|
166
|
+
elif caller_count > 0:
|
|
167
|
+
out.append(
|
|
168
|
+
f"**Utility function**: Called {caller_count} time(s). "
|
|
169
|
+
"Specific to particular use cases."
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
out.append("**Orphaned**: Never called. This may be dead code or internal utility.")
|
|
173
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
174
|
+
out.append("Unable to determine call graph role.")
|
|
175
|
+
|
|
176
|
+
out.append("")
|
|
177
|
+
out.append("---\n")
|
|
178
|
+
out.append("*Use `pycodekg pack` to retrieve the full source code.*")
|
|
179
|
+
|
|
180
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI command for framework node detection in PyCodeKG.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from pycode_kg.analysis.framework_detector import detect_framework_nodes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(description="Show top framework-like modules.")
|
|
12
|
+
parser.add_argument("--db", default="pycodekg.sqlite", help="Path to PyCodeKG SQLite DB")
|
|
13
|
+
parser.add_argument("--top", type=int, default=25, help="Number of top framework nodes to show")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
nodes = detect_framework_nodes(limit=args.top, db_path=args.db)
|
|
16
|
+
print(f"Top {args.top} framework-like modules:")
|
|
17
|
+
for node_id, score, label in nodes:
|
|
18
|
+
print(f"{label:50s} {score:.5f} ({node_id})")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_hooks.py
|
|
3
|
+
|
|
4
|
+
CLI command for installing PyCodeKG git hooks:
|
|
5
|
+
|
|
6
|
+
install-hooks — install the pre-commit snapshot hook into .git/hooks/
|
|
7
|
+
|
|
8
|
+
Author: Eric G. Suchanek, PhD
|
|
9
|
+
Last Revision: 2026-03-08 21:30:55
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from pycode_kg.cli.main import cli
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Hook script content (embedded so this module is self-contained when
|
|
23
|
+
# installed as a package in any repo, not just pycode_kg itself)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_PRE_COMMIT_HOOK = """\
|
|
27
|
+
#!/usr/bin/env bash
|
|
28
|
+
# PyCodeKG + DocKG pre-commit hook — keeps local indices in sync and captures
|
|
29
|
+
# metrics snapshots BEFORE quality checks run.
|
|
30
|
+
# Installed by: pycodekg install-hooks
|
|
31
|
+
# Skip with: PYCODEKG_SKIP_SNAPSHOT=1 git commit ...
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
|
|
34
|
+
[ "${PYCODEKG_SKIP_SNAPSHOT:-0}" = "1" ] && exit 0
|
|
35
|
+
|
|
36
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
37
|
+
WORKSPACE_ROOT="$(cd "$REPO_ROOT/.." && pwd)"
|
|
38
|
+
DOCKG_REPO="${WORKSPACE_ROOT}/doc_kg"
|
|
39
|
+
|
|
40
|
+
cd "$REPO_ROOT"
|
|
41
|
+
|
|
42
|
+
# Capture the tree hash of the staged index NOW — before any tool modifies files.
|
|
43
|
+
TREE_HASH=$(git write-tree)
|
|
44
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
45
|
+
|
|
46
|
+
# Rebuild both local indices to keep them in sync with staged content.
|
|
47
|
+
"$REPO_ROOT/.venv/bin/pycodekg" build --repo "$REPO_ROOT" || exit 1
|
|
48
|
+
if [ -d "$DOCKG_REPO" ]; then
|
|
49
|
+
"$REPO_ROOT/.venv/bin/dockg" build --repo "$DOCKG_REPO" || true
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Snapshot PyCodeKG (version auto-detected from installed package).
|
|
53
|
+
"$REPO_ROOT/.venv/bin/pycodekg" snapshot save \\
|
|
54
|
+
--repo . \\
|
|
55
|
+
--tree-hash "$TREE_HASH" \\
|
|
56
|
+
--branch "$BRANCH" \\
|
|
57
|
+
|| { echo "[pycodekg] snapshot skipped (run 'pycodekg build' to initialize)" >&2; }
|
|
58
|
+
|
|
59
|
+
# Snapshot DocKG if available (version auto-detected from installed package).
|
|
60
|
+
if [ -d "$DOCKG_REPO/.dockg" ]; then
|
|
61
|
+
(cd "$DOCKG_REPO" && "$REPO_ROOT/.venv/bin/dockg" snapshot save \\
|
|
62
|
+
--repo . \\
|
|
63
|
+
--tree-hash "$TREE_HASH" \\
|
|
64
|
+
--branch "$BRANCH") || true
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Stage both snapshot directories so they are included in the commit.
|
|
68
|
+
git add .pycodekg/snapshots/ 2>/dev/null || true
|
|
69
|
+
if [ -d "$DOCKG_REPO" ]; then
|
|
70
|
+
(cd "$DOCKG_REPO" && git add .dockg/snapshots/ 2>/dev/null || true)
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Run pre-commit framework checks (ruff, mypy, detect-secrets, etc.) AFTER
|
|
74
|
+
# snapshots are captured and staged. Delegates to .pre-commit-config.yaml so
|
|
75
|
+
# quality checks stay in one place.
|
|
76
|
+
PRECOMMIT="$REPO_ROOT/.venv/bin/pre-commit"
|
|
77
|
+
if [ -x "$PRECOMMIT" ]; then
|
|
78
|
+
"$PRECOMMIT" run || exit 1
|
|
79
|
+
elif command -v pre-commit &>/dev/null; then
|
|
80
|
+
pre-commit run || exit 1
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
exit 0
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cli.command("install-hooks")
|
|
88
|
+
@click.option(
|
|
89
|
+
"--repo",
|
|
90
|
+
default=".",
|
|
91
|
+
type=click.Path(exists=True),
|
|
92
|
+
show_default=True,
|
|
93
|
+
help="Repository root.",
|
|
94
|
+
)
|
|
95
|
+
@click.option(
|
|
96
|
+
"--force",
|
|
97
|
+
is_flag=True,
|
|
98
|
+
help="Overwrite an existing pre-commit hook.",
|
|
99
|
+
)
|
|
100
|
+
def install_hooks(repo: str, force: bool) -> None:
|
|
101
|
+
"""Install the PyCodeKG pre-commit git hook.
|
|
102
|
+
|
|
103
|
+
After installation, before each commit:
|
|
104
|
+
1. Rebuilds local PyCodeKG index (full wipe)
|
|
105
|
+
2. Rebuilds local DocKG index if available (full wipe)
|
|
106
|
+
3. Captures metrics snapshots for both KGs, keyed by tree hash
|
|
107
|
+
4. Stages both snapshot directories atomically
|
|
108
|
+
|
|
109
|
+
This keeps PyCodeKG and DocKG indices in sync and ensures snapshots
|
|
110
|
+
reflect the current state of both knowledge graphs at commit time.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
pycodekg install-hooks --repo .
|
|
114
|
+
"""
|
|
115
|
+
repo_root = Path(repo).resolve()
|
|
116
|
+
git_dir = repo_root / ".git"
|
|
117
|
+
|
|
118
|
+
if not git_dir.is_dir():
|
|
119
|
+
click.echo(f"Error: {repo_root} is not a git repository.", err=True)
|
|
120
|
+
raise SystemExit(1)
|
|
121
|
+
|
|
122
|
+
hooks_dir = git_dir / "hooks"
|
|
123
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
124
|
+
hook_path = hooks_dir / "pre-commit"
|
|
125
|
+
|
|
126
|
+
if hook_path.exists() and not force:
|
|
127
|
+
click.echo(f"Hook already exists: {hook_path}")
|
|
128
|
+
click.echo("Use --force to overwrite.")
|
|
129
|
+
raise SystemExit(1)
|
|
130
|
+
|
|
131
|
+
hook_path.write_text(_PRE_COMMIT_HOOK)
|
|
132
|
+
mode = hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
133
|
+
hook_path.chmod(mode)
|
|
134
|
+
|
|
135
|
+
click.echo(f"OK Installed pre-commit hook: {hook_path}")
|
|
136
|
+
click.echo(" Snapshots will be captured automatically before each commit.")
|
|
137
|
+
click.echo(" Run 'pycodekg build' first if you haven't built the graph yet.")
|