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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. 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)