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
graphite/cluster.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Community detection using Louvain (and optional Leiden)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import community as community_louvain
|
|
9
|
+
import networkx as nx
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def detect_communities(g: nx.DiGraph, seed: int = 42, weight: str = "weight") -> dict[str, Any]:
|
|
14
|
+
"""Run Louvain on an undirected projection of the graph."""
|
|
15
|
+
# Build deterministic undirected projection manually.
|
|
16
|
+
ug = nx.Graph()
|
|
17
|
+
for n in sorted(g.nodes()):
|
|
18
|
+
ug.add_node(n)
|
|
19
|
+
# Aggregate weights for both directions deterministically.
|
|
20
|
+
edge_weights: dict[tuple[str, str], float] = {}
|
|
21
|
+
for u, v, d in sorted(g.edges(data=True), key=lambda e: (e[0], e[1], e[2].get("relation", ""))):
|
|
22
|
+
key = tuple(sorted((u, v)))
|
|
23
|
+
edge_weights[key] = edge_weights.get(key, 0.0) + d.get("weight", 1.0)
|
|
24
|
+
for (u, v), w in sorted(edge_weights.items()):
|
|
25
|
+
ug.add_edge(u, v, weight=w)
|
|
26
|
+
|
|
27
|
+
partition = community_louvain.best_partition(ug, random_state=seed, weight=weight)
|
|
28
|
+
|
|
29
|
+
communities: dict[int, set[str]] = defaultdict(set)
|
|
30
|
+
for node, comm in partition.items():
|
|
31
|
+
communities[comm].add(node)
|
|
32
|
+
|
|
33
|
+
# Build cluster metadata.
|
|
34
|
+
clusters = []
|
|
35
|
+
for cid, members in sorted(communities.items()):
|
|
36
|
+
file_nodes = [n for n in members if g.nodes[n].get("kind") == "file"]
|
|
37
|
+
func_nodes = [n for n in members if g.nodes[n].get("kind") in ("function", "method")]
|
|
38
|
+
class_nodes = [n for n in members if g.nodes[n].get("kind") == "class"]
|
|
39
|
+
labels = _label_cluster(g, members)
|
|
40
|
+
clusters.append({
|
|
41
|
+
"id": cid,
|
|
42
|
+
"members": sorted(members),
|
|
43
|
+
"size": len(members),
|
|
44
|
+
"file_count": len(file_nodes),
|
|
45
|
+
"function_count": len(func_nodes),
|
|
46
|
+
"class_count": len(class_nodes),
|
|
47
|
+
"labels": labels,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"node_to_community": {k: partition[k] for k in sorted(partition)},
|
|
52
|
+
"clusters": sorted(clusters, key=lambda c: c["size"], reverse=True),
|
|
53
|
+
"count": len(clusters),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _label_cluster(g: nx.DiGraph, members: Iterable[str]) -> list[str]:
|
|
58
|
+
"""Generate zero-LLM cluster labels from shared directory and common node kinds.
|
|
59
|
+
|
|
60
|
+
Iterates in sorted order because the caller passes a `set`, whose order
|
|
61
|
+
`PYTHONHASHSEED` randomizes per process. That reached the artifact: `max()`
|
|
62
|
+
returns the FIRST maximum, so a tie between two kinds resolved to whichever
|
|
63
|
+
the set happened to yield first. Measured -- one cluster's labels were
|
|
64
|
+
`['src/graphite']` in one build of a commit and `['src/graphite',
|
|
65
|
+
'functions']` in the next, because `unknown` and `function` tied and
|
|
66
|
+
`unknown` suppresses the label entirely.
|
|
67
|
+
"""
|
|
68
|
+
labels: list[str] = []
|
|
69
|
+
ordered = sorted(members)
|
|
70
|
+
|
|
71
|
+
# Shared parent directory from file nodes.
|
|
72
|
+
dirs: list[str] = []
|
|
73
|
+
for n in ordered:
|
|
74
|
+
sf = g.nodes[n].get("source_file")
|
|
75
|
+
if sf:
|
|
76
|
+
dirs.append(Path(sf).parent.as_posix())
|
|
77
|
+
if dirs:
|
|
78
|
+
common = _common_prefix(dirs)
|
|
79
|
+
if common and common != ".":
|
|
80
|
+
labels.append(common)
|
|
81
|
+
|
|
82
|
+
# Most common kind, ties broken by name so the winner is a property of the
|
|
83
|
+
# cluster rather than of this process's hash seed.
|
|
84
|
+
kinds: dict[str, int] = defaultdict(int)
|
|
85
|
+
for n in ordered:
|
|
86
|
+
kinds[g.nodes[n].get("kind", "unknown")] += 1
|
|
87
|
+
top_kind = min(kinds.items(), key=lambda kv: (-kv[1], kv[0]))[0]
|
|
88
|
+
if top_kind != "unknown":
|
|
89
|
+
labels.append(f"{top_kind}s")
|
|
90
|
+
|
|
91
|
+
# Dedupe while preserving order.
|
|
92
|
+
seen = set()
|
|
93
|
+
out = []
|
|
94
|
+
for label in labels:
|
|
95
|
+
if label not in seen:
|
|
96
|
+
out.append(label)
|
|
97
|
+
seen.add(label)
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _common_prefix(paths: list[str]) -> str:
|
|
102
|
+
if not paths:
|
|
103
|
+
return ""
|
|
104
|
+
parts = [p.split("/") for p in paths]
|
|
105
|
+
prefix = []
|
|
106
|
+
for segments in zip(*parts):
|
|
107
|
+
if len(set(segments)) == 1:
|
|
108
|
+
prefix.append(segments[0])
|
|
109
|
+
else:
|
|
110
|
+
break
|
|
111
|
+
return "/".join(prefix)
|
graphite/config.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Graphite configuration defaults and environment overrides."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_projects_root() -> Path:
|
|
11
|
+
r"""Base folder for daemon/init defaults: the environment, else the cwd.
|
|
12
|
+
|
|
13
|
+
This used to probe one hardcoded absolute path and return it when that
|
|
14
|
+
directory existed, welding a single machine's drive layout into a
|
|
15
|
+
published tool. The literal is deliberately not repeated here: it would
|
|
16
|
+
ship inside the wheel, which is exactly the defect being removed. Three
|
|
17
|
+
consequences, in rising order of seriousness:
|
|
18
|
+
|
|
19
|
+
* the path reached `--help` text and every README example -- and README is
|
|
20
|
+
the packaging `readme`, so it rendered on PyPI as if it were the default;
|
|
21
|
+
* any user who happens to have that directory would silently get a folder
|
|
22
|
+
that is not theirs, with nothing said about it;
|
|
23
|
+
* `channel_root()` derives from this, so which directory the agent channel
|
|
24
|
+
lived in was decided by whether one drive letter existed.
|
|
25
|
+
|
|
26
|
+
`GRAPHITE_PROJECTS_ROOT` is now the only way to move it, which is what the
|
|
27
|
+
variable was always for. Machines that relied on the old fallback set it
|
|
28
|
+
explicitly -- an operator action, and a visible one, rather than a constant
|
|
29
|
+
in shipped source.
|
|
30
|
+
"""
|
|
31
|
+
env = os.environ.get("GRAPHITE_PROJECTS_ROOT")
|
|
32
|
+
if env:
|
|
33
|
+
return Path(env)
|
|
34
|
+
return Path(".")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
"""Immutable runtime configuration."""
|
|
40
|
+
|
|
41
|
+
output_dir: Path = Path("graph-out")
|
|
42
|
+
cache_dir: Path = Path(".cache/graphite")
|
|
43
|
+
cache_version: str = "v11" # bump on extraction-format changes (v11: destructured hook callables bindable by name; v10: arrow/function-expression definitions bindable by name, new_expression emits calls edges; v9: EXTERNAL_CALL confidence on calls edges; v8: from-package submodule import edges; v7: python import/file-node resolution, import maps, python method dispatch; v6: per-package tsconfig path aliases; v5: resolve JS-extension + ".."-relative imports; v4: full-path node ids, workspace imports, phantom-edge drop)
|
|
44
|
+
workers: int = 4
|
|
45
|
+
max_file_size: int = 1_000_000 # bytes
|
|
46
|
+
max_files: int | None = None
|
|
47
|
+
include_dotfiles: bool = False
|
|
48
|
+
typescript_resolver: str = "auto" # auto | compiler | heuristic | disabled
|
|
49
|
+
typescript_resolver_timeout_seconds: float = 10.0
|
|
50
|
+
typescript_symbol_references: bool = True
|
|
51
|
+
llm_mode: str = "none" # none | auto | local | cloud
|
|
52
|
+
llm_provider: str = "ollama"
|
|
53
|
+
llm_model: str | None = None
|
|
54
|
+
llm_base_url: str | None = None
|
|
55
|
+
llm_api_key: str | None = None
|
|
56
|
+
llm_timeout_seconds: float = 30.0
|
|
57
|
+
llm_max_input_chars: int = 12000
|
|
58
|
+
llm_max_output_tokens: int = 512
|
|
59
|
+
provider_observer_enabled_providers: tuple[str, ...] = ()
|
|
60
|
+
provider_observer_interval_seconds: float = 300.0
|
|
61
|
+
provider_observer_timeout_seconds: float = 15.0
|
|
62
|
+
provider_observer_max_per_cycle: int = 4
|
|
63
|
+
provider_observer_backoff_cap_seconds: float = 3_600.0
|
|
64
|
+
provider_observer_jitter_ratio: float = 0.1
|
|
65
|
+
seed: int = 42
|
|
66
|
+
verbose: bool = False
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
from dataclasses import asdict
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
def routing_settings(self, overrides: dict[str, str] | None = None):
|
|
73
|
+
"""Build isolated routing settings without exporting routing secrets."""
|
|
74
|
+
from .routing.settings import RoutingSettings
|
|
75
|
+
|
|
76
|
+
return RoutingSettings.from_env(overrides)
|
|
77
|
+
|
|
78
|
+
def provider_observer_options(self):
|
|
79
|
+
"""Build validated non-inference daemon observer limits."""
|
|
80
|
+
from .provider_observer import ProviderObserverOptions
|
|
81
|
+
from .routing.lifecycle import LifecycleProviderId
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
providers = tuple(
|
|
85
|
+
LifecycleProviderId(value)
|
|
86
|
+
for value in self.provider_observer_enabled_providers
|
|
87
|
+
)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise ValueError("observer_enabled_providers_invalid") from None
|
|
90
|
+
options = ProviderObserverOptions(
|
|
91
|
+
enabled_providers=providers,
|
|
92
|
+
interval_seconds=self.provider_observer_interval_seconds,
|
|
93
|
+
timeout_seconds=self.provider_observer_timeout_seconds,
|
|
94
|
+
max_observations_per_cycle=self.provider_observer_max_per_cycle,
|
|
95
|
+
backoff_cap_seconds=self.provider_observer_backoff_cap_seconds,
|
|
96
|
+
jitter_ratio=self.provider_observer_jitter_ratio,
|
|
97
|
+
)
|
|
98
|
+
options.validate()
|
|
99
|
+
return options
|
|
100
|
+
|
|
101
|
+
def canonical_graph(self) -> "Config":
|
|
102
|
+
"""Return configuration that cannot carry provider authority into a graph operation."""
|
|
103
|
+
return replace(
|
|
104
|
+
self,
|
|
105
|
+
llm_mode="none",
|
|
106
|
+
llm_provider="none",
|
|
107
|
+
llm_model=None,
|
|
108
|
+
llm_base_url=None,
|
|
109
|
+
llm_api_key=None,
|
|
110
|
+
llm_timeout_seconds=30.0,
|
|
111
|
+
llm_max_input_chars=12_000,
|
|
112
|
+
llm_max_output_tokens=512,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_env(
|
|
117
|
+
cls,
|
|
118
|
+
overrides: dict[str, str] | None = None,
|
|
119
|
+
*,
|
|
120
|
+
include_llm: bool = True,
|
|
121
|
+
) -> "Config":
|
|
122
|
+
"""Build config from environment variables and CLI overrides."""
|
|
123
|
+
if not isinstance(include_llm, bool):
|
|
124
|
+
raise ValueError("include_llm_invalid")
|
|
125
|
+
env: dict[str, str] = {}
|
|
126
|
+
for key in os.environ:
|
|
127
|
+
normalized = key.upper()
|
|
128
|
+
if not normalized.startswith("GRAPHITE_"):
|
|
129
|
+
continue
|
|
130
|
+
if not include_llm and normalized.startswith("GRAPHITE_LLM"):
|
|
131
|
+
continue
|
|
132
|
+
env[key.lower()] = os.environ[key]
|
|
133
|
+
if overrides:
|
|
134
|
+
env.update({
|
|
135
|
+
key.lower(): value
|
|
136
|
+
for key, value in overrides.items()
|
|
137
|
+
if include_llm or not key.upper().startswith("GRAPHITE_LLM")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
def _path(key: str, default: Path) -> Path:
|
|
141
|
+
return Path(env[key]) if key in env else default
|
|
142
|
+
|
|
143
|
+
def _int(key: str, default: int) -> int:
|
|
144
|
+
return int(env[key]) if key in env and env[key].isdigit() else default
|
|
145
|
+
|
|
146
|
+
def _bounded_int(key: str, default: int, minimum: int, maximum: int) -> int:
|
|
147
|
+
return min(max(_int(key, default), minimum), maximum)
|
|
148
|
+
|
|
149
|
+
def _opt_int(key: str, default: int | None) -> int | None:
|
|
150
|
+
if key in env and env[key].isdigit():
|
|
151
|
+
return int(env[key])
|
|
152
|
+
return default
|
|
153
|
+
|
|
154
|
+
def _float(key: str, default: float) -> float:
|
|
155
|
+
try:
|
|
156
|
+
return float(env[key]) if key in env else default
|
|
157
|
+
except ValueError:
|
|
158
|
+
return default
|
|
159
|
+
|
|
160
|
+
def _bool(key: str, default: bool) -> bool:
|
|
161
|
+
return env.get(key, str(default).lower()).lower() in ("1", "true", "yes", "on")
|
|
162
|
+
|
|
163
|
+
def _csv(key: str) -> tuple[str, ...]:
|
|
164
|
+
raw = env.get(key, "")
|
|
165
|
+
return tuple(part.strip().casefold() for part in raw.split(",") if part.strip())
|
|
166
|
+
|
|
167
|
+
config = cls(
|
|
168
|
+
output_dir=_path("graphite_output_dir", Path("graph-out")),
|
|
169
|
+
cache_dir=_path("graphite_cache_dir", Path(".cache/graphite")),
|
|
170
|
+
cache_version=env.get("graphite_cache_version", "v11"),
|
|
171
|
+
workers=_int("graphite_workers", 4),
|
|
172
|
+
max_file_size=_int("graphite_max_file_size", 1_000_000),
|
|
173
|
+
max_files=_opt_int("graphite_max_files", None),
|
|
174
|
+
include_dotfiles=_bool("graphite_include_dotfiles", False),
|
|
175
|
+
typescript_resolver=env.get("graphite_typescript_resolver", "auto"),
|
|
176
|
+
typescript_resolver_timeout_seconds=_float("graphite_typescript_resolver_timeout", 10.0),
|
|
177
|
+
typescript_symbol_references=_bool("graphite_typescript_symbol_references", True),
|
|
178
|
+
llm_mode=env.get("graphite_llm", "none"),
|
|
179
|
+
llm_provider=env.get("graphite_llm_provider", "ollama"),
|
|
180
|
+
llm_model=env.get("graphite_llm_model"),
|
|
181
|
+
llm_base_url=env.get("graphite_llm_base_url"),
|
|
182
|
+
llm_api_key=env.get("graphite_llm_api_key"),
|
|
183
|
+
llm_timeout_seconds=_float("graphite_llm_timeout", 30.0),
|
|
184
|
+
llm_max_input_chars=_int("graphite_llm_max_input_chars", 12000),
|
|
185
|
+
llm_max_output_tokens=_bounded_int(
|
|
186
|
+
"graphite_llm_max_output_tokens", 512, 1, 4096
|
|
187
|
+
),
|
|
188
|
+
provider_observer_enabled_providers=_csv(
|
|
189
|
+
"graphite_provider_observer_enabled_providers"
|
|
190
|
+
),
|
|
191
|
+
provider_observer_interval_seconds=_float(
|
|
192
|
+
"graphite_provider_observer_interval", 300.0
|
|
193
|
+
),
|
|
194
|
+
provider_observer_timeout_seconds=_float(
|
|
195
|
+
"graphite_provider_observer_timeout", 15.0
|
|
196
|
+
),
|
|
197
|
+
provider_observer_max_per_cycle=_int(
|
|
198
|
+
"graphite_provider_observer_max_per_cycle", 4
|
|
199
|
+
),
|
|
200
|
+
provider_observer_backoff_cap_seconds=_float(
|
|
201
|
+
"graphite_provider_observer_backoff_cap", 3_600.0
|
|
202
|
+
),
|
|
203
|
+
provider_observer_jitter_ratio=_float(
|
|
204
|
+
"graphite_provider_observer_jitter_ratio", 0.1
|
|
205
|
+
),
|
|
206
|
+
seed=_int("graphite_seed", 42),
|
|
207
|
+
verbose=_bool("graphite_verbose", False),
|
|
208
|
+
)
|
|
209
|
+
return config if include_llm else config.canonical_graph()
|
graphite/context.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Agent-focused compact graph context for selected files or nodes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from .answer_contract import (
|
|
10
|
+
GRADE_INCONCLUSIVE,
|
|
11
|
+
build_answer_block,
|
|
12
|
+
empty_marker,
|
|
13
|
+
is_degraded,
|
|
14
|
+
is_unmeasured,
|
|
15
|
+
languages_for_nodes,
|
|
16
|
+
)
|
|
17
|
+
from .health import ratio_percent, resolution_health
|
|
18
|
+
from .listing import listing_lines
|
|
19
|
+
from .query import _find_node_detail
|
|
20
|
+
|
|
21
|
+
_CONTEXT_LIST_CAP = 30
|
|
22
|
+
|
|
23
|
+
_TEST_SUFFIXES = (
|
|
24
|
+
".test.ts",
|
|
25
|
+
".spec.ts",
|
|
26
|
+
".test.tsx",
|
|
27
|
+
".spec.tsx",
|
|
28
|
+
".test.js",
|
|
29
|
+
".spec.js",
|
|
30
|
+
".test.py",
|
|
31
|
+
".spec.py",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_context(
|
|
36
|
+
g: nx.DiGraph,
|
|
37
|
+
inputs: list[str],
|
|
38
|
+
*,
|
|
39
|
+
depth: int = 2,
|
|
40
|
+
neighbor_limit: int = 20,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Build compact context for code agents without dumping the full graph."""
|
|
43
|
+
if depth < 0:
|
|
44
|
+
raise ValueError("context depth must be zero or greater")
|
|
45
|
+
if neighbor_limit <= 0:
|
|
46
|
+
raise ValueError("context neighbor limit must be greater than zero")
|
|
47
|
+
|
|
48
|
+
matched: list[dict[str, Any]] = []
|
|
49
|
+
missing: list[str] = []
|
|
50
|
+
start_nodes: list[str] = []
|
|
51
|
+
for item in inputs:
|
|
52
|
+
detail = _find_node_detail(g, item)
|
|
53
|
+
if detail:
|
|
54
|
+
node, match_type, alternates = detail
|
|
55
|
+
start_nodes.append(node)
|
|
56
|
+
entry: dict[str, Any] = {"input": item, "node": _node_summary(g, node), "match_type": match_type}
|
|
57
|
+
if alternates:
|
|
58
|
+
entry["alternates"] = alternates
|
|
59
|
+
matched.append(entry)
|
|
60
|
+
else:
|
|
61
|
+
missing.append(item)
|
|
62
|
+
|
|
63
|
+
impact = _reverse_impact(g, start_nodes, depth)
|
|
64
|
+
dependency_pairs = {node: _neighbors(g, node, outgoing=True, limit=neighbor_limit) for node in start_nodes}
|
|
65
|
+
dependent_pairs = {node: _neighbors(g, node, outgoing=False, limit=neighbor_limit) for node in start_nodes}
|
|
66
|
+
direct_dependencies = {node: items for node, (items, _) in dependency_pairs.items()}
|
|
67
|
+
direct_dependents = {node: items for node, (items, _) in dependent_pairs.items()}
|
|
68
|
+
neighbor_totals = {
|
|
69
|
+
"direct_dependencies": {node: total for node, (_, total) in dependency_pairs.items()},
|
|
70
|
+
"direct_dependents": {node: total for node, (_, total) in dependent_pairs.items()},
|
|
71
|
+
}
|
|
72
|
+
communities = _community_peers(g, start_nodes, limit=neighbor_limit)
|
|
73
|
+
risk = [_risk_summary(g, node) for node in start_nodes]
|
|
74
|
+
|
|
75
|
+
health = resolution_health(g)
|
|
76
|
+
total = len(impact["impacted_files"]) + len(impact["likely_tests"])
|
|
77
|
+
matched_languages = languages_for_nodes(g, start_nodes)
|
|
78
|
+
try:
|
|
79
|
+
block = build_answer_block(
|
|
80
|
+
g,
|
|
81
|
+
relations=("calls", "imports"),
|
|
82
|
+
languages=matched_languages,
|
|
83
|
+
total=total,
|
|
84
|
+
empty_meaning="no impacted files or tests reachable through bound edges",
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
block = None
|
|
88
|
+
if block is not None:
|
|
89
|
+
inconclusive = block["grade"] == GRADE_INCONCLUSIVE
|
|
90
|
+
elif start_nodes and not matched_languages:
|
|
91
|
+
# Matched real nodes, but none have an applicable code language (e.g.
|
|
92
|
+
# markdown/config) -- nothing to grade, not a resolution gap.
|
|
93
|
+
inconclusive = False
|
|
94
|
+
else:
|
|
95
|
+
inconclusive = (
|
|
96
|
+
not impact["impacted_files"]
|
|
97
|
+
and not impact["likely_tests"]
|
|
98
|
+
and not health["healthy"]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
result: dict[str, Any] = {
|
|
102
|
+
"metadata": {
|
|
103
|
+
"node_count": g.number_of_nodes(),
|
|
104
|
+
"edge_count": g.number_of_edges(),
|
|
105
|
+
"density": nx.density(g),
|
|
106
|
+
},
|
|
107
|
+
"inputs": inputs,
|
|
108
|
+
"matched": matched,
|
|
109
|
+
"missing": missing,
|
|
110
|
+
"depth": depth,
|
|
111
|
+
"direct_dependencies": direct_dependencies,
|
|
112
|
+
"direct_dependents": direct_dependents,
|
|
113
|
+
"neighbor_totals": neighbor_totals,
|
|
114
|
+
"impact": impact,
|
|
115
|
+
"communities": communities,
|
|
116
|
+
"risk": risk,
|
|
117
|
+
"resolution_health": health,
|
|
118
|
+
"inconclusive": inconclusive,
|
|
119
|
+
}
|
|
120
|
+
if block is not None:
|
|
121
|
+
result["answer"] = block
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_context_markdown(context: dict[str, Any]) -> str:
|
|
126
|
+
"""Render compact context as Markdown for humans and agents."""
|
|
127
|
+
meta = context["metadata"]
|
|
128
|
+
lines = [
|
|
129
|
+
"# Graphite Context",
|
|
130
|
+
"",
|
|
131
|
+
f"- Nodes: {meta['node_count']}",
|
|
132
|
+
f"- Edges: {meta['edge_count']}",
|
|
133
|
+
f"- Depth: {context['depth']}",
|
|
134
|
+
]
|
|
135
|
+
if context["missing"]:
|
|
136
|
+
lines.append(f"- Missing inputs: {', '.join(context['missing'])}")
|
|
137
|
+
|
|
138
|
+
lines.extend(["", "## Matched"])
|
|
139
|
+
for item in context["matched"]:
|
|
140
|
+
node = item["node"]
|
|
141
|
+
lines.append(
|
|
142
|
+
f"- `{item['input']}` -> `{node['id']}` "
|
|
143
|
+
f"({node['kind']}, in={node['in_degree']}, out={node['out_degree']})"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
lines.extend(["", "## Impact"])
|
|
147
|
+
impact = context["impact"]
|
|
148
|
+
health = context.get("resolution_health") or {}
|
|
149
|
+
unhealthy = health.get("healthy") is False
|
|
150
|
+
answer = context.get("answer")
|
|
151
|
+
if impact["impacted_files"] or impact["likely_tests"]:
|
|
152
|
+
marker = empty_marker(answer)
|
|
153
|
+
lines.extend(
|
|
154
|
+
listing_lines(
|
|
155
|
+
impact["impacted_files"],
|
|
156
|
+
lambda path: f"`{path}`",
|
|
157
|
+
header="Impacted files:",
|
|
158
|
+
cap=_CONTEXT_LIST_CAP,
|
|
159
|
+
indent="",
|
|
160
|
+
empty=marker,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
elif context.get("inconclusive"):
|
|
164
|
+
if answer:
|
|
165
|
+
meaning = answer.get(
|
|
166
|
+
"empty_meaning", "no impacted files or tests reachable through bound edges"
|
|
167
|
+
)
|
|
168
|
+
lines.append(
|
|
169
|
+
f"Impacted files: none found — INCONCLUSIVE: {meaning}; "
|
|
170
|
+
"treat as unverified and confirm with grep."
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
lines.append(
|
|
174
|
+
"Impacted files: none found — INCONCLUSIVE: only "
|
|
175
|
+
f"{ratio_percent(health, 'imports')} of import edges and "
|
|
176
|
+
f"{ratio_percent(health, 'calls')} of call edges resolved in this "
|
|
177
|
+
"graph; treat as unverified and confirm with grep."
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
answer_meaning = (answer or {}).get("empty_meaning")
|
|
181
|
+
if answer_meaning:
|
|
182
|
+
lines.append(f"Impacted files: none found — {answer_meaning}")
|
|
183
|
+
else:
|
|
184
|
+
lines.append("Impacted files: none found")
|
|
185
|
+
if impact["impacted_files"] or impact["likely_tests"]:
|
|
186
|
+
lines.extend(
|
|
187
|
+
listing_lines(
|
|
188
|
+
impact["likely_tests"],
|
|
189
|
+
lambda path: f"`{path}`",
|
|
190
|
+
header="Likely tests:",
|
|
191
|
+
cap=_CONTEXT_LIST_CAP,
|
|
192
|
+
indent="",
|
|
193
|
+
empty=empty_marker(answer),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
if unhealthy and (impact["impacted_files"] or impact["likely_tests"]):
|
|
197
|
+
lines.append(
|
|
198
|
+
f"note: resolution health low (imports {ratio_percent(health, 'imports')}, "
|
|
199
|
+
f"calls {ratio_percent(health, 'calls')}) — this list may be incomplete."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
empty = not impact["impacted_files"] and not impact["likely_tests"]
|
|
203
|
+
if answer:
|
|
204
|
+
if empty or is_degraded(answer) or is_unmeasured(answer):
|
|
205
|
+
cells = ", ".join(
|
|
206
|
+
f"{relation} ({language}) {langs[language]['ratio']:.2f}"
|
|
207
|
+
for relation, langs in sorted(answer.get("health", {}).items())
|
|
208
|
+
for language in sorted(langs)
|
|
209
|
+
)
|
|
210
|
+
grade = answer["grade"].replace("_", "-")
|
|
211
|
+
line = f"answer health: {cells} — {grade}" if cells else f"answer health: — {grade}"
|
|
212
|
+
lines.append(line)
|
|
213
|
+
if answer.get("caveats"):
|
|
214
|
+
lines.append("known limits: " + "; ".join(c["summary"] for c in answer["caveats"]))
|
|
215
|
+
|
|
216
|
+
lines.extend(["", "## Direct Dependents"])
|
|
217
|
+
# Guarded read: contexts built before neighbor_totals existed still render.
|
|
218
|
+
totals = context.get("neighbor_totals") or {}
|
|
219
|
+
dependents = context["direct_dependents"]
|
|
220
|
+
if unhealthy and all(not neighbors for neighbors in dependents.values()):
|
|
221
|
+
lines.append("no direct dependents found — inconclusive (resolution health low)")
|
|
222
|
+
else:
|
|
223
|
+
_append_neighbor_section(lines, dependents, totals.get("direct_dependents"))
|
|
224
|
+
|
|
225
|
+
lines.extend(["", "## Direct Dependencies"])
|
|
226
|
+
_append_neighbor_section(lines, context["direct_dependencies"], totals.get("direct_dependencies"))
|
|
227
|
+
|
|
228
|
+
lines.extend(["", "## Risk Signals"])
|
|
229
|
+
for item in context["risk"]:
|
|
230
|
+
flags = ", ".join(item["flags"]) if item["flags"] else "none"
|
|
231
|
+
lines.append(f"- `{item['id']}`: {flags}")
|
|
232
|
+
|
|
233
|
+
return "\n".join(lines) + "\n"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _append_neighbor_section(
|
|
237
|
+
lines: list[str],
|
|
238
|
+
groups: dict[str, list[dict[str, Any]]],
|
|
239
|
+
totals: dict[str, int] | None = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
if not groups:
|
|
242
|
+
lines.append("none")
|
|
243
|
+
return
|
|
244
|
+
for node, neighbors in groups.items():
|
|
245
|
+
lines.append(f"- `{node}`")
|
|
246
|
+
if not neighbors:
|
|
247
|
+
lines.append(" - none")
|
|
248
|
+
continue
|
|
249
|
+
# No second cap here: build_context already applied neighbor_limit. A
|
|
250
|
+
# slice at this layer silently overrode the user's explicit flag (#11).
|
|
251
|
+
for neighbor in neighbors:
|
|
252
|
+
sf = f" [{neighbor['source_file']}]" if neighbor.get("source_file") else ""
|
|
253
|
+
lines.append(f" - `{neighbor['id']}` ({neighbor['kind']}){sf}")
|
|
254
|
+
hidden = (totals or {}).get(node, len(neighbors)) - len(neighbors)
|
|
255
|
+
if hidden > 0:
|
|
256
|
+
lines.append(f" - ... {hidden} more")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _node_summary(g: nx.DiGraph, node: str) -> dict[str, Any]:
|
|
260
|
+
attrs = g.nodes[node]
|
|
261
|
+
return {
|
|
262
|
+
"id": node,
|
|
263
|
+
"name": attrs.get("name", node),
|
|
264
|
+
"kind": attrs.get("kind", "unknown"),
|
|
265
|
+
"source_file": attrs.get("source_file"),
|
|
266
|
+
"community": attrs.get("community"),
|
|
267
|
+
"in_degree": g.in_degree(node),
|
|
268
|
+
"out_degree": g.out_degree(node),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _neighbors(
|
|
273
|
+
g: nx.DiGraph, node: str, *, outgoing: bool, limit: int
|
|
274
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
275
|
+
"""Capped neighbour summaries, and the uncapped total they were taken from.
|
|
276
|
+
|
|
277
|
+
The total is what lets a consumer tell a complete list from a truncated one;
|
|
278
|
+
without it a list exactly `limit` long is ambiguous.
|
|
279
|
+
"""
|
|
280
|
+
ids = sorted(g.successors(node) if outgoing else g.predecessors(node))
|
|
281
|
+
return [_node_summary(g, neighbor) for neighbor in ids[:limit]], len(ids)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _reverse_impact(g: nx.DiGraph, start_nodes: list[str], depth: int) -> dict[str, Any]:
|
|
285
|
+
visited: set[str] = set(start_nodes)
|
|
286
|
+
queue: deque[tuple[str, int]] = deque((node, 0) for node in start_nodes)
|
|
287
|
+
impacted_nodes: set[str] = set()
|
|
288
|
+
while queue:
|
|
289
|
+
node, dist = queue.popleft()
|
|
290
|
+
if dist >= depth:
|
|
291
|
+
continue
|
|
292
|
+
for pred in sorted(g.predecessors(node)):
|
|
293
|
+
if pred in visited:
|
|
294
|
+
continue
|
|
295
|
+
visited.add(pred)
|
|
296
|
+
impacted_nodes.add(pred)
|
|
297
|
+
queue.append((pred, dist + 1))
|
|
298
|
+
|
|
299
|
+
impacted_files: set[str] = set()
|
|
300
|
+
likely_tests: set[str] = set()
|
|
301
|
+
for node in impacted_nodes.union(start_nodes):
|
|
302
|
+
source_file = g.nodes[node].get("source_file")
|
|
303
|
+
if not source_file:
|
|
304
|
+
continue
|
|
305
|
+
if _is_test_file(source_file):
|
|
306
|
+
likely_tests.add(source_file)
|
|
307
|
+
elif node not in start_nodes:
|
|
308
|
+
impacted_files.add(source_file)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"matched_nodes": sorted(start_nodes),
|
|
312
|
+
"impacted_files": sorted(impacted_files),
|
|
313
|
+
"likely_tests": sorted(likely_tests),
|
|
314
|
+
"impacted_node_count": len(impacted_nodes),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _community_peers(g: nx.DiGraph, start_nodes: list[str], *, limit: int) -> dict[str, Any]:
|
|
319
|
+
result: dict[str, Any] = {}
|
|
320
|
+
for node in start_nodes:
|
|
321
|
+
community = g.nodes[node].get("community")
|
|
322
|
+
if community is None:
|
|
323
|
+
continue
|
|
324
|
+
peers = [n for n in g.nodes if n != node and g.nodes[n].get("community") == community]
|
|
325
|
+
result[node] = {
|
|
326
|
+
"community": community,
|
|
327
|
+
"peer_count": len(peers),
|
|
328
|
+
"sample": [_node_summary(g, peer) for peer in sorted(peers)[:limit]],
|
|
329
|
+
}
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _risk_summary(g: nx.DiGraph, node: str) -> dict[str, Any]:
|
|
334
|
+
in_degree = g.in_degree(node)
|
|
335
|
+
out_degree = g.out_degree(node)
|
|
336
|
+
flags: list[str] = []
|
|
337
|
+
if in_degree >= 10:
|
|
338
|
+
flags.append("high fan-in: many files depend on this")
|
|
339
|
+
if out_degree >= 25:
|
|
340
|
+
flags.append("high fan-out: broad dependency surface")
|
|
341
|
+
if in_degree == 0:
|
|
342
|
+
flags.append("no direct dependents found")
|
|
343
|
+
if out_degree == 0:
|
|
344
|
+
flags.append("no direct dependencies found")
|
|
345
|
+
return {
|
|
346
|
+
"id": node,
|
|
347
|
+
"in_degree": in_degree,
|
|
348
|
+
"out_degree": out_degree,
|
|
349
|
+
"flags": flags,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _is_test_file(path: str) -> bool:
|
|
354
|
+
normalized = path.replace("\\", "/")
|
|
355
|
+
return "/tests/" in f"/{normalized}" or normalized.endswith(_TEST_SUFFIXES)
|