patchground 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 (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,686 @@
1
+ """Run the supported ``patchground-contract-verification`` gates for a challenge folder.
2
+
3
+ Steps run in order and stop at the first failure; later steps are reported as ``not_run``.
4
+ Anti-gaming reports ``not_run`` for unsupported runners or editable-test layouts. Problems with the
5
+ host (engine unreachable, git missing, engine failures) are not verdicts on the challenge and
6
+ raise :class:`VerifyEnvironmentError` instead of failing a step.
7
+
8
+ The challenge folder is only read, except for ``verification.json``, written on success. The
9
+ reference solution is applied to a copy in the system temp directory.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import stat
17
+ import tempfile
18
+ from collections import Counter
19
+ from collections.abc import Callable, Iterator, Sequence
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass, replace
22
+ from pathlib import Path
23
+ from typing import Any, Protocol
24
+
25
+ from ruamel.yaml import YAML
26
+
27
+ from patchground import sandbox
28
+ from patchground.sandbox import (
29
+ BuildFailed,
30
+ BuildResult,
31
+ EngineUnavailable,
32
+ Limits,
33
+ RunOutcome,
34
+ RunResult,
35
+ SandboxError,
36
+ TestStatus,
37
+ )
38
+ from patchground.spec import Challenge, ManifestError, ManifestIssue, load_challenge
39
+ from patchground.verify.errors import VerifyEnvironmentError
40
+ from patchground.verify.record import (
41
+ VERIFICATION_FILE,
42
+ remove_verification,
43
+ verification_document,
44
+ write_verification,
45
+ )
46
+ from patchground.verify.reference import PatchError, apply_patch, copy_workspace, snapshot
47
+ from patchground.verify.result import (
48
+ RunSummary,
49
+ StepName,
50
+ StepResult,
51
+ StepStatus,
52
+ ValidationReport,
53
+ format_ids,
54
+ run_label,
55
+ utc_now,
56
+ )
57
+ from patchground.verify.runners import runner_for
58
+ from patchground.verify.tampering import (
59
+ Mutation,
60
+ UnsupportedTampering,
61
+ mutate,
62
+ test_modules,
63
+ vitest_identities,
64
+ )
65
+
66
+ DEFAULT_LIMITS = Limits()
67
+ BUILD_LOG_TAIL_LINES = 30
68
+ INCONCLUSIVE = frozenset({RunOutcome.MISSING_RESULTS, RunOutcome.LIMIT_EXCEEDED})
69
+ SERVICES_UNSUPPORTED = (
70
+ "environment.services is not supported yet (deferred beyond the local prototype)"
71
+ )
72
+
73
+ Progress = Callable[[str], None]
74
+
75
+
76
+ class Sandbox(Protocol):
77
+ """The two sandbox operations validation needs (injectable for tests)."""
78
+
79
+ def build_image(self, dockerfile: Path, *, name: str) -> BuildResult: ...
80
+
81
+ def run_check(
82
+ self, image_id: str, layers: Sequence[Path], command: str, *, limits: Limits
83
+ ) -> RunResult: ...
84
+
85
+
86
+ class DockerSandbox:
87
+ """The real sandbox: :mod:`patchground.sandbox`."""
88
+
89
+ def build_image(self, dockerfile: Path, *, name: str) -> BuildResult:
90
+ return sandbox.build_image(dockerfile, name=name)
91
+
92
+ def run_check(
93
+ self, image_id: str, layers: Sequence[Path], command: str, *, limits: Limits
94
+ ) -> RunResult:
95
+ return sandbox.run_check(image_id, layers, command, limits=limits)
96
+
97
+
98
+ class _StepFailed(Exception):
99
+ def __init__(self, result: StepResult) -> None:
100
+ super().__init__(result.detail)
101
+ self.result = result
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class _Layer:
106
+ label: str
107
+ path: Path
108
+
109
+
110
+ # --------------------------------------------------------------------------------------------------
111
+ # Entry point
112
+ # --------------------------------------------------------------------------------------------------
113
+
114
+
115
+ def validate_challenge(
116
+ folder: Path | str,
117
+ *,
118
+ limits: Limits = DEFAULT_LIMITS,
119
+ write: bool = True,
120
+ sandbox: Sandbox | None = None,
121
+ progress: Progress | None = None,
122
+ yes: bool = False,
123
+ expected_identity: str | None = None,
124
+ pack_root: Path | None = None,
125
+ input_roots: tuple[Path, ...] = (),
126
+ ) -> ValidationReport:
127
+ """Authorize installed inputs; validate their captured copy without altering the cache."""
128
+ from patchground.packs.models import ConsentRequired, PackError
129
+ from patchground.packs.registry import prepare_execution
130
+
131
+ try:
132
+ with prepare_execution(
133
+ folder, yes=yes, expected_identity=expected_identity, root=pack_root
134
+ ) as prepared:
135
+ report = _validate_challenge(
136
+ prepared.path,
137
+ limits=limits,
138
+ write=write and prepared.pack is None,
139
+ sandbox=sandbox,
140
+ progress=progress,
141
+ input_roots=(*prepared.input_roots, *input_roots),
142
+ )
143
+ if prepared.pack is not None:
144
+ challenge = (
145
+ replace(
146
+ report.challenge,
147
+ root=prepared.source_path,
148
+ manifest_path=prepared.source_path / "challenge.yaml",
149
+ )
150
+ if report.challenge is not None
151
+ else None
152
+ )
153
+ report = replace(report, folder=prepared.source_path, challenge=challenge)
154
+ return report
155
+ except ConsentRequired:
156
+ raise
157
+ except PackError as exc:
158
+ raise VerifyEnvironmentError(str(exc)) from exc
159
+
160
+
161
+ def _validate_challenge(
162
+ folder: Path,
163
+ *,
164
+ limits: Limits = DEFAULT_LIMITS,
165
+ write: bool = True,
166
+ sandbox: Sandbox | None = None,
167
+ progress: Progress | None = None,
168
+ input_roots: tuple[Path, ...] = (),
169
+ ) -> ValidationReport:
170
+ """Validate the challenge in ``folder`` and return the per-step report.
171
+
172
+ On success (every executed step passed) and with ``write``, ``verification.json`` is written
173
+ atomically into the challenge root and its path recorded in the report. On failure with
174
+ ``write``, an existing ``verification.json`` is deleted so no stale record claims validity.
175
+ ``progress`` receives short status messages while long steps run.
176
+
177
+ Raises :class:`VerifyEnvironmentError` when the host cannot run validation.
178
+ """
179
+ folder = Path(folder)
180
+ box: Sandbox = sandbox if sandbox is not None else DockerSandbox()
181
+ notify: Progress = progress if progress is not None else _ignore
182
+ run = _Validation(folder, box, limits, notify, input_roots=input_roots)
183
+ steps = run.execute()
184
+ report = ValidationReport(
185
+ folder=folder,
186
+ steps=tuple(steps),
187
+ validated_at=utc_now(),
188
+ challenge=run.challenge,
189
+ image_id=run.image_id,
190
+ )
191
+ if report.valid and write and run.challenge is not None:
192
+ try:
193
+ path = write_verification(run.challenge.root, verification_document(report))
194
+ except OSError as exc:
195
+ raise VerifyEnvironmentError(
196
+ f"the challenge is valid but {VERIFICATION_FILE} could not be written: {exc}"
197
+ ) from exc
198
+ report = replace(report, verification_file=path)
199
+ elif not report.valid and write:
200
+ try:
201
+ removed = remove_verification(folder)
202
+ except OSError as exc:
203
+ raise VerifyEnvironmentError(
204
+ f"validation failed at step {_failed_name(report)} and the stale "
205
+ f"{VERIFICATION_FILE} could not be removed: {exc}"
206
+ ) from exc
207
+ report = replace(report, removed_verification_file=removed)
208
+ return report
209
+
210
+
211
+ def _failed_name(report: ValidationReport) -> str:
212
+ failed = report.failed_step
213
+ return failed.value if failed is not None else "unknown"
214
+
215
+
216
+ def _ignore(_message: str) -> None:
217
+ return None
218
+
219
+
220
+ class _Validation:
221
+ """State shared between the steps of one validation."""
222
+
223
+ def __init__(
224
+ self,
225
+ folder: Path,
226
+ box: Sandbox,
227
+ limits: Limits,
228
+ progress: Progress,
229
+ *,
230
+ input_roots: tuple[Path, ...] = (),
231
+ ) -> None:
232
+ self.folder = folder
233
+ self.input_roots = input_roots
234
+ self.box = box
235
+ self.limits = limits
236
+ self.progress = progress
237
+ self.challenge: Challenge | None = None
238
+ self.image_id: str | None = None
239
+ self.hidden_ids: list[str] = []
240
+ self.starting_ids: list[str] = []
241
+ self.baselines: list[RunSummary] = []
242
+
243
+ def execute(self) -> list[StepResult]:
244
+ ordered: list[tuple[StepName, Callable[[str], StepResult]]] = [
245
+ (StepName.MANIFEST, self._manifest),
246
+ (StepName.BUILD, self._build),
247
+ (StepName.BROKEN_FAILS, self._broken_fails),
248
+ (StepName.REFERENCE_PASSES, self._reference_passes),
249
+ (StepName.DETERMINISM, self._determinism),
250
+ (StepName.ANTI_GAMING, self._anti_gaming),
251
+ ]
252
+ results: list[StepResult] = []
253
+ failed: StepName | None = None
254
+ for name, step in ordered:
255
+ if failed is not None:
256
+ detail = f"not run: step {failed.value} failed"
257
+ results.append(StepResult(name, StepStatus.NOT_RUN, detail))
258
+ continue
259
+ started = utc_now()
260
+ try:
261
+ result = step(started)
262
+ except _StepFailed as exc:
263
+ result = exc.result
264
+ results.append(result)
265
+ if name in (StepName.BROKEN_FAILS, StepName.REFERENCE_PASSES):
266
+ self.baselines.extend(result.runs)
267
+ if result.status is StepStatus.FAILED:
268
+ failed = name
269
+ return results
270
+
271
+ # ---------------------------------------------------------------------------------- helpers
272
+
273
+ def _loaded(self) -> Challenge:
274
+ if self.challenge is None: # pragma: no cover - guarded by fail-fast ordering
275
+ raise RuntimeError("challenge not loaded")
276
+ return self.challenge
277
+
278
+ def _check_layers(self) -> list[_Layer]:
279
+ challenge = self._loaded()
280
+ checks = challenge.manifest.checks
281
+ layers: list[_Layer] = []
282
+ if checks.visible is not None:
283
+ layers.append(_Layer("checks.visible", challenge.root / checks.visible))
284
+ layers.append(_Layer("checks.hidden", challenge.root / checks.hidden))
285
+ return layers
286
+
287
+ def _run(
288
+ self,
289
+ step: StepName,
290
+ started: str,
291
+ label: str,
292
+ layers: Sequence[_Layer],
293
+ runs: Sequence[RunSummary],
294
+ ) -> tuple[RunResult, RunSummary]:
295
+ problem = _layer_problem(layers)
296
+ if problem is not None:
297
+ raise _StepFailed(_failed(step, started, problem, runs))
298
+ if self.image_id is None: # pragma: no cover - guarded by fail-fast ordering
299
+ raise RuntimeError("image not built")
300
+ command = self._loaded().manifest.environment.test_command
301
+ try:
302
+ result = self.box.run_check(
303
+ self.image_id, [layer.path for layer in layers], command, limits=self.limits
304
+ )
305
+ except EngineUnavailable as exc:
306
+ raise VerifyEnvironmentError(str(exc)) from exc
307
+ except SandboxError as exc:
308
+ raise VerifyEnvironmentError(f"sandbox error during run {label!r}: {exc}") from exc
309
+ summary = RunSummary.from_result(label, result)
310
+ if result.outcome in INCONCLUSIVE:
311
+ detail = f"inconclusive: run {label} {_inconclusive_reason(result)}"
312
+ raise _StepFailed(_failed(step, started, detail, [*runs, summary]))
313
+ return result, summary
314
+
315
+ # ------------------------------------------------------------------------------------ steps
316
+
317
+ def _manifest(self, started: str) -> StepResult:
318
+ self.progress("loading challenge.yaml")
319
+ try:
320
+ challenge = load_challenge(self.folder)
321
+ except ManifestError as exc:
322
+ count = len(exc.issues)
323
+ detail = f"{count} problem{'s' if count != 1 else ''} in the challenge manifest"
324
+ return _manifest_failure(started, detail, exc.issues)
325
+ services = challenge.manifest.environment.services
326
+ if services:
327
+ manifest_file = self.folder / challenge.manifest_path.name
328
+ issue = ManifestIssue(
329
+ manifest_file,
330
+ _services_line(challenge.manifest_path),
331
+ "environment.services",
332
+ "not supported yet (deferred beyond the local prototype); use [] until then",
333
+ )
334
+ return _manifest_failure(started, SERVICES_UNSUPPORTED, [issue])
335
+ self.challenge = challenge
336
+ manifest = challenge.manifest
337
+ detail = f"challenge.yaml is valid ({manifest.id} {manifest.version})"
338
+ return StepResult(StepName.MANIFEST, StepStatus.PASSED, detail, started, utc_now())
339
+
340
+ def _build(self, started: str) -> StepResult:
341
+ challenge = self._loaded()
342
+ dockerfile = challenge.root / challenge.manifest.environment.dockerfile
343
+ self.progress("building image (the first build may take a while)")
344
+ try:
345
+ built = self.box.build_image(dockerfile, name=challenge.manifest.id)
346
+ except BuildFailed as exc:
347
+ tail = "\n".join(exc.log.rstrip("\n").splitlines()[-BUILD_LOG_TAIL_LINES:])
348
+ return StepResult(
349
+ StepName.BUILD,
350
+ StepStatus.FAILED,
351
+ "image build failed",
352
+ started,
353
+ utc_now(),
354
+ log_tail=tail,
355
+ )
356
+ except EngineUnavailable as exc:
357
+ raise VerifyEnvironmentError(str(exc)) from exc
358
+ except SandboxError as exc:
359
+ raise VerifyEnvironmentError(str(exc)) from exc
360
+ self.image_id = built.image_id
361
+ return StepResult(
362
+ StepName.BUILD, StepStatus.PASSED, f"built {built.tag}", started, utc_now()
363
+ )
364
+
365
+ def _broken_fails(self, started: str) -> StepResult:
366
+ step = StepName.BROKEN_FAILS
367
+ workspace = _Layer("workspace", self._loaded().root / "workspace")
368
+ checks = self._check_layers()
369
+ visible = [layer for layer in checks if layer.label == "checks.visible"]
370
+ labels = ["workspace"] + (["visible"] if visible else [])
371
+
372
+ self.progress("running checks on the starting workspace")
373
+ first, first_summary = self._run(
374
+ step, started, run_label(*labels), [workspace, *visible], []
375
+ )
376
+ _, all_summary = self._run(
377
+ step, started, run_label(*labels, "hidden"), [workspace, *checks], [first_summary]
378
+ )
379
+ runs = (first_summary, all_summary)
380
+
381
+ before = {t.id for t in first.tests}
382
+ after = {t.id for t in all_summary.tests}
383
+ vanished = list(dict.fromkeys(t.id for t in first.tests if t.id not in after))
384
+ if vanished:
385
+ detail = (
386
+ "inconclusive: hidden checks changed or deselected visible tests: "
387
+ f"{format_ids(vanished)}"
388
+ )
389
+ return _failed(step, started, detail, runs)
390
+ hidden = [t for t in all_summary.tests if t.id not in before]
391
+ if not hidden:
392
+ detail = "no hidden tests detected: hidden checks must add tests"
393
+ return _failed(step, started, detail, runs)
394
+ failing = [t for t in hidden if t.status in (TestStatus.FAILED, TestStatus.ERRORED)]
395
+ if not failing:
396
+ detail = (
397
+ f"the starting workspace passes every hidden check ({len(hidden)} hidden "
398
+ "tests, none failed or errored)"
399
+ )
400
+ return _failed(step, started, detail, runs)
401
+ self.hidden_ids = [t.id for t in hidden]
402
+ self.starting_ids = list(dict.fromkeys(t.id for t in first.tests))
403
+ detail = (
404
+ f"{len(failing)} of {len(hidden)} hidden tests fail on the starting workspace; "
405
+ f"run {first_summary.label}: {_counts_text(first_summary)}"
406
+ )
407
+ return StepResult(step, StepStatus.PASSED, detail, started, utc_now(), runs=runs)
408
+
409
+ @contextmanager
410
+ def _reference_workspace(
411
+ self, step: StepName, started: str, runs: Sequence[RunSummary]
412
+ ) -> Iterator[Path]:
413
+ challenge = self._loaded()
414
+ patch = challenge.root / challenge.manifest.solution
415
+ try:
416
+ temp_root = Path(tempfile.mkdtemp(prefix="patchground-reference-")).resolve()
417
+ except OSError as exc:
418
+ raise VerifyEnvironmentError(f"could not create a temporary folder: {exc}") from exc
419
+ try:
420
+ copy = temp_root / "workspace"
421
+ copy_workspace(challenge.root / "workspace", copy)
422
+ before = snapshot(copy)
423
+ self.progress("applying reference.patch")
424
+ try:
425
+ apply_patch(patch, copy, temp_root, inputs=(challenge.root, *self.input_roots))
426
+ except PatchError as exc:
427
+ detail = f"reference.patch does not apply with git apply -p1: {exc.stderr}"
428
+ raise _StepFailed(_failed(step, started, detail, runs)) from exc
429
+ if snapshot(copy) == before:
430
+ raise _StepFailed(
431
+ _failed(step, started, "reference.patch applied but changed no files", runs)
432
+ )
433
+ yield copy
434
+ finally:
435
+ shutil.rmtree(temp_root, ignore_errors=True)
436
+
437
+ def _reference_passes(self, started: str) -> StepResult:
438
+ step = StepName.REFERENCE_PASSES
439
+ with self._reference_workspace(step, started, []) as copy:
440
+ self.progress("running checks with reference.patch applied")
441
+ checks = self._check_layers()
442
+ has_visible = any(layer.label == "checks.visible" for layer in checks)
443
+ label = run_label("reference", *(["visible"] if has_visible else []), "hidden")
444
+ layers = [_Layer("the patched workspace (reference.patch)", copy), *checks]
445
+ result, summary = self._run(step, started, label, layers, [])
446
+
447
+ runs = (summary,)
448
+ if not result.tests:
449
+ return _failed(step, started, "the reference run reported no tests", runs)
450
+ present = {t.id for t in result.tests}
451
+ missing_hidden = [test_id for test_id in self.hidden_ids if test_id not in present]
452
+ if missing_hidden:
453
+ detail = f"hidden tests missing from the reference run: {format_ids(missing_hidden)}"
454
+ return _failed(step, started, detail, runs)
455
+ missing = [test_id for test_id in self.starting_ids if test_id not in present]
456
+ if missing:
457
+ detail = (
458
+ "tests of the starting-state run missing from the reference run: "
459
+ f"{format_ids(missing)}"
460
+ )
461
+ return _failed(step, started, detail, runs)
462
+ not_passing = [
463
+ f"{t.id} ({t.status.value})" for t in summary.tests if t.status is not TestStatus.PASSED
464
+ ]
465
+ if not_passing:
466
+ detail = (
467
+ f"{len(not_passing)} of {len(summary.tests)} tests do not pass with "
468
+ f"reference.patch applied: {format_ids(not_passing)}"
469
+ )
470
+ return _failed(step, started, detail, runs)
471
+ detail = (
472
+ f"all {len(summary.tests)} tests pass with reference.patch applied "
473
+ f"({len(self.hidden_ids)} hidden)"
474
+ )
475
+ return StepResult(step, StepStatus.PASSED, detail, started, utc_now(), runs=runs)
476
+
477
+ def _determinism(self, started: str) -> StepResult:
478
+ step = StepName.DETERMINISM
479
+ runs = [replace(run, label=f"round-1:{run.label}") for run in self.baselines]
480
+ workspace = _Layer("workspace", self._loaded().root / "workspace")
481
+ checks = self._check_layers()
482
+ visible = [layer for layer in checks if layer.label == "checks.visible"]
483
+
484
+ def repeat(round_number: int, baseline: RunSummary, layers: Sequence[_Layer]) -> None:
485
+ label = f"round-{round_number}:{baseline.label}"
486
+ self.progress(f"checking determinism: {label}")
487
+ _, summary = self._run(step, started, label, layers, runs)
488
+ runs.append(summary)
489
+ expected = sorted((test.id, test.status.value) for test in baseline.tests)
490
+ actual = sorted((test.id, test.status.value) for test in summary.tests)
491
+ if summary.outcome != baseline.outcome or actual != expected:
492
+ raise _StepFailed(_failed(step, started, f"run {label} differs from round 1", runs))
493
+
494
+ for round_number in (2, 3):
495
+ repeat(round_number, self.baselines[0], [workspace, *visible])
496
+ repeat(round_number, self.baselines[1], [workspace, *checks])
497
+ with self._reference_workspace(step, started, runs) as copy:
498
+ repeat(
499
+ round_number,
500
+ self.baselines[2],
501
+ [_Layer("the patched workspace (reference.patch)", copy), *checks],
502
+ )
503
+ return StepResult(
504
+ step,
505
+ StepStatus.PASSED,
506
+ "identical per-test results across 3 runs of each workspace variant",
507
+ started,
508
+ utc_now(),
509
+ runs=tuple(runs),
510
+ )
511
+
512
+ def _anti_gaming(self, started: str) -> StepResult:
513
+ step = StepName.ANTI_GAMING
514
+ challenge = self._loaded()
515
+ runs: list[RunSummary] = []
516
+ visible_ids = set(self.baselines[0].ids())
517
+ expected = Counter(
518
+ (test.id, test.status) for test in self.baselines[1].tests if test.id not in visible_ids
519
+ )
520
+ try:
521
+ runner = runner_for(challenge.manifest.environment.test_command)
522
+ if runner is None:
523
+ raise UnsupportedTampering(
524
+ "runner is not a supported pytest or frozen Vitest recipe"
525
+ )
526
+ if not visible_ids:
527
+ raise UnsupportedTampering("no editable baseline tests were observed")
528
+ with tempfile.TemporaryDirectory(prefix="patchground-anti-gaming-") as temporary:
529
+ root = Path(temporary).resolve()
530
+ checks = self._check_layers()
531
+ editable = [_Layer("workspace", challenge.root / "workspace")]
532
+ editable.extend(layer for layer in checks if layer.label == "checks.visible")
533
+ hidden = [layer for layer in checks if layer.label == "checks.hidden"]
534
+ mutations: tuple[Mutation, ...] = ("empty", "deleted", "skipped")
535
+ for mutation in mutations:
536
+ label = f"anti-{mutation}"
537
+ parent = root / label
538
+ parent.mkdir()
539
+ copies: list[_Layer] = []
540
+ for index, layer in enumerate(editable):
541
+ copy = parent / f"layer-{index}"
542
+ copy_workspace(layer.path, copy)
543
+ copies.append(_Layer(layer.label, copy))
544
+ problem = _layer_problem([*copies, *hidden])
545
+ if problem is not None:
546
+ return _failed(step, started, problem, runs)
547
+ paths = [layer.path for layer in copies]
548
+ if runner == "vitest" and vitest_identities(paths) != Counter(
549
+ test.id for test in self.baselines[0].tests
550
+ ):
551
+ raise UnsupportedTampering(
552
+ "editable TypeScript collection differs from static test identities"
553
+ )
554
+ modules = test_modules(paths, runner=runner)
555
+ if not modules:
556
+ raise UnsupportedTampering("no conventional editable test modules found")
557
+ mutate(modules, mutation, runner=runner)
558
+ self.progress(f"checking editable-test tampering: {label}")
559
+ _, summary = self._run(step, started, label, [*copies, *hidden], runs)
560
+ runs.append(summary)
561
+ actual = Counter(
562
+ (test.id, test.status)
563
+ for test in summary.tests
564
+ if test.id not in visible_ids
565
+ )
566
+ if actual != expected:
567
+ return _failed(
568
+ step,
569
+ started,
570
+ f"run {label} changed hidden test identities, counts or statuses",
571
+ runs,
572
+ )
573
+ if mutation == "deleted" and visible_ids <= set(summary.ids()):
574
+ raise UnsupportedTampering("deleted modules did not remove editable tests")
575
+ if mutation == "skipped":
576
+ previously_active = {
577
+ test.id
578
+ for test in self.baselines[0].tests
579
+ if test.status is not TestStatus.SKIPPED
580
+ }
581
+ if not any(
582
+ test.id in previously_active and test.status is TestStatus.SKIPPED
583
+ for test in summary.tests
584
+ ):
585
+ raise UnsupportedTampering("skip mutation did not skip editable tests")
586
+ except UnsupportedTampering as exc:
587
+ return StepResult(
588
+ step,
589
+ StepStatus.NOT_RUN,
590
+ f"not verified: {exc}",
591
+ started,
592
+ utc_now(),
593
+ runs=tuple(runs),
594
+ )
595
+ except OSError as exc:
596
+ raise VerifyEnvironmentError("anti-gaming temporary files are unavailable") from exc
597
+ return StepResult(
598
+ step,
599
+ StepStatus.PASSED,
600
+ "hidden outcomes unchanged under empty diff and editable test module deletion/skipping",
601
+ started,
602
+ utc_now(),
603
+ runs=tuple(runs),
604
+ )
605
+
606
+
607
+ # --------------------------------------------------------------------------------------------------
608
+ # Helpers
609
+ # --------------------------------------------------------------------------------------------------
610
+
611
+
612
+ def _failed(
613
+ step: StepName,
614
+ started: str,
615
+ detail: str,
616
+ runs: Sequence[RunSummary],
617
+ *,
618
+ notes: Sequence[str] = (),
619
+ ) -> StepResult:
620
+ return StepResult(
621
+ step, StepStatus.FAILED, detail, started, utc_now(), runs=tuple(runs), notes=tuple(notes)
622
+ )
623
+
624
+
625
+ def _manifest_failure(started: str, detail: str, issues: Sequence[ManifestIssue]) -> StepResult:
626
+ cwd = Path.cwd()
627
+ return StepResult(
628
+ StepName.MANIFEST,
629
+ StepStatus.FAILED,
630
+ detail,
631
+ started,
632
+ utc_now(),
633
+ issues=tuple(issues),
634
+ issue_lines=tuple(issue.format(relative_to=cwd) for issue in issues),
635
+ )
636
+
637
+
638
+ def _services_line(manifest_path: Path) -> int | None:
639
+ """1-based line of the ``services`` key under ``environment``, if it can be found."""
640
+ try:
641
+ data: Any = YAML(typ="rt", pure=True).load(manifest_path.read_text(encoding="utf-8"))
642
+ line = data["environment"].lc.key("services")[0]
643
+ except Exception: # best effort: the manifest already validated; fall back to no line
644
+ return None
645
+ return line + 1 if isinstance(line, int) else None
646
+
647
+
648
+ def _inconclusive_reason(result: RunResult) -> str:
649
+ if result.outcome is RunOutcome.LIMIT_EXCEEDED:
650
+ return f"exceeded the {result.limit or 'resource'} limit"
651
+ return "produced no usable junit.xml (missing results)"
652
+
653
+
654
+ def _counts_text(summary: RunSummary) -> str:
655
+ return ", ".join(
656
+ f"{summary.count(status)} {status.value}"
657
+ for status in TestStatus
658
+ if summary.count(status) or status is TestStatus.PASSED
659
+ )
660
+
661
+
662
+ def _layer_problem(layers: Sequence[_Layer]) -> str | None:
663
+ """Mirror the sandbox's layer rules so challenge problems fail a step, not the engine.
664
+
665
+ Rejects symlinks and special files, and any non-directory path provided by two layers.
666
+ """
667
+ seen: dict[str, tuple[str, bool]] = {}
668
+ for layer in layers:
669
+ for current, dirnames, filenames in os.walk(layer.path):
670
+ base = Path(current)
671
+ for name in [*dirnames, *filenames]:
672
+ path = base / name
673
+ rel = path.relative_to(layer.path).as_posix()
674
+ mode = path.lstat().st_mode
675
+ if stat.S_ISLNK(mode) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
676
+ kind = "a symlink" if stat.S_ISLNK(mode) else "a special file"
677
+ return f"'{rel}' in {layer.label} is {kind}; only regular files are allowed"
678
+ is_dir = stat.S_ISDIR(mode)
679
+ previous = seen.get(rel)
680
+ if previous is not None and not (previous[1] and is_dir):
681
+ return (
682
+ f"'{rel}' is provided by both {previous[0]} and {layer.label}; check "
683
+ "files are merged into the workspace root and must not collide"
684
+ )
685
+ seen.setdefault(rel, (layer.label, is_dir))
686
+ return None