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
patchshuttle/cli.py ADDED
@@ -0,0 +1,766 @@
1
+ """Command-line entry point for PatchShuttle."""
2
+
3
+ from pathlib import Path
4
+ from typing import NoReturn
5
+
6
+ import click
7
+
8
+ from patchshuttle._version import __version__
9
+ from patchshuttle.context import create_handoff, create_snapshot
10
+ from patchshuttle.errors import (
11
+ ExecutionError,
12
+ ExecutionErrorCode,
13
+ JobError,
14
+ PlanningError,
15
+ PlanningErrorCode,
16
+ PolicyError,
17
+ WorkspaceError,
18
+ )
19
+ from patchshuttle.execution import (
20
+ RegisteredRunResult,
21
+ RunResult,
22
+ execute_plan,
23
+ execution_exit_code,
24
+ record_declined_plan,
25
+ resolve_registered_job,
26
+ )
27
+ from patchshuttle.logging import latest_log_path
28
+ from patchshuttle.models import Job, JobKind
29
+ from patchshuttle.operations import ManualRollbackResult, rollback_job
30
+ from patchshuttle.parser import load_job
31
+ from patchshuttle.planner import Plan, plan_job
32
+ from patchshuttle.registry import RegistryJobRecord, get_job, load_registry
33
+ from patchshuttle.workspace import (
34
+ CONFIG_RELATIVE_PATH,
35
+ Workspace,
36
+ discover_workspace,
37
+ init_workspace,
38
+ )
39
+
40
+
41
+ @click.group(
42
+ context_settings={"help_option_names": ["-h", "--help"]},
43
+ no_args_is_help=True,
44
+ )
45
+ @click.version_option(version=__version__, prog_name="patchshuttle")
46
+ def main() -> None:
47
+ """Run local, auditable workflows for AI-assisted project changes."""
48
+
49
+
50
+ @main.command()
51
+ def version() -> None:
52
+ """Print the installed PatchShuttle version."""
53
+
54
+ click.echo(f"PatchShuttle {__version__}")
55
+
56
+
57
+ @main.command("init")
58
+ @click.option(
59
+ "--new-project",
60
+ is_flag=True,
61
+ help="Require an empty directory and record a new-project workspace.",
62
+ )
63
+ def init_command(new_project: bool) -> None:
64
+ """Initialize the current project without overwriting existing entries."""
65
+
66
+ try:
67
+ result = init_workspace(Path.cwd(), new_project=new_project)
68
+ except WorkspaceError as error:
69
+ click.echo(f"INIT_FAILED {error}", err=True)
70
+ raise click.exceptions.Exit(3) from error
71
+
72
+ click.echo(
73
+ "\n".join(
74
+ (
75
+ result.status.value,
76
+ f"project_id: {result.workspace.project_id}",
77
+ f"origin: {result.workspace.origin.value}",
78
+ f"config: {CONFIG_RELATIVE_PATH.as_posix()}",
79
+ f"created_entries: {len(result.created_paths)}",
80
+ )
81
+ )
82
+ )
83
+
84
+
85
+ @main.command("validate")
86
+ @click.argument("job_file", type=click.Path(path_type=Path))
87
+ def validate_command(job_file: Path) -> None:
88
+ """Validate JOB_FILE against the current workspace without changing files."""
89
+
90
+ try:
91
+ workspace = discover_workspace(Path.cwd())
92
+ except WorkspaceError as error:
93
+ click.echo(f"INVALID {error}", err=True)
94
+ raise click.exceptions.Exit(3) from error
95
+
96
+ try:
97
+ job = load_job(
98
+ job_file,
99
+ max_bytes=workspace.config.execution.max_job_bytes,
100
+ )
101
+ except JobError as error:
102
+ click.echo(f"INVALID {error}", err=True)
103
+ raise click.exceptions.Exit(2) from error
104
+
105
+ try:
106
+ workspace.require_project_id(job.project_id)
107
+ except WorkspaceError as error:
108
+ click.echo(f"INVALID {error}", err=True)
109
+ raise click.exceptions.Exit(3) from error
110
+
111
+ click.echo(
112
+ "\n".join(
113
+ (
114
+ "VALID",
115
+ f"job_id: {job.id}",
116
+ f"kind: {job.kind.value}",
117
+ f"protocol: {job.protocol}",
118
+ f"project_id: {job.project_id}",
119
+ f"actions: {len(job.actions)}",
120
+ f"checks: {len(job.checks)}",
121
+ )
122
+ )
123
+ )
124
+
125
+
126
+ @main.command("plan")
127
+ @click.argument("job_file", type=click.Path(path_type=Path))
128
+ def plan_command(job_file: Path) -> None:
129
+ """Plan JOB_FILE completely without changing the workspace."""
130
+
131
+ click.echo(_render_plan(_load_plan(job_file, failure_prefix="PLAN_FAILED")))
132
+
133
+
134
+ @main.command("logs")
135
+ @click.option(
136
+ "--last",
137
+ "show_last",
138
+ is_flag=True,
139
+ help="Print the path to the latest PatchShuttle run log.",
140
+ )
141
+ def logs_command(show_last: bool) -> None:
142
+ """Locate generated PatchShuttle run logs."""
143
+
144
+ if not show_last:
145
+ raise click.UsageError("the --last option is required")
146
+ try:
147
+ workspace = discover_workspace(Path.cwd())
148
+ path = latest_log_path(workspace)
149
+ except WorkspaceError as error:
150
+ click.echo(f"LOGS_FAILED {error}", err=True)
151
+ raise click.exceptions.Exit(3) from error
152
+ except ExecutionError as error:
153
+ click.echo(f"LOGS_FAILED {error}", err=True)
154
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
155
+ click.echo(path.as_posix())
156
+
157
+
158
+ @main.command("snapshot")
159
+ def snapshot_command() -> None:
160
+ """Create a bounded read-only project metadata snapshot."""
161
+
162
+ try:
163
+ workspace = discover_workspace(Path.cwd())
164
+ result = create_snapshot(workspace)
165
+ except WorkspaceError as error:
166
+ click.echo(f"SNAPSHOT_FAILED {error}", err=True)
167
+ raise click.exceptions.Exit(3) from error
168
+ except ExecutionError as error:
169
+ click.echo(
170
+ _render_execution_error(error, prefix="SNAPSHOT_FAILED"),
171
+ err=True,
172
+ )
173
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
174
+ click.echo(
175
+ "\n".join(
176
+ (
177
+ "SNAPSHOT_CREATED",
178
+ f"inventory_entries: {result.inventory_entries}",
179
+ f"output_truncated: {str(result.output_truncated).lower()}",
180
+ f"log: {result.path.as_posix()}",
181
+ )
182
+ )
183
+ )
184
+
185
+
186
+ @main.command("handoff")
187
+ def handoff_command() -> None:
188
+ """Create one upload-friendly context log for an AI service."""
189
+
190
+ try:
191
+ workspace = discover_workspace(Path.cwd())
192
+ result = create_handoff(workspace)
193
+ except WorkspaceError as error:
194
+ click.echo(f"HANDOFF_FAILED {error}", err=True)
195
+ raise click.exceptions.Exit(3) from error
196
+ except ExecutionError as error:
197
+ click.echo(
198
+ _render_execution_error(error, prefix="HANDOFF_FAILED"),
199
+ err=True,
200
+ )
201
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
202
+ click.echo(
203
+ "\n".join(
204
+ (
205
+ "HANDOFF_CREATED",
206
+ f"inventory_entries: {result.inventory_entries}",
207
+ f"recent_jobs: {result.recent_jobs}",
208
+ f"output_truncated: {str(result.output_truncated).lower()}",
209
+ f"log: {result.path.as_posix()}",
210
+ )
211
+ )
212
+ )
213
+
214
+
215
+ @main.command("status")
216
+ @click.argument("job_id", required=False)
217
+ def status_command(job_id: str | None) -> None:
218
+ """Show project-local registry state and the latest log path."""
219
+
220
+ try:
221
+ workspace = discover_workspace(Path.cwd())
222
+ registry = load_registry(workspace)
223
+ latest = _optional_latest_log(workspace)
224
+ selected = get_job(registry, job_id) if job_id is not None else None
225
+ except WorkspaceError as error:
226
+ click.echo(f"STATUS_FAILED {error}", err=True)
227
+ raise click.exceptions.Exit(3) from error
228
+ except ExecutionError as error:
229
+ click.echo(f"STATUS_FAILED {error}", err=True)
230
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
231
+
232
+ lines = [
233
+ "STATUS",
234
+ f"project_id: {registry.project_id}",
235
+ f"latest_log: {latest.as_posix() if latest is not None else 'none'}",
236
+ ]
237
+ if selected is not None:
238
+ lines.extend(_render_registry_record(selected))
239
+ else:
240
+ records = sorted(
241
+ registry.jobs.values(),
242
+ key=lambda item: (item.latest_run_at, item.job_id),
243
+ reverse=True,
244
+ )
245
+ lines.append(f"jobs: {len(records)}")
246
+ lines.extend(
247
+ f" - {item.job_id} {item.latest_result} {item.job_hash[:8]}"
248
+ for item in records
249
+ )
250
+ click.echo("\n".join(lines))
251
+
252
+
253
+ @main.command("rollback")
254
+ @click.argument("job_id")
255
+ @click.option(
256
+ "--yes",
257
+ is_flag=True,
258
+ help="Roll back the completed job without an interactive prompt.",
259
+ )
260
+ def rollback_command(job_id: str, yes: bool) -> None:
261
+ """Restore one completed patch from its retained backup manifest."""
262
+
263
+ try:
264
+ workspace = discover_workspace(Path.cwd())
265
+ except WorkspaceError as error:
266
+ click.echo(f"ROLLBACK_FAILED {error}", err=True)
267
+ raise click.exceptions.Exit(3) from error
268
+ approved = yes
269
+ if not approved:
270
+ try:
271
+ approved = click.confirm(f"Roll back {job_id}?", default=False)
272
+ except click.Abort:
273
+ approved = False
274
+ if not approved:
275
+ error = ExecutionError(
276
+ ExecutionErrorCode.USER_DECLINED,
277
+ "user declined manual rollback",
278
+ item_id=job_id,
279
+ )
280
+ click.echo(
281
+ _render_execution_error(error, prefix="ROLLBACK_FAILED"),
282
+ err=True,
283
+ )
284
+ raise click.exceptions.Exit(4)
285
+ try:
286
+ result = rollback_job(workspace, job_id, approved=True)
287
+ except ExecutionError as error:
288
+ click.echo(
289
+ _render_execution_error(error, prefix="ROLLBACK_FAILED"),
290
+ err=True,
291
+ )
292
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
293
+ click.echo(_render_manual_rollback_result(result))
294
+
295
+
296
+ @main.command("run")
297
+ @click.argument("job_file", type=click.Path(path_type=Path))
298
+ @click.option(
299
+ "--yes",
300
+ is_flag=True,
301
+ help="Execute an approved plan without an interactive prompt.",
302
+ )
303
+ @click.option(
304
+ "--keep-changes",
305
+ is_flag=True,
306
+ help="Keep partial project changes if a patch job fails.",
307
+ )
308
+ def run_command(job_file: Path, yes: bool, keep_changes: bool) -> None:
309
+ """Plan and execute one audit, patch, or verify JOB_FILE."""
310
+
311
+ _execute_job_command(
312
+ job_file,
313
+ yes=yes,
314
+ keep_changes=keep_changes,
315
+ failure_prefix="RUN_FAILED",
316
+ )
317
+
318
+
319
+ @main.command("audit")
320
+ @click.argument("job_file", type=click.Path(path_type=Path))
321
+ def audit_command(job_file: Path) -> None:
322
+ """Execute one read-only audit JOB_FILE without confirmation."""
323
+
324
+ _execute_job_command(
325
+ job_file,
326
+ yes=True,
327
+ expected_kind=JobKind.AUDIT,
328
+ failure_prefix="AUDIT_FAILED",
329
+ )
330
+
331
+
332
+ @main.command("verify")
333
+ @click.argument("job_file", type=click.Path(path_type=Path))
334
+ @click.option(
335
+ "--yes",
336
+ is_flag=True,
337
+ help="Run approved project checks without an interactive prompt.",
338
+ )
339
+ def verify_command(job_file: Path, yes: bool) -> None:
340
+ """Execute one approved verify JOB_FILE."""
341
+
342
+ _execute_job_command(
343
+ job_file,
344
+ yes=yes,
345
+ expected_kind=JobKind.VERIFY,
346
+ failure_prefix="VERIFY_FAILED",
347
+ )
348
+
349
+
350
+ def _execute_job_command(
351
+ job_file: Path,
352
+ *,
353
+ yes: bool,
354
+ failure_prefix: str,
355
+ expected_kind: JobKind | None = None,
356
+ keep_changes: bool = False,
357
+ ) -> None:
358
+ """Shared universal execution flow for run, audit, and verify."""
359
+
360
+ workspace, job = _load_workspace_job(job_file, failure_prefix=failure_prefix)
361
+ if expected_kind is not None and job.kind is not expected_kind:
362
+ error = ExecutionError(
363
+ ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
364
+ f"this command requires a {expected_kind.value} job",
365
+ )
366
+ click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
367
+ raise click.exceptions.Exit(execution_exit_code(error.code))
368
+ try:
369
+ registered = resolve_registered_job(
370
+ workspace,
371
+ job,
372
+ source_path=job_file,
373
+ )
374
+ except ExecutionError as error:
375
+ click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
376
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
377
+ if registered is not None:
378
+ click.echo(_render_registered_result(registered))
379
+ return
380
+
381
+ plan = _plan_loaded_job(
382
+ workspace,
383
+ job,
384
+ failure_prefix=failure_prefix,
385
+ )
386
+ click.echo(_render_plan(plan))
387
+
388
+ if keep_changes and job.kind is not JobKind.PATCH:
389
+ error = ExecutionError(
390
+ ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
391
+ "--keep-changes is supported only for patch jobs",
392
+ )
393
+ click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
394
+ raise click.exceptions.Exit(execution_exit_code(error.code))
395
+ if keep_changes and not workspace.config.execution.allow_keep_changes:
396
+ error = ExecutionError(
397
+ ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
398
+ "local workspace policy does not allow keeping failed-job changes",
399
+ )
400
+ click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
401
+ raise click.exceptions.Exit(execution_exit_code(error.code))
402
+
403
+ if plan.requires_confirmation:
404
+ click.echo(
405
+ "WARNING: Project checks execute local project code. PatchShuttle is not "
406
+ "an OS sandbox. Review the job and changed files before continuing."
407
+ )
408
+ if not _approve_run(yes):
409
+ _decline_job(plan, job_file, failure_prefix=failure_prefix)
410
+
411
+ if keep_changes:
412
+ click.echo(
413
+ "WARNING: --keep-changes disables automatic rollback for this run. "
414
+ "A failed patch may leave partial project changes in place."
415
+ )
416
+ if not _approve_keep_changes(yes):
417
+ _decline_job(plan, job_file, failure_prefix=failure_prefix)
418
+
419
+ try:
420
+ result = execute_plan(
421
+ plan,
422
+ approved=True,
423
+ keep_changes=keep_changes,
424
+ source_path=job_file,
425
+ )
426
+ except ExecutionError as error:
427
+ click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
428
+ raise click.exceptions.Exit(execution_exit_code(error.code)) from error
429
+
430
+ click.echo(_render_run_result(result))
431
+
432
+
433
+ def _load_plan(job_file: Path, *, failure_prefix: str) -> Plan:
434
+ """Load and plan a job with the stable CLI exit-code mapping."""
435
+
436
+ workspace, job = _load_workspace_job(job_file, failure_prefix=failure_prefix)
437
+ return _plan_loaded_job(workspace, job, failure_prefix=failure_prefix)
438
+
439
+
440
+ def _load_workspace_job(
441
+ job_file: Path,
442
+ *,
443
+ failure_prefix: str,
444
+ ) -> tuple[Workspace, Job]:
445
+ try:
446
+ workspace = discover_workspace(Path.cwd())
447
+ except WorkspaceError as error:
448
+ click.echo(f"{failure_prefix} {error}", err=True)
449
+ raise click.exceptions.Exit(3) from error
450
+
451
+ try:
452
+ job = load_job(
453
+ job_file,
454
+ max_bytes=workspace.config.execution.max_job_bytes,
455
+ )
456
+ except JobError as error:
457
+ click.echo(f"{failure_prefix} {error}", err=True)
458
+ raise click.exceptions.Exit(2) from error
459
+ try:
460
+ workspace.require_project_id(job.project_id)
461
+ except WorkspaceError as error:
462
+ click.echo(f"{failure_prefix} {error}", err=True)
463
+ raise click.exceptions.Exit(3) from error
464
+ return workspace, job
465
+
466
+
467
+ def _plan_loaded_job(
468
+ workspace: Workspace,
469
+ job: Job,
470
+ *,
471
+ failure_prefix: str,
472
+ ) -> Plan:
473
+ try:
474
+ plan = plan_job(job, workspace)
475
+ except WorkspaceError as error:
476
+ click.echo(f"{failure_prefix} {error}", err=True)
477
+ raise click.exceptions.Exit(3) from error
478
+ except PolicyError as error:
479
+ click.echo(f"{failure_prefix} {error}", err=True)
480
+ raise click.exceptions.Exit(4) from error
481
+ except PlanningError as error:
482
+ click.echo(f"{failure_prefix} {error}", err=True)
483
+ raise click.exceptions.Exit(_planning_exit_code(error.code)) from error
484
+
485
+ return plan
486
+
487
+
488
+ def _approve_run(yes: bool) -> bool:
489
+ if yes:
490
+ return True
491
+ try:
492
+ return click.confirm("Apply this job?", default=False)
493
+ except click.Abort:
494
+ return False
495
+
496
+
497
+ def _approve_keep_changes(yes: bool) -> bool:
498
+ if yes:
499
+ return True
500
+ try:
501
+ return click.confirm(
502
+ "Keep partial changes if this job fails?",
503
+ default=False,
504
+ )
505
+ except click.Abort:
506
+ return False
507
+
508
+
509
+ def _decline_job(
510
+ plan: Plan,
511
+ job_file: Path,
512
+ *,
513
+ failure_prefix: str,
514
+ ) -> NoReturn:
515
+ try:
516
+ error = record_declined_plan(plan, source_path=job_file)
517
+ except ExecutionError as record_error:
518
+ click.echo(
519
+ _render_execution_error(record_error, prefix=failure_prefix),
520
+ err=True,
521
+ )
522
+ raise click.exceptions.Exit(
523
+ execution_exit_code(record_error.code)
524
+ ) from record_error
525
+ click.echo(
526
+ _render_execution_error(error, prefix=failure_prefix),
527
+ err=True,
528
+ )
529
+ raise click.exceptions.Exit(execution_exit_code(error.code))
530
+
531
+
532
+ def _render_execution_error(
533
+ error: ExecutionError,
534
+ *,
535
+ prefix: str = "RUN_FAILED",
536
+ ) -> str:
537
+ lines = [f"{prefix} {error}"]
538
+ if error.backup_path is not None:
539
+ lines.append(f"backup: {error.backup_path.as_posix()}")
540
+ if error.log_path is not None:
541
+ lines.append(f"log: {error.log_path.as_posix()}")
542
+ if error.archived_job_path is not None:
543
+ lines.append(f"archived_job: {error.archived_job_path.as_posix()}")
544
+ if error.rollback_skipped:
545
+ rollback = (
546
+ "SKIPPED_CHANGES_KEPT" if error.changes_kept else "SKIPPED_NO_CHANGES"
547
+ )
548
+ else:
549
+ rollback = {
550
+ None: "NOT_STARTED",
551
+ True: "SUCCESS",
552
+ False: "FAILED",
553
+ }[error.rollback_succeeded]
554
+ lines.append(f"rollback: {rollback}")
555
+ lines.extend(
556
+ f"check: {result.id} {result.name} {result.status.value}"
557
+ for result in error.check_results
558
+ )
559
+ lines.extend(
560
+ f"formatter: {result.id} {result.name} {result.status.value}"
561
+ for result in error.formatting_results
562
+ )
563
+ if error.workspace_comparison is not None:
564
+ unexpected = error.workspace_comparison.unexpected_changes
565
+ lines.append(f"unexpected_workspace_changes: {len(unexpected)}")
566
+ lines.extend(
567
+ f" - {change.kind.value} {change.path.as_posix()}" for change in unexpected
568
+ )
569
+ return "\n".join(lines)
570
+
571
+
572
+ def _render_run_result(result: RunResult) -> str:
573
+ plan = result.plan
574
+ lines = [
575
+ result.status.value,
576
+ f"project_id: {plan.job.project_id}",
577
+ f"job_id: {plan.job.id}",
578
+ f"job_hash: {plan.job_hash}",
579
+ "backup: "
580
+ + (result.backup_path.as_posix() if result.backup_path is not None else "none"),
581
+ ]
582
+ _append_path_list(lines, "created_files", result.created_files)
583
+ _append_path_list(lines, "modified_files", result.modified_files)
584
+ _append_path_list(lines, "created_directories", result.created_directories)
585
+ lines.append(f"initial_checks: {len(result.initial_checks)}")
586
+ lines.extend(
587
+ f" - {item.id} {item.name} {item.status.value}"
588
+ for item in result.initial_checks
589
+ )
590
+ lines.append(f"formatters: {len(result.formatting_results)}")
591
+ lines.extend(
592
+ f" - {item.id} {item.name} {item.status.value}"
593
+ for item in result.formatting_results
594
+ )
595
+ lines.append(f"final_checks: {len(result.final_checks)}")
596
+ lines.extend(
597
+ f" - {item.id} {item.name} {item.status.value}" for item in result.final_checks
598
+ )
599
+ lines.append(f"audit_results: {len(result.audit_results)}")
600
+ for item in result.audit_results:
601
+ lines.extend(
602
+ (
603
+ f" - {item.id} {item.name} {item.status}",
604
+ " output:",
605
+ *(f" {line}" for line in item.output.splitlines()),
606
+ )
607
+ )
608
+ comparison = result.workspace_comparison
609
+ lines.append(
610
+ "workspace_comparison: "
611
+ + (
612
+ "NOT_APPLICABLE"
613
+ if comparison is None
614
+ else ("PASS" if comparison.success else "UNEXPECTED_CHANGES")
615
+ )
616
+ )
617
+ lines.append(f"workspace_changes: {len(comparison.changes) if comparison else 0}")
618
+ lines.append(
619
+ "unexpected_workspace_changes: "
620
+ f"{len(comparison.unexpected_changes) if comparison else 0}"
621
+ )
622
+ if result.log_path is not None:
623
+ lines.append(f"log: {result.log_path.as_posix()}")
624
+ if result.archived_job_path is not None:
625
+ lines.append(f"archived_job: {result.archived_job_path.as_posix()}")
626
+ return "\n".join(lines)
627
+
628
+
629
+ def _render_registered_result(result: RegisteredRunResult) -> str:
630
+ return "\n".join(
631
+ (
632
+ result.status.value,
633
+ f"project_id: {result.job.project_id}",
634
+ f"job_id: {result.job.id}",
635
+ f"job_hash: {result.job_hash}",
636
+ "backup: none",
637
+ f"log: {result.log_path.as_posix()}",
638
+ f"archived_job: {result.archived_job_path.as_posix()}",
639
+ )
640
+ )
641
+
642
+
643
+ def _render_manual_rollback_result(result: ManualRollbackResult) -> str:
644
+ lines = [
645
+ "ROLLED_BACK",
646
+ f"job_id: {result.job_id}",
647
+ f"job_hash: {result.job_hash}",
648
+ f"backup: {result.backup_path.as_posix()}",
649
+ ]
650
+ _append_path_list(lines, "restored_files", result.restored_files)
651
+ _append_path_list(lines, "removed_files", result.removed_files)
652
+ _append_path_list(lines, "removed_directories", result.removed_directories)
653
+ lines.append(f"log: {result.log_path.as_posix()}")
654
+ return "\n".join(lines)
655
+
656
+
657
+ def _optional_latest_log(workspace: Workspace) -> Path | None:
658
+ try:
659
+ return latest_log_path(workspace)
660
+ except ExecutionError as error:
661
+ if error.code is ExecutionErrorCode.LOG_NOT_FOUND:
662
+ return None
663
+ raise
664
+
665
+
666
+ def _render_registry_record(record: RegistryJobRecord) -> list[str]:
667
+ return [
668
+ f"job_id: {record.job_id}",
669
+ f"job_hash: {record.job_hash}",
670
+ f"kind: {record.kind}",
671
+ f"first_run_at: {record.first_run_at}",
672
+ f"latest_run_at: {record.latest_run_at}",
673
+ f"latest_result: {record.latest_result}",
674
+ f"backup: {record.backup_reference or 'none'}",
675
+ f"rollback: {record.rollback_state}",
676
+ f"archived_job: {record.archived_job_copy}",
677
+ f"completed: {str(record.completed).lower()}",
678
+ f"run_count: {record.run_count}",
679
+ ]
680
+
681
+
682
+ _POLICY_PLANNING_CODES = frozenset(
683
+ {
684
+ PlanningErrorCode.ACTION_LIMIT_EXCEEDED,
685
+ PlanningErrorCode.PATCH_CHECK_REQUIRED,
686
+ PlanningErrorCode.PATH_IGNORED,
687
+ PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
688
+ PlanningErrorCode.FILE_BINARY,
689
+ PlanningErrorCode.FILE_ENCODING_UNSUPPORTED,
690
+ PlanningErrorCode.FILE_NEWLINE_UNSUPPORTED,
691
+ PlanningErrorCode.CONTENT_BINARY_FORBIDDEN,
692
+ PlanningErrorCode.PYTEST_ARGUMENT_FORBIDDEN,
693
+ PlanningErrorCode.CHECK_ARGUMENT_INVALID,
694
+ }
695
+ )
696
+
697
+
698
+ def _planning_exit_code(code: PlanningErrorCode) -> int:
699
+ if code in {
700
+ PlanningErrorCode.CHECK_PROFILE_NOT_FOUND,
701
+ PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
702
+ }:
703
+ return 9
704
+ if code in _POLICY_PLANNING_CODES:
705
+ return 4
706
+ return 5
707
+
708
+
709
+ def _render_plan(plan: Plan) -> str:
710
+ lines = [
711
+ "PLAN",
712
+ f"project_id: {plan.job.project_id}",
713
+ f"job_id: {plan.job.id}",
714
+ f"job_hash: {plan.job_hash}",
715
+ f"kind: {plan.job.kind.value}",
716
+ f"planned_actions: {len(plan.actions)}",
717
+ ]
718
+ lines.extend(
719
+ f" - {action.id} {action.name} {action.disposition.value}: "
720
+ f"{_path_summary(action.paths)}"
721
+ for action in plan.actions
722
+ )
723
+ _append_path_list(lines, "files_to_create", plan.files_to_create)
724
+ _append_path_list(lines, "files_to_modify", plan.files_to_modify)
725
+ _append_path_list(lines, "directories_to_create", plan.directories_to_create)
726
+ lines.append(f"requested_checks: {len(plan.checks)}")
727
+ lines.extend(
728
+ f" - {check.id} {check.name}: {_path_summary(check.paths)}"
729
+ for check in plan.checks
730
+ )
731
+ _append_path_list(lines, "formatting_scope", plan.formatting_targets)
732
+ lines.append(
733
+ f"protected_paths: {'PASS' if plan.protected_paths_passed else 'BLOCKED'}"
734
+ )
735
+ lines.append(
736
+ "backup_destination: "
737
+ + (
738
+ plan.backup_destination.as_posix()
739
+ if plan.backup_destination is not None
740
+ else "none"
741
+ )
742
+ )
743
+ if plan.job.kind is JobKind.PATCH:
744
+ rollback = "enabled" if plan.auto_rollback else "disabled"
745
+ else:
746
+ rollback = "not_applicable"
747
+ lines.extend(
748
+ (
749
+ f"automatic_rollback: {rollback}",
750
+ "confirmation_required: " + ("yes" if plan.requires_confirmation else "no"),
751
+ )
752
+ )
753
+ return "\n".join(lines)
754
+
755
+
756
+ def _append_path_list(lines: list[str], label: str, paths: tuple) -> None:
757
+ lines.append(f"{label}: {len(paths)}")
758
+ lines.extend(f" - {path.as_posix()}" for path in paths)
759
+
760
+
761
+ def _path_summary(paths: tuple) -> str:
762
+ return ", ".join(path.as_posix() for path in paths) if paths else "-"
763
+
764
+
765
+ if __name__ == "__main__": # pragma: no cover
766
+ main()