bugpilot 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.
Files changed (59) hide show
  1. bugpilot/__init__.py +5 -0
  2. bugpilot/__main__.py +5 -0
  3. bugpilot/cli.py +2615 -0
  4. bugpilot/cli_json.py +173 -0
  5. bugpilot/core/__init__.py +1 -0
  6. bugpilot/core/agent_runner.py +138 -0
  7. bugpilot/core/artifact_io.py +40 -0
  8. bugpilot/core/artifacts.py +47 -0
  9. bugpilot/core/attachments.py +247 -0
  10. bugpilot/core/branch_policy.py +223 -0
  11. bugpilot/core/cleanup.py +58 -0
  12. bugpilot/core/code_files.py +99 -0
  13. bugpilot/core/config.py +242 -0
  14. bugpilot/core/context.py +436 -0
  15. bugpilot/core/copilot.py +47 -0
  16. bugpilot/core/delivery_instructions.py +106 -0
  17. bugpilot/core/doctor.py +66 -0
  18. bugpilot/core/email_notify.py +316 -0
  19. bugpilot/core/errors.py +105 -0
  20. bugpilot/core/executables.py +87 -0
  21. bugpilot/core/fix_mode_state.py +212 -0
  22. bugpilot/core/fix_mode_store.py +600 -0
  23. bugpilot/core/fix_modes.py +412 -0
  24. bugpilot/core/fix_report.py +124 -0
  25. bugpilot/core/git_history.py +1579 -0
  26. bugpilot/core/git_ops.py +254 -0
  27. bugpilot/core/handoff.py +127 -0
  28. bugpilot/core/identity.py +112 -0
  29. bugpilot/core/input_adapters.py +97 -0
  30. bugpilot/core/instructions.py +337 -0
  31. bugpilot/core/issue.py +487 -0
  32. bugpilot/core/jira.py +886 -0
  33. bugpilot/core/jira_adf.py +167 -0
  34. bugpilot/core/jira_parse.py +442 -0
  35. bugpilot/core/keywords.py +386 -0
  36. bugpilot/core/logging_utils.py +28 -0
  37. bugpilot/core/memory.py +151 -0
  38. bugpilot/core/models.py +322 -0
  39. bugpilot/core/project_settings.py +283 -0
  40. bugpilot/core/prompts.py +567 -0
  41. bugpilot/core/repository_profile.py +739 -0
  42. bugpilot/core/retrieval.py +485 -0
  43. bugpilot/core/review_changes.py +155 -0
  44. bugpilot/core/review_report.py +171 -0
  45. bugpilot/core/run.py +171 -0
  46. bugpilot/core/safe_paths.py +173 -0
  47. bugpilot/core/search.py +765 -0
  48. bugpilot/core/search_terms.py +310 -0
  49. bugpilot/core/setup.py +212 -0
  50. bugpilot/core/user_config.py +211 -0
  51. bugpilot/core/verification_report.py +313 -0
  52. bugpilot/core/workflow.py +2385 -0
  53. bugpilot/mcp_server.py +651 -0
  54. bugpilot-0.1.0.dist-info/METADATA +270 -0
  55. bugpilot-0.1.0.dist-info/RECORD +59 -0
  56. bugpilot-0.1.0.dist-info/WHEEL +5 -0
  57. bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
  58. bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
  59. bugpilot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,316 @@
