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,600 @@
1
+ """Custom Fix Modes on disk: where they live, and what may be done to them.
2
+
3
+ Two scopes, and the difference between them is who the mode belongs to:
4
+
5
+ ~/.bugpilot/fix_modes/<id>.json one developer's own workflows
6
+ <repo>/.bugpilot/fix_modes/<id>.json a team's, committed with the code
7
+
8
+ Built-ins stay packaged and read-only. A custom file may not claim one of their
9
+ ids, and may not claim to *be* one: `source` is never stored in the file, it is
10
+ derived from the directory the file was found in. A JSON file that could say
11
+ `"source": "builtin"` would be a file that could dress itself up as something
12
+ BugPilot ships.
13
+
14
+ Two views of the same modes, deliberately kept apart:
15
+
16
+ - the **effective registry** answers "which definition does this id run?",
17
+ one mode per id, project over user, built-ins reserved;
18
+ - the **catalog** answers "what exists on disk?", and keeps both
19
+ `user/my-safe` and `project/my-safe` visible so that management can address
20
+ either. Resolving through the effective registry to decide what to edit
21
+ would make the shadowed one unreachable.
22
+
23
+ Nothing here is cached. A store is built per command, so a file edited by hand
24
+ between two runs is seen by the second one — these files are meant to be edited
25
+ and committed like any other repository config.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Iterable, Literal
34
+
35
+ from .artifact_io import atomic_write_text
36
+ from .fix_modes import (
37
+ BUILTIN_FIX_MODES,
38
+ FixMode,
39
+ FixModeError,
40
+ FixModeNotFoundError,
41
+ FixModeRegistry,
42
+ builtin_fix_mode_registry,
43
+ is_valid_fix_mode_id,
44
+ )
45
+ from .safe_paths import is_link_or_junction
46
+ from .user_config import user_config_dir
47
+
48
+ CUSTOM_SCHEMA_VERSION = 1
49
+ FIX_MODE_DIR_NAME = ".bugpilot"
50
+ FIX_MODE_SUBDIR = "fix_modes"
51
+
52
+ CustomScope = Literal["user", "project"]
53
+ CUSTOM_SCOPES: tuple[str, ...] = ("user", "project")
54
+
55
+ BUILTIN_FIX_MODE_IDS: frozenset[str] = frozenset(mode.id for mode in BUILTIN_FIX_MODES)
56
+
57
+ # The content of a mode, as a file carries it. `source` is absent on purpose and
58
+ # `version` is managed by this module, never by whoever wrote the file.
59
+ _CONTENT_FIELDS: tuple[str, ...] = (
60
+ "name",
61
+ "description",
62
+ "objective",
63
+ "investigation",
64
+ "implementation",
65
+ "verification",
66
+ "constraints",
67
+ "completion",
68
+ "execution_kind",
69
+ )
70
+ _ORIGIN_FIELDS: tuple[str, ...] = ("based_on", "based_on_version")
71
+ _ALLOWED_KEYS: frozenset[str] = frozenset(
72
+ ("schema_version", "id", "version", *_CONTENT_FIELDS, *_ORIGIN_FIELDS)
73
+ )
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class FixModeIssue:
78
+ """A custom file that could not be loaded, and why.
79
+
80
+ Carried rather than raised: one unreadable file must not take the other
81
+ modes down with it, and must not be silently skipped either. The developer
82
+ needs the path and the reason, in the listing where they would look.
83
+ """
84
+
85
+ scope: str
86
+ path: str
87
+ message: str
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class FixModeCatalog:
92
+ """Every mode that physically exists, by scope, plus what could not be read."""
93
+
94
+ builtin: tuple[FixMode, ...]
95
+ user: tuple[FixMode, ...]
96
+ project: tuple[FixMode, ...]
97
+ issues: tuple[FixModeIssue, ...] = ()
98
+
99
+ def scoped(self, scope: str) -> tuple[FixMode, ...]:
100
+ if scope == "builtin":
101
+ return self.builtin
102
+ if scope == "user":
103
+ return self.user
104
+ if scope == "project":
105
+ return self.project
106
+ raise FixModeError(f"Unknown Fix Mode scope: {scope!r}.")
107
+
108
+ def effective_modes(self) -> tuple[FixMode, ...]:
109
+ """One mode per id: project over user, built-ins never overridden.
110
+
111
+ Order is deterministic and does not depend on directory enumeration:
112
+ built-ins in their packaged order, then custom ids alphabetically.
113
+ """
114
+ effective: dict[str, FixMode] = {mode.id: mode for mode in self.builtin}
115
+ custom: dict[str, FixMode] = {}
116
+ for mode in self.user:
117
+ custom[mode.id] = mode
118
+ for mode in self.project:
119
+ # Project beats user for a custom id. Stated here rather than left to
120
+ # the order two directories happen to be walked in.
121
+ custom[mode.id] = mode
122
+ for mode_id in sorted(custom):
123
+ if mode_id in effective:
124
+ # Unreachable through `load_catalog`, which refuses a reserved id
125
+ # at load time. Kept so that a hand-built catalog cannot shadow a
126
+ # built-in either.
127
+ continue
128
+ effective[mode_id] = custom[mode_id]
129
+ return tuple(effective.values())
130
+
131
+ def effective_registry(self) -> FixModeRegistry:
132
+ return FixModeRegistry(self.effective_modes())
133
+
134
+ def is_effective(self, mode: FixMode) -> bool:
135
+ """Whether this physical definition is the one its id resolves to."""
136
+ for candidate in self.effective_modes():
137
+ if candidate.id == mode.id:
138
+ return candidate is mode or candidate == mode
139
+ return False
140
+
141
+
142
+ def custom_mode_payload(mode: FixMode) -> dict[str, object]:
143
+ """A mode as its file spells it: content, origin, version — never source."""
144
+ payload: dict[str, object] = {"schema_version": CUSTOM_SCHEMA_VERSION, "id": mode.id}
145
+ for field in _CONTENT_FIELDS:
146
+ payload[field] = getattr(mode, field)
147
+ payload["version"] = mode.version
148
+ payload["based_on"] = mode.based_on
149
+ payload["based_on_version"] = mode.based_on_version
150
+ return payload
151
+
152
+
153
+ def _require_scope(scope: str) -> CustomScope:
154
+ if scope not in CUSTOM_SCOPES:
155
+ raise FixModeError(
156
+ f"Unknown Fix Mode scope {scope!r}. Custom modes live in "
157
+ f"{' or '.join(CUSTOM_SCOPES)} scope; built-in modes cannot be modified."
158
+ )
159
+ return scope # type: ignore[return-value]
160
+
161
+
162
+ def _require_not_reserved(mode_id: str) -> str:
163
+ if mode_id in BUILTIN_FIX_MODE_IDS:
164
+ raise FixModeError(
165
+ f"Built-in Fix Mode {mode_id!r} is reserved and cannot be overridden. "
166
+ "Duplicate it under a different custom id."
167
+ )
168
+ return mode_id
169
+
170
+
171
+ def _require_managed_version(value: object, field: str) -> int:
172
+ """A version a caller sends back to us, checked before it decides anything.
173
+
174
+ `True` is not 1 here: a JSON `true` that slipped into an expected-version
175
+ field must not pass for the first version of a mode and silently authorize
176
+ an overwrite.
177
+ """
178
+ if isinstance(value, bool) or not isinstance(value, int):
179
+ raise FixModeError(
180
+ f"{field} must be an integer, got {type(value).__name__}."
181
+ )
182
+ if value < 1:
183
+ raise FixModeError(f"{field} must be >= 1.")
184
+ return value
185
+
186
+
187
+ def fix_mode_from_payload(
188
+ payload: object,
189
+ *,
190
+ scope: CustomScope,
191
+ mode_id: str | None = None,
192
+ require_version: bool = True,
193
+ where: str = "Fix Mode definition",
194
+ ) -> FixMode:
195
+ """Build a validated `FixMode` from a custom definition.
196
+
197
+ Strict on purpose. An unknown key is an error rather than something ignored,
198
+ because the realistic way to lose a section is to misspell it: a tolerant
199
+ reader would take `"verfication"` as "no verification given" and the mode
200
+ would quietly run without it. `source` is refused outright — the directory
201
+ decides that, not the file.
202
+ """
203
+ if not isinstance(payload, dict):
204
+ raise FixModeError(f"{where} must be a JSON object.")
205
+ if "source" in payload:
206
+ raise FixModeError(
207
+ f"{where} must not set 'source'. A Fix Mode's scope comes from where it "
208
+ "is stored, so a file cannot claim to be built-in."
209
+ )
210
+ unknown = sorted(set(payload) - _ALLOWED_KEYS)
211
+ if unknown:
212
+ raise FixModeError(
213
+ f"{where} has unsupported field(s): {', '.join(unknown)}. Allowed: "
214
+ f"{', '.join(sorted(_ALLOWED_KEYS))}."
215
+ )
216
+ declared = payload.get("schema_version", CUSTOM_SCHEMA_VERSION)
217
+ if declared != CUSTOM_SCHEMA_VERSION:
218
+ raise FixModeError(
219
+ f"{where} has schema_version {declared!r}; this BugPilot understands "
220
+ f"{CUSTOM_SCHEMA_VERSION}."
221
+ )
222
+ missing = [field for field in ("id", *_CONTENT_FIELDS) if field not in payload]
223
+ if require_version and "version" not in payload:
224
+ missing.append("version")
225
+ if missing:
226
+ raise FixModeError(f"{where} is missing required field(s): {', '.join(missing)}.")
227
+
228
+ declared_id = payload["id"]
229
+ if mode_id is not None and declared_id != mode_id:
230
+ raise FixModeError(
231
+ f"{where} declares id {declared_id!r} but belongs to {mode_id!r}. "
232
+ "A custom Fix Mode's id and its file name must match."
233
+ )
234
+ version = (
235
+ _require_managed_version(payload["version"], "Fix Mode version")
236
+ if "version" in payload
237
+ else 1
238
+ )
239
+ mode = FixMode(
240
+ id=declared_id if isinstance(declared_id, str) else "",
241
+ name=payload["name"], # type: ignore[arg-type]
242
+ description=payload["description"], # type: ignore[arg-type]
243
+ objective=payload["objective"], # type: ignore[arg-type]
244
+ investigation=payload["investigation"], # type: ignore[arg-type]
245
+ implementation=payload["implementation"], # type: ignore[arg-type]
246
+ verification=payload["verification"], # type: ignore[arg-type]
247
+ constraints=payload["constraints"], # type: ignore[arg-type]
248
+ completion=payload["completion"], # type: ignore[arg-type]
249
+ execution_kind=payload["execution_kind"], # type: ignore[arg-type]
250
+ source=scope,
251
+ based_on=payload.get("based_on"), # type: ignore[arg-type]
252
+ based_on_version=payload.get("based_on_version"), # type: ignore[arg-type]
253
+ version=version,
254
+ )
255
+ mode.validate()
256
+ _require_not_reserved(mode.id)
257
+ return mode
258
+
259
+
260
+ class FixModeStore:
261
+ """The two custom-mode directories, and everything done to them.
262
+
263
+ Built per command rather than kept: these files are ordinary repository and
264
+ home-directory config, and a long-lived copy in memory would answer with
265
+ yesterday's content after a developer edited one in an editor.
266
+ """
267
+
268
+ def __init__(self, repo_root: Path | None = None, home_dir: Path | None = None) -> None:
269
+ self._repo_root = Path(repo_root) if repo_root is not None else None
270
+ # The same directory the rest of BugPilot calls home, so a test that
271
+ # redirects BUGPILOT_CONFIG_DIR redirects custom modes with it.
272
+ self._home_dir = Path(home_dir) if home_dir is not None else user_config_dir()
273
+
274
+ # --- locations ---------------------------------------------------------
275
+
276
+ def scope_dir(self, scope: str) -> Path:
277
+ """Where a scope's modes live. Not created by reading."""
278
+ _require_scope(scope)
279
+ if scope == "user":
280
+ return self._home_dir / FIX_MODE_SUBDIR
281
+ if self._repo_root is None:
282
+ raise FixModeError(
283
+ "Project Fix Modes need a repository. Run this from inside the "
284
+ "repository you are working on, or use --scope user."
285
+ )
286
+ return self._repo_root / FIX_MODE_DIR_NAME / FIX_MODE_SUBDIR
287
+
288
+ def mode_path(self, scope: str, mode_id: str) -> Path:
289
+ """The one file a mode may occupy, checked to be inside its directory.
290
+
291
+ The id is already restricted to lowercase letters, digits and hyphens,
292
+ which leaves no room for a separator or a `..`. This checks the resolved
293
+ path anyway: the id validator is a different function with a different
294
+ job, and a containment rule that depends on another function's invariant
295
+ is a containment rule that breaks when that function changes.
296
+ """
297
+ if not is_valid_fix_mode_id(mode_id):
298
+ raise FixModeError(
299
+ f"Invalid Fix Mode id {mode_id!r}. Use lowercase letters, digits and "
300
+ "hyphens, starting with a letter."
301
+ )
302
+ directory = self.scope_dir(scope)
303
+ # Before anything is built from it: one guard here covers read, create,
304
+ # duplicate, update and delete, which all address a file through this.
305
+ unsafe = _unsafe_directory_reason(directory, scope)
306
+ if unsafe:
307
+ raise FixModeError(unsafe)
308
+ candidate = directory / f"{mode_id}.json"
309
+ base = _resolved(directory)
310
+ resolved = _resolved(candidate)
311
+ if resolved.parent != base:
312
+ raise FixModeError(
313
+ f"Refusing to work on {candidate}: it resolves outside the "
314
+ f"{scope} Fix Mode directory."
315
+ )
316
+ return candidate
317
+
318
+ # --- reading -----------------------------------------------------------
319
+
320
+ def load_catalog(self) -> FixModeCatalog:
321
+ """Everything on disk, by scope, with unreadable files reported."""
322
+ issues: list[FixModeIssue] = []
323
+ user = self._load_scope("user", issues)
324
+ project = self._load_scope("project", issues) if self._repo_root is not None else ()
325
+ return FixModeCatalog(
326
+ # Through the factory rather than the raw tuple: it is the packaged
327
+ # set already validated, and one source for "what BugPilot ships"
328
+ # keeps the reserved ids and the catalog from ever disagreeing.
329
+ builtin=builtin_fix_mode_registry().list_modes(),
330
+ user=user,
331
+ project=project,
332
+ issues=tuple(issues),
333
+ )
334
+
335
+ def effective_registry(self) -> FixModeRegistry:
336
+ return self.load_catalog().effective_registry()
337
+
338
+ def _load_scope(self, scope: str, issues: list[FixModeIssue]) -> tuple[FixMode, ...]:
339
+ try:
340
+ directory = self.scope_dir(scope)
341
+ except FixModeError:
342
+ return ()
343
+ unsafe = _unsafe_directory_reason(directory, scope)
344
+ if unsafe:
345
+ # Reported rather than raised: one unusable scope must not cost the
346
+ # developer the built-ins and the other scope's modes.
347
+ issues.append(FixModeIssue(scope=scope, path=str(directory), message=unsafe))
348
+ return ()
349
+ if not directory.is_dir():
350
+ return ()
351
+ modes: list[FixMode] = []
352
+ # Sorted, so two machines listing the same directory agree.
353
+ for path in sorted(directory.glob("*.json")):
354
+ try:
355
+ modes.append(self._load_file(path, scope))
356
+ except FixModeError as exc:
357
+ issues.append(FixModeIssue(scope=scope, path=str(path), message=str(exc)))
358
+ return tuple(modes)
359
+
360
+ def _load_file(self, path: Path, scope: str) -> FixMode:
361
+ if not path.is_file():
362
+ raise FixModeError(f"{path.name} is not a file.")
363
+ if path.is_symlink():
364
+ # A link is a definition that lives somewhere else, under rules this
365
+ # store does not set. Reading one would make the scope a lie.
366
+ raise FixModeError(
367
+ f"{path.name} is a symbolic link. Custom Fix Modes must be real "
368
+ "files inside their scope directory."
369
+ )
370
+ try:
371
+ raw = json.loads(path.read_text(encoding="utf-8"))
372
+ except json.JSONDecodeError as exc:
373
+ raise FixModeError(f"{path.name} is not valid JSON: {exc}.") from exc
374
+ except OSError as exc:
375
+ raise FixModeError(f"{path.name} could not be read: {exc}.") from exc
376
+ return fix_mode_from_payload(
377
+ raw,
378
+ scope=scope, # type: ignore[arg-type]
379
+ mode_id=path.stem,
380
+ require_version=True,
381
+ where=path.name,
382
+ )
383
+
384
+ def read(self, scope: str, mode_id: str) -> FixMode:
385
+ """One scope's definition of an id, for management rather than execution."""
386
+ path = self.mode_path(scope, mode_id)
387
+ if not path.exists():
388
+ raise FixModeNotFoundError(
389
+ f"No {scope} Fix Mode {mode_id!r}. Run: bugpilot fix-mode list --all-scopes"
390
+ )
391
+ return self._load_file(path, scope)
392
+
393
+ # --- writing -----------------------------------------------------------
394
+
395
+ def create(self, scope: str, mode_id: str, payload: object) -> FixMode:
396
+ """A new custom mode, complete, at version 1.
397
+
398
+ Deliberately does not fill missing sections from Standard Fix: a mode
399
+ assembled half from a payload and half from a built-in is a mode nobody
400
+ wrote. Starting from a built-in is what `duplicate` is for.
401
+ """
402
+ _require_scope(scope)
403
+ _require_not_reserved(mode_id)
404
+ path = self.mode_path(scope, mode_id)
405
+ if path.exists():
406
+ raise FixModeError(
407
+ f"A {scope} Fix Mode {mode_id!r} already exists. Choose another id, "
408
+ "or update the existing one."
409
+ )
410
+ mode = fix_mode_from_payload(
411
+ payload,
412
+ scope=scope, # type: ignore[arg-type]
413
+ mode_id=mode_id,
414
+ require_version=False,
415
+ where=f"{mode_id}.json",
416
+ )
417
+ # Version is managed here, not by whoever wrote the payload.
418
+ mode = _with(mode, version=1)
419
+ self._write(path, mode)
420
+ return mode
421
+
422
+ def duplicate(
423
+ self,
424
+ source_id: str,
425
+ new_id: str,
426
+ scope: str,
427
+ *,
428
+ name: str | None = None,
429
+ registry: FixModeRegistry | None = None,
430
+ ) -> FixMode:
431
+ """A complete, independent copy that remembers where it came from.
432
+
433
+ `based_on` and `based_on_version` are audit metadata and nothing else:
434
+ the copy carries every section itself, so the original can change or be
435
+ deleted afterwards without touching it. There is no inheritance to break.
436
+ """
437
+ _require_scope(scope)
438
+ _require_not_reserved(new_id)
439
+ source = (registry or self.effective_registry()).resolve(source_id)
440
+ copy = FixMode(
441
+ id=new_id,
442
+ name=name if name and name.strip() else f"{source.name} (copy)",
443
+ description=source.description,
444
+ objective=source.objective,
445
+ investigation=source.investigation,
446
+ implementation=source.implementation,
447
+ verification=source.verification,
448
+ constraints=source.constraints,
449
+ completion=source.completion,
450
+ execution_kind=source.execution_kind,
451
+ source=scope, # type: ignore[arg-type]
452
+ based_on=source.id,
453
+ based_on_version=source.version,
454
+ version=1,
455
+ )
456
+ copy.validate()
457
+ path = self.mode_path(scope, new_id)
458
+ if path.exists():
459
+ raise FixModeError(
460
+ f"A {scope} Fix Mode {new_id!r} already exists. Choose another id."
461
+ )
462
+ self._write(path, copy)
463
+ return copy
464
+
465
+ def update(self, scope: str, mode_id: str, payload: object, expected_version: object) -> FixMode:
466
+ """Replace a custom mode's content, one version at a time.
467
+
468
+ The version is BugPilot's to increment and the caller's to state: a
469
+ client that could choose the next number could also skip past someone
470
+ else's save. Everything — the payload, the expected version, the new
471
+ definition — is checked before the file on disk is touched, so a refused
472
+ update leaves exactly what was there.
473
+ """
474
+ _require_scope(scope)
475
+ # Before the lookup: a built-in id is reserved, not merely absent from a
476
+ # custom directory, and "No user Fix Mode 'standard'" would send the
477
+ # developer looking for a file rather than telling them why.
478
+ _require_not_reserved(mode_id)
479
+ current = self.read(scope, mode_id)
480
+ expected = _require_managed_version(expected_version, "expected_version")
481
+ if expected != current.version:
482
+ raise FixModeError(
483
+ f"Fix Mode {mode_id!r} changed since this editor was opened "
484
+ f"(expected version {expected}, found {current.version}). "
485
+ "Reload it before saving."
486
+ )
487
+ updated = fix_mode_from_payload(
488
+ payload,
489
+ scope=scope, # type: ignore[arg-type]
490
+ mode_id=mode_id,
491
+ require_version=False,
492
+ where=f"{mode_id}.json",
493
+ )
494
+ # Identity, origin and version are not the payload's to change: an id
495
+ # rename would orphan every work item that recorded it, and `based_on`
496
+ # records where this mode came from rather than what it says today.
497
+ updated = _with(
498
+ updated,
499
+ version=current.version + 1,
500
+ based_on=current.based_on,
501
+ based_on_version=current.based_on_version,
502
+ )
503
+ self._write(self.mode_path(scope, mode_id), updated)
504
+ return updated
505
+
506
+ def delete(self, scope: str, mode_id: str, expected_version: object) -> FixMode:
507
+ """Remove one custom mode, if it is still the version the caller saw."""
508
+ _require_scope(scope)
509
+ _require_not_reserved(mode_id)
510
+ current = self.read(scope, mode_id)
511
+ expected = _require_managed_version(expected_version, "expected_version")
512
+ if expected != current.version:
513
+ raise FixModeError(
514
+ f"Fix Mode {mode_id!r} changed since it was listed "
515
+ f"(expected version {expected}, found {current.version}). "
516
+ "Reload it before deleting."
517
+ )
518
+ path = self.mode_path(scope, mode_id)
519
+ if path.is_symlink():
520
+ raise FixModeError(
521
+ f"{path.name} is a symbolic link and will not be deleted through BugPilot."
522
+ )
523
+ path.unlink()
524
+ return current
525
+
526
+ def _write(self, path: Path, mode: FixMode) -> None:
527
+ if path.is_symlink():
528
+ raise FixModeError(
529
+ f"{path.name} is a symbolic link. BugPilot will not write through it."
530
+ )
531
+ path.parent.mkdir(parents=True, exist_ok=True)
532
+ text = json.dumps(custom_mode_payload(mode), indent=2, sort_keys=True) + "\n"
533
+ atomic_write_text(path, text)
534
+
535
+
536
+ def _with(mode: FixMode, **changes: object) -> FixMode:
537
+ from dataclasses import replace
538
+
539
+ updated = replace(mode, **changes) # type: ignore[arg-type]
540
+ updated.validate()
541
+ return updated
542
+
543
+
544
+ def _redirects_elsewhere(path: Path) -> bool:
545
+ """Whether this path is a link that sends writes somewhere else.
546
+
547
+ `resolve()` cannot answer this, and that is exactly how a symlinked store
548
+ escaped containment: resolving *both* the base directory and the candidate
549
+ file follows the same link, so the two agree and the check passes while the
550
+ file lands outside the repository.
551
+
552
+ The one link test BugPilot has (`safe_paths.is_link_or_junction`), shared
553
+ with `clean` and `--fresh`: symbolic links everywhere, and on Windows every
554
+ name-surrogate reparse point, junctions included.
555
+ """
556
+ return is_link_or_junction(path)
557
+
558
+
559
+ def _unsafe_directory_reason(directory: Path, scope: str) -> str | None:
560
+ """Why this scope's directory may not be used, if it may not be.
561
+
562
+ The store is `<config dir>/fix_modes` and `<repo>/.bugpilot/fix_modes`, so
563
+ both the directory and the config directory holding it have to be real: a
564
+ link at either level redirects the whole store.
565
+ """
566
+ for candidate in (directory, directory.parent):
567
+ if _redirects_elsewhere(candidate):
568
+ return (
569
+ f"{candidate} is a symbolic link or junction. {scope.capitalize()} Fix "
570
+ "Mode directories must be real directories, so that modes cannot be "
571
+ "read from or written to somewhere else."
572
+ )
573
+ return None
574
+
575
+
576
+ def _resolved(path: Path) -> Path:
577
+ """`resolve()` that also works for a path that does not exist yet."""
578
+ try:
579
+ return path.resolve()
580
+ except OSError: # pragma: no cover - only on an unreadable parent
581
+ return path.absolute()
582
+
583
+
584
+ def catalog_for(repo_root: Path | None, home_dir: Path | None = None) -> FixModeCatalog:
585
+ """Read every mode available to one repository. The common entry point."""
586
+ return FixModeStore(repo_root, home_dir).load_catalog()
587
+
588
+
589
+ def effective_registry_for(
590
+ repo_root: Path | None, home_dir: Path | None = None
591
+ ) -> FixModeRegistry:
592
+ """The registry an id resolves through for this repository."""
593
+ return catalog_for(repo_root, home_dir).effective_registry()
594
+
595
+
596
+ def scoped_modes(catalog: FixModeCatalog) -> Iterable[tuple[str, FixMode]]:
597
+ """Every physical definition with its scope, in listing order."""
598
+ for scope in ("builtin", "user", "project"):
599
+ for mode in catalog.scoped(scope):
600
+ yield scope, mode