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,420 @@
1
+ """Private pack state: catalog, managed cache layout, locking and verified tree capture.
2
+
3
+ Everything under the state directory is data. Metadata is validated strictly on every read and
4
+ never repaired silently; cache paths are derived from validated names and commits, never stored.
5
+ Trees are copied through descriptor-pinned ``safeio`` primitives while being hashed, so the bytes
6
+ that were reviewed are the bytes that are kept or executed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import secrets
16
+ import stat
17
+ from collections.abc import Iterator, Mapping
18
+ from contextlib import ExitStack, contextmanager, suppress
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Literal
22
+ from urllib.parse import urlsplit
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
25
+
26
+ from patchground.packs.discovery import ChallengeSummary
27
+ from patchground.packs.models import PackError, PackIdentity
28
+ from patchground.safeio import SafetyError, open_directory, open_entry, safe_path
29
+ from patchground.spec.models import SemVer, Slug
30
+
31
+ REGISTRY_FILE = "registry.json"
32
+ LOCK_FILE = ".lock"
33
+ PACKS_DIR = "packs"
34
+ MAX_FILES = 10_000
35
+ MAX_BYTES = 100 * 1024 * 1024
36
+ MAX_STATE_BYTES = 8 * 1024 * 1024
37
+ _COMMIT = r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$"
38
+ _CHUNK = 1024 * 1024
39
+
40
+
41
+ def _error(message: str) -> PackError:
42
+ return PackError(message)
43
+
44
+
45
+ def state_root(root: Path | None) -> Path:
46
+ """Canonical state directory. Resolving here is the one trusted step; nothing below may link."""
47
+ base = Path(root) if root is not None else Path("~/.patchground")
48
+ return base.expanduser().absolute().resolve()
49
+
50
+
51
+ def packs_dir(root: Path) -> Path:
52
+ return root / PACKS_DIR
53
+
54
+
55
+ def cache_path(root: Path, name: str, commit: str) -> Path:
56
+ return packs_dir(root) / f"{name}@{commit}"
57
+
58
+
59
+ def make_identity(name: str, version: str, kind: str, source: str, commit: str, digest: str) -> str:
60
+ payload = json.dumps([name, version, kind, source, commit, digest], separators=(",", ":"))
61
+ return hashlib.sha256(payload.encode()).hexdigest()
62
+
63
+
64
+ class _Model(BaseModel):
65
+ model_config = ConfigDict(extra="forbid", frozen=True)
66
+
67
+
68
+ class Version(_Model):
69
+ version: SemVer
70
+ commit: str = Field(pattern=_COMMIT)
71
+ digest: str = Field(pattern=r"^[0-9a-f]{64}$")
72
+ identity: str = Field(pattern=r"^[0-9a-f]{64}$")
73
+ consented: bool = False
74
+ challenges: tuple[ChallengeSummary, ...]
75
+ folders: dict[str, str]
76
+ """Per-challenge content digest, so updates can report changed challenges."""
77
+
78
+
79
+ class Entry(_Model):
80
+ source: str = Field(min_length=1, max_length=4096)
81
+ source_kind: Literal["https", "local"]
82
+ ref: str = Field(min_length=1, max_length=256, pattern=r"^[^\x00-\x1f\x7f]+$")
83
+ active: str = Field(pattern=_COMMIT)
84
+ versions: dict[str, Version]
85
+
86
+
87
+ class State(_Model):
88
+ format: Literal[1] = 1
89
+ packs: dict[Slug, Entry] = Field(default_factory=dict)
90
+
91
+ @model_validator(mode="after")
92
+ def _consistent(self) -> State:
93
+ for name, entry in self.packs.items():
94
+ _check_source(entry.source_kind, entry.source)
95
+ if entry.active not in entry.versions:
96
+ raise ValueError("active version is not registered")
97
+ for commit, ver in entry.versions.items():
98
+ expected = make_identity(
99
+ name, ver.version, entry.source_kind, entry.source, commit, ver.digest
100
+ )
101
+ if ver.commit != commit or ver.identity != expected:
102
+ raise ValueError("version identity does not match its record")
103
+ names = [summary.id for summary in ver.challenges]
104
+ if len(set(names)) != len(names) or set(names) != set(ver.folders):
105
+ raise ValueError("challenge index is inconsistent")
106
+ return self
107
+
108
+
109
+ def _check_source(kind: str, source: str) -> None:
110
+ if "\x00" in source:
111
+ raise ValueError("unsafe source")
112
+ if kind == "local":
113
+ if not Path(source).is_absolute():
114
+ raise ValueError("local source must be absolute")
115
+ return
116
+ parts = urlsplit(source)
117
+ if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
118
+ raise ValueError("source must be a credential-free HTTPS URL")
119
+
120
+
121
+ def identity_of(name: str, entry: Entry, ver: Version) -> PackIdentity:
122
+ return PackIdentity(
123
+ name=name,
124
+ version=ver.version,
125
+ source=entry.source,
126
+ source_kind=entry.source_kind,
127
+ commit=ver.commit,
128
+ digest=ver.digest,
129
+ identity=ver.identity,
130
+ )
131
+
132
+
133
+ def load_state(root: Path) -> State:
134
+ """Read the catalog without creating anything; missing state is simply empty."""
135
+ path = root / REGISTRY_FILE
136
+ try:
137
+ path.lstat()
138
+ except FileNotFoundError:
139
+ return State()
140
+ except OSError as exc:
141
+ raise _error("pack registry is unavailable") from exc
142
+ try:
143
+ safe_path(path, directory=False, error=PackError, label="pack registry")
144
+ with (
145
+ open_directory(path.parent, error=PackError, label="pack registry") as parent,
146
+ open_entry(parent, path.name, error=PackError, label="pack registry") as fd,
147
+ ):
148
+ data = _read_all(fd, MAX_STATE_BYTES)
149
+ return State.model_validate_json(data)
150
+ except (OSError, ValueError, SafetyError, PackError) as exc:
151
+ raise _error("pack registry is corrupt or unsafe") from exc
152
+
153
+
154
+ def _read_all(fd: int, limit: int) -> bytes:
155
+ chunks: list[bytes] = []
156
+ total = 0
157
+ while True:
158
+ chunk = os.read(fd, min(_CHUNK, limit + 1 - total))
159
+ if not chunk:
160
+ return b"".join(chunks)
161
+ chunks.append(chunk)
162
+ total += len(chunk)
163
+ if total > limit:
164
+ raise _error("file exceeds its size limit")
165
+
166
+
167
+ def read_limited(path: Path, limit: int) -> bytes:
168
+ """Read one regular file through pinned descriptors; oversize is an error, never truncation."""
169
+ safe_path(path, directory=False, error=PackError, label="pack")
170
+ with (
171
+ open_directory(path.parent, error=PackError, label="pack") as parent,
172
+ open_entry(parent, path.name, error=PackError, label="pack") as fd,
173
+ ):
174
+ return _read_all(fd, limit)
175
+
176
+
177
+ def ensure_dirs(root: Path) -> None:
178
+ try:
179
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
180
+ packs_dir(root).mkdir(exist_ok=True, mode=0o700)
181
+ safe_path(root, directory=True, error=PackError, label="pack state")
182
+ safe_path(packs_dir(root), directory=True, error=PackError, label="pack state")
183
+ except OSError as exc:
184
+ raise _error("pack state directory cannot be prepared") from exc
185
+
186
+
187
+ @contextmanager
188
+ def locked(root: Path) -> Iterator[None]:
189
+ """Exclusive cross-process lock for every state mutation; initializes the state directory."""
190
+ ensure_dirs(root)
191
+ try:
192
+ fd = os.open(root / LOCK_FILE, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
193
+ except OSError as exc:
194
+ raise _error("pack state lock is unavailable") from exc
195
+ try:
196
+ fcntl.flock(fd, fcntl.LOCK_EX)
197
+ yield
198
+ finally:
199
+ os.close(fd)
200
+
201
+
202
+ class PublishedStateError(PackError):
203
+ """Catalog publication happened; deleting its newly referenced cache would corrupt state."""
204
+
205
+
206
+ def save_state(root: Path, state: State) -> None:
207
+ """Publish through a pinned directory; distinguish pre- and post-publication failures."""
208
+ data = state.model_dump_json(indent=2).encode()
209
+ if len(data) > MAX_STATE_BYTES:
210
+ raise PackError("pack registry would exceed its size limit; state was not changed")
211
+ tmp = f".registry-{os.getpid()}-{secrets.token_hex(6)}.tmp"
212
+ published = False
213
+ try:
214
+ with open_directory(root, error=PackError, label="pack state") as directory:
215
+ created = False
216
+ try:
217
+ fd = os.open(
218
+ tmp,
219
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
220
+ 0o600,
221
+ dir_fd=directory,
222
+ )
223
+ created = True
224
+ with os.fdopen(fd, "wb") as handle:
225
+ handle.write(data)
226
+ handle.flush()
227
+ os.fsync(handle.fileno())
228
+ os.replace(tmp, REGISTRY_FILE, src_dir_fd=directory, dst_dir_fd=directory)
229
+ published = True
230
+ os.fsync(directory)
231
+ finally:
232
+ if created and not published:
233
+ with suppress(OSError):
234
+ os.unlink(tmp, dir_fd=directory)
235
+ except (OSError, PackError) as exc:
236
+ if published:
237
+ raise PublishedStateError(
238
+ "pack state was published but durability could not be confirmed; "
239
+ "inspect pack list before retrying; cached files may remain"
240
+ ) from exc
241
+ raise PackError("pack registry could not be written") from exc
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class Capture:
246
+ digest: str
247
+ files: Mapping[str, str]
248
+ """Relative path -> file executable flag/content digest or a directory marker."""
249
+
250
+ def folder_digests(self, names: tuple[str, ...]) -> dict[str, str]:
251
+ result: dict[str, str] = {}
252
+ for name in names:
253
+ h = hashlib.sha256()
254
+ for rel, value in sorted(self.files.items()):
255
+ if rel.startswith(f"{name}/"):
256
+ h.update(f"{rel}\n{value}\n".encode())
257
+ result[name] = h.hexdigest()
258
+ return result
259
+
260
+
261
+ def capture_tree(source: Path, destination: Path | None, *, seal: bool) -> Capture:
262
+ """Hash regular files and directories while optionally copying exactly the bytes read.
263
+
264
+ ``destination`` must be an existing empty directory, or ``None`` to only verify (the digest is
265
+ the same either way). ``seal`` makes a copy read-only (cache); otherwise it is private and
266
+ owner-writable (execution copy). Symlinks, special files, racing replacements and over-limit
267
+ trees fail with a safe error.
268
+ """
269
+ files: dict[str, str] = {}
270
+ file_count = 0
271
+ total = 0
272
+
273
+ def copy(parent: int, target: int | None, prefix: str) -> None:
274
+ nonlocal total, file_count
275
+ for name in sorted(os.listdir(parent)):
276
+ rel = f"{prefix}{name}"
277
+ with open_entry(parent, name, error=PackError, label="pack") as fd:
278
+ info = os.fstat(fd)
279
+ if stat.S_ISDIR(info.st_mode):
280
+ files[f"{rel}/"] = "d"
281
+ if target is None:
282
+ copy(fd, None, f"{rel}/")
283
+ continue
284
+ os.mkdir(name, mode=0o700, dir_fd=target)
285
+ with open_entry(
286
+ target, name, os.O_RDONLY | os.O_DIRECTORY, error=PackError, label="pack"
287
+ ) as child:
288
+ copy(fd, child, f"{rel}/")
289
+ os.fchmod(child, 0o555 if seal else 0o700)
290
+ if seal:
291
+ os.fsync(child)
292
+ continue
293
+ if file_count >= MAX_FILES:
294
+ raise _error("pack exceeds the file count limit")
295
+ file_count += 1
296
+ executable = bool(info.st_mode & 0o111)
297
+ digest = hashlib.sha256()
298
+ writer = None
299
+ if target is not None:
300
+ output = os.open(
301
+ name,
302
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
303
+ 0o600,
304
+ dir_fd=target,
305
+ )
306
+ writer = os.fdopen(output, "wb")
307
+ try:
308
+ while chunk := os.read(fd, _CHUNK):
309
+ total += len(chunk)
310
+ if total > MAX_BYTES:
311
+ raise _error("pack exceeds the size limit")
312
+ digest.update(chunk)
313
+ if writer is not None:
314
+ writer.write(chunk)
315
+ if writer is not None:
316
+ mode = (
317
+ (0o555 if executable else 0o444)
318
+ if seal
319
+ else (0o700 if executable else 0o600)
320
+ )
321
+ os.fchmod(writer.fileno(), mode)
322
+ if seal:
323
+ writer.flush()
324
+ os.fsync(writer.fileno())
325
+ finally:
326
+ if writer is not None:
327
+ writer.close()
328
+ files[rel] = f"{'x' if executable else '-'}{digest.hexdigest()}"
329
+
330
+ try:
331
+ with ExitStack() as stack:
332
+ source_fd = stack.enter_context(open_directory(source, error=PackError, label="pack"))
333
+ target_fd = (
334
+ stack.enter_context(open_directory(destination, error=PackError, label="pack"))
335
+ if destination is not None
336
+ else None
337
+ )
338
+ copy(source_fd, target_fd, "")
339
+ if target_fd is not None:
340
+ os.fchmod(target_fd, 0o555 if seal else 0o700)
341
+ if seal:
342
+ os.fsync(target_fd)
343
+ except OSError as exc:
344
+ raise _error("pack tree cannot be read or copied safely") from exc
345
+ overall = hashlib.sha256()
346
+ for rel in sorted(files):
347
+ encoded = rel.encode()
348
+ overall.update(len(encoded).to_bytes(8, "big") + encoded + files[rel].encode())
349
+ return Capture(overall.hexdigest(), files)
350
+
351
+
352
+ def cache_problem(root: Path, name: str, ver: Version) -> str | None:
353
+ """Why a registered cache directory cannot be trusted (None when bytes match the catalog)."""
354
+ path = cache_path(root, name, ver.commit)
355
+ try:
356
+ safe_path(path, directory=True, error=PackError, label="installed pack")
357
+ actual = capture_tree(path, None, seal=False).digest
358
+ except (OSError, PackError):
359
+ return "installed pack content is missing or unsafe"
360
+ return None if actual == ver.digest else "installed pack content failed integrity verification"
361
+
362
+
363
+ @contextmanager
364
+ def hold_stage(path: Path) -> Iterator[tuple[int, int]]:
365
+ """Keep stage ownership live through cleanup; see packs/SPEC.md for identity rules."""
366
+ try:
367
+ with open_directory(path.parent, error=PackError, label="pack state") as parent:
368
+ fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
369
+ except OSError as exc:
370
+ raise _error("pack stage cannot be opened") from exc
371
+ try:
372
+ info = os.fstat(fd)
373
+ yield info.st_dev, info.st_ino
374
+ finally:
375
+ os.close(fd)
376
+
377
+
378
+ def remove_tree(path: Path, *, expect: tuple[int, int] | None = None) -> None:
379
+ """Remove one directory tree without following links, restoring owner access as needed.
380
+
381
+ With ``expect`` (device, inode) only that exact directory is removed; anything that replaced
382
+ it since is left alone.
383
+ """
384
+
385
+ def clear(fd: int) -> None:
386
+ os.fchmod(fd, 0o700)
387
+ for name in os.listdir(fd):
388
+ if stat.S_ISDIR(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode):
389
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
390
+ try:
391
+ clear(child)
392
+ finally:
393
+ os.close(child)
394
+ os.rmdir(name, dir_fd=fd)
395
+ else:
396
+ os.unlink(name, dir_fd=fd)
397
+
398
+ try:
399
+ with open_directory(path.parent, error=PackError, label="pack state") as parent:
400
+ try:
401
+ found = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
402
+ mode = found.st_mode
403
+ except FileNotFoundError:
404
+ return
405
+ if expect is not None and (found.st_dev, found.st_ino) != expect:
406
+ return
407
+ if not stat.S_ISDIR(mode):
408
+ os.unlink(path.name, dir_fd=parent)
409
+ return
410
+ fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
411
+ try:
412
+ opened = os.fstat(fd)
413
+ if (opened.st_dev, opened.st_ino) != (found.st_dev, found.st_ino):
414
+ return # swapped between stat and open: not the directory that was vetted
415
+ clear(fd)
416
+ finally:
417
+ os.close(fd)
418
+ os.rmdir(path.name, dir_fd=parent)
419
+ except OSError as exc:
420
+ raise _error("pack content could not be removed") from exc
patchground/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Deterministic verified outcomes; optional coaching never overrides these facts."""
2
+
3
+ from patchground.report.models import VerifiedReport
4
+ from patchground.report.workflow import saved_report, submit_session
5
+
6
+ __all__ = ["VerifiedReport", "saved_report", "submit_session"]
@@ -0,0 +1,188 @@
1
+ """Bounded file-scope and structural-test facts; never infer semantic test quality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ import os
8
+ import stat
9
+ from difflib import SequenceMatcher
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from patchground.sessions.files import open_directory, open_entry, tree
16
+ from patchground.verify.pytest_adapter import UnsupportedPytest, test_definitions
17
+ from patchground.verify.runners import Runner, is_vitest_path, runner_for, vitest_identity
18
+ from patchground.verify.vitest_adapter import UnsupportedVitest, parse_module
19
+
20
+ TEXT_LIMIT = 1024 * 1024
21
+
22
+
23
+ class Fact(BaseModel):
24
+ model_config = ConfigDict(extra="forbid", frozen=True)
25
+
26
+
27
+ class FileChange(Fact):
28
+ path: str
29
+ kind: Literal["added", "deleted", "modified"]
30
+ added_lines: int | None
31
+ removed_lines: int | None
32
+ outside_expected: bool | None
33
+
34
+
35
+ class DiffEvidence(Fact):
36
+ expected_paths: tuple[str, ...]
37
+ files: tuple[FileChange, ...]
38
+
39
+
40
+ class AssertionReduction(Fact):
41
+ test: str
42
+ before: int
43
+ after: int
44
+
45
+
46
+ class StructureEvidence(Fact):
47
+ status: Literal["established", "not_established"]
48
+ reason: str
49
+ removed_files: tuple[str, ...] = ()
50
+ removed_tests: tuple[str, ...] = ()
51
+ assertion_reductions: tuple[AssertionReduction, ...] = ()
52
+ unsupported_files: tuple[str, ...] = ()
53
+
54
+
55
+ def _file_paths(root: Path) -> dict[str, Path]:
56
+ result: dict[str, Path] = {}
57
+ for path in tree(root, skip=True):
58
+ with open_directory(path.parent) as parent, open_entry(parent, path.name) as descriptor:
59
+ if stat.S_ISREG(os.fstat(descriptor).st_mode):
60
+ result[path.relative_to(root).as_posix()] = path
61
+ return result
62
+
63
+
64
+ def _content(path: Path | None) -> tuple[str | None, list[str] | None]:
65
+ if path is None:
66
+ return None, []
67
+ digest = hashlib.sha256()
68
+ chunks: list[bytes] = []
69
+ length = 0
70
+ with (
71
+ open_directory(path.parent) as parent,
72
+ open_entry(parent, path.name) as descriptor,
73
+ os.fdopen(os.dup(descriptor), "rb") as stream,
74
+ ):
75
+ mode = os.fstat(descriptor).st_mode
76
+ digest.update(b"executable:" if mode & 0o111 else b"ordinary:")
77
+ while chunk := stream.read(65536):
78
+ digest.update(chunk)
79
+ length += len(chunk)
80
+ if length <= TEXT_LIMIT:
81
+ chunks.append(chunk)
82
+ if length > TEXT_LIMIT:
83
+ return digest.hexdigest(), None
84
+ content = b"".join(chunks)
85
+ if b"\0" in content:
86
+ return digest.hexdigest(), None
87
+ try:
88
+ return digest.hexdigest(), content.decode("utf-8").splitlines(keepends=True)
89
+ except UnicodeError:
90
+ return digest.hexdigest(), None
91
+
92
+
93
+ def diff_evidence(original: Path, final: Path, expected_paths: tuple[str, ...]) -> DiffEvidence:
94
+ before, after = _file_paths(original), _file_paths(final)
95
+ changes: list[FileChange] = []
96
+ for name in sorted(before.keys() | after.keys()):
97
+ old_hash, old_lines = _content(before.get(name))
98
+ new_hash, new_lines = _content(after.get(name))
99
+ if old_hash == new_hash:
100
+ continue
101
+ added: int | None = None
102
+ removed: int | None = None
103
+ if old_lines is not None and new_lines is not None:
104
+ added = removed = 0
105
+ for operation, i, j, a, b in SequenceMatcher(None, old_lines, new_lines).get_opcodes():
106
+ if operation != "equal":
107
+ removed += j - i
108
+ added += b - a
109
+ outside = None
110
+ if expected_paths:
111
+ outside = not any(
112
+ name == expected.rstrip("/") or name.startswith(expected.rstrip("/") + "/")
113
+ for expected in expected_paths
114
+ )
115
+ changes.append(
116
+ FileChange(
117
+ path=name,
118
+ kind="added"
119
+ if name not in before
120
+ else "deleted"
121
+ if name not in after
122
+ else "modified",
123
+ added_lines=added,
124
+ removed_lines=removed,
125
+ outside_expected=outside,
126
+ )
127
+ )
128
+ return DiffEvidence(expected_paths=expected_paths, files=tuple(changes))
129
+
130
+
131
+ def _assertion_counts(path: Path, runner: Runner, relative: str) -> dict[str, int]:
132
+ if runner == "vitest":
133
+ definitions = parse_module(path).definitions
134
+ for name in definitions:
135
+ vitest_identity(relative, name)
136
+ return {name: definition.assertions for name, definition in definitions.items()}
137
+ return {
138
+ name: sum(isinstance(node, ast.Assert) for node in ast.walk(definition))
139
+ for name, definition in test_definitions(path).items()
140
+ }
141
+
142
+
143
+ def structural_evidence(original: Path, final: Path, command: str) -> StructureEvidence:
144
+ runner = runner_for(command)
145
+ if runner is None:
146
+ return StructureEvidence(status="not_established", reason="unsupported test runner")
147
+ before, after = _file_paths(original), _file_paths(final)
148
+ removed_files: list[str] = []
149
+ removed_tests: list[str] = []
150
+ reductions: list[AssertionReduction] = []
151
+ unsupported: list[str] = []
152
+ for name in sorted(before.keys() | after.keys()):
153
+ path = Path(name)
154
+ if runner == "vitest":
155
+ if not is_vitest_path(path):
156
+ continue
157
+ elif path.suffix != ".py" or not (
158
+ path.name.startswith("test_") or path.name.endswith("_test.py")
159
+ ):
160
+ continue
161
+ try:
162
+ old = _assertion_counts(before[name], runner, name) if name in before else {}
163
+ new = _assertion_counts(after[name], runner, name) if name in after else {}
164
+ except (UnsupportedPytest, UnsupportedVitest, ValueError):
165
+ unsupported.append(name)
166
+ continue
167
+ if old and name not in after:
168
+ removed_files.append(name)
169
+ removed_tests.extend(f"{name}::{test}" for test in sorted(old.keys() - new.keys()))
170
+ for test in sorted(old.keys() & new.keys()):
171
+ previous = old[test]
172
+ current = new[test]
173
+ if current < previous:
174
+ reductions.append(
175
+ AssertionReduction(test=f"{name}::{test}", before=previous, after=current)
176
+ )
177
+ return StructureEvidence(
178
+ status="not_established" if unsupported else "established",
179
+ reason="some test source is unsupported"
180
+ if unsupported
181
+ else "structural canonical expect-call counts only"
182
+ if runner == "vitest"
183
+ else "structural counts only",
184
+ removed_files=tuple(removed_files),
185
+ removed_tests=tuple(removed_tests),
186
+ assertion_reductions=tuple(reductions),
187
+ unsupported_files=tuple(unsupported),
188
+ )
@@ -0,0 +1,59 @@
1
+ """Versioned, round-trippable verified-outcome records; no overall score."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, Self
6
+
7
+ from pydantic import AwareDatetime, Field, model_validator
8
+
9
+ from patchground.report.analysis import DiffEvidence, Fact, StructureEvidence
10
+ from patchground.report.regression import RegressionEvidence
11
+ from patchground.sandbox import RunOutcome
12
+ from patchground.sessions import CheckResult
13
+ from patchground.sessions.models import CheckTest
14
+
15
+
16
+ class CheckEvidence(Fact):
17
+ outcome: RunOutcome
18
+ tests: tuple[CheckTest, ...]
19
+ duration_seconds: float = Field(ge=0, allow_inf_nan=False)
20
+ limit: Literal["timeout", "memory"] | None
21
+ passed: bool
22
+ counts: dict[str, int]
23
+
24
+ @model_validator(mode="after")
25
+ def consistent(self) -> Self:
26
+ canonical = CheckResult(
27
+ outcome=self.outcome,
28
+ tests=self.tests,
29
+ duration_seconds=self.duration_seconds,
30
+ limit=self.limit,
31
+ )
32
+ if self.passed != canonical.passed or self.counts != canonical.counts:
33
+ raise ValueError("inconsistent check evidence")
34
+ return self
35
+
36
+ @classmethod
37
+ def from_result(cls, result: CheckResult) -> Self:
38
+ return cls.model_validate(result.model_dump(mode="json", include=set(cls.model_fields)))
39
+
40
+
41
+ class EffortEvidence(Fact):
42
+ timer_enabled: bool
43
+ elapsed_seconds: float | None = Field(default=None, ge=0, allow_inf_nan=False)
44
+ timebox_minutes: int = Field(gt=0)
45
+ practice_checks: int = Field(ge=0)
46
+ hints_revealed: int = Field(ge=0)
47
+
48
+
49
+ class VerifiedReport(Fact):
50
+ schema_version: Literal[1] = 1
51
+ challenge_id: str
52
+ challenge_version: str
53
+ image_id: str
54
+ submitted_at: AwareDatetime
55
+ checks: CheckEvidence
56
+ regression: RegressionEvidence
57
+ diff: DiffEvidence
58
+ test_structure: StructureEvidence
59
+ effort: EffortEvidence