1
+ """Build and send the post-fix notification email.
2
+
3
+ The email is assembled entirely from local bugpilot artifacts (fix_report.md
4
+ and issue.json) and sent over SMTP using credentials supplied through the
5
+ environment. bugpilot never hardcodes SMTP secrets and never commits, pushes, or
6
+ updates Jira as part of sending this notification.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+ import smtplib
14
+ import ssl
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from email.generator import BytesGenerator
19
+ from email.message import EmailMessage
20
+ from email.utils import formataddr, parseaddr
21
+ from pathlib import Path
22
+ from urllib.parse import urlencode
23
+
24
+ from .config import EmailConfig, GraphConfig, issue_dir, load_config
25
+ from .fix_report import read_fix_report
26
+ from .issue import read_issue_quietly
27
+ from .jira import JiraSiteError, normalize_jira_site, sanitize_comment_text
28
+ from .models import SOURCE_MANUAL
29
+
30
+ GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
31
+ GRAPH_SENDMAIL_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
32
+ GRAPH_SCOPE = "https://graph.microsoft.com/.default"
33
+
34
+ MAX_SECTION_CHARS = 4000
35
+ MAX_BODY_CHARS = 60000
36
+ _NOT_AVAILABLE = "Not available in local artifacts."
37
+
38
+
39
+ @dataclass
40
+ class EmailDraft:
41
+ issue_key: str
42
+ subject: str
43
+ body: str
44
+
45
+
46
+ @dataclass
47
+ class EmailSendResult:
48
+ issue_key: str
49
+ sent: bool
50
+ recipients: tuple[str, ...]
51
+ skipped_reason: str | None = None
52
+
53
+
54
+ class EmailSendError(Exception):
55
+ """Raised when an SMTP send fails. The message never contains credentials."""
56
+
57
+
58
+ def build_email_draft(repo_root: Path, issue_key: str) -> EmailDraft:
59
+ report = read_fix_report(repo_root, issue_key)
60
+
61
+ # The title and the original problem come from the normalized issue, which a
62
+ # Jira work item and a hand-written one share. Without them the notification
63
+ # says "not available" for the one thing the reader most needs.
64
+ issue = read_issue_quietly(repo_root, issue_key)
65
+ is_manual = issue is not None and issue.source == SOURCE_MANUAL
66
+
67
+ summary_line = (issue.title if issue else "") or (report.summary if report else "")
68
+ summary_line = _first_meaningful_line(summary_line)
69
+
70
+ original_problem = (issue.description if issue else "") or _NOT_AVAILABLE
71
+ root_cause = (report.analysis if report else "") or _NOT_AVAILABLE
72
+ changes = (report.changes if report else "") or _NOT_AVAILABLE
73
+ test_summary = (report.tests if report else "") or _NOT_AVAILABLE
74
+
75
+ subject = f"[bugpilot] {issue_key} fix ready to commit"
76
+ if summary_line:
77
+ subject = f"{subject}: {summary_line[:80]}"
78
+
79
+ link_line = "" if is_manual else _jira_link(repo_root, issue_key)
80
+ item_heading = "Work Item" if is_manual else "Jira Item"
81
+ body = (
82
+ "bugpilot bug fix notification\n"
83
+ "===========================\n\n"
84
+ "Automated notification generated at the commit decision point.\n"
85
+ "Review before committing. bugpilot did NOT commit, push, merge, open a PR,\n"
86
+ "or update Jira.\n\n"
87
+ f"## {item_heading}\n"
88
+ f"{issue_key}" + (f" — {summary_line}" if summary_line else "") + "\n"
89
+ + (f"{link_line}\n" if link_line else "")
90
+ + "\n"
91
+ "## Original Problem\n"
92
+ f"{_cap(original_problem)}\n\n"
93
+ "## Bug Cause / Root Cause\n"
94
+ f"{_cap(root_cause)}\n\n"
95
+ "## Changes Made\n"
96
+ f"{_cap(changes)}\n\n"
97
+ "## Tests\n"
98
+ f"{_cap(test_summary)}\n\n"
99
+ "--\n"
100
+ f"Generated by bugpilot from local .ai/{issue_key}/ artifacts.\n"
101
+ )
102
+ body = _cap(sanitize_comment_text(body), MAX_BODY_CHARS)
103
+ return EmailDraft(issue_key=issue_key, subject=subject, body=body)
104
+
105
+
106
+ def send_notification(config: EmailConfig, draft: EmailDraft) -> EmailSendResult:
107
+ if not config.is_configured:
108
+ missing = ", ".join(config.missing_fields())
109
+ return EmailSendResult(
110
+ issue_key=draft.issue_key,
111
+ sent=False,
112
+ recipients=config.recipients,
113
+ skipped_reason=f"Email not configured. Missing: {missing}.",
114
+ )
115
+
116
+ invalid = [addr for addr in config.recipients if not _looks_like_email(addr)]
117
+ if invalid or not _looks_like_email(config.sender or ""):
118
+ bad = ", ".join(invalid or [config.sender or "(empty)"])
119
+ raise EmailSendError(f"Invalid email address configured: {bad}")
120
+
121
+ message = _build_message(config, draft)
122
+ try:
123
+ _deliver(config, message)
124
+ except (smtplib.SMTPException, OSError) as exc:
125
+ # Surface the error class/reason, never the credentials.
126
+ raise EmailSendError(f"SMTP send failed: {type(exc).__name__}: {exc}") from exc
127
+
128
+ return EmailSendResult(
129
+ issue_key=draft.issue_key,
130
+ sent=True,
131
+ recipients=config.recipients,
132
+ )
133
+
134
+
135
+ def render_eml(draft: EmailDraft, sender: str | None = None, recipients: tuple[str, ...] = ()) -> bytes:
136
+ """Render the draft as a standalone RFC 822 .eml file.
137
+
138
+ Works without SMTP configuration: From/To headers are filled in only when
139
+ available so the file can be opened in Outlook (which sends over its own
140
+ modern-auth channel, sidestepping SMTP entirely).
141
+ """
142
+ message = _message(draft, sender, recipients)
143
+ buffer = io.BytesIO()
144
+ BytesGenerator(buffer, policy=message.policy).flatten(message)
145
+ return buffer.getvalue()
146
+
147
+
148
+ def send_via_graph(
149
+ graph_config: GraphConfig,
150
+ draft: EmailDraft,
151
+ sender: str | None,
152
+ recipients: tuple[str, ...],
153
+ ) -> EmailSendResult:
154
+ """Send the notification through Microsoft Graph (client-credentials flow).
155
+
156
+ Uses HTTPS only, so it works when the tenant disables SMTP client auth and
157
+ when outbound port 25 is blocked. Requires an app registration with the
158
+ application permission Mail.Send (admin-consented).
159
+ """
160
+ if not graph_config.is_configured or not sender or not recipients:
161
+ missing = ", ".join(graph_config.missing_fields())
162
+ if not sender:
163
+ missing = (missing + ", BUGPILOT_EMAIL_FROM").lstrip(", ")
164
+ if not recipients:
165
+ missing = (missing + ", BUGPILOT_EMAIL_TO").lstrip(", ")
166
+ return EmailSendResult(
167
+ issue_key=draft.issue_key,
168
+ sent=False,
169
+ recipients=recipients,
170
+ skipped_reason=f"Graph email not configured. Missing: {missing}.",
171
+ )
172
+
173
+ invalid = [addr for addr in recipients if not _looks_like_email(addr)]
174
+ if invalid or not _looks_like_email(sender):
175
+ bad = ", ".join(invalid or [sender])
176
+ raise EmailSendError(f"Invalid email address configured: {bad}")
177
+
178
+ token = _graph_token(graph_config)
179
+ _graph_send_mail(token, sender, recipients, draft)
180
+ return EmailSendResult(issue_key=draft.issue_key, sent=True, recipients=recipients)
181
+
182
+
183
+ def _graph_token(graph_config: GraphConfig) -> str:
184
+ data = urlencode(
185
+ {
186
+ "client_id": graph_config.client_id,
187
+ "client_secret": graph_config.client_secret,
188
+ "scope": GRAPH_SCOPE,
189
+ "grant_type": "client_credentials",
190
+ }
191
+ ).encode("utf-8")
192
+ request = urllib.request.Request(
193
+ GRAPH_TOKEN_URL.format(tenant=graph_config.tenant_id),
194
+ data=data,
195
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
196
+ method="POST",
197
+ )
198
+ try:
199
+ with urllib.request.urlopen(request, timeout=30) as response:
200
+ payload = json.loads(response.read().decode("utf-8"))
201
+ except urllib.error.HTTPError as exc:
202
+ # Do not echo the request body (it carries the client secret).
203
+ raise EmailSendError(f"Graph token request failed: HTTP {exc.code}") from exc
204
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
205
+ raise EmailSendError(f"Graph token request failed: {type(exc).__name__}: {exc}") from exc
206
+ token = payload.get("access_token") if isinstance(payload, dict) else None
207
+ if not token:
208
+ raise EmailSendError("Graph token response did not contain an access token.")
209
+ return str(token)
210
+
211
+
212
+ def _graph_send_mail(token: str, sender: str, recipients: tuple[str, ...], draft: EmailDraft) -> None:
213
+ body = json.dumps(
214
+ {
215
+ "message": {
216
+ "subject": draft.subject,
217
+ "body": {"contentType": "Text", "content": draft.body},
218
+ "toRecipients": [{"emailAddress": {"address": addr}} for addr in recipients],
219
+ },
220
+ "saveToSentItems": True,
221
+ }
222
+ ).encode("utf-8")
223
+ request = urllib.request.Request(
224
+ GRAPH_SENDMAIL_URL.format(sender=sender),
225
+ data=body,
226
+ headers={
227
+ "Authorization": f"Bearer {token}",
228
+ "Content-Type": "application/json",
229
+ },
230
+ method="POST",
231
+ )
232
+ try:
233
+ with urllib.request.urlopen(request, timeout=30) as response:
234
+ response.read()
235
+ except urllib.error.HTTPError as exc:
236
+ detail = _graph_error_detail(exc)
237
+ raise EmailSendError(f"Graph sendMail failed: HTTP {exc.code}{detail}") from exc
238
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
239
+ raise EmailSendError(f"Graph sendMail failed: {type(exc).__name__}: {exc}") from exc
240
+
241
+
242
+ def _graph_error_detail(exc: urllib.error.HTTPError) -> str:
243
+ try:
244
+ payload = json.loads(exc.read().decode("utf-8"))
245
+ code = payload.get("error", {}).get("code")
246
+ return f" ({code})" if code else ""
247
+ except Exception:
248
+ return ""
249
+
250
+
251
+ def _build_message(config: EmailConfig, draft: EmailDraft) -> EmailMessage:
252
+ return _message(draft, config.sender, config.recipients)
253
+
254
+
255
+ def _message(draft: EmailDraft, sender: str | None, recipients: tuple[str, ...]) -> EmailMessage:
256
+ message = EmailMessage()
257
+ message["Subject"] = draft.subject
258
+ if sender:
259
+ message["From"] = formataddr(("bugpilot", sender))
260
+ if recipients:
261
+ message["To"] = ", ".join(recipients)
262
+ message.set_content(draft.body)
263
+ return message
264
+
265
+
266
+ def _deliver(config: EmailConfig, message: EmailMessage) -> None:
267
+ host = config.host or ""
268
+ if config.use_ssl:
269
+ context = ssl.create_default_context()
270
+ with smtplib.SMTP_SSL(host, config.port, timeout=30, context=context) as server:
271
+ _authenticate_and_send(server, config, message)
272
+ return
273
+ with smtplib.SMTP(host, config.port, timeout=30) as server:
274
+ if config.use_starttls:
275
+ server.starttls(context=ssl.create_default_context())
276
+ _authenticate_and_send(server, config, message)
277
+
278
+
279
+ def _authenticate_and_send(server: smtplib.SMTP, config: EmailConfig, message: EmailMessage) -> None:
280
+ if config.uses_auth:
281
+ server.login(config.username or "", config.password or "")
282
+ server.send_message(message)
283
+
284
+
285
+ def _jira_link(repo_root: Path, issue_key: str) -> str:
286
+ base_url = load_config(repo_root).jira_base_url
287
+ if not base_url:
288
+ return ""
289
+ # The site Jira requests accept, or no link: an http:// or credential-bearing
290
+ # value never reaches a recipient's mailbox.
291
+ try:
292
+ site = normalize_jira_site(base_url)
293
+ except JiraSiteError:
294
+ return ""
295
+ return f"Link: {site}/browse/{issue_key}"
296
+
297
+
298
+ def _first_meaningful_line(text: str) -> str:
299
+ for line in text.splitlines():
300
+ stripped = line.strip()
301
+ if stripped and stripped != "TBD":
302
+ return stripped
303
+ return ""
304
+
305
+
306
+ def _cap(text: str, limit: int = MAX_SECTION_CHARS) -> str:
307
+ text = text.strip() or _NOT_AVAILABLE
308
+ if len(text) <= limit:
309
+ return text
310
+ return text[:limit].rstrip() + "\n\n[truncated by bugpilot]"
311
+
312
+
313
+ def _looks_like_email(address: str) -> bool:
314
+ name, addr = parseaddr(address)
315
+ del name
316
+ return "@" in addr and "." in addr.split("@")[-1] and " " not in addr
@@ -0,0 +1,105 @@
1
+ """Stable error codes shared by every entry point.
2
+
3
+ The codes are the machine-facing half of a failure; the human message is the
4
+ other half and may be reworded freely. A VS Code extension branches on
5
+ ``error.code`` and never parses the message — see ``docs/adapter_design.md``
6
+ section 5.1.
7
+
8
+ A code says *what went wrong*. The command being run is reported separately, so
9
+ the same JIRA_AUTH_FAILED covers a failed fetch and a failed comment post rather
10
+ than needing one code per operation.
11
+
12
+ Adding a code is a contract change: append, never rename or repurpose.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # --- Jira -------------------------------------------------------------------
18
+
19
+ JIRA_NOT_CONFIGURED = "JIRA_NOT_CONFIGURED"
20
+ JIRA_AUTH_FAILED = "JIRA_AUTH_FAILED"
21
+ JIRA_ISSUE_NOT_FOUND = "JIRA_ISSUE_NOT_FOUND"
22
+ JIRA_RATE_LIMITED = "JIRA_RATE_LIMITED"
23
+ JIRA_TIMEOUT = "JIRA_TIMEOUT"
24
+ JIRA_NETWORK_ERROR = "JIRA_NETWORK_ERROR"
25
+ JIRA_INVALID_RESPONSE = "JIRA_INVALID_RESPONSE"
26
+ JIRA_ERROR = "JIRA_ERROR"
27
+ # The configured Jira site is not an https:// address BugPilot will send
28
+ # credentials to.
29
+ JIRA_INVALID_SITE = "JIRA_INVALID_SITE"
30
+ # Jira redirected to another origin, and the credentials were not sent there.
31
+ JIRA_REDIRECT_REFUSED = "JIRA_REDIRECT_REFUSED"
32
+
33
+ # --- source-mode gates (design section 5.1) ---------------------------------
34
+
35
+ # The command only makes sense for a Jira work item (fetch, jira-validate, parse).
36
+ JIRA_ONLY_COMMAND = "JIRA_ONLY_COMMAND"
37
+ # There is no external issue to write back to: a manual work item has no source_ref.
38
+ NO_JIRA_TARGET = "NO_JIRA_TARGET"
39
+
40
+ # --- local state ------------------------------------------------------------
41
+
42
+ WORK_ITEM_NOT_FOUND = "WORK_ITEM_NOT_FOUND"
43
+ ARTIFACT_NOT_FOUND = "ARTIFACT_NOT_FOUND"
44
+ MISSING_RESULTS = "MISSING_RESULTS"
45
+ INVALID_INPUT = "INVALID_INPUT"
46
+ # The artifact a command would write is already there, and the command keeps it
47
+ # unless asked to replace it (record-review or record-verification without
48
+ # --replace).
49
+ ARTIFACT_EXISTS = "ARTIFACT_EXISTS"
50
+
51
+ # --- other ------------------------------------------------------------------
52
+
53
+ EMAIL_SEND_FAILED = "EMAIL_SEND_FAILED"
54
+ INTERNAL_ERROR = "INTERNAL_ERROR"
55
+
56
+ # jira.ERROR_MESSAGES keys -> codes. That vocabulary already exists and is
57
+ # stable, so the mapping is a rename rather than a second classification.
58
+ _JIRA_ERROR_TYPES: dict[str, str] = {
59
+ "missing_env": JIRA_NOT_CONFIGURED,
60
+ "auth_or_permission": JIRA_AUTH_FAILED,
61
+ "not_found": JIRA_ISSUE_NOT_FOUND,
62
+ "rate_limited": JIRA_RATE_LIMITED,
63
+ "timeout": JIRA_TIMEOUT,
64
+ "network_error": JIRA_NETWORK_ERROR,
65
+ "invalid_response": JIRA_INVALID_RESPONSE,
66
+ "unknown_error": JIRA_ERROR,
67
+ "invalid_site": JIRA_INVALID_SITE,
68
+ "redirect_refused": JIRA_REDIRECT_REFUSED,
69
+ }
70
+
71
+
72
+ def code_for_jira_error_type(error_type: str | None) -> str:
73
+ """Map a ``jira.ERROR_MESSAGES`` key to its stable code."""
74
+ return _JIRA_ERROR_TYPES.get(error_type or "", JIRA_ERROR)
75
+
76
+
77
+ def error_code_for(exc: BaseException) -> str:
78
+ """Classify an exception for the machine-facing envelope.
79
+
80
+ Imports are local so this module stays a leaf: every adapter needs the code
81
+ vocabulary, and a top-level import of ``jira`` would drag its network stack
82
+ into anything that only wants to name a code.
83
+ """
84
+ from .email_notify import EmailSendError
85
+ from .jira import JiraCommentPostError, JiraFetchError
86
+
87
+ if isinstance(exc, JiraFetchError):
88
+ return code_for_jira_error_type(getattr(exc.result, "error_type", None))
89
+ if isinstance(exc, JiraCommentPostError):
90
+ return code_for_jira_error_type(getattr(exc, "error_type", None))
91
+ if isinstance(exc, EmailSendError):
92
+ return EMAIL_SEND_FAILED
93
+ from .run import RunArtifactError
94
+
95
+ if isinstance(exc, FileNotFoundError):
96
+ return ARTIFACT_NOT_FOUND
97
+ if isinstance(exc, FileExistsError):
98
+ return ARTIFACT_EXISTS
99
+ # Before the ValueError arm it subclasses: an unusable run.json is a state
100
+ # problem the caller fixes by re-preparing, not a bad argument.
101
+ if isinstance(exc, RunArtifactError):
102
+ return ARTIFACT_NOT_FOUND
103
+ if isinstance(exc, ValueError):
104
+ return INVALID_INPUT
105
+ return INTERNAL_ERROR
@@ -0,0 +1,87 @@
1
+ """Finding external programs without trusting the current directory.
2
+
3
+ BugPilot runs in the repository it is preparing: that directory is the process's
4
+ current directory, and every ``git`` and ``rg`` call passes it as ``cwd``. On
5
+ Windows a bare program name is looked up in the current directory *before*
6
+ ``PATH`` — by ``CreateProcess`` and by ``shutil.which`` alike, unless the
7
+ ``NoDefaultCurrentDirectoryInExePath`` variable happens to be set — so a
8
+ ``git.exe`` committed to a repository would run instead of Git the moment
9
+ BugPilot touched it.
10
+
11
+ So no program is started by its bare name. :func:`find_executable` resolves a
12
+ name to an absolute path using only the absolute entries of ``PATH``, in order,
13
+ with ``PATHEXT`` on Windows; it never consults the current directory, and it
14
+ skips empty, ``.`` and other relative ``PATH`` entries, which mean the same
15
+ thing. Callers start the absolute path it returns.
16
+
17
+ The VS Code extension applies the same policy to the programs it starts
18
+ (``extension/src/executablePath.ts``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ _DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD"
28
+
29
+
30
+ def _is_windows() -> bool:
31
+ return sys.platform == "win32"
32
+
33
+
34
+ def _runnable(candidate: Path) -> bool:
35
+ try:
36
+ if not candidate.is_file():
37
+ return False
38
+ except OSError:
39
+ return False
40
+ return True if _is_windows() else os.access(candidate, os.X_OK)
41
+
42
+
43
+ def find_executable(name: str, path: str | None = None) -> str | None:
44
+ """The absolute path ``name`` resolves to, or ``None``.
45
+
46
+ - An absolute path is returned as it is, if it names a runnable file.
47
+ - A relative path with a directory part (``tools/rg``) is refused: it would
48
+ resolve against the repository.
49
+ - A bare name is searched in the absolute entries of ``path`` (default: the
50
+ ``PATH`` environment variable), never in the current directory.
51
+ """
52
+ if not name:
53
+ return None
54
+ given = Path(name)
55
+ if given.is_absolute():
56
+ return str(given) if _runnable(given) else None
57
+ if os.sep in name or (os.altsep and os.altsep in name) or "/" in name:
58
+ return None
59
+ search = os.environ.get("PATH", "") if path is None else path
60
+ if _is_windows():
61
+ extensions = [ext for ext in (os.environ.get("PATHEXT") or _DEFAULT_PATHEXT).split(";") if ext]
62
+ has_extension = any(name.lower().endswith(ext.lower()) for ext in extensions)
63
+ names = [name] if has_extension else [name + ext for ext in extensions]
64
+ else:
65
+ names = [name]
66
+ for entry in search.split(os.pathsep):
67
+ entry = entry.strip().strip('"')
68
+ if not entry or not os.path.isabs(entry):
69
+ continue
70
+ for candidate_name in names:
71
+ candidate = Path(entry) / candidate_name
72
+ if _runnable(candidate):
73
+ return str(candidate)
74
+ return None
75
+
76
+
77
+ def child_environment(base: dict[str, str] | None = None) -> dict[str, str]:
78
+ """An environment for a child that starts programs of its own.
79
+
80
+ On Windows, ``NoDefaultCurrentDirectoryInExePath`` makes ``cmd.exe`` and
81
+ ``CreateProcess`` callers inside the child skip the current directory too —
82
+ an npm ``claude.cmd`` shim, for one, looks up a bare ``node``.
83
+ """
84
+ env = dict(os.environ if base is None else base)
85
+ if _is_windows():
86
+ env["NoDefaultCurrentDirectoryInExePath"] = "1"
87
+ return env