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,158 @@
1
+ """Apply ``reference.patch`` to a temporary copy of the starting workspace with host git.
2
+
3
+ ``git apply`` here is not a challenge command: it only edits files in a copy that lives in the
4
+ system temp directory, outside any repository. Git must never read configuration the challenge
5
+ controls: a ``.git`` inside the workspace could define ``filter.*.clean``/``smudge`` commands that
6
+ ``.gitattributes`` would make git run on the host. So the copy never contains ``.git`` entries
7
+ (:func:`copy_workspace`), and git runs with ``GIT_DIR`` pointing at a fresh, empty bare repository
8
+ created next to the copy (no discovery at all), with ``GIT_CEILING_DIRECTORIES`` as a second fence,
9
+ system and global config and system attributes ignored, and hooks, fsmonitor and the attributes
10
+ file disabled. ``--unsafe-paths`` is never passed, so the patch cannot touch anything outside the
11
+ copy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import shutil
19
+ import stat
20
+ import subprocess
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from patchground import safeio
25
+ from patchground.verify.errors import VerifyEnvironmentError
26
+
27
+ GIT_TIMEOUT_SECONDS = 60.0
28
+
29
+
30
+ class PatchError(Exception):
31
+ """``git apply`` rejected the patch; ``stderr`` is git's explanation."""
32
+
33
+ def __init__(self, stderr: str) -> None:
34
+ super().__init__(stderr)
35
+ self.stderr = stderr
36
+
37
+
38
+ def snapshot(directory: Path) -> dict[str, str]:
39
+ """Map relative POSIX path -> sha256 of the content for every non-directory entry.
40
+
41
+ Symlinks are recorded by their target text and never followed.
42
+ """
43
+ entries: dict[str, str] = {}
44
+ for current, dirnames, filenames in directory.walk(follow_symlinks=False):
45
+ names = filenames + [d for d in dirnames if (current / d).is_symlink()]
46
+ for name in names:
47
+ path = current / name
48
+ rel = path.relative_to(directory).as_posix()
49
+ if path.is_symlink():
50
+ entries[rel] = "L" + hashlib.sha256(os.fsencode(path.readlink())).hexdigest()
51
+ elif stat.S_ISREG(path.lstat().st_mode):
52
+ entries[rel] = hashlib.sha256(path.read_bytes()).hexdigest()
53
+ else:
54
+ entries[rel] = "special"
55
+ return entries
56
+
57
+
58
+ def _is_git_entry(name: str) -> bool:
59
+ return name.casefold() == ".git"
60
+
61
+
62
+ def _ignore_git(_directory: str, names: list[str]) -> set[str]:
63
+ return {name for name in names if _is_git_entry(name)}
64
+
65
+
66
+ def copy_workspace(source: Path, target: Path) -> None:
67
+ """Copy ``source`` to the new directory ``target`` without any ``.git`` entry (any case/depth).
68
+
69
+ Symlinks are copied as symlinks. Raises :class:`VerifyEnvironmentError` if the copy fails.
70
+ """
71
+ try:
72
+ shutil.copytree(source, target, symlinks=True, ignore=_ignore_git)
73
+ except (OSError, shutil.Error) as exc:
74
+ raise VerifyEnvironmentError(
75
+ f"could not copy the workspace to a temporary folder: {exc}"
76
+ ) from exc
77
+
78
+
79
+ _GIT_HARDENING = (
80
+ "-c",
81
+ f"core.hooksPath={os.devnull}",
82
+ "-c",
83
+ "core.fsmonitor=false",
84
+ "-c",
85
+ f"core.attributesFile={os.devnull}",
86
+ )
87
+
88
+
89
+ def _git_env(temp_root: Path) -> dict[str, str]:
90
+ return {
91
+ "PATH": os.defpath,
92
+ "HOME": str(temp_root),
93
+ "LC_ALL": "C",
94
+ "GIT_CEILING_DIRECTORIES": str(temp_root.parent),
95
+ "GIT_CONFIG_NOSYSTEM": "1",
96
+ "GIT_CONFIG_GLOBAL": os.devnull,
97
+ "GIT_ATTR_NOSYSTEM": "1",
98
+ "GIT_TERMINAL_PROMPT": "0",
99
+ }
100
+
101
+
102
+ def _git(*inputs: Path) -> str:
103
+ return safeio.resolve_git(*inputs, error=VerifyEnvironmentError)
104
+
105
+
106
+ def apply_patch(
107
+ patch: Path, target: Path, temp_root: Path, *, inputs: tuple[Path, ...] = ()
108
+ ) -> None:
109
+ """Apply ``patch`` (paths relative to ``target``, ``-p1``) inside ``target``.
110
+
111
+ ``temp_root`` is the temporary directory that contains ``target``; an empty bare repository
112
+ is created there (outside ``target``) and used as ``GIT_DIR``, so git never discovers or
113
+ reads any other repository, including a ``.git`` inside ``target``. Raises
114
+ :class:`PatchError` if git rejects the patch and :class:`VerifyEnvironmentError` if git is
115
+ missing or cannot run.
116
+ """
117
+ git = _git(patch.parent, target, temp_root, *inputs)
118
+ target = target.resolve()
119
+ env = _git_env(temp_root)
120
+ try:
121
+ git_dir = Path(tempfile.mkdtemp(prefix="gitdir-", dir=temp_root)).resolve()
122
+ except OSError as exc:
123
+ raise VerifyEnvironmentError(f"could not create a temporary git directory: {exc}") from exc
124
+ if git_dir.is_relative_to(target):
125
+ raise ValueError("temp_root must not lie inside the target")
126
+ init = _run_git(
127
+ [git, *_GIT_HARDENING, "init", "--bare", "-q", "--template=", str(git_dir)], temp_root, env
128
+ )
129
+ if init.returncode != 0:
130
+ raise VerifyEnvironmentError("could not initialize the isolated reference repository")
131
+ env = {**env, "GIT_DIR": str(git_dir), "GIT_WORK_TREE": str(target)}
132
+ patch_arg = str(patch.resolve())
133
+ for argv in (
134
+ [git, *_GIT_HARDENING, "apply", "--check", "-p1", patch_arg],
135
+ [git, *_GIT_HARDENING, "apply", "-p1", "--whitespace=nowarn", patch_arg],
136
+ ):
137
+ completed = _run_git(argv, target, env)
138
+ if completed.returncode != 0:
139
+ raise PatchError(f"git apply exited {completed.returncode}")
140
+
141
+
142
+ def _run_git(argv: list[str], cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
143
+ try:
144
+ return subprocess.run(
145
+ argv,
146
+ cwd=cwd,
147
+ env=env,
148
+ stdin=subprocess.DEVNULL,
149
+ capture_output=True,
150
+ text=True,
151
+ errors="replace",
152
+ timeout=GIT_TIMEOUT_SECONDS,
153
+ check=False,
154
+ )
155
+ except OSError as exc:
156
+ raise VerifyEnvironmentError(f"could not run git: {exc}") from exc
157
+ except subprocess.TimeoutExpired as exc:
158
+ raise VerifyEnvironmentError(f"git did not finish within {GIT_TIMEOUT_SECONDS:g}s") from exc
@@ -0,0 +1,199 @@
1
+ """Structured results of a validation: per-step results and the overall report.
2
+
3
+ Everything here is plain data with a JSON form (``to_dict``). The JSON form never carries file
4
+ contents, the reference patch or logs of runs that include hidden checks: only test ids and
5
+ statuses, counts, and short human-readable details.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from patchground.sandbox import RunOutcome, RunResult, TestStatus
18
+ from patchground.spec import Challenge, ManifestIssue
19
+
20
+
21
+ class StepName(StrEnum):
22
+ """The verification steps of ``patchground-contract-verification``, in order."""
23
+
24
+ MANIFEST = "manifest"
25
+ BUILD = "build"
26
+ BROKEN_FAILS = "broken_fails"
27
+ REFERENCE_PASSES = "reference_passes"
28
+ DETERMINISM = "determinism"
29
+ ANTI_GAMING = "anti_gaming"
30
+
31
+
32
+ class StepStatus(StrEnum):
33
+ PASSED = "passed"
34
+ FAILED = "failed"
35
+ NOT_RUN = "not_run"
36
+
37
+
38
+ def utc_now() -> str:
39
+ """Current UTC time as ISO 8601 with millisecond precision and a ``Z`` suffix."""
40
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TestOutcome:
45
+ """One test of a run: its id and status only."""
46
+
47
+ __test__ = False # not a pytest test class
48
+
49
+ id: str
50
+ status: TestStatus
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {"id": self.id, "status": self.status.value}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RunSummary:
58
+ """One sandbox run as recorded by validation (no log text)."""
59
+
60
+ label: str
61
+ outcome: RunOutcome
62
+ tests: tuple[TestOutcome, ...]
63
+
64
+ @classmethod
65
+ def from_result(cls, label: str, result: RunResult) -> RunSummary:
66
+ return cls(label, result.outcome, tuple(TestOutcome(t.id, t.status) for t in result.tests))
67
+
68
+ def count(self, status: TestStatus) -> int:
69
+ return sum(1 for t in self.tests if t.status is status)
70
+
71
+ def ids(self) -> list[str]:
72
+ return [t.id for t in self.tests]
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ return {
76
+ "label": self.label,
77
+ "outcome": self.outcome.value,
78
+ "counts": {
79
+ "passed": self.count(TestStatus.PASSED),
80
+ "failed": self.count(TestStatus.FAILED),
81
+ "errored": self.count(TestStatus.ERRORED),
82
+ "skipped": self.count(TestStatus.SKIPPED),
83
+ },
84
+ "tests": [t.to_dict() for t in self.tests],
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class StepResult:
90
+ """Result of one step.
91
+
92
+ ``issues``/``issue_lines`` are set for a failed ``manifest`` step (``issue_lines`` are the
93
+ ``file:line: field: message`` renderings, relative to the working directory when possible);
94
+ ``log_tail`` holds the end of the build log for a failed ``build`` step. ``notes`` are
95
+ sandbox explanations (``[patchground]`` lines) for inconclusive runs.
96
+ """
97
+
98
+ name: StepName
99
+ status: StepStatus
100
+ detail: str
101
+ started_at: str | None = None
102
+ finished_at: str | None = None
103
+ runs: tuple[RunSummary, ...] = ()
104
+ issues: tuple[ManifestIssue, ...] = ()
105
+ issue_lines: tuple[str, ...] = ()
106
+ log_tail: str | None = None
107
+ notes: tuple[str, ...] = ()
108
+
109
+ def to_record(self) -> dict[str, Any]:
110
+ """The shape stored in ``verification.json``."""
111
+ return {
112
+ "name": self.name.value,
113
+ "status": self.status.value,
114
+ "detail": self.detail,
115
+ "started_at": self.started_at,
116
+ "finished_at": self.finished_at,
117
+ "runs": [run.to_dict() for run in self.runs],
118
+ }
119
+
120
+ def to_dict(self) -> dict[str, Any]:
121
+ """The record shape plus the failure diagnostics that are set."""
122
+ data = self.to_record()
123
+ if self.issues:
124
+ data["issues"] = [
125
+ {
126
+ "file": str(issue.file),
127
+ "line": issue.line,
128
+ "field": issue.field,
129
+ "message": issue.message,
130
+ "text": text,
131
+ }
132
+ for issue, text in zip(self.issues, self.issue_lines, strict=True)
133
+ ]
134
+ if self.log_tail is not None:
135
+ data["log_tail"] = self.log_tail
136
+ if self.notes:
137
+ data["notes"] = list(self.notes)
138
+ return data
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class ValidationReport:
143
+ """Outcome of :func:`patchground.verify.validate_challenge`."""
144
+
145
+ folder: Path
146
+ steps: tuple[StepResult, ...]
147
+ validated_at: str
148
+ challenge: Challenge | None = None
149
+ image_id: str | None = None
150
+ verification_file: Path | None = field(default=None)
151
+ removed_verification_file: Path | None = field(default=None)
152
+
153
+ @property
154
+ def valid(self) -> bool:
155
+ """True when no step failed and at least one step ran."""
156
+ return self.failed_step is None and any(
157
+ step.status is StepStatus.PASSED for step in self.steps
158
+ )
159
+
160
+ @property
161
+ def failed_step(self) -> StepName | None:
162
+ for step in self.steps:
163
+ if step.status is StepStatus.FAILED:
164
+ return step.name
165
+ return None
166
+
167
+ def step(self, name: StepName) -> StepResult:
168
+ for step in self.steps:
169
+ if step.name is name:
170
+ return step
171
+ raise KeyError(name)
172
+
173
+ def to_dict(self) -> dict[str, Any]:
174
+ failed = self.failed_step
175
+ return {
176
+ "valid": self.valid,
177
+ "failed_step": failed.value if failed is not None else None,
178
+ "steps": [step.to_dict() for step in self.steps],
179
+ "verification_file": (
180
+ str(self.verification_file) if self.verification_file is not None else None
181
+ ),
182
+ "removed_verification_file": (
183
+ str(self.removed_verification_file)
184
+ if self.removed_verification_file is not None
185
+ else None
186
+ ),
187
+ }
188
+
189
+
190
+ def run_label(*parts: str) -> str:
191
+ return "+".join(parts)
192
+
193
+
194
+ def format_ids(ids: Sequence[str], limit: int = 10) -> str:
195
+ """Comma-separated ids, truncated after ``limit`` with a count of the rest."""
196
+ shown = ", ".join(ids[:limit])
197
+ if len(ids) > limit:
198
+ shown += f", … ({len(ids) - limit} more)"
199
+ return shown
@@ -0,0 +1,47 @@
1
+ """Recognize the bounded runner recipes without executing authored commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from pathlib import PurePath, PurePosixPath
8
+ from typing import Literal
9
+
10
+ from patchground.runner_assets import VITEST_COMMAND
11
+ from patchground.verify.pytest_adapter import supports_pytest
12
+
13
+ Runner = Literal["pytest", "vitest"]
14
+ VITEST_EXCLUDED_PARTS = frozenset({"node_modules", ".git"})
15
+ _SAFE_MODULE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*")
16
+
17
+
18
+ def is_vitest_path(path: PurePath) -> bool:
19
+ return path.name.endswith(".test.ts") and not VITEST_EXCLUDED_PARTS.intersection(path.parts)
20
+
21
+
22
+ def vitest_identity(module: str, name: str) -> str:
23
+ path = PurePosixPath(module)
24
+ identity = f"{module}::{name}"
25
+ if (
26
+ not _SAFE_MODULE.fullmatch(module)
27
+ or not is_vitest_path(path)
28
+ or path.is_absolute()
29
+ or ".." in path.parts
30
+ or any(part.startswith("-") for part in path.parts)
31
+ or not name
32
+ or not identity.isprintable()
33
+ or len(identity) > 240
34
+ ):
35
+ raise ValueError("TypeScript test identity cannot be represented safely")
36
+ return identity
37
+
38
+
39
+ def runner_for(command: str) -> Runner | None:
40
+ if supports_pytest(command):
41
+ return "pytest"
42
+ try:
43
+ if shlex.split(command) == shlex.split(VITEST_COMMAND):
44
+ return "vitest"
45
+ except ValueError:
46
+ pass
47
+ return None
@@ -0,0 +1,106 @@
1
+ """Bounded pytest test-module mutations in disposable editable layers only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from patchground.verify.pytest_adapter import (
11
+ UnsupportedPytest,
12
+ is_test_filename,
13
+ iter_test_definitions,
14
+ supports_pytest,
15
+ )
16
+ from patchground.verify.pytest_adapter import (
17
+ parse_module as parse_pytest_module,
18
+ )
19
+ from patchground.verify.runners import Runner, is_vitest_path, vitest_identity
20
+ from patchground.verify.vitest_adapter import UnsupportedVitest, parse_module, skip_module
21
+
22
+ __all__ = [
23
+ "Mutation",
24
+ "UnsupportedTampering",
25
+ "mutate",
26
+ "supports_pytest",
27
+ "test_modules",
28
+ "vitest_identities",
29
+ ]
30
+
31
+ Mutation = Literal["empty", "deleted", "skipped"]
32
+
33
+
34
+ class UnsupportedTampering(Exception):
35
+ """No supported mutation can be established; never report this as verified."""
36
+
37
+
38
+ def _syntax(path: Path) -> ast.Module:
39
+ try:
40
+ return parse_pytest_module(path)
41
+ except UnsupportedPytest as exc:
42
+ raise UnsupportedTampering("editable modules are not supported Python source") from exc
43
+
44
+
45
+ def _vitest_modules(layers: list[Path]) -> list[tuple[Path, str]]:
46
+ return [
47
+ (path, path.relative_to(layer).as_posix())
48
+ for layer in layers
49
+ for path in sorted(layer.rglob("*.test.ts"))
50
+ if is_vitest_path(path.relative_to(layer))
51
+ ]
52
+
53
+
54
+ def vitest_identities(layers: list[Path]) -> Counter[str]:
55
+ """Expected editable identities, checked against actual collection before mutation."""
56
+ try:
57
+ return Counter(
58
+ vitest_identity(relative, name)
59
+ for path, relative in _vitest_modules(layers)
60
+ for name in parse_module(path).definitions
61
+ )
62
+ except (UnsupportedVitest, ValueError) as exc:
63
+ raise UnsupportedTampering(
64
+ "TypeScript test identities cannot be established safely"
65
+ ) from exc
66
+
67
+
68
+ def test_modules(layers: list[Path], *, runner: Runner = "pytest") -> list[Path]:
69
+ """Select conventional modules with static tests, preserving helper-only modules."""
70
+ if runner == "vitest":
71
+ try:
72
+ return [path for path, _ in _vitest_modules(layers) if parse_module(path).definitions]
73
+ except UnsupportedVitest as exc:
74
+ raise UnsupportedTampering("unsupported TypeScript test module") from exc
75
+ candidates = sorted(
76
+ path for layer in layers for path in layer.rglob("*.py") if is_test_filename(path.name)
77
+ )
78
+ return [
79
+ path for path in candidates if next(iter_test_definitions(_syntax(path)), None) is not None
80
+ ]
81
+
82
+
83
+ def mutate(modules: list[Path], mutation: Mutation, *, runner: Runner = "pytest") -> None:
84
+ for path in modules:
85
+ if mutation == "deleted":
86
+ path.unlink()
87
+ elif mutation == "skipped" and runner == "vitest":
88
+ try:
89
+ skip_module(path)
90
+ except UnsupportedVitest as exc:
91
+ raise UnsupportedTampering("unsupported TypeScript skip mutation") from exc
92
+ elif mutation == "skipped":
93
+ syntax = _syntax(path)
94
+ names = {node.id for node in ast.walk(syntax) if isinstance(node, ast.Name)}
95
+ alias = "_patchground_anti_gaming_pytest"
96
+ while alias in names:
97
+ alias += "_"
98
+ suffix = (
99
+ f"\nimport pytest as {alias}\n"
100
+ "pytestmark = globals().get('pytestmark', [])\n"
101
+ "pytestmark = (list(pytestmark) if isinstance(pytestmark, (list, tuple)) "
102
+ "else [pytestmark]) + "
103
+ f"[{alias}.mark.skip(reason='Patchground editable-test probe')]\n"
104
+ )
105
+ with path.open("ab") as stream:
106
+ stream.write(suffix.encode("ascii"))