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,597 @@
1
+ """Regression-test evidence: do the learner's new or changed tests fail before and pass after?
2
+
3
+ The probe supports direct ``pytest`` commands and the canonical frozen Vitest runner, using
4
+ static conventional test definitions. Candidates are exactly the test definitions
5
+ that are new or changed between the captured original and final trees. They are selected alone
6
+ twice inside the sandbox with identical test material and a fixed runner configuration: once
7
+ against the original code and once against the final code. Anything the probe cannot reason about
8
+ is reported as ``not_established`` rather than guessed, and nothing here ever runs on the host.
9
+
10
+ Evidence carries test identities and statuses only, never raw runner output.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import filecmp
17
+ import os
18
+ import re
19
+ import shlex
20
+ import shutil
21
+ import stat
22
+ import tempfile
23
+ from collections import Counter
24
+ from collections.abc import Sequence
25
+ from dataclasses import dataclass
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Literal, Protocol
28
+
29
+ from pydantic import BaseModel, ConfigDict
30
+
31
+ from patchground import sandbox as engine
32
+ from patchground.runner_assets import VITEST_COMMAND
33
+ from patchground.sandbox import RunOutcome, RunResult, TestResult, TestStatus
34
+ from patchground.sessions.models import CheckTest
35
+ from patchground.verify import vitest_adapter
36
+ from patchground.verify.pytest_adapter import (
37
+ TestNode,
38
+ UnsupportedPytest,
39
+ is_test_filename,
40
+ split_pytest_command,
41
+ test_definitions,
42
+ )
43
+ from patchground.verify.runners import (
44
+ VITEST_EXCLUDED_PARTS,
45
+ is_vitest_path,
46
+ runner_for,
47
+ vitest_identity,
48
+ )
49
+
50
+ Status = Literal["verified", "not_verified", "not_established"]
51
+
52
+ MAX_CANDIDATES = 200
53
+ MAX_TEXT_BYTES = 1024 * 1024 # runner configuration files
54
+ JUNIT_TARGET = "--junitxml=$PG_RESULTS/junit.xml"
55
+ JUNIT_VALUES = ("$PG_RESULTS/junit.xml", "${PG_RESULTS}/junit.xml")
56
+ SKIPPED_PARTS = frozenset({".git", "__pycache__", ".pytest_cache"})
57
+ RUNNER_CONFIG = frozenset(
58
+ {"pytest.ini", ".pytest.ini", "pytest.toml", ".pytest.toml", "tox.ini", "setup.cfg"}
59
+ | {"pyproject.toml", "conftest.py"}
60
+ )
61
+ HARNESS_PARTS = frozenset({"tests", "test", "testing"})
62
+ SAFE_PATH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*")
63
+ # Settings and hooks that change what pytest collects, selects or skips.
64
+ CONFIG_CONTROLS = re.compile(
65
+ r"^\s*(python_files|python_classes|python_functions|norecursedirs|addopts|"
66
+ r"confcutdir|consider_namespace_packages)\b",
67
+ re.MULTILINE,
68
+ )
69
+ CONFTEST_CONTROLS = re.compile(
70
+ r"\b(collect_ignore(_glob)?|pytest_(ignore_collect|collect_file|collection\w*|"
71
+ r"pycollect\w*|generate_tests|configure|runtest\w*|deselected))\b"
72
+ )
73
+ COLLECTION_OPTIONS = frozenset(
74
+ {"-k", "-m", "-x", "-c", "--deselect", "--ignore", "--ignore-glob", "--lf", "--ff", "--nf"}
75
+ | {"--sw", "--co", "--collect-only", "--maxfail", "--exitfirst", "--pyargs", "--rootdir"}
76
+ | {"--confcutdir", "--import-mode", "--last-failed", "--failed-first", "--new-first"}
77
+ | {"--stepwise", "--override-ini", "-o", "--junitxml", "--junit-xml"}
78
+ )
79
+ SIMPLE_OPTIONS = re.compile(
80
+ r"-[qvsl]+|-r[A-Za-z]+|--(quiet|verbose|no-header|no-summary|disable-warnings|"
81
+ r"strict-markers|strict-config|showlocals)|--color=(yes|no|auto)|"
82
+ r"--tb=(auto|long|short|line|native|no)|--durations=\d+"
83
+ )
84
+
85
+
86
+ class RegressionSandbox(Protocol):
87
+ """The one sandbox operation regression evidence needs (injectable for tests)."""
88
+
89
+ def run_check(self, image_id: str, layers: Sequence[Path], command: str) -> RunResult: ...
90
+
91
+
92
+ class RegressionEvidence(BaseModel):
93
+ """Factual regression-test outcome; never a score or a submission gate."""
94
+
95
+ model_config = ConfigDict(extra="forbid", frozen=True)
96
+
97
+ status: Status
98
+ reason: str
99
+ candidates: tuple[str, ...] = ()
100
+ verified_tests: tuple[str, ...] = ()
101
+ before_tests: tuple[CheckTest, ...] = ()
102
+ after_tests: tuple[CheckTest, ...] = ()
103
+ before_outcome: str | None = None
104
+ after_outcome: str | None = None
105
+
106
+
107
+ class _Unsupported(Exception):
108
+ """The probe cannot establish evidence; the message becomes the reason."""
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class _Candidate:
113
+ module: str # relative POSIX path of the test module
114
+ qualname: str # Python qualified name or exact Vitest literal name
115
+ exact_identity: bool = False
116
+
117
+ @property
118
+ def id(self) -> str:
119
+ """The runner-specific JUnit identity, preserving literal Vitest names."""
120
+ if self.exact_identity:
121
+ return vitest_identity(self.module, self.qualname)
122
+ *classes, name = self.qualname.split("::")
123
+ dotted = ".".join([self.module.removesuffix(".py").replace("/", "."), *classes])
124
+ return f"{dotted}::{name}"
125
+
126
+ @property
127
+ def node_id(self) -> str:
128
+ return f"{self.module}::{self.qualname}"
129
+
130
+
131
+ def evaluate_regression(
132
+ original: Path,
133
+ final: Path,
134
+ image_id: str,
135
+ command: str,
136
+ *,
137
+ sandbox: RegressionSandbox = engine,
138
+ ) -> RegressionEvidence:
139
+ """Probe whether the final tests fail on the original code and pass on the final code.
140
+
141
+ ``original`` and ``final`` are captured private trees, never the live workspace. Unsupported
142
+ inputs return ``not_established``. Engine and storage faults raise
143
+ :class:`patchground.sandbox.SandboxError` (or ``OSError``) so the caller can retry.
144
+ """
145
+ try:
146
+ plan = _plan(Path(original), Path(final), command)
147
+ except _Unsupported as exc:
148
+ return RegressionEvidence(status="not_established", reason=str(exc))
149
+ if not plan.candidates:
150
+ return RegressionEvidence(
151
+ status="not_verified", reason="no new or changed test definitions were found"
152
+ )
153
+ ids = tuple(c.id for c in plan.candidates)
154
+ with tempfile.TemporaryDirectory(prefix="patchground-regression-") as scratch:
155
+ before_tree, after_tree = Path(scratch, "original"), Path(scratch, "final")
156
+ _copy(plan.original_files, before_tree)
157
+ _copy(plan.final_files, after_tree)
158
+ for module in plan.test_modules:
159
+ if plan.exact_identities and module not in plan.final_files:
160
+ (before_tree / module).unlink()
161
+ continue
162
+ material = plan.final_files[module]
163
+ _copy_file(material, before_tree / module)
164
+ if plan.exact_identities:
165
+ _copy_file(material, after_tree / module)
166
+ names = {c.qualname for c in plan.candidates if c.module == module}
167
+ for tree in (before_tree, after_tree):
168
+ vitest_adapter.select_tests(tree / module, names)
169
+ before = sandbox.run_check(image_id, [before_tree], plan.command)
170
+ before_tests = _sanitized(before.tests)
171
+ stop = _judge_before(before, ids, exact=plan.exact_identities)
172
+ if stop is not None:
173
+ return RegressionEvidence(
174
+ status=stop[0],
175
+ reason=stop[1],
176
+ candidates=ids,
177
+ before_tests=before_tests,
178
+ before_outcome=before.outcome.value,
179
+ )
180
+ after = sandbox.run_check(image_id, [after_tree], plan.command)
181
+ after_tests = _sanitized(after.tests)
182
+ outcomes = {"before_outcome": before.outcome.value, "after_outcome": after.outcome.value}
183
+ stop = _judge_after(after, ids, exact=plan.exact_identities)
184
+ if stop is None:
185
+ stop = _same_identities(before, after)
186
+ verified = _verified(before, after)
187
+ if stop is None and not verified:
188
+ stop = ("not_verified", "no selected test failed on the original and passed on the final")
189
+ status: Status = stop[0] if stop else "verified"
190
+ return RegressionEvidence(
191
+ status=status,
192
+ reason=stop[1]
193
+ if stop
194
+ else "selected tests fail on the original code and pass on the final",
195
+ candidates=ids,
196
+ verified_tests=() if stop else verified,
197
+ before_tests=before_tests,
198
+ after_tests=after_tests,
199
+ **outcomes,
200
+ )
201
+
202
+
203
+ # -------------------------------------------------------------------------------------- planning
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class _Plan:
208
+ candidates: tuple[_Candidate, ...]
209
+ test_modules: tuple[str, ...]
210
+ original_files: dict[str, Path]
211
+ final_files: dict[str, Path]
212
+ command: str
213
+ exact_identities: bool = False
214
+
215
+
216
+ def _plan(original: Path, final: Path, command: str) -> _Plan:
217
+ split = split_pytest_command(command)
218
+ if split is None:
219
+ if runner_for(command) == "vitest":
220
+ return _vitest_plan(original, final, command)
221
+ raise _Unsupported("the test command is not a supported frozen runner invocation")
222
+ invocation, arguments = split
223
+ options, scope = _runner_options(arguments)
224
+ original_files, final_files = _files(original), _files(final)
225
+ changed = {
226
+ rel
227
+ for rel in original_files.keys() | final_files.keys()
228
+ if rel not in original_files
229
+ or rel not in final_files
230
+ or not filecmp.cmp(original_files[rel], final_files[rel], shallow=False)
231
+ }
232
+ modules, candidates = _candidates(original_files, final_files, changed)
233
+ if candidates:
234
+ _reject_unreliable(original_files, final_files, changed, candidates)
235
+ if len(candidates) > MAX_CANDIDATES:
236
+ raise _Unsupported("too many new or changed tests to select safely")
237
+ _check_scope(candidates, scope)
238
+ command = _selected_command(invocation, options, candidates)
239
+ return _Plan(tuple(candidates), tuple(modules), original_files, final_files, command)
240
+
241
+
242
+ def _vitest_plan(original: Path, final: Path, command: str) -> _Plan:
243
+ original_files = _files(original, excluded=VITEST_EXCLUDED_PARTS)
244
+ final_files = _files(final, excluded=VITEST_EXCLUDED_PARTS)
245
+ changed = {
246
+ rel
247
+ for rel in original_files.keys() | final_files.keys()
248
+ if rel not in original_files
249
+ or rel not in final_files
250
+ or not filecmp.cmp(original_files[rel], final_files[rel], shallow=False)
251
+ }
252
+ parsed: list[dict[str, vitest_adapter.TestModule]] = []
253
+ try:
254
+ for files in (original_files, final_files):
255
+ parsed.append(
256
+ {
257
+ rel: vitest_adapter.parse_module(path)
258
+ for rel, path in files.items()
259
+ if is_vitest_path(PurePosixPath(rel))
260
+ }
261
+ )
262
+ except vitest_adapter.UnsupportedVitest as exc:
263
+ raise _Unsupported(f"a TypeScript test module cannot be analysed: {exc}") from exc
264
+ before, after = parsed
265
+ candidates: list[_Candidate] = []
266
+ for rel, module in sorted(after.items()):
267
+ previous = before.get(rel)
268
+ for name, definition in module.definitions.items():
269
+ old = previous.definitions.get(name) if previous is not None else None
270
+ if old is not None and old.fingerprint == definition.fingerprint:
271
+ continue
272
+ candidate = _Candidate(rel, name, exact_identity=True)
273
+ try:
274
+ vitest_identity(rel, name)
275
+ except ValueError as exc:
276
+ raise _Unsupported("a TypeScript test identity cannot be selected safely") from exc
277
+ candidates.append(candidate)
278
+ if len(candidates) > MAX_CANDIDATES:
279
+ raise _Unsupported("too many new or changed tests to select safely")
280
+ if candidates:
281
+ for rel in before.keys() & after.keys():
282
+ if before[rel].helper_fingerprint != after[rel].helper_fingerprint:
283
+ raise _Unsupported("TypeScript test imports or helpers changed")
284
+ homes = {rel.rpartition("/")[0] for rel in before.keys() | after.keys()}
285
+ for rel in changed:
286
+ name = rel.rsplit("/", 1)[-1]
287
+ if _vitest_harness_file(name):
288
+ raise _Unsupported("TypeScript runner, dependency or configuration files changed")
289
+ if (rel in before and before[rel].definitions) or (
290
+ rel in after and after[rel].definitions
291
+ ):
292
+ continue
293
+ directory = rel.rpartition("/")[0]
294
+ near = any(
295
+ directory == home or (home and directory.startswith(home + "/")) for home in homes
296
+ )
297
+ if near or HARNESS_PARTS & set(directory.split("/")):
298
+ raise _Unsupported("helper or harness files near the tests changed")
299
+ if candidates:
300
+ selected_modules = sorted({c.module for c in candidates})
301
+ command = f"{VITEST_COMMAND} --files {shlex.join(selected_modules)}"
302
+ return _Plan(
303
+ tuple(candidates),
304
+ tuple(sorted(before.keys() | after.keys())),
305
+ original_files,
306
+ final_files,
307
+ command,
308
+ exact_identities=True,
309
+ )
310
+
311
+
312
+ def _vitest_harness_file(name: str) -> bool:
313
+ return name in {
314
+ "package.json",
315
+ "package-lock.json",
316
+ "npm-shrinkwrap.json",
317
+ "pnpm-lock.yaml",
318
+ "yarn.lock",
319
+ "bun.lock",
320
+ "bun.lockb",
321
+ "pnpm-workspace.yaml",
322
+ ".npmrc",
323
+ ".yarnrc",
324
+ ".yarnrc.yml",
325
+ ".node-version",
326
+ ".nvmrc",
327
+ "Dockerfile",
328
+ "vitest-runner.mjs",
329
+ } or name.startswith(("vitest.config.", "vite.config.", "tsconfig.", "Dockerfile."))
330
+
331
+
332
+ def _runner_options(arguments: list[str]) -> tuple[list[str], list[str]]:
333
+ """Split the frozen pytest arguments into preserved options and positional test paths."""
334
+ options: list[str] = []
335
+ scope: list[str] = []
336
+ junit = False
337
+ index = 0
338
+ while index < len(arguments):
339
+ word = arguments[index]
340
+ index += 1
341
+ name = word.split("=", 1)[0]
342
+ if name in ("--junitxml", "--junit-xml"):
343
+ value = word.partition("=")[2]
344
+ if "=" not in word and index < len(arguments):
345
+ value, index = arguments[index], index + 1
346
+ if value not in JUNIT_VALUES:
347
+ raise _Unsupported("the JUnit target must be $PG_RESULTS/junit.xml")
348
+ junit = True
349
+ elif word == "-p" or word.startswith("-p"):
350
+ plugin = arguments[index] if word == "-p" and index < len(arguments) else word[2:]
351
+ index += word == "-p"
352
+ if plugin != "no:cacheprovider":
353
+ raise _Unsupported("only -p no:cacheprovider is supported")
354
+ options += ["-p", plugin]
355
+ elif word.startswith("-"):
356
+ if name in COLLECTION_OPTIONS or not SIMPLE_OPTIONS.fullmatch(word):
357
+ kind = "collection control" if name in COLLECTION_OPTIONS else "runner option"
358
+ raise _Unsupported(f"{kind} {_shown(name)} is not supported")
359
+ options.append(word)
360
+ elif "::" in word:
361
+ raise _Unsupported("test selection by node id in the test command is not supported")
362
+ else:
363
+ scope.append(_normalized(word))
364
+ if not junit:
365
+ raise _Unsupported("the test command must write --junitxml=$PG_RESULTS/junit.xml")
366
+ return options, scope
367
+
368
+
369
+ def _shown(text: str) -> str:
370
+ return repr("".join(c for c in text if c.isprintable())[:40])
371
+
372
+
373
+ def _normalized(path: str) -> str:
374
+ parts = [part for part in path.split("/") if part not in ("", ".")]
375
+ if path.startswith("/") or ".." in parts:
376
+ raise _Unsupported("test paths outside the workspace are not supported")
377
+ return "/".join(parts)
378
+
379
+
380
+ def _files(root: Path, *, excluded: frozenset[str] = frozenset()) -> dict[str, Path]:
381
+ """Map relative POSIX path -> ordinary file; anything unusual makes the tree unsupported."""
382
+ if not root.is_dir() or root.is_symlink():
383
+ raise _Unsupported("a captured tree is missing or not a directory")
384
+ files: dict[str, Path] = {}
385
+ for dirpath, dirnames, filenames in os.walk(root):
386
+ base = Path(dirpath)
387
+ kept = []
388
+ for name in sorted(dirnames):
389
+ if name in SKIPPED_PARTS or name in excluded:
390
+ continue
391
+ if (base / name).is_symlink():
392
+ raise _Unsupported("a captured tree contains a symbolic link")
393
+ kept.append(name)
394
+ dirnames[:] = kept
395
+ for name in filenames:
396
+ path = base / name
397
+ mode = path.lstat().st_mode
398
+ if name.endswith(".pyc"):
399
+ continue
400
+ if not stat.S_ISREG(mode):
401
+ raise _Unsupported("a captured tree contains a link or special file")
402
+ files[path.relative_to(root).as_posix()] = path
403
+ return files
404
+
405
+
406
+ def _definitions(path: Path) -> dict[str, TestNode]:
407
+ try:
408
+ return test_definitions(path)
409
+ except UnsupportedPytest as exc:
410
+ raise _Unsupported(f"a test module cannot be analysed: {exc}") from exc
411
+
412
+
413
+ def _candidates(
414
+ original: dict[str, Path], final: dict[str, Path], changed: set[str]
415
+ ) -> tuple[list[str], list[_Candidate]]:
416
+ """Changed static test modules and the test definitions that are new or changed in them."""
417
+ modules: list[str] = []
418
+ candidates: list[_Candidate] = []
419
+ for rel in sorted(changed & final.keys()):
420
+ if not rel.endswith(".py") or not is_test_filename(rel.rsplit("/", 1)[-1]):
421
+ continue
422
+ after = _definitions(final[rel])
423
+ if not after:
424
+ continue # helper-only module with a test-like name; handled as harness
425
+ modules.append(rel)
426
+ before = _definitions(original[rel]) if rel in original else {}
427
+ for qualname, node in after.items():
428
+ old = before.get(qualname)
429
+ if old is None or ast.dump(old) != ast.dump(node):
430
+ if not SAFE_PATH.fullmatch(rel) or any(p.startswith("-") for p in rel.split("/")):
431
+ raise _Unsupported("a test path cannot be selected safely")
432
+ candidates.append(_Candidate(rel, qualname))
433
+ return modules, candidates
434
+
435
+
436
+ def _reject_unreliable(
437
+ original: dict[str, Path],
438
+ final: dict[str, Path],
439
+ changed: set[str],
440
+ candidates: Sequence[_Candidate],
441
+ ) -> None:
442
+ """Refuse when runner configuration, collection controls or test harness files differ."""
443
+ for rel in sorted(changed):
444
+ if rel.rsplit("/", 1)[-1] in RUNNER_CONFIG:
445
+ raise _Unsupported("pytest configuration, pyproject or conftest files changed")
446
+ for files in (original, final):
447
+ for rel, path in files.items():
448
+ name = rel.rsplit("/", 1)[-1]
449
+ if name not in RUNNER_CONFIG:
450
+ continue
451
+ if path.stat().st_size > MAX_TEXT_BYTES:
452
+ raise _Unsupported("a runner configuration file is too large to analyse")
453
+ text = path.read_text(encoding="utf-8", errors="replace")
454
+ pattern = CONFTEST_CONTROLS if name == "conftest.py" else CONFIG_CONTROLS
455
+ if pattern.search(text):
456
+ raise _Unsupported("collection controls in pytest configuration or conftest")
457
+ homes = {c.module.rpartition("/")[0] for c in candidates}
458
+ for rel in sorted(changed):
459
+ if _is_test_file(final, original, rel):
460
+ continue
461
+ directory = rel.rpartition("/")[0]
462
+ near = any(
463
+ directory == home or (home != "" and directory.startswith(home + "/")) for home in homes
464
+ )
465
+ if near or HARNESS_PARTS & set(directory.split("/")):
466
+ raise _Unsupported("helper or harness files near the tests changed")
467
+
468
+
469
+ def _is_test_file(final: dict[str, Path], original: dict[str, Path], rel: str) -> bool:
470
+ """A changed conventional module with static tests (compared test material, not harness)."""
471
+ path = final.get(rel)
472
+ if path is None:
473
+ return rel in original and rel.endswith(".py") and _static_test_module(original[rel])
474
+ return rel.endswith(".py") and _static_test_module(path)
475
+
476
+
477
+ def _static_test_module(path: Path) -> bool:
478
+ name = path.name
479
+ return is_test_filename(name) and bool(_definitions(path))
480
+
481
+
482
+ def _check_scope(candidates: Sequence[_Candidate], scope: Sequence[str]) -> None:
483
+ for candidate in candidates:
484
+ if scope and not any(
485
+ part == "" or candidate.module == part or candidate.module.startswith(part + "/")
486
+ for part in scope
487
+ ):
488
+ raise _Unsupported("a selected test is outside the paths in the test command")
489
+
490
+
491
+ def _selected_command(
492
+ invocation: list[str], options: list[str], candidates: Sequence[_Candidate]
493
+ ) -> str:
494
+ fixed = shlex.join([*invocation, *options, "--rootdir=."])
495
+ return f"{fixed} {JUNIT_TARGET} {shlex.join(c.node_id for c in candidates)}"
496
+
497
+
498
+ # ------------------------------------------------------------------------------------- copying
499
+
500
+
501
+ def _copy_file(source: Path, target: Path) -> None:
502
+ target.parent.mkdir(parents=True, exist_ok=True)
503
+ target.unlink(missing_ok=True)
504
+ shutil.copy2(source, target)
505
+ # Captures are read-only; only these private disposable copies may be mutated.
506
+ target.chmod(stat.S_IMODE(target.stat().st_mode) | stat.S_IWUSR)
507
+
508
+
509
+ def _copy(files: dict[str, Path], target: Path) -> None:
510
+ target.mkdir()
511
+ for rel, path in files.items():
512
+ _copy_file(path, target / rel)
513
+
514
+
515
+ # ------------------------------------------------------------------------------------- judging
516
+
517
+
518
+ def _sanitized(tests: Sequence[TestResult]) -> tuple[CheckTest, ...]:
519
+ return tuple(
520
+ CheckTest(id="".join(c for c in t.id if c.isprintable())[:240], status=t.status)
521
+ for t in tests
522
+ )
523
+
524
+
525
+ Verdict = tuple[Status, str] | None
526
+
527
+
528
+ def _base(test: TestResult) -> str:
529
+ return f"{test.classname}::{test.name.split('[', 1)[0]}"
530
+
531
+
532
+ def _inconclusive(label: str, run: RunResult) -> Verdict:
533
+ if run.outcome in (RunOutcome.MISSING_RESULTS, RunOutcome.LIMIT_EXCEEDED):
534
+ return "not_established", f"the {label} run was inconclusive ({run.outcome.value})"
535
+ return None
536
+
537
+
538
+ def _collected(label: str, run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
539
+ if any(len(test.id) > 240 or not test.id.isprintable() for test in run.tests):
540
+ return "not_established", "test identities cannot be reported safely without alteration"
541
+ if exact and Counter(t.id for t in run.tests) != Counter(ids):
542
+ return "not_verified", f"selected tests were not collected exactly on the {label} code"
543
+ seen = {t.id if exact else _base(t) for t in run.tests}
544
+ if not set(ids) <= seen or not seen <= set(ids):
545
+ return (
546
+ "not_verified",
547
+ f"selected tests were not collected exactly on the {label} code",
548
+ )
549
+ return None
550
+
551
+
552
+ def _judge_before(run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
553
+ stop = _inconclusive("original", run) or _collected("original", run, ids, exact=exact)
554
+ if stop is not None:
555
+ return stop
556
+ if any(t.status not in (TestStatus.PASSED, TestStatus.FAILED) for t in run.tests):
557
+ return "not_verified", "original run has errored or skipped tests"
558
+ if all(t.status == TestStatus.PASSED for t in run.tests):
559
+ return "not_verified", "selected tests already pass on the original code"
560
+ if run.outcome != RunOutcome.FAILED:
561
+ return "not_verified", "the original runner did not complete as a test failure"
562
+ return None
563
+
564
+
565
+ def _judge_after(run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
566
+ stop = _inconclusive("final", run) or _collected("final", run, ids, exact=exact)
567
+ if stop is not None:
568
+ return stop
569
+ if any(t.status != TestStatus.PASSED for t in run.tests):
570
+ return "not_verified", "a selected test does not pass on the final code"
571
+ if run.outcome != RunOutcome.PASSED:
572
+ return "not_verified", "the final runner did not complete successfully"
573
+ return None
574
+
575
+
576
+ def _statuses(run: RunResult) -> dict[str, list[TestStatus]]:
577
+ grouped: dict[str, list[TestStatus]] = {}
578
+ for test in run.tests:
579
+ grouped.setdefault(test.id, []).append(test.status)
580
+ return grouped
581
+
582
+
583
+ def _same_identities(before: RunResult, after: RunResult) -> Verdict:
584
+ counts = Counter(t.id for t in before.tests)
585
+ if counts != Counter(t.id for t in after.tests):
586
+ return "not_verified", "selected test identities differ between original and final runs"
587
+ return None
588
+
589
+
590
+ def _verified(before: RunResult, after: RunResult) -> tuple[str, ...]:
591
+ after_statuses = _statuses(after)
592
+ return tuple(
593
+ test_id
594
+ for test_id, statuses in _statuses(before).items()
595
+ if all(s == TestStatus.FAILED for s in statuses)
596
+ and after_statuses.get(test_id, []) == [TestStatus.PASSED] * len(statuses)
597
+ )
@@ -0,0 +1,92 @@
1
+ """Deterministic Markdown rendering of facts, never source code or raw test output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+
7
+ from patchground.report.models import VerifiedReport
8
+
9
+
10
+ def _printable(value: str) -> str:
11
+ return "".join(char if char.isprintable() else " " for char in value)
12
+
13
+
14
+ def _escaped(value: str) -> str:
15
+ escaped = html.escape(_printable(value), quote=True)
16
+ for char in ("\\", "`", "*", "_", "[", "]", "#", "|", "!"):
17
+ escaped = escaped.replace(char, "\\" + char)
18
+ return escaped
19
+
20
+
21
+ def render_markdown(report: VerifiedReport, *, terminal: bool = False) -> str:
22
+ """The saved ``report.md``; ``terminal`` renders the same facts as plain text for a console,
23
+ without Markdown or HTML escaping (control characters are still replaced)."""
24
+ _text = _printable if terminal else _escaped
25
+ checks = report.checks
26
+ lines = [
27
+ f"# Verified outcomes: {_text(report.challenge_id)}",
28
+ "",
29
+ f"Challenge version: {_text(report.challenge_version)}",
30
+ f"Submitted: {report.submitted_at.isoformat()}",
31
+ "",
32
+ "## Checks",
33
+ "",
34
+ f"Outcome: {checks.outcome.value}; passing: {checks.passed}.",
35
+ ", ".join(f"{name}: {count}" for name, count in sorted(checks.counts.items())),
36
+ ]
37
+ if checks.limit:
38
+ lines.append(f"Resource limit: {checks.limit}.")
39
+ lines.extend(f"- {test.status.value}: {_text(test.id)}" for test in checks.tests)
40
+ lines += [
41
+ "",
42
+ "## Regression test",
43
+ "",
44
+ f"{report.regression.status}: {_text(report.regression.reason)}.",
45
+ ]
46
+ lines.extend(f"- Verified: {_text(test)}" for test in report.regression.verified_tests)
47
+ lines += ["", "## Diff scope", ""]
48
+ if not report.diff.expected_paths:
49
+ lines.append("Expected paths were not declared.")
50
+ if not report.diff.files:
51
+ lines.append("No ordinary checked-file changes.")
52
+ for change in report.diff.files:
53
+ counts = (
54
+ f"+{change.added_lines} -{change.removed_lines}"
55
+ if change.added_lines is not None
56
+ else "line counts not established (binary, non-UTF-8 or over 1 MiB)"
57
+ )
58
+ scope = "; outside expected paths" if change.outside_expected else ""
59
+ lines.append(f"- {_text(change.path)}: {change.kind}, {counts}{scope}")
60
+ lines += [
61
+ "",
62
+ "## Test structure",
63
+ "",
64
+ f"{report.test_structure.status}: {_text(report.test_structure.reason)}.",
65
+ "Structural removals are not a judgment of semantic test strength.",
66
+ ]
67
+ lines.extend(f"- Removed file: {_text(path)}" for path in report.test_structure.removed_files)
68
+ lines.extend(f"- Removed test: {_text(test)}" for test in report.test_structure.removed_tests)
69
+ lines.extend(
70
+ f"- Assertion count reduced: {_text(item.test)} ({item.before} → {item.after})"
71
+ for item in report.test_structure.assertion_reductions
72
+ )
73
+ lines.extend(
74
+ f"- Unsupported source: {_text(path)}" for path in report.test_structure.unsupported_files
75
+ )
76
+ effort = report.effort
77
+ lines += ["", "## Time and effort", ""]
78
+ if effort.elapsed_seconds is None:
79
+ lines.append("Timer off; elapsed time not established.")
80
+ else:
81
+ lines.append(
82
+ f"Elapsed at submission request: {int(effort.elapsed_seconds)}s / "
83
+ f"{effort.timebox_minutes} minutes."
84
+ )
85
+ lines += [
86
+ f"Practice checks: {effort.practice_checks}; hints revealed: {effort.hints_revealed}.",
87
+ "Submission and regression verification runs are separate from practice-check counts.",
88
+ "",
89
+ "No overall score. This report is local evidence, not tamper-proof attestation.",
90
+ "",
91
+ ]
92
+ return "\n".join(lines)