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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|