codegraph-network 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.
- codegraph/__init__.py +1 -0
- codegraph/bench.py +93 -0
- codegraph/cli.py +128 -0
- codegraph/community.py +77 -0
- codegraph/docs.py +141 -0
- codegraph/embed.py +221 -0
- codegraph/indexer.py +190 -0
- codegraph/install.py +219 -0
- codegraph/keys.py +94 -0
- codegraph/links.py +220 -0
- codegraph/parser.py +459 -0
- codegraph/py.typed +0 -0
- codegraph/queries.py +657 -0
- codegraph/rank.py +121 -0
- codegraph/report.py +199 -0
- codegraph/review.py +145 -0
- codegraph/server.py +81 -0
- codegraph/store.py +638 -0
- codegraph/tools.py +99 -0
- codegraph_network-0.1.0.dist-info/METADATA +220 -0
- codegraph_network-0.1.0.dist-info/RECORD +25 -0
- codegraph_network-0.1.0.dist-info/WHEEL +5 -0
- codegraph_network-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_network-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_network-0.1.0.dist-info/top_level.txt +1 -0
codegraph/indexer.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Incremental repository indexer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import community, embed, links
|
|
12
|
+
from .docs import DOC_EXT, DOC_LANGS, parse_doc
|
|
13
|
+
from .parser import EXT, parse
|
|
14
|
+
from .store import SCHEMA_VERSION, Store, recompute_rank
|
|
15
|
+
|
|
16
|
+
SKIP_DIRS = {"node_modules", "venv", "__pycache__", "dist", "build", "target", "vendor", "site-packages"}
|
|
17
|
+
MAX_BYTES = 1_000_000
|
|
18
|
+
# bump when parser output changes; forces a full re-index
|
|
19
|
+
PARSER_VERSION = 6 # 6: edge confidence, docs/SQL/PDF symbols
|
|
20
|
+
IGNORE_FILE = ".codegraphignore"
|
|
21
|
+
PARALLEL_MIN = 48 # below this, starting worker processes costs more than it saves
|
|
22
|
+
ALL_EXT = {**EXT, **DOC_EXT}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def db_path(root: Path) -> Path:
|
|
26
|
+
return root / ".codegraph" / "graph.db"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_ignore(root: Path) -> list[tuple[str, bool, bool, bool]]:
|
|
30
|
+
"""Parse `.codegraphignore` (gitignore-style, minus `**` and escapes) into
|
|
31
|
+
(pattern, negated, dir_only, anchored) rules. Later rules win, as in gitignore."""
|
|
32
|
+
try:
|
|
33
|
+
text = (root / IGNORE_FILE).read_text(errors="replace")
|
|
34
|
+
except OSError:
|
|
35
|
+
return []
|
|
36
|
+
rules = []
|
|
37
|
+
for line in text.splitlines():
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line or line.startswith("#"):
|
|
40
|
+
continue
|
|
41
|
+
neg = line.startswith("!")
|
|
42
|
+
line = line.lstrip("!")
|
|
43
|
+
dir_only = line.endswith("/")
|
|
44
|
+
line = line.strip("/") if dir_only else line
|
|
45
|
+
anchored = line.startswith("/") or "/" in line
|
|
46
|
+
if line:
|
|
47
|
+
rules.append((line.lstrip("/"), neg, dir_only, anchored))
|
|
48
|
+
return rules
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_ignored(rel: str, is_dir: bool, rules) -> bool:
|
|
52
|
+
segs = rel.split("/")
|
|
53
|
+
hit = False
|
|
54
|
+
for pat, neg, dir_only, anchored in rules:
|
|
55
|
+
if anchored:
|
|
56
|
+
# the path itself, or any directory prefix of it
|
|
57
|
+
cands = ["/".join(segs[:i]) for i in range(1, len(segs) + 1)]
|
|
58
|
+
kinds = [True] * (len(segs) - 1) + [is_dir]
|
|
59
|
+
else:
|
|
60
|
+
cands, kinds = segs, [True] * (len(segs) - 1) + [is_dir]
|
|
61
|
+
if any(fnmatch.fnmatchcase(c, pat) and (k or not dir_only) for c, k in zip(cands, kinds, strict=True)):
|
|
62
|
+
hit = not neg
|
|
63
|
+
return hit
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _scan(root: Path) -> dict[str, tuple[Path, str]]:
|
|
67
|
+
seen: dict[str, tuple[Path, str]] = {}
|
|
68
|
+
rules = load_ignore(root)
|
|
69
|
+
for dirpath, dirs, files in os.walk(root):
|
|
70
|
+
rel_dir = Path(dirpath).relative_to(root).as_posix()
|
|
71
|
+
rel_dir = "" if rel_dir == "." else rel_dir + "/"
|
|
72
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")
|
|
73
|
+
and not (rules and is_ignored(rel_dir + d, True, rules))]
|
|
74
|
+
for f in files:
|
|
75
|
+
lang = ALL_EXT.get(Path(f).suffix.lower())
|
|
76
|
+
if not lang or (rules and is_ignored(rel_dir + f, False, rules)):
|
|
77
|
+
continue
|
|
78
|
+
p = Path(dirpath) / f
|
|
79
|
+
try:
|
|
80
|
+
if p.stat().st_size > MAX_BYTES:
|
|
81
|
+
continue
|
|
82
|
+
except OSError:
|
|
83
|
+
continue
|
|
84
|
+
seen[p.relative_to(root).as_posix()] = (p, lang)
|
|
85
|
+
return seen
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_one(job):
|
|
89
|
+
"""Worker: parse one file. Module level so it can be pickled into a process pool."""
|
|
90
|
+
data, lang, stem = job
|
|
91
|
+
try:
|
|
92
|
+
return (parse_doc if lang in DOC_LANGS else parse)(data, lang, stem), None
|
|
93
|
+
except Exception as e: # a broken file must not kill the index
|
|
94
|
+
return None, str(e)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_all(jobs: list) -> list:
|
|
98
|
+
"""Parse in parallel across processes: tree-sitter plus the Python tree walk is CPU-bound, so
|
|
99
|
+
threads would not help. Falls back to serial if a pool cannot be started."""
|
|
100
|
+
if len(jobs) >= PARALLEL_MIN and (os.cpu_count() or 1) > 1:
|
|
101
|
+
try:
|
|
102
|
+
with ProcessPoolExecutor(max_workers=min(os.cpu_count() or 1, 8)) as pool:
|
|
103
|
+
return list(pool.map(_parse_one, jobs, chunksize=16))
|
|
104
|
+
except Exception as e: # sandboxes without fork/semaphores, broken pool, ...
|
|
105
|
+
print(f"codegraph: parallel parse unavailable ({e}); parsing serially", file=sys.stderr)
|
|
106
|
+
return [_parse_one(j) for j in jobs]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def index_repo(root: str | Path, force: bool = False, progress=None) -> dict:
|
|
110
|
+
"""Update the index for `root`. Only changed files are re-parsed, and only the edges that
|
|
111
|
+
those changes can affect are re-resolved."""
|
|
112
|
+
root = Path(root).resolve()
|
|
113
|
+
store = Store(db_path(root))
|
|
114
|
+
|
|
115
|
+
def note(msg: str):
|
|
116
|
+
if progress is not None:
|
|
117
|
+
progress(msg)
|
|
118
|
+
|
|
119
|
+
# a database written by an older parser, or by an older schema, is rebuilt from scratch
|
|
120
|
+
if store.get_meta("parser_version") != str(PARSER_VERSION):
|
|
121
|
+
note("parser version changed, rebuilding index")
|
|
122
|
+
force = True
|
|
123
|
+
|
|
124
|
+
if force:
|
|
125
|
+
store.clear() # one bulk delete instead of one per file
|
|
126
|
+
seen = _scan(root)
|
|
127
|
+
old = store.file_hashes()
|
|
128
|
+
removed = set(old) - set(seen)
|
|
129
|
+
|
|
130
|
+
# hash first, so we know the full dirty set before mutating anything
|
|
131
|
+
to_parse: list[tuple[str, Path, str, bytes, str]] = []
|
|
132
|
+
for rel, (p, lang) in sorted(seen.items()):
|
|
133
|
+
try:
|
|
134
|
+
data = p.read_bytes()
|
|
135
|
+
except OSError:
|
|
136
|
+
continue
|
|
137
|
+
h = hashlib.sha1(data).hexdigest()
|
|
138
|
+
if not force and old.get(rel) == h:
|
|
139
|
+
continue
|
|
140
|
+
to_parse.append((rel, p, lang, data, h))
|
|
141
|
+
|
|
142
|
+
dirty_files = set(removed) | {rel for rel, *_ in to_parse}
|
|
143
|
+
# names are captured before deletion: a definition that disappears can invalidate edges
|
|
144
|
+
# elsewhere that referred to it by name
|
|
145
|
+
dirty_names = store.names_in(dirty_files) if dirty_files else set()
|
|
146
|
+
# ...and importers must be captured before delete_file drops the rows that point at them,
|
|
147
|
+
# otherwise their import edges would never be recomputed and would stay missing
|
|
148
|
+
if dirty_files:
|
|
149
|
+
dirty_files |= store.importers_of(dirty_files)
|
|
150
|
+
|
|
151
|
+
for r in sorted(removed):
|
|
152
|
+
store.delete_file(r)
|
|
153
|
+
for rel, *_ in to_parse:
|
|
154
|
+
if rel in old: # a new file has nothing to delete
|
|
155
|
+
store.delete_file(rel)
|
|
156
|
+
changed = 0
|
|
157
|
+
parsed_all = _parse_all([(data, lang, Path(rel).stem) for rel, _p, lang, data, _h in to_parse])
|
|
158
|
+
for (rel, _p, lang, data, h), (parsed, err) in zip(to_parse, parsed_all, strict=True):
|
|
159
|
+
if parsed is None:
|
|
160
|
+
print(f"codegraph: skipped {rel}: {err}", file=sys.stderr) # stdout is the MCP channel
|
|
161
|
+
continue
|
|
162
|
+
store.add_file(rel, lang, h, parsed, data.count(b"\n") + 1)
|
|
163
|
+
dirty_names |= {s.name for s in parsed.syms}
|
|
164
|
+
changed += 1
|
|
165
|
+
if changed % 200 == 0:
|
|
166
|
+
note(f"parsed {changed} files")
|
|
167
|
+
|
|
168
|
+
dirty_names -= {None}
|
|
169
|
+
resolve_stats: dict = {}
|
|
170
|
+
rank_stats: dict = {}
|
|
171
|
+
link_stats: dict = {}
|
|
172
|
+
if changed or removed or force or store.migrated:
|
|
173
|
+
resolve_stats = store.resolve(full=force or not dirty_names,
|
|
174
|
+
dirty_files=dirty_files,
|
|
175
|
+
dirty_names=dirty_names)
|
|
176
|
+
store.set_meta("parser_version", str(PARSER_VERSION))
|
|
177
|
+
store.set_meta("schema_version", str(SCHEMA_VERSION))
|
|
178
|
+
rank_stats = recompute_rank(store)
|
|
179
|
+
link_stats.update(links.link_static(store.db))
|
|
180
|
+
link_stats["communities"] = community.detect(store.db)
|
|
181
|
+
link_stats["embed"] = embed.update(store, note)
|
|
182
|
+
link_stats["cochange"] = links.link_cochange(store, root, force) # -1: unchanged, 0: no git
|
|
183
|
+
store.commit()
|
|
184
|
+
db = store.db
|
|
185
|
+
n_sym = db.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
|
|
186
|
+
n_edge = db.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
|
187
|
+
store.close()
|
|
188
|
+
return {"files": len(seen), "changed": changed, "removed": len(removed),
|
|
189
|
+
"symbols": n_sym, "edges": n_edge,
|
|
190
|
+
"resolve": resolve_stats, "rank": rank_stats, "links": link_stats}
|
codegraph/install.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""`codegraph install` and `codegraph hook`: wire the graph into coding assistants and into git.
|
|
2
|
+
|
|
3
|
+
Everything written here is idempotent and reversible: instruction text lives between markers inside
|
|
4
|
+
files the user may also edit by hand, JSON configs are merged key by key, and git hooks are appended to
|
|
5
|
+
rather than replaced.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
START, END = "<!-- codegraph:start -->", "<!-- codegraph:end -->"
|
|
16
|
+
SH_START, SH_END = "# >>> codegraph >>>", "# <<< codegraph <<<"
|
|
17
|
+
GIT_HOOKS = ("post-commit", "post-checkout", "post-merge", "post-rewrite")
|
|
18
|
+
NUDGE_MARK = "codegraph-nudge" # lets us find our own hook entry again
|
|
19
|
+
PLATFORMS = ("claude", "cursor", "gemini", "agents")
|
|
20
|
+
|
|
21
|
+
GUIDE = """\
|
|
22
|
+
## Code navigation: use codegraph before reading files
|
|
23
|
+
|
|
24
|
+
This repository is indexed by codegraph (symbols, call graph, imports, docs, SQL). Ask the graph instead
|
|
25
|
+
of reading whole files or grepping:
|
|
26
|
+
|
|
27
|
+
1. `repo_map` for an overview (pass `focus=` to personalise it), `search` to find a symbol by name or meaning.
|
|
28
|
+
2. `context <symbol>` for its source, what it uses and who uses it, in one reply.
|
|
29
|
+
3. `impact <symbol>` before changing anything; `review` before opening a PR (what the diff touches,
|
|
30
|
+
blast radius, untested changes).
|
|
31
|
+
4. `callers` / `callees` / `related` / `deps` to walk the graph.
|
|
32
|
+
|
|
33
|
+
Edges tagged `[inferred]` or `[ambiguous]` (or `?` in `impact`) are name-based guesses, so treat them as
|
|
34
|
+
upper bounds and confirm by reading the code. Read files directly only for exact surrounding code.
|
|
35
|
+
|
|
36
|
+
No MCP server connected? The same tools run from the shell: `codegraph context <symbol>`,
|
|
37
|
+
`codegraph impact <symbol>`, `codegraph review`. Exit code 3 means the name is ambiguous, 4 that it was
|
|
38
|
+
not found.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
SKILL = f"""---
|
|
42
|
+
name: codegraph
|
|
43
|
+
description: Navigate this codebase through its code graph (symbols, callers, impact, docs, SQL) instead of reading whole files. Use before searching or reading code, before editing a function, and before opening a PR.
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
{GUIDE}"""
|
|
47
|
+
|
|
48
|
+
NUDGE = ("codegraph is available for this repo: try repo_map / search / context before grepping or "
|
|
49
|
+
"reading whole files (CLI: codegraph search <query>).")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write(path: Path, text: str, report: list[str], what: str):
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
if path.exists() and path.read_text() == text:
|
|
55
|
+
report.append(f"unchanged {what}")
|
|
56
|
+
return
|
|
57
|
+
verb = "updated" if path.exists() else "created"
|
|
58
|
+
path.write_text(text)
|
|
59
|
+
report.append(f"{verb:<10} {what}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _block(text: str) -> str:
|
|
63
|
+
return f"{START}\n{text.rstrip()}\n{END}\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _upsert_block(path: Path, text: str, report: list[str], what: str):
|
|
67
|
+
"""Put `text` between markers in a file that may hold other content; replace it if already there."""
|
|
68
|
+
block = _block(text)
|
|
69
|
+
cur = path.read_text() if path.exists() else ""
|
|
70
|
+
if START in cur and END in cur:
|
|
71
|
+
a, b = cur.index(START), cur.index(END) + len(END)
|
|
72
|
+
new = cur[:a] + block.rstrip("\n") + cur[b:]
|
|
73
|
+
else:
|
|
74
|
+
new = cur + ("\n" if cur and not cur.endswith("\n\n") else "") + block
|
|
75
|
+
_write(path, new, report, what)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_json(path: Path) -> dict:
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(path.read_text() or "{}")
|
|
83
|
+
except json.JSONDecodeError as e:
|
|
84
|
+
raise SystemExit(f"codegraph: {path} is not valid JSON ({e}); fix it or move it aside, then re-run") from e
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise SystemExit(f"codegraph: {path} must hold a JSON object")
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _merge_mcp(path: Path, report: list[str], what: str):
|
|
91
|
+
data = _load_json(path)
|
|
92
|
+
servers = data.setdefault("mcpServers", {})
|
|
93
|
+
# relative root: MCP clients start servers in the project directory, so the config stays portable
|
|
94
|
+
servers["codegraph"] = {"command": "codegraph", "args": ["-r", ".", "serve"]}
|
|
95
|
+
_write(path, json.dumps(data, indent=2) + "\n", report, what)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _claude_hook(root: Path, report: list[str]):
|
|
99
|
+
path = root / ".claude" / "settings.json"
|
|
100
|
+
data = _load_json(path)
|
|
101
|
+
payload = json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": NUDGE}})
|
|
102
|
+
entry = {"matcher": "Glob|Grep", "hooks": [{"type": "command", "command": f"echo '{payload}' # {NUDGE_MARK}"}]}
|
|
103
|
+
pre = data.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
104
|
+
pre[:] = [e for e in pre if NUDGE_MARK not in json.dumps(e)] + [entry]
|
|
105
|
+
_write(path, json.dumps(data, indent=2) + "\n", report, ".claude/settings.json (Glob|Grep hint)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install(root: str | Path, platform: str = "all", hooks: bool = True) -> list[str]:
|
|
109
|
+
root = Path(root).resolve()
|
|
110
|
+
chosen = PLATFORMS if platform == "all" else (platform,)
|
|
111
|
+
unknown = [p for p in chosen if p not in PLATFORMS]
|
|
112
|
+
if unknown:
|
|
113
|
+
raise SystemExit(f"codegraph: unknown platform {unknown[0]!r}; choose from {', '.join(PLATFORMS)}, all")
|
|
114
|
+
report: list[str] = []
|
|
115
|
+
for p in chosen:
|
|
116
|
+
if p == "claude":
|
|
117
|
+
_write(root / ".claude" / "skills" / "codegraph" / "SKILL.md", SKILL, report,
|
|
118
|
+
".claude/skills/codegraph/SKILL.md")
|
|
119
|
+
_merge_mcp(root / ".mcp.json", report, ".mcp.json (MCP server)")
|
|
120
|
+
_upsert_block(root / "CLAUDE.md", GUIDE, report, "CLAUDE.md (guidance block)")
|
|
121
|
+
if hooks:
|
|
122
|
+
_claude_hook(root, report)
|
|
123
|
+
elif p == "cursor":
|
|
124
|
+
rule = ("---\ndescription: Navigate the codebase with codegraph before reading files\n"
|
|
125
|
+
f"alwaysApply: true\n---\n\n{GUIDE}")
|
|
126
|
+
_write(root / ".cursor" / "rules" / "codegraph.mdc", rule, report, ".cursor/rules/codegraph.mdc")
|
|
127
|
+
_merge_mcp(root / ".cursor" / "mcp.json", report, ".cursor/mcp.json (MCP server)")
|
|
128
|
+
elif p == "gemini":
|
|
129
|
+
_upsert_block(root / "GEMINI.md", GUIDE, report, "GEMINI.md (guidance block)")
|
|
130
|
+
_merge_mcp(root / ".gemini" / "settings.json", report, ".gemini/settings.json (MCP server)")
|
|
131
|
+
elif p == "agents": # Codex, opencode, Aider (via --read), Copilot and others read AGENTS.md
|
|
132
|
+
_upsert_block(root / "AGENTS.md", GUIDE, report, "AGENTS.md (guidance block)")
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def uninstall(root: str | Path) -> list[str]:
|
|
137
|
+
"""Remove what `install` wrote. Files that held only our block are deleted."""
|
|
138
|
+
root = Path(root).resolve()
|
|
139
|
+
report: list[str] = []
|
|
140
|
+
for rel in ("CLAUDE.md", "GEMINI.md", "AGENTS.md"):
|
|
141
|
+
path = root / rel
|
|
142
|
+
if path.exists() and START in (cur := path.read_text()) and END in cur:
|
|
143
|
+
rest = (cur[:cur.index(START)] + cur[cur.index(END) + len(END):]).strip()
|
|
144
|
+
if rest:
|
|
145
|
+
path.write_text(rest + "\n")
|
|
146
|
+
else:
|
|
147
|
+
path.unlink()
|
|
148
|
+
report.append(f"cleaned {rel}")
|
|
149
|
+
for rel in (".claude/skills/codegraph/SKILL.md", ".cursor/rules/codegraph.mdc"):
|
|
150
|
+
if (root / rel).exists():
|
|
151
|
+
(root / rel).unlink()
|
|
152
|
+
report.append(f"removed {rel}")
|
|
153
|
+
for rel in (".mcp.json", ".cursor/mcp.json", ".gemini/settings.json"):
|
|
154
|
+
path = root / rel
|
|
155
|
+
if path.exists() and "codegraph" in _load_json(path).get("mcpServers", {}):
|
|
156
|
+
data = _load_json(path)
|
|
157
|
+
del data["mcpServers"]["codegraph"]
|
|
158
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
159
|
+
report.append(f"cleaned {rel}")
|
|
160
|
+
path = root / ".claude" / "settings.json"
|
|
161
|
+
if path.exists() and NUDGE_MARK in path.read_text():
|
|
162
|
+
data = _load_json(path)
|
|
163
|
+
pre = data.get("hooks", {}).get("PreToolUse", [])
|
|
164
|
+
pre[:] = [e for e in pre if NUDGE_MARK not in json.dumps(e)]
|
|
165
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
166
|
+
report.append("cleaned .claude/settings.json")
|
|
167
|
+
return report or ["nothing to remove"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- git hooks ----
|
|
171
|
+
|
|
172
|
+
def _hooks_dir(root: Path) -> Path:
|
|
173
|
+
p = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
|
|
174
|
+
capture_output=True, text=True)
|
|
175
|
+
if p.returncode != 0:
|
|
176
|
+
raise SystemExit("codegraph: not a git repository")
|
|
177
|
+
d = Path(p.stdout.strip())
|
|
178
|
+
return d if d.is_absolute() else root / d
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hook_block() -> str:
|
|
182
|
+
# backgrounded: a commit or checkout must never wait for, or fail because of, the indexer
|
|
183
|
+
return (f"{SH_START}\ncommand -v codegraph >/dev/null 2>&1 && "
|
|
184
|
+
f'(codegraph -r "$(git rev-parse --show-toplevel)" index >/dev/null 2>&1 &)\n{SH_END}\n')
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def hook_install(root: str | Path) -> list[str]:
|
|
188
|
+
root = Path(root).resolve()
|
|
189
|
+
d = _hooks_dir(root)
|
|
190
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
report = []
|
|
192
|
+
for name in GIT_HOOKS:
|
|
193
|
+
path = d / name
|
|
194
|
+
cur = path.read_text() if path.exists() else "#!/bin/sh\n"
|
|
195
|
+
if SH_START in cur:
|
|
196
|
+
a, b = cur.index(SH_START), cur.index(SH_END) + len(SH_END)
|
|
197
|
+
new = cur[:a] + _hook_block().rstrip("\n") + cur[b:]
|
|
198
|
+
else:
|
|
199
|
+
new = cur + ("" if cur.endswith("\n") else "\n") + _hook_block()
|
|
200
|
+
path.write_text(new)
|
|
201
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
202
|
+
report.append(f"installed {os.path.relpath(path, root)}")
|
|
203
|
+
return report
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def hook_uninstall(root: str | Path) -> list[str]:
|
|
207
|
+
root = Path(root).resolve()
|
|
208
|
+
d, report = _hooks_dir(root), []
|
|
209
|
+
for name in GIT_HOOKS:
|
|
210
|
+
path = d / name
|
|
211
|
+
if not path.exists() or SH_START not in (cur := path.read_text()):
|
|
212
|
+
continue
|
|
213
|
+
rest = (cur[:cur.index(SH_START)] + cur[cur.index(SH_END) + len(SH_END):]).strip()
|
|
214
|
+
if rest in ("", "#!/bin/sh"):
|
|
215
|
+
path.unlink()
|
|
216
|
+
else:
|
|
217
|
+
path.write_text(rest + "\n")
|
|
218
|
+
report.append(f"removed {os.path.relpath(path, root)}")
|
|
219
|
+
return report or ["no codegraph hooks found"]
|
codegraph/keys.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""String-keyed hidden links: routes, event names and config keys.
|
|
2
|
+
|
|
3
|
+
Two symbols that mention the same string literal in a recognisable role (a route path registered
|
|
4
|
+
here and requested there, an event emitted here and listened to there) are related even though
|
|
5
|
+
no call edge connects them. Recognition is table-driven: add a `Pattern` row to support a new
|
|
6
|
+
framework. The parser feeds this module the callee text and the first string argument of every
|
|
7
|
+
call; resolution (matching roles across files) lives in links.py.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
PY = frozenset({"python"})
|
|
15
|
+
JS = frozenset({"javascript", "typescript", "tsx"})
|
|
16
|
+
ANY = PY | JS
|
|
17
|
+
|
|
18
|
+
_HTTP = "get|post|put|patch|delete|head|options"
|
|
19
|
+
_STRING = re.compile(r"""^[rbfuRBFU]{0,2}(\"\"\"|'''|"|'|`)(.*)\1$""", re.DOTALL)
|
|
20
|
+
# events every Node/DOM program emits and listens to: linking on them would connect unrelated code
|
|
21
|
+
COMMON_EVENTS = frozenset(
|
|
22
|
+
"error data end close exit open message connect finish drain readable line abort timeout "
|
|
23
|
+
"click change input submit keydown keyup load resize sigint sigterm uncaughtexception "
|
|
24
|
+
"unhandledrejection".split())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _route(k: str) -> bool:
|
|
28
|
+
return k.startswith("/") and len(k) > 1 and " " not in k
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _event(k: str) -> bool:
|
|
32
|
+
return len(k) >= 3 and k.lower() not in COMMON_EVENTS and re.fullmatch(r"[\w.:/@-]+", k) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _config(k: str) -> bool:
|
|
36
|
+
return len(k) >= 3 and re.fullmatch(r"[\w.:-]+", k) is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Pattern:
|
|
41
|
+
langs: frozenset
|
|
42
|
+
callee: re.Pattern # matched against the whole callee text, e.g. `app.get`
|
|
43
|
+
ns: str # namespace of the key: keys only match inside one namespace
|
|
44
|
+
role: str # see links.KEY_ROLES for how roles pair up
|
|
45
|
+
valid: object # str -> bool, rejects keys that are too generic to mean anything
|
|
46
|
+
decorator: bool | None = None # True: only as a decorator, False: only as a plain call
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _p(langs, callee, ns, role, valid, decorator=None) -> Pattern:
|
|
50
|
+
return Pattern(langs, re.compile(callee), ns, role, valid, decorator)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Add rows here to teach codegraph a new framework. Roles per namespace: see links.KEY_ROLES.
|
|
54
|
+
PATTERNS: tuple[Pattern, ...] = (
|
|
55
|
+
# routes: registered (provides) vs requested (uses)
|
|
56
|
+
_p(PY, rf"^\w+\.({_HTTP}|route|websocket|api_route)$", "route", "provides", _route, decorator=True),
|
|
57
|
+
_p(PY, rf"^(?:\w+\.)*(client|requests|httpx|session|http|api)\.({_HTTP})$", "route", "uses", _route,
|
|
58
|
+
decorator=False),
|
|
59
|
+
_p(JS, rf"^(app|router|server|\w+Router)\.({_HTTP}|all)$", "route", "provides", _route),
|
|
60
|
+
_p(JS, rf"^(fetch|axios|ky|\$http)$|^(?:\w+\.)*(axios|client|http|api|request|supertest)\.({_HTTP})$",
|
|
61
|
+
"route", "uses", _route),
|
|
62
|
+
# events: emitted vs listened to
|
|
63
|
+
_p(ANY, r"(?:^|\.)(emit|dispatch|dispatchEvent|trigger|publish)$", "event", "emits", _event),
|
|
64
|
+
_p(ANY, r"(?:^|\.)(on|once|addListener|addEventListener|subscribe|prependListener)$",
|
|
65
|
+
"event", "listens", _event),
|
|
66
|
+
# config keys: everything that reads the same key is related
|
|
67
|
+
_p(PY, r"(?:^|\.)(getenv|environ\.get|environ\.setdefault)$", "config", "reads", _config),
|
|
68
|
+
_p(ANY, r"^(?:self\.|this\.)?(config|settings|cfg|conf|env)\.(get|getint|getboolean|require)$",
|
|
69
|
+
"config", "reads", _config),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def string_value(text: str) -> str | None:
|
|
74
|
+
"""The contents of a plain string literal, or None for anything computed or interpolated."""
|
|
75
|
+
m = _STRING.match(text.strip())
|
|
76
|
+
if not m:
|
|
77
|
+
return None
|
|
78
|
+
body = m.group(2)
|
|
79
|
+
if m.group(1) == "`" and "${" in body:
|
|
80
|
+
return None
|
|
81
|
+
if text.lstrip()[:1] in "fF" and "{" in body: # f-string: not a constant
|
|
82
|
+
return None
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def match(lang: str, callee: str, key: str, decorator: bool) -> list[tuple[str, str, str]]:
|
|
87
|
+
"""Every (namespace, key, role) a call `callee("key", ...)` contributes."""
|
|
88
|
+
out = []
|
|
89
|
+
for p in PATTERNS:
|
|
90
|
+
if lang not in p.langs or (p.decorator is not None and p.decorator != decorator):
|
|
91
|
+
continue
|
|
92
|
+
if p.callee.search(callee) and p.valid(key):
|
|
93
|
+
out.append((p.ns, key, p.role))
|
|
94
|
+
return out
|