patchshuttle 0.1.0a2__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 (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1144 @@
1
+ """Immutable plans produced by read-only workspace inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from importlib.util import find_spec
14
+ from os import PathLike
15
+ from pathlib import Path, PurePosixPath
16
+ from typing import cast
17
+
18
+ from patchshuttle._diff import apply_file_diff, parse_unified_diff
19
+ from patchshuttle.errors import PlanningError, PlanningErrorCode
20
+ from patchshuttle.models import Action, Check, Job, JobKind
21
+ from patchshuttle.policy import PathKind, Policy, WorkspacePath
22
+ from patchshuttle.workspace import Workspace, discover_workspace
23
+
24
+
25
+ class ActionDisposition(str, Enum):
26
+ """Read-only conclusion for one requested action."""
27
+
28
+ INSPECT = "INSPECT"
29
+ CREATE = "CREATE"
30
+ MODIFY = "MODIFY"
31
+ NO_CHANGE = "NO_CHANGE"
32
+
33
+
34
+ class FileDisposition(str, Enum):
35
+ """Net file operation required by a complete plan."""
36
+
37
+ CREATE = "CREATE"
38
+ MODIFY = "MODIFY"
39
+
40
+
41
+ class NewlineStyle(str, Enum):
42
+ """Newline style preserved or requested for a planned text file."""
43
+
44
+ LF = "lf"
45
+ CRLF = "crlf"
46
+ NONE = "none"
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class PlannedAction:
51
+ """One sequential action and its dry-run disposition."""
52
+
53
+ id: str
54
+ name: str
55
+ disposition: ActionDisposition
56
+ paths: tuple[PurePosixPath, ...] = ()
57
+ detail: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class PlannedCheck:
62
+ """One requested controlled check and its validated workspace paths."""
63
+
64
+ id: str
65
+ name: str
66
+ paths: tuple[PurePosixPath, ...] = ()
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class PlannedFileChange:
71
+ """The final bytes and fingerprints for one net file change."""
72
+
73
+ path: PurePosixPath
74
+ disposition: FileDisposition
75
+ before_sha256: str | None
76
+ after_sha256: str
77
+ before_size: int | None
78
+ after_size: int
79
+ encoding: str
80
+ newline: NewlineStyle
81
+ content: bytes = field(repr=False)
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class PathFingerprint:
86
+ """Read-only metadata retained for future plan revalidation."""
87
+
88
+ path: PurePosixPath
89
+ kind: PathKind
90
+ size: int
91
+ modified_ns: int
92
+ sha256: str | None = None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Plan:
97
+ """A complete immutable read-only plan for one validated job."""
98
+
99
+ workspace: Workspace = field(repr=False)
100
+ job: Job
101
+ job_hash: str
102
+ actions: tuple[PlannedAction, ...]
103
+ checks: tuple[PlannedCheck, ...]
104
+ file_changes: tuple[PlannedFileChange, ...]
105
+ directories_to_create: tuple[PurePosixPath, ...]
106
+ formatting_targets: tuple[PurePosixPath, ...]
107
+ fingerprints: tuple[PathFingerprint, ...]
108
+ protected_paths_passed: bool
109
+ backup_destination: PurePosixPath | None
110
+ auto_rollback: bool
111
+
112
+ @property
113
+ def files_to_create(self) -> tuple[PurePosixPath, ...]:
114
+ return tuple(
115
+ change.path
116
+ for change in self.file_changes
117
+ if change.disposition is FileDisposition.CREATE
118
+ )
119
+
120
+ @property
121
+ def files_to_modify(self) -> tuple[PurePosixPath, ...]:
122
+ return tuple(
123
+ change.path
124
+ for change in self.file_changes
125
+ if change.disposition is FileDisposition.MODIFY
126
+ )
127
+
128
+ @property
129
+ def requires_confirmation(self) -> bool:
130
+ return (
131
+ self.job.kind is not JobKind.AUDIT
132
+ and self.workspace.config.execution.confirm
133
+ )
134
+
135
+
136
+ @dataclass(slots=True)
137
+ class _TextState:
138
+ path: PurePosixPath
139
+ original_bytes: bytes | None
140
+ current_bytes: bytes
141
+ text: str
142
+ encoding: str
143
+ codec: str
144
+ bom: bytes
145
+ newline: NewlineStyle
146
+
147
+
148
+ _UTF32_LE_BOM = b"\xff\xfe\x00\x00"
149
+ _UTF32_BE_BOM = b"\x00\x00\xfe\xff"
150
+ _UTF16_LE_BOM = b"\xff\xfe"
151
+ _UTF16_BE_BOM = b"\xfe\xff"
152
+ _UTF8_BOM = b"\xef\xbb\xbf"
153
+ _PYTEST_EXACT_ARGS = frozenset(
154
+ {
155
+ "-q",
156
+ "--quiet",
157
+ "-v",
158
+ "--verbose",
159
+ "-x",
160
+ "--exitfirst",
161
+ "-s",
162
+ "--disable-warnings",
163
+ "--strict-config",
164
+ "--strict-markers",
165
+ }
166
+ )
167
+ _PYTEST_TB_VALUES = frozenset({"auto", "long", "short", "line", "native", "no"})
168
+ _PYTEST_CAPTURE_VALUES = frozenset({"fd", "sys", "no", "tee-sys"})
169
+ _DJANGO_LABEL = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
170
+
171
+
172
+ def plan_job(
173
+ job: Job,
174
+ workspace: Workspace | str | PathLike[str] = ".",
175
+ ) -> Plan:
176
+ """Validate local authority and fully dry-run one immutable job."""
177
+
178
+ resolved_workspace = (
179
+ workspace if isinstance(workspace, Workspace) else discover_workspace(workspace)
180
+ )
181
+ resolved_workspace.require_project_id(job.project_id)
182
+ return _Planner(job, resolved_workspace).build()
183
+
184
+
185
+ class _Planner:
186
+ def __init__(self, job: Job, workspace: Workspace) -> None:
187
+ self.job = job
188
+ self.workspace = workspace
189
+ self.policy = Policy(workspace)
190
+ self.action_plans: list[PlannedAction] = []
191
+ self.check_plans: list[PlannedCheck] = []
192
+ self.files: dict[PurePosixPath, _TextState] = {}
193
+ self.created_directories: dict[PurePosixPath, None] = {}
194
+ self.fingerprints: dict[PurePosixPath, PathFingerprint] = {}
195
+ self.max_file_bytes = workspace.config.execution.max_single_file_bytes
196
+
197
+ def build(self) -> Plan:
198
+ self._validate_job_policy()
199
+ for index, action in enumerate(self.job.actions, start=1):
200
+ item_id = f"action_{index:03d}"
201
+ if self.job.kind is JobKind.AUDIT:
202
+ self._plan_audit_action(action, item_id=item_id)
203
+ else:
204
+ self._plan_change_action(action, item_id=item_id)
205
+
206
+ for index, check in enumerate(self.job.checks, start=1):
207
+ self._plan_check(check, item_id=f"check_{index:03d}")
208
+
209
+ file_changes = self._build_file_changes()
210
+ formatting_targets = self._formatting_targets(file_changes)
211
+ if formatting_targets:
212
+ self._require_module("isort", item_id="formatting")
213
+ self._require_module("black", item_id="formatting")
214
+ backup_destination = (
215
+ PurePosixPath(
216
+ "patches",
217
+ "backups",
218
+ self.job.id,
219
+ "<RUN_TIMESTAMP>",
220
+ )
221
+ if self.job.kind is JobKind.PATCH
222
+ else None
223
+ )
224
+ return Plan(
225
+ workspace=self.workspace,
226
+ job=self.job,
227
+ job_hash=normalized_job_hash(self.job),
228
+ actions=tuple(self.action_plans),
229
+ checks=tuple(self.check_plans),
230
+ file_changes=file_changes,
231
+ directories_to_create=tuple(self.created_directories),
232
+ formatting_targets=formatting_targets,
233
+ fingerprints=tuple(self.fingerprints.values()),
234
+ protected_paths_passed=True,
235
+ backup_destination=backup_destination,
236
+ auto_rollback=(
237
+ self.workspace.config.execution.auto_rollback
238
+ if self.job.kind is JobKind.PATCH
239
+ else False
240
+ ),
241
+ )
242
+
243
+ def _validate_job_policy(self) -> None:
244
+ action_limit = self.workspace.config.execution.max_actions
245
+ if len(self.job.actions) > action_limit:
246
+ raise PlanningError(
247
+ PlanningErrorCode.ACTION_LIMIT_EXCEEDED,
248
+ f"job has more than the configured {action_limit} action(s)",
249
+ )
250
+ if (
251
+ self.job.kind is JobKind.PATCH
252
+ and self.workspace.config.checks.require_at_least_one_for_patch
253
+ and not self.job.checks
254
+ ):
255
+ raise PlanningError(
256
+ PlanningErrorCode.PATCH_CHECK_REQUIRED,
257
+ "local policy requires at least one check for a patch job",
258
+ )
259
+
260
+ def _plan_audit_action(self, action: Action, *, item_id: str) -> None:
261
+ parameters = action.parameters
262
+ paths: tuple[PurePosixPath, ...] = ()
263
+ if action.name in {"tree", "find_files"}:
264
+ target = self._audit_target(
265
+ cast(str, parameters.path),
266
+ item_id=item_id,
267
+ expected=PathKind.DIRECTORY,
268
+ )
269
+ paths = (target.relative,)
270
+ elif action.name == "read":
271
+ requested_max = parameters.max_bytes
272
+ if requested_max is not None and requested_max > self.max_file_bytes:
273
+ raise self._error(
274
+ PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
275
+ f"read max_bytes exceeds the configured {self.max_file_bytes}-byte limit",
276
+ item_id,
277
+ path=parameters.path,
278
+ )
279
+ target = self._audit_target(
280
+ parameters.path,
281
+ item_id=item_id,
282
+ expected=PathKind.FILE,
283
+ )
284
+ raw = self._read_existing_file(target, item_id=item_id)
285
+ self._decode_existing(raw, item_id=item_id, path=target.relative)
286
+ paths = (target.relative,)
287
+ elif action.name == "search":
288
+ target = self._audit_target(parameters.path, item_id=item_id)
289
+ if target.kind is PathKind.FILE:
290
+ raw = self._read_existing_file(target, item_id=item_id)
291
+ self._decode_existing(raw, item_id=item_id, path=target.relative)
292
+ paths = (target.relative,)
293
+ elif action.name in {"file_info", "hash"}:
294
+ expected = PathKind.FILE if action.name == "hash" else None
295
+ target = self._audit_target(
296
+ parameters.path,
297
+ item_id=item_id,
298
+ expected=expected,
299
+ )
300
+ if action.name == "hash":
301
+ self._read_existing_file(target, item_id=item_id)
302
+ paths = (target.relative,)
303
+
304
+ self.action_plans.append(
305
+ PlannedAction(
306
+ id=item_id,
307
+ name=action.name,
308
+ disposition=ActionDisposition.INSPECT,
309
+ paths=paths,
310
+ )
311
+ )
312
+
313
+ def _audit_target(
314
+ self,
315
+ path: str,
316
+ *,
317
+ item_id: str,
318
+ expected: PathKind | None = None,
319
+ ) -> WorkspacePath:
320
+ relative = self.policy.normalize(path)
321
+ if self.policy.is_ignored(relative):
322
+ raise self._error(
323
+ PlanningErrorCode.PATH_IGNORED,
324
+ "audit target is ignored by local policy",
325
+ item_id,
326
+ path=relative.as_posix(),
327
+ )
328
+ target = self.policy.resolve(relative, allow_root=True)
329
+ if expected is not None and target.kind is not expected:
330
+ raise self._error(
331
+ PlanningErrorCode.TARGET_TYPE_INVALID,
332
+ f"expected a {expected.value}",
333
+ item_id,
334
+ path=relative.as_posix(),
335
+ )
336
+ self._record_fingerprint(target, item_id=item_id)
337
+ return target
338
+
339
+ def _plan_change_action(self, action: Action, *, item_id: str) -> None:
340
+ if action.name == "create_directory":
341
+ self._plan_create_directory(action, item_id=item_id)
342
+ elif action.name == "create_file":
343
+ self._plan_create_file(action, item_id=item_id)
344
+ elif action.name in {
345
+ "replace_exact",
346
+ "insert_before",
347
+ "insert_after",
348
+ "delete_exact",
349
+ }:
350
+ self._plan_exact_edit(action, item_id=item_id)
351
+ else:
352
+ self._plan_apply_diff(action, item_id=item_id)
353
+
354
+ def _plan_create_directory(self, action: Action, *, item_id: str) -> None:
355
+ path = self.policy.normalize(action.parameters.path)
356
+ target = self.policy.resolve(path, allow_missing=True)
357
+ kind = self._virtual_kind(path, target, item_id=item_id)
358
+ if kind is PathKind.FILE:
359
+ raise self._error(
360
+ PlanningErrorCode.TARGET_TYPE_INVALID,
361
+ "create_directory target is an existing file",
362
+ item_id,
363
+ path=path.as_posix(),
364
+ )
365
+ if kind is PathKind.DIRECTORY:
366
+ disposition = ActionDisposition.NO_CHANGE
367
+ self._record_fingerprint(target, item_id=item_id)
368
+ else:
369
+ self._ensure_parent_directories(path, item_id=item_id)
370
+ self.created_directories.setdefault(path, None)
371
+ disposition = ActionDisposition.CREATE
372
+ self.action_plans.append(
373
+ PlannedAction(
374
+ id=item_id,
375
+ name=action.name,
376
+ disposition=disposition,
377
+ paths=(path,),
378
+ )
379
+ )
380
+
381
+ def _plan_create_file(self, action: Action, *, item_id: str) -> None:
382
+ parameters = action.parameters
383
+ path = self.policy.normalize(parameters.path)
384
+ target = self.policy.resolve(path, allow_missing=True)
385
+ self._ensure_parent_directories(path, item_id=item_id)
386
+ kind = self._virtual_kind(path, target, item_id=item_id)
387
+ if kind is PathKind.DIRECTORY:
388
+ raise self._error(
389
+ PlanningErrorCode.TARGET_TYPE_INVALID,
390
+ "create_file target is an existing directory",
391
+ item_id,
392
+ path=path.as_posix(),
393
+ )
394
+
395
+ new_state = self._new_file_state(
396
+ path,
397
+ parameters.content,
398
+ parameters.encoding,
399
+ NewlineStyle(parameters.newline),
400
+ item_id=item_id,
401
+ )
402
+ if kind is PathKind.FILE:
403
+ current = self._get_file(path, target, item_id=item_id)
404
+ if current.current_bytes != new_state.current_bytes:
405
+ raise self._error(
406
+ PlanningErrorCode.CREATE_FILE_CONFLICT,
407
+ "create_file target already exists with different content",
408
+ item_id,
409
+ path=path.as_posix(),
410
+ )
411
+ disposition = ActionDisposition.NO_CHANGE
412
+ else:
413
+ self.files[path] = new_state
414
+ disposition = ActionDisposition.CREATE
415
+
416
+ self.action_plans.append(
417
+ PlannedAction(
418
+ id=item_id,
419
+ name=action.name,
420
+ disposition=disposition,
421
+ paths=(path,),
422
+ )
423
+ )
424
+
425
+ def _plan_exact_edit(self, action: Action, *, item_id: str) -> None:
426
+ parameters = action.parameters
427
+ path = self.policy.normalize(parameters.path)
428
+ target = self.policy.resolve(path, allow_missing=True)
429
+ state = self._get_file(path, target, item_id=item_id)
430
+ text = state.text
431
+
432
+ if action.name == "replace_exact":
433
+ old = _normalize_newlines(parameters.old)
434
+ new = _normalize_newlines(parameters.new)
435
+ actual = text.count(old)
436
+ if actual == parameters.expected_count:
437
+ updated = text.replace(old, new)
438
+ elif actual == 0 and new and text.count(new) == parameters.expected_count:
439
+ updated = text
440
+ else:
441
+ raise self._occurrence_error(
442
+ item_id,
443
+ path,
444
+ expected=parameters.expected_count,
445
+ actual=actual,
446
+ )
447
+ elif action.name in {"insert_before", "insert_after"}:
448
+ anchor = _normalize_newlines(parameters.anchor)
449
+ content = _normalize_newlines(parameters.content)
450
+ positions = _non_overlapping_positions(text, anchor)
451
+ if len(positions) != parameters.expected_count:
452
+ raise self._occurrence_error(
453
+ item_id,
454
+ path,
455
+ expected=parameters.expected_count,
456
+ actual=len(positions),
457
+ )
458
+ adjacency = [
459
+ _is_adjacent(
460
+ text,
461
+ position,
462
+ anchor,
463
+ content,
464
+ before=action.name == "insert_before",
465
+ )
466
+ for position in positions
467
+ ]
468
+ if all(adjacency):
469
+ updated = text
470
+ elif any(adjacency):
471
+ raise self._error(
472
+ PlanningErrorCode.INSERTION_STATE_CONFLICT,
473
+ "insert content is adjacent to only some expected anchors",
474
+ item_id,
475
+ path=path.as_posix(),
476
+ )
477
+ elif action.name == "insert_before":
478
+ updated = text.replace(anchor, f"{content}{anchor}")
479
+ else:
480
+ updated = text.replace(anchor, f"{anchor}{content}")
481
+ else:
482
+ deleted = _normalize_newlines(parameters.text)
483
+ actual = text.count(deleted)
484
+ if actual != parameters.expected_count:
485
+ raise self._occurrence_error(
486
+ item_id,
487
+ path,
488
+ expected=parameters.expected_count,
489
+ actual=actual,
490
+ )
491
+ updated = text.replace(deleted, "")
492
+
493
+ disposition = self._update_state(state, updated, item_id=item_id)
494
+ self.action_plans.append(
495
+ PlannedAction(
496
+ id=item_id,
497
+ name=action.name,
498
+ disposition=disposition,
499
+ paths=(path,),
500
+ )
501
+ )
502
+
503
+ def _plan_apply_diff(self, action: Action, *, item_id: str) -> None:
504
+ parameters = action.parameters
505
+ file_diffs = parse_unified_diff(
506
+ parameters.diff,
507
+ strip=parameters.strip,
508
+ item_id=item_id,
509
+ )
510
+ paths: list[PurePosixPath] = []
511
+ changed = False
512
+ for file_diff in file_diffs:
513
+ path = self.policy.normalize(file_diff.path)
514
+ target = self.policy.resolve(path, allow_missing=True)
515
+ state = self._get_file(path, target, item_id=item_id)
516
+ if state.original_bytes is None:
517
+ raise self._error(
518
+ PlanningErrorCode.DIFF_PATH_INVALID,
519
+ "apply_diff accepts only files that existed before this job",
520
+ item_id,
521
+ path=path.as_posix(),
522
+ )
523
+ updated = apply_file_diff(state.text, file_diff, item_id=item_id)
524
+ disposition = self._update_state(state, updated, item_id=item_id)
525
+ changed = changed or disposition is ActionDisposition.MODIFY
526
+ paths.append(path)
527
+ self.action_plans.append(
528
+ PlannedAction(
529
+ id=item_id,
530
+ name=action.name,
531
+ disposition=(
532
+ ActionDisposition.MODIFY if changed else ActionDisposition.NO_CHANGE
533
+ ),
534
+ paths=tuple(paths),
535
+ )
536
+ )
537
+
538
+ def _plan_check(self, check: Check, *, item_id: str) -> None:
539
+ parameters = check.parameters
540
+ paths: tuple[PurePosixPath, ...] = ()
541
+ if check.name == "compileall":
542
+ paths = tuple(
543
+ self._check_target(path, item_id=item_id).relative
544
+ for path in parameters.paths
545
+ )
546
+ elif check.name == "pytest":
547
+ self._validate_pytest_args(parameters.args, item_id=item_id)
548
+ paths = tuple(
549
+ self._check_target(path, item_id=item_id).relative
550
+ for path in parameters.paths
551
+ )
552
+ self._require_module("pytest", item_id=item_id)
553
+ elif check.name == "unittest":
554
+ target = self._check_target(
555
+ parameters.discover,
556
+ item_id=item_id,
557
+ expected=PathKind.DIRECTORY,
558
+ )
559
+ paths = (target.relative,)
560
+ elif check.name in {
561
+ "django_check",
562
+ "django_migrations_check",
563
+ "django_test",
564
+ }:
565
+ target = self._check_target(
566
+ parameters.manage_py,
567
+ item_id=item_id,
568
+ expected=PathKind.FILE,
569
+ )
570
+ paths = (target.relative,)
571
+ if check.name == "django_test":
572
+ invalid_labels = [
573
+ label
574
+ for label in parameters.labels
575
+ if _DJANGO_LABEL.fullmatch(label) is None
576
+ ]
577
+ if invalid_labels:
578
+ raise self._error(
579
+ PlanningErrorCode.CHECK_ARGUMENT_INVALID,
580
+ "Django test labels must be dotted Python identifiers",
581
+ item_id,
582
+ path=invalid_labels[0],
583
+ )
584
+ self._require_module("django", item_id=item_id)
585
+ elif check.name == "profile":
586
+ if parameters.name not in self.workspace.config.checks.profiles:
587
+ raise self._error(
588
+ PlanningErrorCode.CHECK_PROFILE_NOT_FOUND,
589
+ "requested check profile is not defined in local configuration",
590
+ item_id,
591
+ path=parameters.name,
592
+ )
593
+ self._require_profile_command(parameters.name, item_id=item_id)
594
+
595
+ self.check_plans.append(PlannedCheck(id=item_id, name=check.name, paths=paths))
596
+
597
+ def _check_target(
598
+ self,
599
+ value: str,
600
+ *,
601
+ item_id: str,
602
+ expected: PathKind | None = None,
603
+ ) -> WorkspacePath:
604
+ path = self.policy.normalize(value)
605
+ if self.policy.is_ignored(path):
606
+ raise self._error(
607
+ PlanningErrorCode.PATH_IGNORED,
608
+ "check target is ignored by local policy",
609
+ item_id,
610
+ path=path.as_posix(),
611
+ )
612
+ target = self.policy.resolve(path, allow_root=True, allow_missing=True)
613
+ kind = self._virtual_kind(path, target, item_id=item_id)
614
+ if kind is PathKind.MISSING:
615
+ raise self._error(
616
+ PlanningErrorCode.CHECK_PATH_NOT_FOUND,
617
+ "check target does not exist in the planned workspace",
618
+ item_id,
619
+ path=path.as_posix(),
620
+ )
621
+ if expected is not None and kind is not expected:
622
+ raise self._error(
623
+ PlanningErrorCode.TARGET_TYPE_INVALID,
624
+ f"expected a {expected.value} check target",
625
+ item_id,
626
+ path=path.as_posix(),
627
+ )
628
+ if target.exists:
629
+ self._record_fingerprint(target, item_id=item_id)
630
+ return WorkspacePath(relative=path, absolute=target.absolute, kind=kind)
631
+
632
+ def _validate_pytest_args(self, args: tuple[str, ...], *, item_id: str) -> None:
633
+ for argument in args:
634
+ allowed = argument in _PYTEST_EXACT_ARGS
635
+ if argument.startswith("--maxfail="):
636
+ value = argument.removeprefix("--maxfail=")
637
+ allowed = value.isdigit() and int(value) > 0
638
+ elif argument.startswith("--tb="):
639
+ allowed = argument.removeprefix("--tb=") in _PYTEST_TB_VALUES
640
+ elif argument.startswith("--capture="):
641
+ allowed = argument.removeprefix("--capture=") in _PYTEST_CAPTURE_VALUES
642
+ if not allowed:
643
+ raise self._error(
644
+ PlanningErrorCode.PYTEST_ARGUMENT_FORBIDDEN,
645
+ "pytest argument is not in the local safe allowlist",
646
+ item_id,
647
+ path=argument,
648
+ )
649
+
650
+ def _require_module(self, name: str, *, item_id: str) -> None:
651
+ try:
652
+ available = find_spec(name) is not None
653
+ except (ImportError, ValueError):
654
+ available = False
655
+ if not available:
656
+ raise self._error(
657
+ PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
658
+ f"required Python module {name!r} is not available",
659
+ item_id,
660
+ path=name,
661
+ )
662
+
663
+ def _require_profile_command(self, name: str, *, item_id: str) -> None:
664
+ profile = self.workspace.config.checks.profiles[name]
665
+ command = profile.argv[0]
666
+ if command == "{python}":
667
+ available = Path(sys.executable).is_file()
668
+ elif Path(command).is_absolute() or "/" in command or "\\" in command:
669
+ candidate = Path(command)
670
+ if not candidate.is_absolute():
671
+ candidate = self.workspace.root / candidate
672
+ available = candidate.is_file()
673
+ else:
674
+ available = shutil.which(command) is not None
675
+ if not available:
676
+ raise self._error(
677
+ PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
678
+ "local check profile executable is not available",
679
+ item_id,
680
+ path=command,
681
+ )
682
+
683
+ def _virtual_kind(
684
+ self,
685
+ path: PurePosixPath,
686
+ target: WorkspacePath,
687
+ *,
688
+ item_id: str,
689
+ ) -> PathKind:
690
+ self._reject_virtual_file_parent(path, item_id=item_id)
691
+ if path in self.files:
692
+ return PathKind.FILE
693
+ if path in self.created_directories:
694
+ return PathKind.DIRECTORY
695
+ return target.kind
696
+
697
+ def _reject_virtual_file_parent(
698
+ self,
699
+ path: PurePosixPath,
700
+ *,
701
+ item_id: str,
702
+ ) -> None:
703
+ for index in range(1, len(path.parts)):
704
+ parent = PurePosixPath(*path.parts[:index])
705
+ if parent in self.files:
706
+ raise self._error(
707
+ PlanningErrorCode.VIRTUAL_PATH_CONFLICT,
708
+ "a planned file cannot be used as a parent directory",
709
+ item_id,
710
+ path=parent.as_posix(),
711
+ )
712
+
713
+ def _ensure_parent_directories(
714
+ self,
715
+ path: PurePosixPath,
716
+ *,
717
+ item_id: str,
718
+ ) -> None:
719
+ for index in range(1, len(path.parts)):
720
+ parent = PurePosixPath(*path.parts[:index])
721
+ if parent in self.files:
722
+ raise self._error(
723
+ PlanningErrorCode.VIRTUAL_PATH_CONFLICT,
724
+ "a planned file cannot be used as a parent directory",
725
+ item_id,
726
+ path=parent.as_posix(),
727
+ )
728
+ if parent in self.created_directories:
729
+ continue
730
+ target = self.policy.resolve(parent, allow_missing=True)
731
+ if target.kind is PathKind.FILE:
732
+ raise self._error(
733
+ PlanningErrorCode.TARGET_TYPE_INVALID,
734
+ "an existing parent is not a directory",
735
+ item_id,
736
+ path=parent.as_posix(),
737
+ )
738
+ if target.kind is PathKind.MISSING:
739
+ self.created_directories.setdefault(parent, None)
740
+ else:
741
+ self._record_fingerprint(target, item_id=item_id)
742
+
743
+ def _get_file(
744
+ self,
745
+ path: PurePosixPath,
746
+ target: WorkspacePath,
747
+ *,
748
+ item_id: str,
749
+ ) -> _TextState:
750
+ self._reject_virtual_file_parent(path, item_id=item_id)
751
+ if path in self.created_directories:
752
+ raise self._error(
753
+ PlanningErrorCode.TARGET_TYPE_INVALID,
754
+ "text action target is a planned directory",
755
+ item_id,
756
+ path=path.as_posix(),
757
+ )
758
+ existing = self.files.get(path)
759
+ if existing is not None:
760
+ return existing
761
+ if target.kind is not PathKind.FILE:
762
+ raise self._error(
763
+ PlanningErrorCode.TARGET_TYPE_INVALID,
764
+ "text action requires an existing regular file",
765
+ item_id,
766
+ path=path.as_posix(),
767
+ )
768
+ raw = self._read_existing_file(target, item_id=item_id)
769
+ state = self._decode_existing(raw, item_id=item_id, path=path)
770
+ self.files[path] = state
771
+ return state
772
+
773
+ def _new_file_state(
774
+ self,
775
+ path: PurePosixPath,
776
+ content: str,
777
+ encoding: str,
778
+ newline: NewlineStyle,
779
+ *,
780
+ item_id: str,
781
+ ) -> _TextState:
782
+ normalized = _normalize_newlines(content)
783
+ if any(_is_binary_control(character) for character in normalized):
784
+ raise self._error(
785
+ PlanningErrorCode.CONTENT_BINARY_FORBIDDEN,
786
+ "create_file content contains binary control characters",
787
+ item_id,
788
+ path=path.as_posix(),
789
+ )
790
+ rendered = _render_newlines(normalized, newline)
791
+ try:
792
+ codec = codecs.lookup(encoding).name
793
+ raw = rendered.encode(encoding)
794
+ except (LookupError, UnicodeError, TypeError) as exc:
795
+ raise self._error(
796
+ PlanningErrorCode.CONTENT_ENCODING_INVALID,
797
+ "content cannot be encoded with the requested text encoding",
798
+ item_id,
799
+ path=path.as_posix(),
800
+ ) from exc
801
+ self._require_size(raw, item_id=item_id, path=path)
802
+ return _TextState(
803
+ path=path,
804
+ original_bytes=None,
805
+ current_bytes=raw,
806
+ text=normalized,
807
+ encoding=codec,
808
+ codec=encoding,
809
+ bom=b"",
810
+ newline=newline,
811
+ )
812
+
813
+ def _decode_existing(
814
+ self,
815
+ raw: bytes,
816
+ *,
817
+ item_id: str,
818
+ path: PurePosixPath,
819
+ ) -> _TextState:
820
+ bom = b""
821
+ if raw.startswith(_UTF32_LE_BOM):
822
+ bom, codec, encoding = _UTF32_LE_BOM, "utf-32-le", "utf-32-le"
823
+ elif raw.startswith(_UTF32_BE_BOM):
824
+ bom, codec, encoding = _UTF32_BE_BOM, "utf-32-be", "utf-32-be"
825
+ elif raw.startswith(_UTF8_BOM):
826
+ bom, codec, encoding = _UTF8_BOM, "utf-8", "utf-8-sig"
827
+ elif raw.startswith(_UTF16_LE_BOM):
828
+ bom, codec, encoding = _UTF16_LE_BOM, "utf-16-le", "utf-16-le"
829
+ elif raw.startswith(_UTF16_BE_BOM):
830
+ bom, codec, encoding = _UTF16_BE_BOM, "utf-16-be", "utf-16-be"
831
+ else:
832
+ codec = encoding = "utf-8"
833
+ if b"\0" in raw:
834
+ raise self._error(
835
+ PlanningErrorCode.FILE_BINARY,
836
+ "file contains null bytes",
837
+ item_id,
838
+ path=path.as_posix(),
839
+ )
840
+
841
+ try:
842
+ text = raw[len(bom) :].decode(codec)
843
+ except UnicodeDecodeError as exc:
844
+ raise self._error(
845
+ PlanningErrorCode.FILE_ENCODING_UNSUPPORTED,
846
+ "file is not valid UTF text in a supported encoding",
847
+ item_id,
848
+ path=path.as_posix(),
849
+ ) from exc
850
+ if any(_is_binary_control(character) for character in text):
851
+ raise self._error(
852
+ PlanningErrorCode.FILE_BINARY,
853
+ "file contains binary control characters",
854
+ item_id,
855
+ path=path.as_posix(),
856
+ )
857
+
858
+ newline, normalized = self._detect_newlines(
859
+ text,
860
+ item_id=item_id,
861
+ path=path,
862
+ )
863
+ return _TextState(
864
+ path=path,
865
+ original_bytes=raw,
866
+ current_bytes=raw,
867
+ text=normalized,
868
+ encoding=encoding,
869
+ codec=codec,
870
+ bom=bom,
871
+ newline=newline,
872
+ )
873
+
874
+ def _detect_newlines(
875
+ self,
876
+ text: str,
877
+ *,
878
+ item_id: str,
879
+ path: PurePosixPath,
880
+ ) -> tuple[NewlineStyle, str]:
881
+ without_crlf = text.replace("\r\n", "")
882
+ has_crlf = "\r\n" in text
883
+ has_lf = "\n" in without_crlf
884
+ if "\r" in without_crlf or (has_crlf and has_lf):
885
+ raise self._error(
886
+ PlanningErrorCode.FILE_NEWLINE_UNSUPPORTED,
887
+ "mixed or CR-only newlines are not supported for text changes",
888
+ item_id,
889
+ path=path.as_posix(),
890
+ )
891
+ if has_crlf:
892
+ return NewlineStyle.CRLF, text.replace("\r\n", "\n")
893
+ if has_lf:
894
+ return NewlineStyle.LF, text
895
+ return NewlineStyle.NONE, text
896
+
897
+ def _update_state(
898
+ self,
899
+ state: _TextState,
900
+ updated: str,
901
+ *,
902
+ item_id: str,
903
+ ) -> ActionDisposition:
904
+ newline = state.newline
905
+ if newline is NewlineStyle.NONE and "\n" in updated:
906
+ newline = NewlineStyle.LF
907
+ rendered = _render_newlines(updated, newline)
908
+ try:
909
+ raw = state.bom + rendered.encode(state.codec)
910
+ except (LookupError, UnicodeError) as exc:
911
+ raise self._error(
912
+ PlanningErrorCode.CONTENT_ENCODING_INVALID,
913
+ "planned content cannot be represented in the target encoding",
914
+ item_id,
915
+ path=state.path.as_posix(),
916
+ ) from exc
917
+ self._require_size(raw, item_id=item_id, path=state.path)
918
+ if raw == state.current_bytes:
919
+ return ActionDisposition.NO_CHANGE
920
+ state.text = updated
921
+ state.current_bytes = raw
922
+ state.newline = newline
923
+ return ActionDisposition.MODIFY
924
+
925
+ def _read_existing_file(
926
+ self,
927
+ target: WorkspacePath,
928
+ *,
929
+ item_id: str,
930
+ ) -> bytes:
931
+ try:
932
+ size = target.absolute.lstat().st_size
933
+ except OSError as exc:
934
+ raise self._error(
935
+ PlanningErrorCode.FILE_READ_FAILED,
936
+ "file metadata could not be read",
937
+ item_id,
938
+ path=target.relative.as_posix(),
939
+ ) from exc
940
+ if size > self.max_file_bytes:
941
+ raise self._error(
942
+ PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
943
+ f"file exceeds the configured {self.max_file_bytes}-byte limit",
944
+ item_id,
945
+ path=target.relative.as_posix(),
946
+ )
947
+ try:
948
+ raw = target.absolute.read_bytes()
949
+ except OSError as exc:
950
+ raise self._error(
951
+ PlanningErrorCode.FILE_READ_FAILED,
952
+ "file could not be read",
953
+ item_id,
954
+ path=target.relative.as_posix(),
955
+ ) from exc
956
+ self._require_size(raw, item_id=item_id, path=target.relative)
957
+ revalidated = self.policy.resolve(target.relative)
958
+ if revalidated.absolute != target.absolute:
959
+ raise self._error(
960
+ PlanningErrorCode.FILE_READ_FAILED,
961
+ "file path changed during planning",
962
+ item_id,
963
+ path=target.relative.as_posix(),
964
+ )
965
+ self._record_fingerprint(target, item_id=item_id, raw=raw)
966
+ return raw
967
+
968
+ def _record_fingerprint(
969
+ self,
970
+ target: WorkspacePath,
971
+ *,
972
+ item_id: str,
973
+ raw: bytes | None = None,
974
+ ) -> None:
975
+ if not target.exists:
976
+ return
977
+ try:
978
+ metadata = target.absolute.lstat()
979
+ except OSError as exc:
980
+ raise self._error(
981
+ PlanningErrorCode.FILE_READ_FAILED,
982
+ "target metadata could not be retained for revalidation",
983
+ item_id,
984
+ path=target.relative.as_posix(),
985
+ ) from exc
986
+ fingerprint = PathFingerprint(
987
+ path=target.relative,
988
+ kind=target.kind,
989
+ size=metadata.st_size,
990
+ modified_ns=metadata.st_mtime_ns,
991
+ sha256=hashlib.sha256(raw).hexdigest() if raw is not None else None,
992
+ )
993
+ previous = self.fingerprints.get(target.relative)
994
+ if previous is None or fingerprint.sha256 is not None:
995
+ self.fingerprints[target.relative] = fingerprint
996
+
997
+ def _require_size(
998
+ self,
999
+ raw: bytes,
1000
+ *,
1001
+ item_id: str,
1002
+ path: PurePosixPath,
1003
+ ) -> None:
1004
+ if len(raw) > self.max_file_bytes:
1005
+ raise self._error(
1006
+ PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
1007
+ f"planned file exceeds the configured {self.max_file_bytes}-byte limit",
1008
+ item_id,
1009
+ path=path.as_posix(),
1010
+ )
1011
+
1012
+ def _occurrence_error(
1013
+ self,
1014
+ item_id: str,
1015
+ path: PurePosixPath,
1016
+ *,
1017
+ expected: int,
1018
+ actual: int,
1019
+ ) -> PlanningError:
1020
+ return self._error(
1021
+ PlanningErrorCode.OCCURRENCE_COUNT_MISMATCH,
1022
+ f"expected {expected} exact occurrence(s), found {actual}",
1023
+ item_id,
1024
+ path=path.as_posix(),
1025
+ )
1026
+
1027
+ def _build_file_changes(self) -> tuple[PlannedFileChange, ...]:
1028
+ changes: list[PlannedFileChange] = []
1029
+ for state in self.files.values():
1030
+ if (
1031
+ state.original_bytes is not None
1032
+ and state.current_bytes == state.original_bytes
1033
+ ):
1034
+ continue
1035
+ disposition = (
1036
+ FileDisposition.CREATE
1037
+ if state.original_bytes is None
1038
+ else FileDisposition.MODIFY
1039
+ )
1040
+ changes.append(
1041
+ PlannedFileChange(
1042
+ path=state.path,
1043
+ disposition=disposition,
1044
+ before_sha256=(
1045
+ hashlib.sha256(state.original_bytes).hexdigest()
1046
+ if state.original_bytes is not None
1047
+ else None
1048
+ ),
1049
+ after_sha256=hashlib.sha256(state.current_bytes).hexdigest(),
1050
+ before_size=(
1051
+ len(state.original_bytes)
1052
+ if state.original_bytes is not None
1053
+ else None
1054
+ ),
1055
+ after_size=len(state.current_bytes),
1056
+ encoding=state.encoding,
1057
+ newline=state.newline,
1058
+ content=state.current_bytes,
1059
+ )
1060
+ )
1061
+ return tuple(changes)
1062
+
1063
+ def _formatting_targets(
1064
+ self,
1065
+ changes: tuple[PlannedFileChange, ...],
1066
+ ) -> tuple[PurePosixPath, ...]:
1067
+ if not self.workspace.config.formatting.enabled:
1068
+ return ()
1069
+ return tuple(change.path for change in changes if change.path.suffix == ".py")
1070
+
1071
+ @staticmethod
1072
+ def _error(
1073
+ code: PlanningErrorCode,
1074
+ message: str,
1075
+ item_id: str,
1076
+ *,
1077
+ path: str | None = None,
1078
+ ) -> PlanningError:
1079
+ return PlanningError(code, message, item_id=item_id, path=path)
1080
+
1081
+
1082
+ def normalized_job_hash(job: Job) -> str:
1083
+ """Return the stable protocol hash used by plans and the registry."""
1084
+
1085
+ normalized = json.dumps(
1086
+ job.model_dump(mode="json"),
1087
+ ensure_ascii=False,
1088
+ sort_keys=True,
1089
+ separators=(",", ":"),
1090
+ ).encode("utf-8")
1091
+ return hashlib.sha256(normalized).hexdigest()
1092
+
1093
+
1094
+ def _normalize_newlines(value: str) -> str:
1095
+ return value.replace("\r\n", "\n").replace("\r", "\n")
1096
+
1097
+
1098
+ def _render_newlines(value: str, newline: NewlineStyle) -> str:
1099
+ return value.replace("\n", "\r\n") if newline is NewlineStyle.CRLF else value
1100
+
1101
+
1102
+ def _is_binary_control(character: str) -> bool:
1103
+ value = ord(character)
1104
+ return (value < 32 and character not in "\t\n\r") or value == 127
1105
+
1106
+
1107
+ def _non_overlapping_positions(text: str, value: str) -> tuple[int, ...]:
1108
+ positions: list[int] = []
1109
+ start = 0
1110
+ while True:
1111
+ position = text.find(value, start)
1112
+ if position < 0:
1113
+ return tuple(positions)
1114
+ positions.append(position)
1115
+ start = position + len(value)
1116
+
1117
+
1118
+ def _is_adjacent(
1119
+ text: str,
1120
+ position: int,
1121
+ anchor: str,
1122
+ content: str,
1123
+ *,
1124
+ before: bool,
1125
+ ) -> bool:
1126
+ if before:
1127
+ start = position - len(content)
1128
+ return start >= 0 and text[start:position] == content
1129
+ start = position + len(anchor)
1130
+ return text[start : start + len(content)] == content
1131
+
1132
+
1133
+ __all__ = [
1134
+ "ActionDisposition",
1135
+ "FileDisposition",
1136
+ "NewlineStyle",
1137
+ "PathFingerprint",
1138
+ "Plan",
1139
+ "PlannedAction",
1140
+ "PlannedCheck",
1141
+ "PlannedFileChange",
1142
+ "normalized_job_hash",
1143
+ "plan_job",
1144
+ ]