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/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
+ )