gdmcode 0.1.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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""DocumentWriter — create and modify Word/Excel documents; PDF export via LibreOffice.
|
|
2
|
+
|
|
3
|
+
All document library imports are guarded so the module loads without optional deps.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import logging, shutil, subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
__all__ = ["DocumentWriter", "WriteResult", "DocxSpec", "XlsxSpec"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class WriteResult:
|
|
18
|
+
path: str
|
|
19
|
+
format: str
|
|
20
|
+
bytes_written: int = 0
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def success(self) -> bool:
|
|
25
|
+
return self.error is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DocxSpec:
|
|
30
|
+
"""Declarative spec for a Word document."""
|
|
31
|
+
title: str = ""
|
|
32
|
+
author: str = ""
|
|
33
|
+
sections: list[dict] = field(default_factory=list)
|
|
34
|
+
# Each section: {"heading": str, "level": int, "paragraphs": [str], "table": [[str]]}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class XlsxSpec:
|
|
39
|
+
"""Declarative spec for an Excel workbook."""
|
|
40
|
+
sheets: list[dict] = field(default_factory=list)
|
|
41
|
+
# Each sheet: {"name": str, "headers": [str], "rows": [[str|int|float]]}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DocumentWriter:
|
|
45
|
+
|
|
46
|
+
# ── Word ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def create_docx(self, spec: DocxSpec, output_path: Path | str) -> WriteResult:
|
|
49
|
+
output_path = Path(output_path)
|
|
50
|
+
try:
|
|
51
|
+
import docx
|
|
52
|
+
except ImportError:
|
|
53
|
+
return WriteResult(str(output_path), "docx",
|
|
54
|
+
error="python-docx required. pip install 'gdm-code[docs]'")
|
|
55
|
+
try:
|
|
56
|
+
doc = docx.Document()
|
|
57
|
+
props = doc.core_properties
|
|
58
|
+
if spec.title:
|
|
59
|
+
props.title = spec.title
|
|
60
|
+
if spec.author:
|
|
61
|
+
props.author = spec.author
|
|
62
|
+
for section in spec.sections:
|
|
63
|
+
heading = section.get("heading", "")
|
|
64
|
+
level = section.get("level", 1)
|
|
65
|
+
if heading:
|
|
66
|
+
doc.add_heading(heading, level=level)
|
|
67
|
+
for para in section.get("paragraphs", []):
|
|
68
|
+
doc.add_paragraph(para)
|
|
69
|
+
table_data = section.get("table")
|
|
70
|
+
if table_data and len(table_data) > 0:
|
|
71
|
+
cols = max(len(r) for r in table_data)
|
|
72
|
+
t = doc.add_table(rows=len(table_data), cols=cols, style="Table Grid")
|
|
73
|
+
for r_idx, row in enumerate(table_data):
|
|
74
|
+
for c_idx, cell in enumerate(row):
|
|
75
|
+
t.cell(r_idx, c_idx).text = str(cell)
|
|
76
|
+
doc.save(str(output_path))
|
|
77
|
+
return WriteResult(str(output_path), "docx",
|
|
78
|
+
bytes_written=output_path.stat().st_size)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return WriteResult(str(output_path), "docx", error=str(exc))
|
|
81
|
+
|
|
82
|
+
def append_docx(self, path: Path | str, paragraphs: list[str],
|
|
83
|
+
heading: str = "") -> WriteResult:
|
|
84
|
+
path = Path(path)
|
|
85
|
+
try:
|
|
86
|
+
import docx
|
|
87
|
+
except ImportError:
|
|
88
|
+
return WriteResult(str(path), "docx",
|
|
89
|
+
error="python-docx required. pip install 'gdm-code[docs]'")
|
|
90
|
+
try:
|
|
91
|
+
doc = docx.Document(str(path))
|
|
92
|
+
if heading:
|
|
93
|
+
doc.add_heading(heading, level=2)
|
|
94
|
+
for para in paragraphs:
|
|
95
|
+
doc.add_paragraph(para)
|
|
96
|
+
doc.save(str(path))
|
|
97
|
+
return WriteResult(str(path), "docx", bytes_written=path.stat().st_size)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
return WriteResult(str(path), "docx", error=str(exc))
|
|
100
|
+
|
|
101
|
+
# ── Excel ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def create_xlsx(self, spec: XlsxSpec, output_path: Path | str) -> WriteResult:
|
|
104
|
+
output_path = Path(output_path)
|
|
105
|
+
try:
|
|
106
|
+
import openpyxl
|
|
107
|
+
from openpyxl.styles import Font
|
|
108
|
+
except ImportError:
|
|
109
|
+
return WriteResult(str(output_path), "xlsx",
|
|
110
|
+
error="openpyxl required. pip install 'gdm-code[docs]'")
|
|
111
|
+
try:
|
|
112
|
+
wb = openpyxl.Workbook()
|
|
113
|
+
wb.remove(wb.active)
|
|
114
|
+
for sheet_spec in spec.sheets:
|
|
115
|
+
ws = wb.create_sheet(title=sheet_spec.get("name", "Sheet"))
|
|
116
|
+
headers = sheet_spec.get("headers", [])
|
|
117
|
+
if headers:
|
|
118
|
+
ws.append(headers)
|
|
119
|
+
for cell in ws[1]:
|
|
120
|
+
cell.font = Font(bold=True)
|
|
121
|
+
for row in sheet_spec.get("rows", []):
|
|
122
|
+
ws.append(list(row))
|
|
123
|
+
wb.save(str(output_path))
|
|
124
|
+
return WriteResult(str(output_path), "xlsx",
|
|
125
|
+
bytes_written=output_path.stat().st_size)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return WriteResult(str(output_path), "xlsx", error=str(exc))
|
|
128
|
+
|
|
129
|
+
def update_xlsx_cell(self, path: Path | str, sheet_name: str,
|
|
130
|
+
row: int, col: int, value) -> WriteResult:
|
|
131
|
+
"""Update a single cell (1-indexed row/col) in an existing xlsx."""
|
|
132
|
+
path = Path(path)
|
|
133
|
+
try:
|
|
134
|
+
import openpyxl
|
|
135
|
+
except ImportError:
|
|
136
|
+
return WriteResult(str(path), "xlsx",
|
|
137
|
+
error="openpyxl required. pip install 'gdm-code[docs]'")
|
|
138
|
+
try:
|
|
139
|
+
wb = openpyxl.load_workbook(str(path))
|
|
140
|
+
if sheet_name not in wb.sheetnames:
|
|
141
|
+
return WriteResult(str(path), "xlsx",
|
|
142
|
+
error=f"Sheet '{sheet_name}' not found")
|
|
143
|
+
ws = wb[sheet_name]
|
|
144
|
+
ws.cell(row=row, column=col, value=value)
|
|
145
|
+
wb.save(str(path))
|
|
146
|
+
return WriteResult(str(path), "xlsx", bytes_written=path.stat().st_size)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
return WriteResult(str(path), "xlsx", error=str(exc))
|
|
149
|
+
|
|
150
|
+
# ── PDF conversion ────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def docx_to_pdf(self, docx_path: Path | str, output_dir: Path | str = None) -> WriteResult:
|
|
153
|
+
docx_path = Path(docx_path)
|
|
154
|
+
output_dir = Path(output_dir) if output_dir else docx_path.parent
|
|
155
|
+
if not shutil.which("libreoffice"):
|
|
156
|
+
return WriteResult(str(docx_path), "pdf",
|
|
157
|
+
error="LibreOffice not found. Install LibreOffice to enable PDF export.")
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
["libreoffice", "--headless", "--convert-to", "pdf",
|
|
161
|
+
"--outdir", str(output_dir), str(docx_path)],
|
|
162
|
+
capture_output=True, timeout=60,
|
|
163
|
+
)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
return WriteResult(str(docx_path), "pdf",
|
|
166
|
+
error=result.stderr.decode(errors="replace"))
|
|
167
|
+
pdf_path = output_dir / (docx_path.stem + ".pdf")
|
|
168
|
+
return WriteResult(str(pdf_path), "pdf",
|
|
169
|
+
bytes_written=pdf_path.stat().st_size if pdf_path.exists() else 0)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
return WriteResult(str(docx_path), "pdf", error=str(exc))
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Impact analysis tool — blast radius before signature changes.
|
|
2
|
+
|
|
3
|
+
Given a symbol name (function, class, or variable), returns:
|
|
4
|
+
- Every file that calls or imports it
|
|
5
|
+
- Line numbers and context snippets for each call site
|
|
6
|
+
- Suggested test files to run after the change
|
|
7
|
+
- Risk level: low / medium / high based on total caller count
|
|
8
|
+
|
|
9
|
+
Call before any breaking rename or signature change to understand scope.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, ClassVar
|
|
18
|
+
|
|
19
|
+
from src.tools import REGISTRY, ToolBase, ToolResult
|
|
20
|
+
|
|
21
|
+
__all__ = ["ImpactAnalysisTool"]
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_EXCLUDED_DIRS: frozenset[str] = frozenset(
|
|
26
|
+
{".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"}
|
|
27
|
+
)
|
|
28
|
+
_SOURCE_EXTS: frozenset[str] = frozenset(
|
|
29
|
+
{".py", ".ts", ".js", ".tsx", ".jsx"}
|
|
30
|
+
)
|
|
31
|
+
_MAX_HITS_PER_FILE: int = 5
|
|
32
|
+
_RISK_LOW: int = 2
|
|
33
|
+
_RISK_MEDIUM: int = 10
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Tool
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
class ImpactAnalysisTool(ToolBase):
|
|
41
|
+
"""Analyse the blast radius of a rename or signature change.
|
|
42
|
+
|
|
43
|
+
Returns all call sites for a symbol with file/line info, a risk level
|
|
44
|
+
(low / medium / high), and suggested test targets.
|
|
45
|
+
|
|
46
|
+
**Call before any breaking refactor.**
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: ClassVar[str] = "impact_analysis"
|
|
50
|
+
description: ClassVar[str] = (
|
|
51
|
+
"Analyse the blast radius of a rename or signature change. "
|
|
52
|
+
"Returns all callers of a symbol with file/line info, risk level, "
|
|
53
|
+
"and suggested tests to run. Call before any breaking refactor."
|
|
54
|
+
)
|
|
55
|
+
input_schema: ClassVar[dict[str, Any]] = {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["symbol"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"symbol": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "The function, class, or variable name to analyse.",
|
|
62
|
+
},
|
|
63
|
+
"project_root": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "Root directory. Defaults to cwd.",
|
|
66
|
+
},
|
|
67
|
+
"include_tests": {
|
|
68
|
+
"type": "boolean",
|
|
69
|
+
"description": "Include test files in analysis (default: false).",
|
|
70
|
+
},
|
|
71
|
+
"change_type": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": ["rename", "signature_change", "delete", "behavior_change"],
|
|
74
|
+
"description": "Type of change being made",
|
|
75
|
+
"default": "signature_change",
|
|
76
|
+
},
|
|
77
|
+
"new_signature": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "New signature (for rename/signature_change)",
|
|
80
|
+
"default": "",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def execute(self, args: dict[str, Any]) -> ToolResult:
|
|
86
|
+
symbol: str = (args.get("symbol") or "").strip()
|
|
87
|
+
if not symbol:
|
|
88
|
+
return ToolResult(output="", error="'symbol' is required")
|
|
89
|
+
|
|
90
|
+
root = Path(args.get("project_root") or Path.cwd()).resolve()
|
|
91
|
+
include_tests: bool = args.get("include_tests", False)
|
|
92
|
+
change_type: str = args.get("change_type", "signature_change")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
callers = self._find_callers(symbol, root, include_tests)
|
|
96
|
+
is_public = self._is_public_api(symbol, root)
|
|
97
|
+
semver = self._semver_recommendation(symbol, callers, is_public)
|
|
98
|
+
|
|
99
|
+
from src.models.schemas import ImpactReport
|
|
100
|
+
impact_report = ImpactReport(
|
|
101
|
+
symbol=symbol,
|
|
102
|
+
callers_count=len(callers),
|
|
103
|
+
callers=callers,
|
|
104
|
+
affected_tests=[c["file"] for c in callers if "test" in c["file"].lower()],
|
|
105
|
+
is_public_api=is_public,
|
|
106
|
+
semver_recommendation=semver,
|
|
107
|
+
change_type=change_type,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
tool_result = ToolResult(output=self._format_report(symbol, callers))
|
|
111
|
+
tool_result.metadata["structured"] = impact_report.model_dump()
|
|
112
|
+
return tool_result
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
log.warning("impact_analysis failed for %r: %s", symbol, exc)
|
|
115
|
+
return ToolResult(output="", error=str(exc))
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Caller discovery
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _find_callers(
|
|
122
|
+
self, symbol: str, root: Path, include_tests: bool
|
|
123
|
+
) -> list[dict[str, Any]]:
|
|
124
|
+
"""Try CodeIndex first (fast), fall back to regex grep."""
|
|
125
|
+
try:
|
|
126
|
+
from src.memory.code_index import CodeIndex
|
|
127
|
+
from src.memory.db import GdmDatabase
|
|
128
|
+
|
|
129
|
+
db = GdmDatabase(project_root=root)
|
|
130
|
+
project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(root)))
|
|
131
|
+
idx = CodeIndex(db=db, project_id=project_id, project_root=root)
|
|
132
|
+
raw = idx.find_callers(symbol)
|
|
133
|
+
if raw:
|
|
134
|
+
callers = [{"file": file, "line": line, "context": ""} for file, line in raw]
|
|
135
|
+
if not include_tests:
|
|
136
|
+
callers = [
|
|
137
|
+
c for c in callers
|
|
138
|
+
if "test" not in c["file"].lower()
|
|
139
|
+
]
|
|
140
|
+
return callers
|
|
141
|
+
except (ImportError, OSError):
|
|
142
|
+
pass # fall through to grep
|
|
143
|
+
|
|
144
|
+
raw_tuples = self._grep_callers(symbol, root)
|
|
145
|
+
callers = [{"file": f, "line": ln, "context": ctx} for f, ln, ctx in raw_tuples]
|
|
146
|
+
if not include_tests:
|
|
147
|
+
callers = [c for c in callers if "test" not in c["file"].lower()]
|
|
148
|
+
return callers
|
|
149
|
+
|
|
150
|
+
def _grep_callers(self, symbol: str, root: Path) -> list[tuple[str, int, str]]:
|
|
151
|
+
"""Regex-based fallback: scan Python files for symbol references, excluding definition lines."""
|
|
152
|
+
results: list[tuple[str, int, str]] = []
|
|
153
|
+
for py_file in root.rglob("*.py"):
|
|
154
|
+
if any(part in _EXCLUDED_DIRS for part in py_file.parts):
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
content = py_file.read_text(errors="ignore")
|
|
158
|
+
except OSError:
|
|
159
|
+
continue
|
|
160
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
161
|
+
if re.match(rf'^\s*(?:def|class)\s+{re.escape(symbol)}\b', line):
|
|
162
|
+
continue
|
|
163
|
+
if re.search(rf'\b{re.escape(symbol)}\s*[\(\[]', line):
|
|
164
|
+
results.append((str(py_file), i, line.strip()))
|
|
165
|
+
return results
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# New helper methods
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _semver_recommendation(self, symbol: str, callers: list, is_public: bool) -> str:
|
|
172
|
+
if not callers:
|
|
173
|
+
return "PATCH — no callers, internal cleanup"
|
|
174
|
+
if is_public:
|
|
175
|
+
return "MAJOR — exported symbol changed, breaking consumers"
|
|
176
|
+
if len(callers) > 10:
|
|
177
|
+
return "MINOR — internal change affecting many files"
|
|
178
|
+
return "PATCH — local change, limited blast radius"
|
|
179
|
+
|
|
180
|
+
def _is_public_api(self, symbol: str, project_root: Path) -> bool:
|
|
181
|
+
for init in project_root.rglob("__init__.py"):
|
|
182
|
+
try:
|
|
183
|
+
if symbol in init.read_text(errors="ignore"):
|
|
184
|
+
return True
|
|
185
|
+
except OSError:
|
|
186
|
+
continue
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Report formatting
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _format_report(
|
|
194
|
+
self, symbol: str, callers: list[dict[str, Any]]
|
|
195
|
+
) -> str:
|
|
196
|
+
if not callers:
|
|
197
|
+
return (
|
|
198
|
+
f"Symbol `{symbol}` not found in any source file.\n"
|
|
199
|
+
"✅ Safe to rename — no call sites detected."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
by_file: dict[str, list[dict[str, Any]]] = {}
|
|
203
|
+
test_files: set[str] = set()
|
|
204
|
+
for c in callers:
|
|
205
|
+
by_file.setdefault(c["file"], []).append(c)
|
|
206
|
+
if "test" in c["file"].lower():
|
|
207
|
+
test_files.add(c["file"])
|
|
208
|
+
|
|
209
|
+
total = len(callers)
|
|
210
|
+
file_count = len(by_file)
|
|
211
|
+
risk = "low" if total <= _RISK_LOW else "medium" if total <= _RISK_MEDIUM else "high"
|
|
212
|
+
icons = {"low": "🟢", "medium": "🟡", "high": "🔴"}
|
|
213
|
+
|
|
214
|
+
lines: list[str] = [
|
|
215
|
+
f"## Impact analysis for `{symbol}`",
|
|
216
|
+
"",
|
|
217
|
+
f"Risk: {icons[risk]} **{risk.upper()}** — {total} reference(s) in {file_count} file(s)",
|
|
218
|
+
"",
|
|
219
|
+
"### Call sites",
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
for filepath, hits in sorted(by_file.items()):
|
|
223
|
+
lines.append(f"\n**{filepath}**")
|
|
224
|
+
for h in hits[:_MAX_HITS_PER_FILE]:
|
|
225
|
+
lines.append(f" L{h['line']}: `{h['context']}`")
|
|
226
|
+
if len(hits) > _MAX_HITS_PER_FILE:
|
|
227
|
+
lines.append(f" … and {len(hits) - _MAX_HITS_PER_FILE} more")
|
|
228
|
+
|
|
229
|
+
lines += ["", "### Suggested tests after change"]
|
|
230
|
+
if test_files:
|
|
231
|
+
for tf in sorted(test_files):
|
|
232
|
+
lines.append(f" pytest {tf}")
|
|
233
|
+
else:
|
|
234
|
+
lines.append(f" pytest -k {symbol!r}")
|
|
235
|
+
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Self-register
|
|
240
|
+
REGISTRY.register(ImpactAnalysisTool())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Playwright-based browser automation tool for gdm agents.
|
|
2
|
+
|
|
3
|
+
Provides a high-level async interface for navigating pages, taking screenshots,
|
|
4
|
+
clicking elements, filling forms, and evaluating JavaScript.
|
|
5
|
+
|
|
6
|
+
playwright is an optional dependency — a clear ImportError with install
|
|
7
|
+
instructions is raised if the package is missing.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from playwright.async_api import async_playwright
|
|
16
|
+
_PLAYWRIGHT_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
_PLAYWRIGHT_AVAILABLE = False
|
|
19
|
+
async_playwright = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
__all__ = ["PlaywrightResult", "PlaywrightTool"]
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# JS safety: patterns blocked in evaluate()
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_DANGEROUS_JS_PATTERNS: tuple[str, ...] = (
|
|
28
|
+
"fetch(",
|
|
29
|
+
"XMLHttpRequest",
|
|
30
|
+
"eval(",
|
|
31
|
+
"import(",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# PlaywrightResult
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PlaywrightResult:
|
|
41
|
+
"""Result of a Playwright browser operation."""
|
|
42
|
+
|
|
43
|
+
success: bool
|
|
44
|
+
data: Any = None
|
|
45
|
+
error: str | None = None
|
|
46
|
+
screenshot: bytes | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# PlaywrightTool
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class PlaywrightTool:
|
|
54
|
+
"""Async browser automation tool backed by Playwright.
|
|
55
|
+
|
|
56
|
+
Usage (async context manager)::
|
|
57
|
+
|
|
58
|
+
async with PlaywrightTool() as tool:
|
|
59
|
+
await tool.navigate("https://example.com")
|
|
60
|
+
result = await tool.screenshot()
|
|
61
|
+
|
|
62
|
+
Requires playwright to be installed::
|
|
63
|
+
|
|
64
|
+
pip install playwright && playwright install chromium
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._playwright: Any = None
|
|
69
|
+
self._browser: Any = None
|
|
70
|
+
self._page: Any = None
|
|
71
|
+
|
|
72
|
+
# -- Lifecycle -----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _check_playwright() -> None:
|
|
76
|
+
"""Raise ImportError with install hint if playwright is not available."""
|
|
77
|
+
if not _PLAYWRIGHT_AVAILABLE:
|
|
78
|
+
raise ImportError(
|
|
79
|
+
"Install playwright: pip install playwright && playwright install chromium"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "PlaywrightTool":
|
|
83
|
+
self._check_playwright()
|
|
84
|
+
self._playwright = await async_playwright().start()
|
|
85
|
+
self._browser = await self._playwright.chromium.launch(headless=True)
|
|
86
|
+
self._page = await self._browser.new_page()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
90
|
+
await self.close()
|
|
91
|
+
|
|
92
|
+
# -- Public async API ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def navigate(self, url: str) -> PlaywrightResult:
|
|
95
|
+
"""Navigate to *url* and return the page title."""
|
|
96
|
+
self._check_playwright()
|
|
97
|
+
try:
|
|
98
|
+
await self._page.goto(url)
|
|
99
|
+
title = await self._page.title()
|
|
100
|
+
return PlaywrightResult(success=True, data=title)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
103
|
+
|
|
104
|
+
async def screenshot(self, full_page: bool = False) -> PlaywrightResult:
|
|
105
|
+
"""Take a screenshot of the current page.
|
|
106
|
+
|
|
107
|
+
Returns raw PNG bytes in ``result.screenshot``.
|
|
108
|
+
"""
|
|
109
|
+
self._check_playwright()
|
|
110
|
+
try:
|
|
111
|
+
png_bytes = await self._page.screenshot(full_page=full_page)
|
|
112
|
+
return PlaywrightResult(success=True, screenshot=png_bytes)
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
115
|
+
|
|
116
|
+
async def click(self, selector: str) -> PlaywrightResult:
|
|
117
|
+
"""Click the element matching *selector*."""
|
|
118
|
+
self._check_playwright()
|
|
119
|
+
try:
|
|
120
|
+
await self._page.click(selector)
|
|
121
|
+
return PlaywrightResult(success=True)
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
124
|
+
|
|
125
|
+
async def fill(self, selector: str, value: str) -> PlaywrightResult:
|
|
126
|
+
"""Fill an input element matching *selector* with *value*."""
|
|
127
|
+
self._check_playwright()
|
|
128
|
+
try:
|
|
129
|
+
await self._page.fill(selector, value)
|
|
130
|
+
return PlaywrightResult(success=True)
|
|
131
|
+
except Exception as exc: # noqa: BLE001
|
|
132
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
133
|
+
|
|
134
|
+
async def evaluate(self, expression: str) -> PlaywrightResult:
|
|
135
|
+
"""Evaluate a JavaScript *expression* in the page context.
|
|
136
|
+
|
|
137
|
+
Blocks dangerous patterns: ``fetch(``, ``XMLHttpRequest``,
|
|
138
|
+
``eval(``, ``import(``.
|
|
139
|
+
"""
|
|
140
|
+
self._check_playwright()
|
|
141
|
+
for pattern in _DANGEROUS_JS_PATTERNS:
|
|
142
|
+
if pattern in expression:
|
|
143
|
+
return PlaywrightResult(
|
|
144
|
+
success=False,
|
|
145
|
+
error=f"Unsafe JS pattern rejected: '{pattern}'",
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
result = await self._page.evaluate(expression)
|
|
149
|
+
return PlaywrightResult(success=True, data=result)
|
|
150
|
+
except Exception as exc: # noqa: BLE001
|
|
151
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
152
|
+
|
|
153
|
+
async def get_text(self, selector: str = "body") -> PlaywrightResult:
|
|
154
|
+
"""Return the inner text of *selector* (default: ``body``)."""
|
|
155
|
+
self._check_playwright()
|
|
156
|
+
try:
|
|
157
|
+
text = await self._page.inner_text(selector)
|
|
158
|
+
return PlaywrightResult(success=True, data=text)
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
return PlaywrightResult(success=False, error=str(exc))
|
|
161
|
+
|
|
162
|
+
async def close(self) -> None:
|
|
163
|
+
"""Close the browser and clean up Playwright resources."""
|
|
164
|
+
try:
|
|
165
|
+
if self._browser is not None:
|
|
166
|
+
await self._browser.close()
|
|
167
|
+
self._browser = None
|
|
168
|
+
if self._playwright is not None:
|
|
169
|
+
await self._playwright.stop()
|
|
170
|
+
self._playwright = None
|
|
171
|
+
except Exception: # noqa: BLE001
|
|
172
|
+
pass
|