graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Idempotent Claude Code settings wiring for graphite agent hooks.
|
|
2
|
+
|
|
3
|
+
Graphite owns exactly the hook commands whose command string starts with
|
|
4
|
+
``HOOK_COMMAND_PREFIX``; those are stripped and re-written on every install.
|
|
5
|
+
Everything else in ``.claude/settings.json`` is preserved untouched, and a file
|
|
6
|
+
that fails to parse is never modified.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .io import atomic_write_text
|
|
17
|
+
|
|
18
|
+
# `-P` omits the working directory from `sys.path` (3.11+). Without it,
|
|
19
|
+
# `python -m graphite` puts the CWD at `sys.path[0]`, so a `graphite.py` -- or a
|
|
20
|
+
# `graphite/` directory -- in a managed repo's root wins over the installed
|
|
21
|
+
# package and the hook executes it. The pre-tool-use hook fires on EVERY tool
|
|
22
|
+
# use, so such a file never has to be invoked by anyone: it runs on the next
|
|
23
|
+
# agent action after it lands. `.claude/settings.json` is tracked in consumer
|
|
24
|
+
# repos, so it would arrive through an ordinary reviewed commit, and `init`
|
|
25
|
+
# ships this identical command line to every managed repo.
|
|
26
|
+
#
|
|
27
|
+
# Reported by aramid-agent (channel round 49) and reproduced here: with a
|
|
28
|
+
# `graphite.py` in the CWD, bare `python -m graphite --version` ran the shadow
|
|
29
|
+
# file; `python -P -m graphite --version` ran graphite.
|
|
30
|
+
#
|
|
31
|
+
# `-P` over PYTHONSAFEPATH=1 because it is visible in the command string itself,
|
|
32
|
+
# which matters for a generated file an operator audits by reading.
|
|
33
|
+
HOOK_COMMAND_PREFIX = "python -P -m graphite agent-hook"
|
|
34
|
+
# Every prefix graphite has ever written. Load-bearing, not history: this string
|
|
35
|
+
# is both what we WRITE and what we MATCH to find our own hooks. A matcher that
|
|
36
|
+
# knew only the current spelling would fail to strip the legacy hook from an
|
|
37
|
+
# already-onboarded repo (leaving two, the vulnerable one still firing) and
|
|
38
|
+
# would make `existing_mode` return None, silently downgrading every strict repo
|
|
39
|
+
# to DEFAULT_MODE on its next `init`. Never drop an entry; only ever append.
|
|
40
|
+
_OWNED_COMMAND_PREFIXES = (HOOK_COMMAND_PREFIX, "python -m graphite agent-hook")
|
|
41
|
+
DEFAULT_MODE = "remind"
|
|
42
|
+
_MODES = ("remind", "strict")
|
|
43
|
+
_SESSION_START_COMMAND = f"{HOOK_COMMAND_PREFIX} session-start"
|
|
44
|
+
_STOP_COMMAND = f"{HOOK_COMMAND_PREFIX} stop"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pre_tool_use_command(mode: str) -> str:
|
|
48
|
+
return f"{HOOK_COMMAND_PREFIX} pre-tool-use --mode {mode}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_graphite_command(hook: Any) -> bool:
|
|
52
|
+
return isinstance(hook, dict) and str(hook.get("command", "")).startswith(_OWNED_COMMAND_PREFIXES)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_graphite(groups: Any) -> list[Any]:
|
|
56
|
+
"""Drop graphite-owned commands; keep foreign groups byte-identical."""
|
|
57
|
+
if not isinstance(groups, list):
|
|
58
|
+
return []
|
|
59
|
+
kept: list[Any] = []
|
|
60
|
+
for entry in groups:
|
|
61
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
|
|
62
|
+
kept.append(entry)
|
|
63
|
+
continue
|
|
64
|
+
remaining = [hook for hook in entry["hooks"] if not _is_graphite_command(hook)]
|
|
65
|
+
if remaining == entry["hooks"]:
|
|
66
|
+
kept.append(entry)
|
|
67
|
+
elif remaining:
|
|
68
|
+
kept.append({**entry, "hooks": remaining})
|
|
69
|
+
return kept
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_settings(path: Path) -> dict[str, Any] | None:
|
|
73
|
+
"""Parsed settings dict, {} when absent, None when malformed."""
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return {}
|
|
76
|
+
try:
|
|
77
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
79
|
+
return None
|
|
80
|
+
return loaded if isinstance(loaded, dict) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_settings(root: Path) -> dict[str, Any] | None:
|
|
84
|
+
"""A repo's parsed settings: `{}` when absent, `None` when malformed.
|
|
85
|
+
|
|
86
|
+
The tri-state matters to callers that report rather than write: "absent"
|
|
87
|
+
and "unreadable" are different answers, and collapsing them lets a corrupt
|
|
88
|
+
file read as a clean one.
|
|
89
|
+
"""
|
|
90
|
+
return _load_settings(root / ".claude" / "settings.json")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#: Head tokens that mean the command runs graphite's own console-script entry
|
|
94
|
+
#: point. Measured 2026-08-03: this form is shadowed exactly like `python -m`
|
|
95
|
+
#: -- an installed console script does NOT put its own directory on
|
|
96
|
+
#: `sys.path[0]`, contrary to the obvious assumption -- and it cannot express
|
|
97
|
+
#: the fix, because there is no `-P` to add. It has to change form.
|
|
98
|
+
_CONSOLE_SCRIPT_STEMS = frozenset({"graphite"})
|
|
99
|
+
_INTERPRETER_STEMS = frozenset({"python", "python3", "py", "pythonw"})
|
|
100
|
+
|
|
101
|
+
#: Split on BOTH separators rather than using `PurePath`, whose `.name` follows
|
|
102
|
+
#: the HOST's flavour: on POSIX, `PurePath(r"C:\Python314\python.exe").name` is
|
|
103
|
+
#: the whole string, so a Windows-spelled hook command went unclassified on any
|
|
104
|
+
#: non-Windows host. A hook command is data read out of a file, not a path on
|
|
105
|
+
#: the machine reading it, so host semantics are the wrong tool entirely.
|
|
106
|
+
#: Caught by the portability matrix on macOS; never observable on Windows.
|
|
107
|
+
_PATH_SEPARATORS = re.compile(r"[\\/]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _executable_stem(token: str) -> str:
|
|
111
|
+
"""`C:\\Python314\\python.exe` and `/usr/bin/python3` -> `python`, `python3`.
|
|
112
|
+
|
|
113
|
+
Identical on every host by construction.
|
|
114
|
+
"""
|
|
115
|
+
return _PATH_SEPARATORS.split(token.strip('"'))[-1].lower().removesuffix(".exe")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def classify_hook_command(command: Any) -> str | None:
|
|
119
|
+
"""Which shadowable invocation form this hook uses, else `None`.
|
|
120
|
+
|
|
121
|
+
`None` covers three different "not a finding" cases on purpose: the command
|
|
122
|
+
is one of ours (`graphite init` maintains those), it does not resolve to the
|
|
123
|
+
graphite package at all, or it cannot be parsed. Only the invocation HEAD is
|
|
124
|
+
examined -- a command that merely mentions graphite in an argument, like a
|
|
125
|
+
path or an `echo`, is not our business and must not be reported, because a
|
|
126
|
+
check that cries wolf is one nobody reads.
|
|
127
|
+
|
|
128
|
+
Never returns the command text. Callers report the form; these commands are
|
|
129
|
+
mostly absolute path.
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(command, str) or not command.strip():
|
|
132
|
+
return None
|
|
133
|
+
if command.startswith(_OWNED_COMMAND_PREFIXES):
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
# posix=False keeps Windows backslashes intact; the POSIX lexer eats
|
|
137
|
+
# them as escapes and would mangle every real consumer command.
|
|
138
|
+
tokens = shlex.split(command, posix=False)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None # unbalanced quotes: decline to guess
|
|
141
|
+
if not tokens:
|
|
142
|
+
return None
|
|
143
|
+
stem = _executable_stem(tokens[0])
|
|
144
|
+
if stem in _CONSOLE_SCRIPT_STEMS:
|
|
145
|
+
return "console_script"
|
|
146
|
+
if stem not in _INTERPRETER_STEMS:
|
|
147
|
+
return None
|
|
148
|
+
flags = tokens[1:]
|
|
149
|
+
if "-m" not in flags:
|
|
150
|
+
return None
|
|
151
|
+
marker = flags.index("-m")
|
|
152
|
+
module = flags[marker + 1].strip('"') if marker + 1 < len(flags) else ""
|
|
153
|
+
if module.split(".")[0] != "graphite":
|
|
154
|
+
return None
|
|
155
|
+
return None if "-P" in flags[:marker] else "python_m_without_P"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def existing_mode(root: Path) -> str | None:
|
|
159
|
+
settings = _load_settings(root / ".claude" / "settings.json")
|
|
160
|
+
if not settings:
|
|
161
|
+
return None
|
|
162
|
+
hooks = settings.get("hooks")
|
|
163
|
+
groups = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
|
|
164
|
+
for entry in groups if isinstance(groups, list) else []:
|
|
165
|
+
if not isinstance(entry, dict):
|
|
166
|
+
continue
|
|
167
|
+
for hook in entry.get("hooks", []) if isinstance(entry.get("hooks"), list) else []:
|
|
168
|
+
command = str(hook.get("command", "")) if isinstance(hook, dict) else ""
|
|
169
|
+
if command.startswith(_OWNED_COMMAND_PREFIXES) and "pre-tool-use" in command:
|
|
170
|
+
return "strict" if "--mode strict" in command else "remind"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ensure_claude_settings(root: Path, *, mode: str | None = None) -> dict[str, Any]:
|
|
175
|
+
path = root / ".claude" / "settings.json"
|
|
176
|
+
resolved = mode or existing_mode(root) or DEFAULT_MODE
|
|
177
|
+
if resolved not in _MODES:
|
|
178
|
+
raise ValueError(f"unknown agent-hook mode: {resolved}")
|
|
179
|
+
existed = path.exists()
|
|
180
|
+
original = _load_settings(path)
|
|
181
|
+
if original is None:
|
|
182
|
+
return {"path": str(path), "changed": False, "action": "malformed settings", "mode": resolved}
|
|
183
|
+
|
|
184
|
+
hooks_value = original.get("hooks")
|
|
185
|
+
if "hooks" in original and not isinstance(hooks_value, dict):
|
|
186
|
+
# A non-dict "hooks" value is foreign malformed content we must not
|
|
187
|
+
# silently overwrite with {} — treat it like unparseable JSON: no-op.
|
|
188
|
+
return {"path": str(path), "changed": False, "action": "malformed settings", "mode": resolved}
|
|
189
|
+
hooks = dict(hooks_value) if isinstance(hooks_value, dict) else {}
|
|
190
|
+
desired = {
|
|
191
|
+
"PreToolUse": {
|
|
192
|
+
# The shells are matched because the previous matcher, "Grep|Glob",
|
|
193
|
+
# named TOOLS rather than behaviour: `grep -rn ...` through the Bash
|
|
194
|
+
# tool and `Select-String` through the PowerShell tool never reached
|
|
195
|
+
# the hook at all, and consumer agents reported taking exactly those
|
|
196
|
+
# routes for cross-file work. Enforcing on the Grep tool alone
|
|
197
|
+
# enforces a naming convention, not a rule.
|
|
198
|
+
#
|
|
199
|
+
# `agent_hooks.handle_pre_tool_use` returns None for every shell
|
|
200
|
+
# command that is not a repo-wide search, so matching the shells
|
|
201
|
+
# here costs one fail-open hook invocation per command and never
|
|
202
|
+
# interferes with ordinary work.
|
|
203
|
+
"matcher": "Grep|Glob|Bash|PowerShell",
|
|
204
|
+
"hooks": [{"type": "command", "command": _pre_tool_use_command(resolved)}],
|
|
205
|
+
},
|
|
206
|
+
"SessionStart": {
|
|
207
|
+
"hooks": [{"type": "command", "command": _SESSION_START_COMMAND}],
|
|
208
|
+
},
|
|
209
|
+
"Stop": {
|
|
210
|
+
"hooks": [{"type": "command", "command": _STOP_COMMAND}],
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
changed = False
|
|
214
|
+
for event, entry in desired.items():
|
|
215
|
+
groups = _strip_graphite(hooks.get(event))
|
|
216
|
+
groups.append(entry)
|
|
217
|
+
if hooks.get(event) != groups:
|
|
218
|
+
changed = True
|
|
219
|
+
hooks[event] = groups
|
|
220
|
+
|
|
221
|
+
if not changed:
|
|
222
|
+
return {"path": str(path), "changed": False, "action": "already current", "mode": resolved}
|
|
223
|
+
updated = {**original, "hooks": hooks}
|
|
224
|
+
atomic_write_text(path, json.dumps(updated, ensure_ascii=False, indent=2) + "\n")
|
|
225
|
+
action = "updated" if existed else "created"
|
|
226
|
+
return {"path": str(path), "changed": True, "action": action, "mode": resolved}
|
graphite/analyze.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Graph analysis: god nodes, orphans, entry points, cycles."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import networkx as nx
|
|
7
|
+
|
|
8
|
+
from .health import resolution_health
|
|
9
|
+
|
|
10
|
+
_PROJECT_NODE_KINDS = frozenset({"file", "function", "class"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_project_node(g: nx.DiGraph, node: str) -> bool:
|
|
14
|
+
"""Return true for nodes extracted from repository-owned source files."""
|
|
15
|
+
return g.nodes[node].get("kind") in _PROJECT_NODE_KINDS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _project_nodes(g: nx.DiGraph) -> list[str]:
|
|
19
|
+
return [n for n in g.nodes() if _is_project_node(g, n)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _stable_cycles(g: nx.DiGraph, top_n: int) -> list[list[str]]:
|
|
23
|
+
"""Cycles in a fixed order, each rotated to start at its smallest node.
|
|
24
|
+
|
|
25
|
+
`nx.simple_cycles` yields in an order that follows set iteration, which
|
|
26
|
+
`PYTHONHASHSEED` randomizes per process. That is not a cosmetic difference
|
|
27
|
+
once `[:top_n]` truncates the result: two builds of one commit reported a
|
|
28
|
+
*different set* of cycles and so disagreed about which cycles the repository
|
|
29
|
+
contains. Measured on this repository -- `nodes`, `edges` and `metadata`
|
|
30
|
+
were byte-identical between builds while `analysis.cycles` was not.
|
|
31
|
+
|
|
32
|
+
A cycle is also the same cycle under rotation, so one spelling has to be
|
|
33
|
+
chosen or `[a, b, c]` and `[b, c, a]` both appear depending on where the
|
|
34
|
+
traversal entered. Nodes within a simple cycle are distinct, so the minimum
|
|
35
|
+
is unique and rotating to it is canonical. Shortest first, then
|
|
36
|
+
lexicographic, so the truncation keeps the tightest cycles.
|
|
37
|
+
"""
|
|
38
|
+
canonical: list[list[str]] = []
|
|
39
|
+
for cycle in nx.simple_cycles(g):
|
|
40
|
+
pivot = cycle.index(min(cycle))
|
|
41
|
+
canonical.append(cycle[pivot:] + cycle[:pivot])
|
|
42
|
+
canonical.sort(key=lambda cycle: (len(cycle), cycle))
|
|
43
|
+
return canonical[:top_n]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def analyze(g: nx.DiGraph, top_n: int = 20) -> dict[str, Any]:
|
|
47
|
+
"""Run the full analysis suite."""
|
|
48
|
+
project_subgraph = g.subgraph(_project_nodes(g)).copy()
|
|
49
|
+
return {
|
|
50
|
+
"god_nodes": god_nodes(g, top_n),
|
|
51
|
+
"orphans": orphan_nodes(g, top_n),
|
|
52
|
+
"entry_points": entry_points(g, top_n),
|
|
53
|
+
"surprising_connections": surprising_connections(g, top_n),
|
|
54
|
+
"cycles": _stable_cycles(project_subgraph, top_n),
|
|
55
|
+
"top_files_by_links": top_files_by_links(g, top_n),
|
|
56
|
+
"resolution_health": resolution_health(g),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def god_nodes(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
|
|
61
|
+
"""Project-owned nodes with the highest total degree (in + out)."""
|
|
62
|
+
degrees = [(n, g.degree(n)) for n in _project_nodes(g)]
|
|
63
|
+
degrees.sort(key=lambda x: x[1], reverse=True)
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
"id": n,
|
|
67
|
+
"name": g.nodes[n].get("name", n),
|
|
68
|
+
"kind": g.nodes[n].get("kind", "unknown"),
|
|
69
|
+
"degree": deg,
|
|
70
|
+
"in_degree": g.in_degree(n),
|
|
71
|
+
"out_degree": g.out_degree(n),
|
|
72
|
+
}
|
|
73
|
+
for n, deg in degrees[:top_n]
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def orphan_nodes(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
|
|
78
|
+
"""Project-owned nodes with zero connections."""
|
|
79
|
+
orphans = [n for n in _project_nodes(g) if g.degree(n) == 0]
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
"id": n,
|
|
83
|
+
"name": g.nodes[n].get("name", n),
|
|
84
|
+
"kind": g.nodes[n].get("kind", "unknown"),
|
|
85
|
+
}
|
|
86
|
+
for n in orphans[:top_n]
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def entry_points(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
|
|
91
|
+
"""File nodes with many outgoing edges and few incoming edges."""
|
|
92
|
+
file_nodes = [n for n in g.nodes() if g.nodes[n].get("kind") == "file"]
|
|
93
|
+
scored = []
|
|
94
|
+
for n in file_nodes:
|
|
95
|
+
out_deg = g.out_degree(n)
|
|
96
|
+
in_deg = g.in_degree(n)
|
|
97
|
+
if out_deg > 0:
|
|
98
|
+
scored.append((n, out_deg, in_deg, out_deg / max(in_deg, 1)))
|
|
99
|
+
scored.sort(key=lambda x: x[3], reverse=True)
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
"id": n,
|
|
103
|
+
"name": g.nodes[n].get("name", n),
|
|
104
|
+
"out_degree": out_deg,
|
|
105
|
+
"in_degree": in_deg,
|
|
106
|
+
"ratio": ratio,
|
|
107
|
+
}
|
|
108
|
+
for n, out_deg, in_deg, ratio in scored[:top_n]
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def surprising_connections(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
|
|
113
|
+
"""Project-owned edges between nodes that share no common neighbours."""
|
|
114
|
+
scored = []
|
|
115
|
+
for u, v in g.edges():
|
|
116
|
+
if not (_is_project_node(g, u) and _is_project_node(g, v)):
|
|
117
|
+
continue
|
|
118
|
+
common = len(set(g.predecessors(u)).intersection(g.predecessors(v))) + len(
|
|
119
|
+
set(g.successors(u)).intersection(g.successors(v))
|
|
120
|
+
)
|
|
121
|
+
if common == 0:
|
|
122
|
+
scored.append(
|
|
123
|
+
{
|
|
124
|
+
"source": u,
|
|
125
|
+
"target": v,
|
|
126
|
+
"source_kind": g.nodes[u].get("kind", "unknown"),
|
|
127
|
+
"target_kind": g.nodes[v].get("kind", "unknown"),
|
|
128
|
+
"relation": g[u][v].get("relation", "unknown"),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
return scored[:top_n]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def top_files_by_links(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
|
|
135
|
+
"""File nodes ranked by total degree."""
|
|
136
|
+
file_nodes = [n for n in g.nodes() if g.nodes[n].get("kind") == "file"]
|
|
137
|
+
ranked = sorted(file_nodes, key=lambda n: g.degree(n), reverse=True)
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
"id": n,
|
|
141
|
+
"name": g.nodes[n].get("name", n),
|
|
142
|
+
"degree": g.degree(n),
|
|
143
|
+
"source_file": g.nodes[n].get("source_file"),
|
|
144
|
+
}
|
|
145
|
+
for n in ranked[:top_n]
|
|
146
|
+
]
|