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/review.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Safe collection of explicit and Git-reported review changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
from .context import build_context
|
|
11
|
+
from .graph import graph_from_json
|
|
12
|
+
from .git import (
|
|
13
|
+
GitLaunchError,
|
|
14
|
+
GitOutputLimitError,
|
|
15
|
+
GitRunner,
|
|
16
|
+
GitTimeoutError,
|
|
17
|
+
GitUnavailableError,
|
|
18
|
+
GitUnsupportedVersionError,
|
|
19
|
+
git_version_failure_message,
|
|
20
|
+
)
|
|
21
|
+
from .validation import validate_graph_bundle
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReviewError(ValueError):
|
|
25
|
+
"""Raised when review change evidence cannot be collected safely."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MAX_GIT_STATUS_RECORDS = 100_000
|
|
29
|
+
MAX_REVIEW_PACKET_ITEMS = 10_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, order=True)
|
|
33
|
+
class Change:
|
|
34
|
+
"""A project-relative changed path and its normalized status."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
status: str
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, str]:
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_explicit_changes(root: Path, paths: Iterable[str]) -> list[Change]:
|
|
44
|
+
"""Normalize user-supplied paths while containing them within *root*."""
|
|
45
|
+
resolved_root = root.resolve()
|
|
46
|
+
normalized: set[str] = set()
|
|
47
|
+
|
|
48
|
+
for raw_path in paths:
|
|
49
|
+
candidate = Path(raw_path)
|
|
50
|
+
if not candidate.is_absolute():
|
|
51
|
+
candidate = resolved_root / candidate
|
|
52
|
+
resolved_path = candidate.resolve()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
relative_path = resolved_path.relative_to(resolved_root)
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise ReviewError("path is outside project root") from exc
|
|
58
|
+
|
|
59
|
+
if relative_path == Path("."):
|
|
60
|
+
raise ReviewError("a change path cannot be the project root")
|
|
61
|
+
normalized.add(relative_path.as_posix())
|
|
62
|
+
|
|
63
|
+
return [Change(path, "explicit") for path in sorted(normalized)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover_git_changes(root: Path, *, timeout_seconds: float = 5.0) -> list[Change]:
|
|
67
|
+
"""Collect normalized changes from Git porcelain output."""
|
|
68
|
+
if timeout_seconds <= 0:
|
|
69
|
+
raise ReviewError("Git status timeout must be greater than zero")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
resolved_root = root.resolve()
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise ReviewError("unable to resolve project path") from exc
|
|
75
|
+
|
|
76
|
+
runner = _review_git_runner(resolved_root)
|
|
77
|
+
top_level_result = _run_review_git(
|
|
78
|
+
runner, ["rev-parse", "--show-toplevel"], timeout_seconds
|
|
79
|
+
)
|
|
80
|
+
if top_level_result.returncode != 0:
|
|
81
|
+
raise ReviewError("not a Git worktree")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
decoded_git_root = top_level_result.stdout.decode("utf-8")
|
|
85
|
+
except UnicodeDecodeError as exc:
|
|
86
|
+
raise ReviewError("unable to decode Git worktree root") from exc
|
|
87
|
+
try:
|
|
88
|
+
git_root = Path(decoded_git_root.rstrip("\r\n")).resolve()
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise ReviewError("unable to resolve Git worktree root") from exc
|
|
91
|
+
if not top_level_result.stdout or git_root != resolved_root:
|
|
92
|
+
raise ReviewError("project path must be the Git worktree root")
|
|
93
|
+
|
|
94
|
+
status_result = _run_review_git(
|
|
95
|
+
runner,
|
|
96
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
97
|
+
timeout_seconds,
|
|
98
|
+
)
|
|
99
|
+
if status_result.returncode != 0:
|
|
100
|
+
raise ReviewError("not a Git worktree")
|
|
101
|
+
|
|
102
|
+
return _parse_porcelain(status_result.stdout)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _review_git_runner(root: Path) -> GitRunner:
|
|
106
|
+
try:
|
|
107
|
+
return GitRunner(root)
|
|
108
|
+
except GitUnavailableError as exc:
|
|
109
|
+
raise ReviewError("Git executable was not found") from exc
|
|
110
|
+
except GitLaunchError as exc:
|
|
111
|
+
raise ReviewError("unable to run Git command") from exc
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_review_git(
|
|
115
|
+
runner: GitRunner, arguments: list[str], timeout_seconds: float
|
|
116
|
+
):
|
|
117
|
+
try:
|
|
118
|
+
return runner.run(arguments, timeout_seconds=timeout_seconds)
|
|
119
|
+
except GitUnsupportedVersionError as exc:
|
|
120
|
+
# Looked up from `reason`, never taken from `str(exc)`: the message on
|
|
121
|
+
# the exception could carry Git's own output, and keeping that off a
|
|
122
|
+
# user's terminal is what the hardcoded literal was protecting. The
|
|
123
|
+
# literal also asserted a version requirement for two causes where no
|
|
124
|
+
# version was ever read -- sanitized and wrong at the same time.
|
|
125
|
+
raise ReviewError(git_version_failure_message(exc.reason)) from None
|
|
126
|
+
except GitUnavailableError as exc:
|
|
127
|
+
raise ReviewError("Git executable was not found") from exc
|
|
128
|
+
except GitTimeoutError as exc:
|
|
129
|
+
raise ReviewError("Git command timeout") from exc
|
|
130
|
+
except GitOutputLimitError as exc:
|
|
131
|
+
raise ReviewError("Git command output limit exceeded") from exc
|
|
132
|
+
except GitLaunchError as exc:
|
|
133
|
+
raise ReviewError("unable to run Git command") from exc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_porcelain(
|
|
137
|
+
output: bytes, *, max_records: int = MAX_GIT_STATUS_RECORDS
|
|
138
|
+
) -> list[Change]:
|
|
139
|
+
if not output:
|
|
140
|
+
return []
|
|
141
|
+
if not output.endswith(b"\0"):
|
|
142
|
+
raise ReviewError("malformed Git status output: missing NUL terminator")
|
|
143
|
+
if output.count(b"\0") > max_records:
|
|
144
|
+
raise ReviewError("Git status record limit exceeded")
|
|
145
|
+
|
|
146
|
+
records = output[:-1].split(b"\0")
|
|
147
|
+
changes: dict[str, Change] = {}
|
|
148
|
+
index = 0
|
|
149
|
+
while index < len(records):
|
|
150
|
+
record = records[index]
|
|
151
|
+
if len(record) < 4 or record[2:3] != b" " or not record[3:]:
|
|
152
|
+
raise ReviewError("malformed Git status output")
|
|
153
|
+
|
|
154
|
+
status_bytes = record[:2]
|
|
155
|
+
if status_bytes != b"??" and (
|
|
156
|
+
status_bytes == b" " or any(value not in b" MADRCUT" for value in status_bytes)
|
|
157
|
+
):
|
|
158
|
+
raise ReviewError("malformed Git status output: invalid status")
|
|
159
|
+
|
|
160
|
+
is_rename = b"R" in status_bytes or b"C" in status_bytes
|
|
161
|
+
if is_rename:
|
|
162
|
+
index += 1
|
|
163
|
+
if index >= len(records) or not records[index]:
|
|
164
|
+
raise ReviewError("malformed Git status output: missing rename source")
|
|
165
|
+
_decode_git_path(records[index])
|
|
166
|
+
|
|
167
|
+
path = _decode_git_path(record[3:])
|
|
168
|
+
status = _normalize_status(status_bytes)
|
|
169
|
+
change = Change(path, status)
|
|
170
|
+
previous = changes.get(path)
|
|
171
|
+
if previous is not None and previous != change:
|
|
172
|
+
raise ReviewError("Git returned conflicting status data")
|
|
173
|
+
changes[path] = change
|
|
174
|
+
index += 1
|
|
175
|
+
|
|
176
|
+
return sorted(changes.values())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _normalize_status(status: bytes) -> str:
|
|
180
|
+
if status == b"??":
|
|
181
|
+
return "untracked"
|
|
182
|
+
if b"D" in status:
|
|
183
|
+
return "deleted"
|
|
184
|
+
if b"A" in status:
|
|
185
|
+
return "added"
|
|
186
|
+
if b"R" in status or b"C" in status:
|
|
187
|
+
return "renamed"
|
|
188
|
+
return "modified"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _decode_git_path(value: bytes) -> str:
|
|
192
|
+
try:
|
|
193
|
+
path = value.decode("utf-8")
|
|
194
|
+
except UnicodeDecodeError as exc:
|
|
195
|
+
raise ReviewError("malformed Git status output: invalid path encoding") from exc
|
|
196
|
+
|
|
197
|
+
posix_path = PurePosixPath(path)
|
|
198
|
+
if not path or path == "." or posix_path.is_absolute() or ".." in posix_path.parts:
|
|
199
|
+
raise ReviewError("unsafe path in Git status output")
|
|
200
|
+
return path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_RISK_ORDER = (
|
|
204
|
+
"GRAPH_STALE",
|
|
205
|
+
"DELETED_FILES",
|
|
206
|
+
"SENSITIVE_CONFIG",
|
|
207
|
+
"BROAD_IMPACT",
|
|
208
|
+
"MISSING_GRAPH_MATCHES",
|
|
209
|
+
"NO_LIKELY_TESTS",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
_CHANGE_STATUSES = frozenset(
|
|
213
|
+
{"explicit", "untracked", "deleted", "added", "renamed", "modified"}
|
|
214
|
+
)
|
|
215
|
+
_DISCOVERY_MODES = frozenset({"explicit", "git"})
|
|
216
|
+
_UNSAFE_UNICODE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
|
|
217
|
+
|
|
218
|
+
_CRITERIA = {
|
|
219
|
+
"CONFIRM_CLEAN": {
|
|
220
|
+
"description": "Confirm that there are no changes in the requested review scope.",
|
|
221
|
+
"verify": "Re-run change discovery and confirm the result is empty.",
|
|
222
|
+
},
|
|
223
|
+
"REVIEW_SCOPE": {
|
|
224
|
+
"description": "Review every changed path in the requested scope.",
|
|
225
|
+
"verify": "Confirm each listed change has been inspected.",
|
|
226
|
+
},
|
|
227
|
+
"REFRESH_GRAPH": {
|
|
228
|
+
"description": "Refresh the stale dependency graph before relying on graph evidence.",
|
|
229
|
+
"verify": "Rebuild the graph and confirm its status is current.",
|
|
230
|
+
},
|
|
231
|
+
"REVIEW_IMPACT": {
|
|
232
|
+
"description": "Review the files identified as potentially impacted.",
|
|
233
|
+
"verify": "Confirm the impacted files remain compatible with the changes.",
|
|
234
|
+
},
|
|
235
|
+
"RUN_LIKELY_TESTS": {
|
|
236
|
+
"description": "Run the tests identified by dependency evidence.",
|
|
237
|
+
"verify": "Record successful results for every listed likely test.",
|
|
238
|
+
},
|
|
239
|
+
"ADD_TEST_PLAN": {
|
|
240
|
+
"description": "Define test coverage because no likely tests were identified.",
|
|
241
|
+
"verify": "Document and execute an appropriate test plan for the changes.",
|
|
242
|
+
},
|
|
243
|
+
"REVIEW_DELETIONS": {
|
|
244
|
+
"description": "Review deleted files and their consumers for intentional removal.",
|
|
245
|
+
"verify": "Confirm each deletion is intentional and leaves no broken references.",
|
|
246
|
+
},
|
|
247
|
+
"VERIFY_CONFIG": {
|
|
248
|
+
"description": "Verify changes to sensitive build, package, or workflow configuration.",
|
|
249
|
+
"verify": "Validate configuration and dependency integrity in a clean environment.",
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def build_review_packet(
|
|
255
|
+
*,
|
|
256
|
+
root_name: str,
|
|
257
|
+
changes: list[Change],
|
|
258
|
+
discovery: str,
|
|
259
|
+
graph_bundle: dict[str, Any] | None,
|
|
260
|
+
graph_status: dict[str, Any],
|
|
261
|
+
depth: int,
|
|
262
|
+
graph_error: str | None = None,
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
"""Build deterministic, bounded evidence for a change review."""
|
|
265
|
+
del graph_error # Exception details are deliberately never copied into review evidence.
|
|
266
|
+
if depth < 0:
|
|
267
|
+
raise ReviewError("review depth must be zero or greater")
|
|
268
|
+
|
|
269
|
+
_validate_review_inputs(root_name, changes, discovery, graph_status)
|
|
270
|
+
|
|
271
|
+
ordered_changes = sorted(changes, key=lambda item: (item.path, item.status))
|
|
272
|
+
change_paths = sorted({change.path for change in ordered_changes})
|
|
273
|
+
safe_status = _sanitize_graph_status(graph_status)
|
|
274
|
+
blockers: list[dict[str, str]] = []
|
|
275
|
+
validation: dict[str, Any]
|
|
276
|
+
graph_usable = False
|
|
277
|
+
impact = _empty_impact()
|
|
278
|
+
|
|
279
|
+
if graph_bundle is None:
|
|
280
|
+
validation = _empty_validation_summary()
|
|
281
|
+
blockers.append(
|
|
282
|
+
{
|
|
283
|
+
"code": "MISSING_GRAPH",
|
|
284
|
+
"message": "Dependency graph evidence is unavailable; build the graph before review.",
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
validation, impact, graph_usable = _derive_graph_evidence(
|
|
289
|
+
graph_bundle, change_paths, depth
|
|
290
|
+
)
|
|
291
|
+
if not graph_usable:
|
|
292
|
+
blockers.append(
|
|
293
|
+
{
|
|
294
|
+
"code": "INVALID_GRAPH",
|
|
295
|
+
"message": "Dependency graph validation failed; rebuild a valid graph before review.",
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
stale = safe_status.get("stale") is True
|
|
300
|
+
deleted = any(change.status.casefold() == "deleted" for change in ordered_changes)
|
|
301
|
+
sensitive = any(_is_sensitive_path(change.path) for change in ordered_changes)
|
|
302
|
+
risk_flags = {
|
|
303
|
+
"GRAPH_STALE": stale,
|
|
304
|
+
"DELETED_FILES": deleted,
|
|
305
|
+
"SENSITIVE_CONFIG": sensitive,
|
|
306
|
+
"BROAD_IMPACT": len(impact["impacted_files"]) >= 10,
|
|
307
|
+
"MISSING_GRAPH_MATCHES": graph_usable and bool(impact["missing"]),
|
|
308
|
+
"NO_LIKELY_TESTS": graph_usable and bool(ordered_changes) and not impact["likely_tests"],
|
|
309
|
+
}
|
|
310
|
+
signals = [signal for signal in _RISK_ORDER if risk_flags[signal]]
|
|
311
|
+
high_signals = set(_RISK_ORDER[:4])
|
|
312
|
+
level = "high" if high_signals.intersection(signals) else "medium" if signals else "low"
|
|
313
|
+
|
|
314
|
+
criterion_ids: list[str]
|
|
315
|
+
if not ordered_changes:
|
|
316
|
+
criterion_ids = ["CONFIRM_CLEAN"]
|
|
317
|
+
else:
|
|
318
|
+
criterion_ids = ["REVIEW_SCOPE"]
|
|
319
|
+
if stale:
|
|
320
|
+
criterion_ids.append("REFRESH_GRAPH")
|
|
321
|
+
if impact["impacted_files"]:
|
|
322
|
+
criterion_ids.append("REVIEW_IMPACT")
|
|
323
|
+
criterion_ids.append("RUN_LIKELY_TESTS" if impact["likely_tests"] else "ADD_TEST_PLAN")
|
|
324
|
+
if deleted:
|
|
325
|
+
criterion_ids.append("REVIEW_DELETIONS")
|
|
326
|
+
if sensitive:
|
|
327
|
+
criterion_ids.append("VERIFY_CONFIG")
|
|
328
|
+
|
|
329
|
+
warnings: list[dict[str, str]] = []
|
|
330
|
+
if stale:
|
|
331
|
+
warnings.append(
|
|
332
|
+
{
|
|
333
|
+
"code": "GRAPH_STALE",
|
|
334
|
+
"message": "Dependency graph evidence may be stale and should be refreshed.",
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
if impact["missing"]:
|
|
338
|
+
warnings.append(
|
|
339
|
+
{
|
|
340
|
+
"code": "MISSING_GRAPH_MATCHES",
|
|
341
|
+
"message": "Some changed paths were not matched in the dependency graph.",
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
change_dicts, changes_truncated = _bounded_list(
|
|
346
|
+
[change.to_dict() for change in ordered_changes]
|
|
347
|
+
)
|
|
348
|
+
bounded_impact: dict[str, list[str]] = {}
|
|
349
|
+
impact_truncated = False
|
|
350
|
+
for impact_field in ("matched_nodes", "missing", "impacted_files", "likely_tests"):
|
|
351
|
+
bounded_values, field_truncated = _bounded_list(impact[impact_field])
|
|
352
|
+
bounded_impact[impact_field] = bounded_values
|
|
353
|
+
impact_truncated = impact_truncated or field_truncated
|
|
354
|
+
if changes_truncated or impact_truncated:
|
|
355
|
+
warnings.append(
|
|
356
|
+
{
|
|
357
|
+
"code": "OUTPUT_TRUNCATED",
|
|
358
|
+
"message": "Review output was truncated to a bounded size; some entries are not shown.",
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
"schema_version": 1,
|
|
364
|
+
"project": root_name,
|
|
365
|
+
"discovery": discovery,
|
|
366
|
+
"changes": change_dicts,
|
|
367
|
+
"graph": {"status": safe_status, "validation": validation},
|
|
368
|
+
"impact": bounded_impact,
|
|
369
|
+
"risk": {"level": level, "signals": signals},
|
|
370
|
+
"acceptance_criteria": [
|
|
371
|
+
{"id": criterion_id, **_CRITERIA[criterion_id]} for criterion_id in criterion_ids
|
|
372
|
+
],
|
|
373
|
+
"warnings": warnings,
|
|
374
|
+
"blockers": blockers,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def format_review_markdown(packet: dict[str, Any]) -> str:
|
|
379
|
+
"""Render a review packet as bounded, control-character-free Markdown."""
|
|
380
|
+
_validate_formatter_packet(packet)
|
|
381
|
+
graph = packet.get("graph", {})
|
|
382
|
+
status = graph.get("status", {})
|
|
383
|
+
validation = graph.get("validation", {})
|
|
384
|
+
lines = [
|
|
385
|
+
"# Graphite Change Review",
|
|
386
|
+
"",
|
|
387
|
+
f"- Schema: {_safe_markdown_text(packet.get('schema_version', 'unknown'))}",
|
|
388
|
+
f"- Project: {_safe_markdown_text(packet.get('project', 'unknown'))}",
|
|
389
|
+
f"- Discovery: {_safe_markdown_text(packet.get('discovery', 'unknown'))}",
|
|
390
|
+
f"- Graph stale: {_safe_markdown_text(status.get('stale', False))}",
|
|
391
|
+
f"- Graph valid: {_safe_markdown_text(validation.get('ok', False))}",
|
|
392
|
+
"",
|
|
393
|
+
"## Changes",
|
|
394
|
+
]
|
|
395
|
+
changes = packet.get("changes", [])
|
|
396
|
+
if changes:
|
|
397
|
+
for change in changes:
|
|
398
|
+
lines.append(
|
|
399
|
+
f"- {_inline_code(change.get('path', ''))} — "
|
|
400
|
+
f"{_safe_markdown_text(change.get('status', 'unknown'))}"
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
lines.append("- None")
|
|
404
|
+
|
|
405
|
+
impact = packet.get("impact", {})
|
|
406
|
+
lines.extend(["", "## Impact", "", "Impacted files:"])
|
|
407
|
+
_append_code_items(lines, impact.get("impacted_files", []))
|
|
408
|
+
lines.extend(["", "Likely tests:"])
|
|
409
|
+
_append_code_items(lines, impact.get("likely_tests", []))
|
|
410
|
+
if impact.get("missing"):
|
|
411
|
+
lines.extend(["", "Unmatched changes:"])
|
|
412
|
+
_append_code_items(lines, impact["missing"])
|
|
413
|
+
|
|
414
|
+
risk = packet.get("risk", {})
|
|
415
|
+
lines.extend(
|
|
416
|
+
[
|
|
417
|
+
"",
|
|
418
|
+
"## Risk Signals",
|
|
419
|
+
"",
|
|
420
|
+
f"Level: **{_safe_markdown_text(risk.get('level', 'unknown'))}**",
|
|
421
|
+
]
|
|
422
|
+
)
|
|
423
|
+
signals = risk.get("signals", [])
|
|
424
|
+
lines.extend(f"- {_safe_markdown_identifier(signal)}" for signal in signals)
|
|
425
|
+
if not signals:
|
|
426
|
+
lines.append("- None")
|
|
427
|
+
|
|
428
|
+
lines.extend(["", "## Acceptance Criteria"])
|
|
429
|
+
for criterion in packet.get("acceptance_criteria", []):
|
|
430
|
+
lines.append(
|
|
431
|
+
f"- [ ] **{_safe_markdown_identifier(criterion.get('id', ''))}** — "
|
|
432
|
+
f"{_safe_markdown_text(criterion.get('description', ''))}"
|
|
433
|
+
)
|
|
434
|
+
lines.append(f" - Verify: {_safe_markdown_text(criterion.get('verify', ''))}")
|
|
435
|
+
|
|
436
|
+
_append_notice_section(lines, "Blockers", packet.get("blockers", []))
|
|
437
|
+
_append_notice_section(lines, "Warnings", packet.get("warnings", []))
|
|
438
|
+
return "\n".join(lines) + "\n"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _validate_review_inputs(
|
|
442
|
+
root_name: Any,
|
|
443
|
+
changes: Any,
|
|
444
|
+
discovery: Any,
|
|
445
|
+
graph_status: Any,
|
|
446
|
+
) -> None:
|
|
447
|
+
if (
|
|
448
|
+
not isinstance(root_name, str)
|
|
449
|
+
or not root_name.strip()
|
|
450
|
+
or "/" in root_name
|
|
451
|
+
or "\\" in root_name
|
|
452
|
+
or _contains_unsafe_unicode(root_name)
|
|
453
|
+
):
|
|
454
|
+
raise ReviewError("project label is invalid")
|
|
455
|
+
if not isinstance(discovery, str) or discovery not in _DISCOVERY_MODES:
|
|
456
|
+
raise ReviewError("review discovery is invalid")
|
|
457
|
+
if not isinstance(changes, list) or not all(isinstance(change, Change) for change in changes):
|
|
458
|
+
raise ReviewError("review changes are invalid")
|
|
459
|
+
for change in changes:
|
|
460
|
+
if _normalize_safe_relative_path(change.path) is None:
|
|
461
|
+
raise ReviewError("change path is invalid")
|
|
462
|
+
if not isinstance(change.status, str) or change.status not in _CHANGE_STATUSES:
|
|
463
|
+
raise ReviewError("change status is invalid")
|
|
464
|
+
if not isinstance(graph_status, dict):
|
|
465
|
+
raise ReviewError("graph status is invalid")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _derive_graph_evidence(
|
|
469
|
+
graph_bundle: Any,
|
|
470
|
+
change_paths: list[str],
|
|
471
|
+
depth: int,
|
|
472
|
+
) -> tuple[dict[str, Any], dict[str, list[str]], bool]:
|
|
473
|
+
empty_impact = _empty_impact()
|
|
474
|
+
if not isinstance(graph_bundle, dict):
|
|
475
|
+
return _invalid_graph_summary("bundle_type"), empty_impact, False
|
|
476
|
+
|
|
477
|
+
validation: dict[str, Any] | None = None
|
|
478
|
+
try:
|
|
479
|
+
report = validate_graph_bundle(graph_bundle)
|
|
480
|
+
validation = _validation_summary(report)
|
|
481
|
+
if not report["ok"]:
|
|
482
|
+
return validation, empty_impact, False
|
|
483
|
+
if _bundle_has_unsafe_source_file(graph_bundle):
|
|
484
|
+
return _invalid_graph_summary("graph_processing_error", validation), empty_impact, False
|
|
485
|
+
|
|
486
|
+
context = build_context(graph_from_json(graph_bundle), change_paths, depth=depth)
|
|
487
|
+
impact = _validated_context_impact(context)
|
|
488
|
+
return validation, impact, True
|
|
489
|
+
except Exception:
|
|
490
|
+
return _invalid_graph_summary("graph_processing_error", validation), empty_impact, False
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _validated_context_impact(context: Any) -> dict[str, list[str]]:
|
|
494
|
+
if not isinstance(context, dict) or not isinstance(context.get("impact"), dict):
|
|
495
|
+
raise ValueError("invalid graph context")
|
|
496
|
+
context_impact = context["impact"]
|
|
497
|
+
matched_nodes = context_impact.get("matched_nodes")
|
|
498
|
+
if (
|
|
499
|
+
not isinstance(matched_nodes, list)
|
|
500
|
+
or not all(
|
|
501
|
+
isinstance(node, str) and node and not _contains_unsafe_unicode(node)
|
|
502
|
+
for node in matched_nodes
|
|
503
|
+
)
|
|
504
|
+
):
|
|
505
|
+
raise ValueError("invalid graph context")
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
"matched_nodes": sorted(set(matched_nodes)),
|
|
509
|
+
"missing": _validated_graph_paths(context.get("missing")),
|
|
510
|
+
"impacted_files": _validated_graph_paths(context_impact.get("impacted_files")),
|
|
511
|
+
"likely_tests": _validated_graph_paths(context_impact.get("likely_tests")),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _validated_graph_paths(values: Any) -> list[str]:
|
|
516
|
+
if not isinstance(values, list):
|
|
517
|
+
raise ValueError("invalid graph paths")
|
|
518
|
+
normalized: set[str] = set()
|
|
519
|
+
for value in values:
|
|
520
|
+
safe_path = _normalize_safe_relative_path(value)
|
|
521
|
+
if safe_path is None:
|
|
522
|
+
raise ValueError("invalid graph paths")
|
|
523
|
+
normalized.add(safe_path)
|
|
524
|
+
return sorted(normalized)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _bundle_has_unsafe_source_file(bundle: dict[str, Any]) -> bool:
|
|
528
|
+
nodes = bundle.get("nodes", [])
|
|
529
|
+
if not isinstance(nodes, list):
|
|
530
|
+
return True
|
|
531
|
+
for node in nodes:
|
|
532
|
+
if not isinstance(node, dict):
|
|
533
|
+
continue
|
|
534
|
+
source_file = node.get("source_file")
|
|
535
|
+
if source_file is None or source_file == "":
|
|
536
|
+
continue
|
|
537
|
+
if _normalize_safe_relative_path(source_file) is None:
|
|
538
|
+
return True
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _bounded_list(values: list[Any]) -> tuple[list[Any], bool]:
|
|
543
|
+
"""Return values truncated to MAX_REVIEW_PACKET_ITEMS and whether truncation occurred."""
|
|
544
|
+
if len(values) > MAX_REVIEW_PACKET_ITEMS:
|
|
545
|
+
return values[:MAX_REVIEW_PACKET_ITEMS], True
|
|
546
|
+
return values, False
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _empty_impact() -> dict[str, list[str]]:
|
|
550
|
+
return {
|
|
551
|
+
"matched_nodes": [],
|
|
552
|
+
"missing": [],
|
|
553
|
+
"impacted_files": [],
|
|
554
|
+
"likely_tests": [],
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _invalid_graph_summary(
|
|
559
|
+
code: str, base: dict[str, Any] | None = None
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
return {
|
|
562
|
+
"ok": False,
|
|
563
|
+
"error_count": 1,
|
|
564
|
+
"warning_count": _safe_count(base.get("warning_count")) if base else 0,
|
|
565
|
+
"node_count": _safe_count(base.get("node_count")) if base else 0,
|
|
566
|
+
"edge_count": _safe_count(base.get("edge_count")) if base else 0,
|
|
567
|
+
"error_codes": [code],
|
|
568
|
+
"warning_codes": list(base.get("warning_codes", [])) if base else [],
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _validate_formatter_packet(packet: Any) -> None:
|
|
573
|
+
if not isinstance(packet, dict):
|
|
574
|
+
raise ReviewError("review packet is invalid")
|
|
575
|
+
|
|
576
|
+
mapping_fields = ("graph", "impact", "risk")
|
|
577
|
+
for field in mapping_fields:
|
|
578
|
+
if field in packet and not isinstance(packet[field], dict):
|
|
579
|
+
raise ReviewError("review packet is invalid")
|
|
580
|
+
graph = packet.get("graph", {})
|
|
581
|
+
for field in ("status", "validation"):
|
|
582
|
+
if field in graph and not isinstance(graph[field], dict):
|
|
583
|
+
raise ReviewError("review packet is invalid")
|
|
584
|
+
|
|
585
|
+
object_list_fields = ("changes", "acceptance_criteria", "blockers", "warnings")
|
|
586
|
+
for field in object_list_fields:
|
|
587
|
+
values = packet.get(field, [])
|
|
588
|
+
if not isinstance(values, list) or not all(isinstance(value, dict) for value in values):
|
|
589
|
+
raise ReviewError("review packet is invalid")
|
|
590
|
+
impact = packet.get("impact", {})
|
|
591
|
+
for field in ("matched_nodes", "impacted_files", "likely_tests", "missing"):
|
|
592
|
+
values = impact.get(field, [])
|
|
593
|
+
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
|
|
594
|
+
raise ReviewError("review packet is invalid")
|
|
595
|
+
risk = packet.get("risk", {})
|
|
596
|
+
signals = risk.get("signals", [])
|
|
597
|
+
if not isinstance(signals, list) or not all(isinstance(signal, str) for signal in signals):
|
|
598
|
+
raise ReviewError("review packet is invalid")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _validation_summary(report: dict[str, Any]) -> dict[str, Any]:
|
|
602
|
+
return {
|
|
603
|
+
"ok": bool(report.get("ok", False)),
|
|
604
|
+
"error_count": _safe_count(report.get("error_count")),
|
|
605
|
+
"warning_count": _safe_count(report.get("warning_count")),
|
|
606
|
+
"node_count": _safe_count(report.get("node_count")),
|
|
607
|
+
"edge_count": _safe_count(report.get("edge_count")),
|
|
608
|
+
"error_codes": _sorted_issue_codes(report.get("errors", [])),
|
|
609
|
+
"warning_codes": _sorted_issue_codes(report.get("warnings", [])),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _empty_validation_summary() -> dict[str, Any]:
|
|
614
|
+
return {
|
|
615
|
+
"ok": False,
|
|
616
|
+
"error_count": 0,
|
|
617
|
+
"warning_count": 0,
|
|
618
|
+
"node_count": 0,
|
|
619
|
+
"edge_count": 0,
|
|
620
|
+
"error_codes": [],
|
|
621
|
+
"warning_codes": [],
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _sanitize_graph_status(status: dict[str, Any]) -> dict[str, Any]:
|
|
626
|
+
safe: dict[str, Any] = {}
|
|
627
|
+
if isinstance(status.get("stale"), bool):
|
|
628
|
+
safe["stale"] = status["stale"]
|
|
629
|
+
for key in (
|
|
630
|
+
"node_count",
|
|
631
|
+
"edge_count",
|
|
632
|
+
"file_count",
|
|
633
|
+
"manifest_file_count",
|
|
634
|
+
"added_count",
|
|
635
|
+
"changed_count",
|
|
636
|
+
"removed_count",
|
|
637
|
+
):
|
|
638
|
+
value = status.get(key)
|
|
639
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
640
|
+
safe[key] = value
|
|
641
|
+
for key in ("added", "changed", "removed"):
|
|
642
|
+
values = status.get(key)
|
|
643
|
+
if isinstance(values, list):
|
|
644
|
+
safe[key] = sorted(
|
|
645
|
+
{
|
|
646
|
+
normalized
|
|
647
|
+
for value in values
|
|
648
|
+
if isinstance(value, str)
|
|
649
|
+
for normalized in [_normalize_safe_relative_path(value)]
|
|
650
|
+
if normalized is not None
|
|
651
|
+
}
|
|
652
|
+
)
|
|
653
|
+
reason = status.get("reason")
|
|
654
|
+
if isinstance(reason, str):
|
|
655
|
+
lowered = reason.casefold()
|
|
656
|
+
safe["reason"] = next(
|
|
657
|
+
(
|
|
658
|
+
category
|
|
659
|
+
for category in ("missing", "changed", "stale", "invalid", "error")
|
|
660
|
+
if category in lowered
|
|
661
|
+
),
|
|
662
|
+
"unknown",
|
|
663
|
+
)
|
|
664
|
+
return safe
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _normalize_safe_relative_path(value: Any) -> str | None:
|
|
668
|
+
if not isinstance(value, str) or _contains_unsafe_unicode(value):
|
|
669
|
+
return None
|
|
670
|
+
normalized = value.replace("\\", "/")
|
|
671
|
+
if not normalized or normalized.startswith("/"):
|
|
672
|
+
return None
|
|
673
|
+
if len(normalized) >= 2 and normalized[0].isalpha() and normalized[1] == ":":
|
|
674
|
+
return None
|
|
675
|
+
path = PurePosixPath(normalized)
|
|
676
|
+
if path == PurePosixPath(".") or ".." in path.parts:
|
|
677
|
+
return None
|
|
678
|
+
return path.as_posix()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _is_sensitive_path(path: str) -> bool:
|
|
682
|
+
normalized = path.replace("\\", "/").casefold()
|
|
683
|
+
name = normalized.rsplit("/", 1)[-1]
|
|
684
|
+
sensitive_names = {
|
|
685
|
+
"pyproject.toml",
|
|
686
|
+
"package.json",
|
|
687
|
+
"package-lock.json",
|
|
688
|
+
"npm-shrinkwrap.json",
|
|
689
|
+
"yarn.lock",
|
|
690
|
+
"pnpm-lock.yaml",
|
|
691
|
+
"bun.lock",
|
|
692
|
+
"bun.lockb",
|
|
693
|
+
"requirements.txt",
|
|
694
|
+
"uv.lock",
|
|
695
|
+
"pipfile.lock",
|
|
696
|
+
"poetry.lock",
|
|
697
|
+
"cargo.toml",
|
|
698
|
+
"cargo.lock",
|
|
699
|
+
"go.mod",
|
|
700
|
+
"go.sum",
|
|
701
|
+
"dockerfile",
|
|
702
|
+
}
|
|
703
|
+
return name in sensitive_names or normalized.startswith(".github/workflows/")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _sorted_issue_codes(issues: Any) -> list[str]:
|
|
707
|
+
if not isinstance(issues, list):
|
|
708
|
+
return []
|
|
709
|
+
return sorted(
|
|
710
|
+
{
|
|
711
|
+
code
|
|
712
|
+
for issue in issues
|
|
713
|
+
if isinstance(issue, dict)
|
|
714
|
+
for code in [issue.get("code")]
|
|
715
|
+
if isinstance(code, str) and not _contains_unsafe_unicode(code)
|
|
716
|
+
}
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _safe_count(value: Any) -> int:
|
|
721
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _contains_unsafe_unicode(value: str) -> bool:
|
|
725
|
+
return any(_is_unsafe_unicode_character(character) for character in value)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _is_unsafe_unicode_character(character: str) -> bool:
|
|
729
|
+
return unicodedata.category(character) in _UNSAFE_UNICODE_CATEGORIES
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _safe_markdown_text(value: Any) -> str:
|
|
733
|
+
text = str(value)
|
|
734
|
+
text = "".join(
|
|
735
|
+
character if not _is_unsafe_unicode_character(character) else " "
|
|
736
|
+
for character in text
|
|
737
|
+
)
|
|
738
|
+
for character in "\\`*_{}[]<>#|":
|
|
739
|
+
text = text.replace(character, f"\\{character}")
|
|
740
|
+
return text
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _safe_markdown_identifier(value: Any) -> str:
|
|
744
|
+
return "".join(
|
|
745
|
+
character if character.isalnum() or character in "_-" else "_"
|
|
746
|
+
for character in str(value)
|
|
747
|
+
if not _is_unsafe_unicode_character(character)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _inline_code(value: Any) -> str:
|
|
752
|
+
text = str(value)
|
|
753
|
+
text = "".join(
|
|
754
|
+
character if not _is_unsafe_unicode_character(character) else " "
|
|
755
|
+
for character in text
|
|
756
|
+
)
|
|
757
|
+
longest_run = 0
|
|
758
|
+
current_run = 0
|
|
759
|
+
for character in text:
|
|
760
|
+
current_run = current_run + 1 if character == "`" else 0
|
|
761
|
+
longest_run = max(longest_run, current_run)
|
|
762
|
+
delimiter = "`" * (longest_run + 1)
|
|
763
|
+
padding = " " if text.startswith("`") or text.endswith("`") else ""
|
|
764
|
+
return f"{delimiter}{padding}{text}{padding}{delimiter}"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _append_code_items(lines: list[str], values: Any) -> None:
|
|
768
|
+
if values:
|
|
769
|
+
lines.extend(f"- {_inline_code(value)}" for value in values)
|
|
770
|
+
else:
|
|
771
|
+
lines.append("- None")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _append_notice_section(lines: list[str], heading: str, notices: Any) -> None:
|
|
775
|
+
if not notices:
|
|
776
|
+
return
|
|
777
|
+
lines.extend(["", f"## {heading}"])
|
|
778
|
+
for notice in notices:
|
|
779
|
+
lines.append(
|
|
780
|
+
f"- **{_safe_markdown_identifier(notice.get('code', ''))}** — "
|
|
781
|
+
f"{_safe_markdown_text(notice.get('message', ''))}"
|
|
782
|
+
)
|