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,252 @@
1
+ """Bounded, non-executing TypeScript syntax analysis for the canonical Vitest runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+
12
+ import tree_sitter_typescript
13
+ from tree_sitter import Language, Node, Parser
14
+
15
+ MAX_SOURCE_BYTES = 1024 * 1024
16
+ _MAX_NODES = 200_000
17
+ _MAX_DEPTH = 256
18
+ _LANGUAGE = Language(tree_sitter_typescript.language_typescript())
19
+ _FORBIDDEN = frozenset(
20
+ {
21
+ "describe",
22
+ "suite",
23
+ "it",
24
+ "beforeAll",
25
+ "afterAll",
26
+ "beforeEach",
27
+ "afterEach",
28
+ }
29
+ )
30
+
31
+
32
+ class UnsupportedVitest(Exception):
33
+ """Unsupported or ambiguous syntax; diagnostics never contain authored source."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TestDefinition:
38
+ name: str
39
+ fingerprint: str
40
+ start: int
41
+ end: int
42
+ callee_end: int
43
+ assertions: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class TestModule:
48
+ definitions: Mapping[str, TestDefinition]
49
+ helper_fingerprint: str
50
+
51
+ def __post_init__(self) -> None:
52
+ object.__setattr__(self, "definitions", MappingProxyType(dict(self.definitions)))
53
+
54
+
55
+ def is_test_filename(name: str) -> bool:
56
+ return name.endswith(".test.ts")
57
+
58
+
59
+ def _walk(root: Node) -> list[Node]:
60
+ result: list[Node] = []
61
+ stack = [(root, 0)]
62
+ while stack:
63
+ node, depth = stack.pop()
64
+ if depth > _MAX_DEPTH or len(result) >= _MAX_NODES:
65
+ raise UnsupportedVitest("Module syntax exceeds analysis bounds")
66
+ result.append(node)
67
+ stack.extend((child, depth + 1) for child in reversed(node.children))
68
+ return result
69
+
70
+
71
+ def _literal(node: Node) -> str:
72
+ raw = node.text
73
+ if node.type != "string" or raw is None:
74
+ raise UnsupportedVitest("Test names must be literal strings")
75
+ # Deliberately reject JS escape syntax rather than approximate its decoding.
76
+ if b"\\" in raw:
77
+ raise UnsupportedVitest("Escaped literals are unsupported")
78
+ try:
79
+ value = raw[1:-1].decode("utf-8")
80
+ except UnicodeError as exc:
81
+ raise UnsupportedVitest("Invalid source encoding") from exc
82
+ return value
83
+
84
+
85
+ def _fingerprint(nodes: list[Node]) -> str:
86
+ digest = hashlib.sha256()
87
+ for root in nodes:
88
+ for node in _walk(root):
89
+ if node.type in {"comment", ";"}:
90
+ continue
91
+ # Token text plus node kinds preserves syntax without whitespace or comments.
92
+ if not node.children:
93
+ digest.update(
94
+ json.dumps(
95
+ [node.type, (node.text or b"").decode("utf-8")], ensure_ascii=True
96
+ ).encode()
97
+ )
98
+ else:
99
+ digest.update(node.type.encode() + b"\0")
100
+ return digest.hexdigest()
101
+
102
+
103
+ def _read(path: Path) -> bytes:
104
+ with path.open("rb") as stream:
105
+ source = stream.read(MAX_SOURCE_BYTES + 1)
106
+ if len(source) > MAX_SOURCE_BYTES:
107
+ raise UnsupportedVitest("Module exceeds 1 MiB")
108
+ try:
109
+ source.decode("utf-8")
110
+ except UnicodeError as exc:
111
+ raise UnsupportedVitest("Invalid source encoding") from exc
112
+ return source
113
+
114
+
115
+ def parse_module(path: Path) -> TestModule:
116
+ source = _read(path)
117
+ root = Parser(_LANGUAGE).parse(source).root_node
118
+ nodes = _walk(root)
119
+ if root.has_error:
120
+ raise UnsupportedVitest("Invalid TypeScript syntax")
121
+ allowed: set[int] = set()
122
+ imported: set[str] = set()
123
+ for statement in root.named_children:
124
+ if statement.type != "import_statement":
125
+ continue
126
+ module = statement.child_by_field_name("source")
127
+ if module is None or _literal(module) != "vitest":
128
+ continue
129
+ if any(n.type == "type" for n in _walk(statement)):
130
+ raise UnsupportedVitest("Type-only Vitest imports are unsupported")
131
+ specifiers = [n for n in _walk(statement) if n.type == "import_specifier"]
132
+ if not specifiers:
133
+ raise UnsupportedVitest("Canonical named Vitest imports required")
134
+ for specifier in specifiers:
135
+ name = specifier.child_by_field_name("name")
136
+ if (
137
+ name is None
138
+ or specifier.child_by_field_name("alias") is not None
139
+ or name.text not in {b"test", b"expect"}
140
+ or any(n.type == "type" for n in specifier.children)
141
+ ):
142
+ raise UnsupportedVitest("Unsupported Vitest import")
143
+ value = (name.text or b"").decode()
144
+ if value in imported:
145
+ raise UnsupportedVitest("Duplicate Vitest import")
146
+ imported.add(value)
147
+ allowed.add(name.id)
148
+ if any(
149
+ n.type in {"namespace_import", "identifier"} and n.id not in allowed
150
+ for n in _walk(statement)
151
+ ):
152
+ raise UnsupportedVitest("Unsupported Vitest import")
153
+ definitions: dict[str, TestDefinition] = {}
154
+ statements: set[int] = set()
155
+ for statement in root.named_children:
156
+ if statement.type != "expression_statement" or len(statement.named_children) != 1:
157
+ continue
158
+ call = statement.named_children[0]
159
+ callee = call.child_by_field_name("function")
160
+ if call.type != "call_expression" or callee is None or callee.text != b"test":
161
+ continue
162
+ if any(n.type in {"type_arguments", "optional_chain", "?."} for n in call.children):
163
+ raise UnsupportedVitest("Unsupported test call syntax")
164
+ arguments = call.child_by_field_name("arguments")
165
+ args = (
166
+ []
167
+ if arguments is None
168
+ else [n for n in arguments.named_children if n.type != "comment"]
169
+ )
170
+ if (
171
+ "test" not in imported
172
+ or len(args) != 2
173
+ or args[1].type not in {"arrow_function", "function_expression"}
174
+ ):
175
+ raise UnsupportedVitest("Unsupported test registration")
176
+ test_name = _literal(args[0])
177
+ if (
178
+ not test_name
179
+ or len(test_name) > 200
180
+ or not test_name.isprintable()
181
+ or test_name.strip() != test_name
182
+ ):
183
+ raise UnsupportedVitest("Test identity is not safely representable")
184
+ if test_name in definitions:
185
+ raise UnsupportedVitest("Duplicate test identity")
186
+ allowed.add(callee.id)
187
+ statements.add(statement.id)
188
+ callback = args[1]
189
+ assertions = 0
190
+ for node in _walk(callback):
191
+ if node.type != "call_expression":
192
+ continue
193
+ fn = node.child_by_field_name("function")
194
+ if fn is None or fn.type != "member_expression":
195
+ continue
196
+ obj = fn.child_by_field_name("object")
197
+ while obj is not None and obj.type == "member_expression":
198
+ prop = obj.child_by_field_name("property")
199
+ if prop is None or prop.text not in {b"not", b"resolves", b"rejects"}:
200
+ break
201
+ obj = obj.child_by_field_name("object")
202
+ if obj is not None and obj.type == "call_expression":
203
+ expect = obj.child_by_field_name("function")
204
+ if expect is not None and expect.type == "identifier" and expect.text == b"expect":
205
+ if any(n.type in {"optional_chain", "?."} for n in _walk(node)):
206
+ raise UnsupportedVitest("Optional assertion calls are unsupported")
207
+ allowed.add(expect.id)
208
+ assertions += 1
209
+ definitions[test_name] = TestDefinition(
210
+ test_name,
211
+ _fingerprint([statement]),
212
+ statement.start_byte,
213
+ statement.end_byte,
214
+ callee.end_byte,
215
+ assertions,
216
+ )
217
+ for node in nodes:
218
+ if node.type not in {
219
+ "identifier",
220
+ "shorthand_property_identifier",
221
+ "shorthand_property_identifier_pattern",
222
+ "property_identifier",
223
+ }:
224
+ continue
225
+ if b"\\" in (node.text or b""):
226
+ raise UnsupportedVitest("Escaped identifiers are unsupported")
227
+ text = (node.text or b"").decode("utf-8")
228
+ if text in _FORBIDDEN or (text in {"test", "expect"} and node.id not in allowed):
229
+ raise UnsupportedVitest("Registration symbols are shadowed, leaked or unsupported")
230
+ if "expect" not in imported and any(d.assertions for d in definitions.values()):
231
+ raise UnsupportedVitest("Canonical expect import required")
232
+ helpers = [n for n in root.named_children if n.id not in statements and n.type != "comment"]
233
+ return TestModule(definitions, _fingerprint(helpers))
234
+
235
+
236
+ def skip_module(path: Path) -> None:
237
+ module = parse_module(path)
238
+ source = _read(path)
239
+ for definition in sorted(module.definitions.values(), key=lambda d: d.callee_end, reverse=True):
240
+ source = source[: definition.callee_end] + b".skip" + source[definition.callee_end :]
241
+ path.write_bytes(source)
242
+
243
+
244
+ def select_tests(path: Path, names: set[str]) -> None:
245
+ module = parse_module(path)
246
+ if not names <= module.definitions.keys():
247
+ raise UnsupportedVitest("Selected test identity is missing")
248
+ source = _read(path)
249
+ for definition in sorted(module.definitions.values(), key=lambda d: d.start, reverse=True):
250
+ if definition.name not in names:
251
+ source = source[: definition.start] + source[definition.end :]
252
+ path.write_bytes(source)
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.5
2
+ Name: patchground
3
+ Version: 0.1.0
4
+ Summary: Local-first CLI for practicing real software engineering on broken repositories.
5
+ Project-URL: Homepage, https://patchground.dev
6
+ Project-URL: Documentation, https://github.com/franndeli/patchground#readme
7
+ Project-URL: Repository, https://github.com/franndeli/patchground
8
+ Project-URL: Issues, https://github.com/franndeli/patchground/issues
9
+ Project-URL: Changelog, https://github.com/franndeli/patchground/blob/main/CHANGELOG.md
10
+ Author: Fran Delicado
11
+ License-Expression: Apache-2.0
12
+ License-File: LICENSE
13
+ Keywords: challenges,cli,debugging,docker,practice,software-engineering
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Education
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Education
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Requires-Python: >=3.12
26
+ Requires-Dist: defusedxml>=0.7.1
27
+ Requires-Dist: docker>=7.1
28
+ Requires-Dist: junitparser>=4.0
29
+ Requires-Dist: pydantic>=2.10
30
+ Requires-Dist: requests>=2.32
31
+ Requires-Dist: rich>=13.9
32
+ Requires-Dist: ruamel-yaml>=0.18.10
33
+ Requires-Dist: tree-sitter-typescript==0.23.2
34
+ Requires-Dist: tree-sitter==0.26.0
35
+ Requires-Dist: typer>=0.15
36
+ Requires-Dist: urllib3>=2.0
37
+ Provides-Extra: ai
38
+ Description-Content-Type: text/markdown
39
+
40
+ <p align="center">
41
+ <img src="https://raw.githubusercontent.com/franndeli/patchground/main/web/public/favicon.svg" width="72" height="72" alt="">
42
+ </p>
43
+
44
+ <h1 align="center">Patchground</h1>
45
+
46
+ <p align="center"><em>Real code. Real problems. Your tools.</em></p>
47
+
48
+ <p align="center">
49
+ <a href="https://github.com/franndeli/patchground/actions/workflows/ci.yml"><img src="https://github.com/franndeli/patchground/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
50
+ <a href="https://github.com/franndeli/patchground/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="License: Apache-2.0"></a>
51
+ <img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python 3.12+">
52
+ <a href="https://patchground.dev"><img src="https://img.shields.io/badge/website-patchground.dev-0a7" alt="Website"></a>
53
+ </p>
54
+
55
+ Patchground is an open-source, local-first CLI (`patchground`, short alias `pgnd`) for practising
56
+ real software engineering. You get a small but realistic repository with a bug to find or a feature
57
+ to finish. You solve it in your own editor, with your own AI or none. Patchground runs the checks,
58
+ including hidden ones, in Docker and reports verified outcomes rather than a score.
59
+
60
+ - **Local-first.** No account, no hosted execution, no telemetry. Sessions are plain folders on your
61
+ machine.
62
+ - **Your tools.** Any editor, any assistant. Patchground never needs an API key.
63
+ - **Verified, not graded.** Checks run in a locked-down container. The final report states facts,
64
+ such as which tests pass, whether you added a regression test and what you changed. It never gives
65
+ an overall score.
66
+
67
+ > **Status: 0.1.0, early release.** Seven curated Python and TypeScript challenges ship with the
68
+ > CLI: debugging, feature work and an architecture refactor. Difficulty is not yet human-rated.
69
+
70
+ ## Quick start
71
+
72
+ You need Python 3.12+, [uv](https://docs.astral.sh/uv/), Git and a running Docker engine (Docker
73
+ Desktop on macOS, Docker Engine on Linux, WSL2 on Windows).
74
+
75
+ ```sh
76
+ uv tool install git+https://github.com/franndeli/patchground # or: pipx install git+https://...
77
+ pgnd doctor # checks Docker, Git and disk space, and prints exact fixes
78
+ pgnd list --builtin # the curated challenges bundled with the CLI
79
+ pgnd show pagination-last-page # read the ticket first
80
+ pgnd start pagination-last-page # builds the image and creates your session workspace
81
+ ```
82
+
83
+ Open the printed workspace in your editor. From that workspace:
84
+
85
+ ```sh
86
+ pgnd status
87
+ pgnd check
88
+ # Edit the code, then run pgnd check again.
89
+ # To discard edits and try again:
90
+ pgnd reset
91
+ ```
92
+
93
+ Outside a session directory, select it explicitly with `pgnd check --session <session-path>` or
94
+ `pgnd status --session <session-path>`. There is no implicit latest-session fallback. All commands
95
+ support `--json`; `start` also accepts `--no-timer` and `--no-history`. `reset --json` requires `--yes`.
96
+
97
+ `list --builtin` shows the curated challenges bundled with the CLI; `pgnd start <id>` and
98
+ `pgnd show <id>` accept their IDs. `list [directory]` scans only immediate challenge folders
99
+ (default: the current directory), and `list --installed` shows pinned packs. All of them take
100
+ `--type`, `--lang`, `--difficulty` and `--tag` filters. Invalid candidates are reported even when
101
+ filters are active. Discovery never builds, runs or grants trust. A bare ID resolves to an existing
102
+ local folder first, then a unique installed pack challenge, then a built-in challenge.
103
+
104
+ Reset restores ordinary starting files and removes ordinary additions, but preserves `.git`,
105
+ credential-pattern entries (`.env*`, `*.pem`, `*.key`, `id_*`), the timer and event history. Stop
106
+ editors/agents from writing while resetting. Unsafe paths or protected-file collisions are rejected;
107
+ filesystem failure can leave a partial reset. Status keeps the last historical check; run a new check
108
+ for the restored workspace. There is no undo or automatic backup.
109
+
110
+ Doctor probes engine reachability/version, Git, and at least 1 GiB free on the session disk. It does
111
+ not measure the Docker VM disk or guarantee enough space for every image. It never builds or runs a
112
+ challenge. Follow its suggested fixes before starting.
113
+
114
+ See [the first-session guide](https://github.com/franndeli/patchground/blob/main/docs/first-session.md)
115
+ for the full loop and troubleshooting, and [challenge packs](https://github.com/franndeli/patchground/blob/main/docs/packs.md)
116
+ for installing third-party challenges.
117
+
118
+ Sessions live under `~/patchground/sessions/`; set `PATCHGROUND_SESSIONS_DIR` to an absolute path
119
+ to keep them elsewhere. Each gets a new Git repository with an initial
120
+ baseline commit. Checks never change its HEAD, branches or staging area. The manifest and hidden
121
+ checks are frozen in private session metadata outside the editable workspace; removing the original
122
+ challenge folder does not break the attempt. The reference solution is never copied.
123
+
124
+ Image builds can use the network; checks run on an internal Docker network with resource limits.
125
+ Start validates structure and builds—it does not run the reference-validation gate. Use
126
+ `pgnd validate <challenge-path>` when authoring. Only start local challenges you authored or reviewed:
127
+ opening a workspace in your editor can execute project tooling outside Patchground's sandbox.
128
+
129
+ A check exits `0` only when it has tests and all pass, `1` for nonpassing results (including skipped
130
+ checks), and `2` for invalid input or infrastructure problems. Raw test output is never printed.
131
+ Status works without Docker. A removed session image must be restored or a new session started;
132
+ check does not silently rebuild or pull it. Multi-container services remain deferred; challenges
133
+ must use `environment.services: []`.
134
+
135
+ ## Practice tools
136
+
137
+ ```sh
138
+ pgnd hint
139
+ pgnd run -- python -m pytest tests/test_pagination.py
140
+ pgnd history
141
+ pgnd sessions
142
+ ```
143
+
144
+ Hints come from frozen authored metadata; exhaustion succeeds without another event. `run` executes
145
+ literal arguments in a disposable container copy with no hidden checks and no host environment
146
+ forwarding. Container edits are discarded. It displays only a sanitized output tail (up to 64 KiB),
147
+ not verification evidence, and saves neither arguments nor output in session events. Put Patchground
148
+ options before `--`, for example `pgnd run --json -- python --version`. Use explicit `sh -c` only
149
+ when you want shell syntax inside the container.
150
+
151
+ New sessions capture initial and completed-check checkpoints in private session Git storage, never
152
+ in your branches or staging area. `start --no-history` opts out; older sessions are not migrated.
153
+ History skips credential patterns and Git-ignored files, and reports unavailable capture separately
154
+ from passing/failing tests. It is filtered history, not a complete replay of ignored files used by
155
+ checks or frozen submission evidence. `history` is read-only and remains available after submission;
156
+ periodic capture and snapshot restoration are not implemented. Reset still restores originals.
157
+ Unsupported or unsafe ignore contexts—including Git includes, linked-worktree metadata, relative
158
+ exclusion paths and unsupported Git environment overrides—make history unavailable rather than
159
+ silently retaining ignored files; checks remain usable.
160
+
161
+ ## Finish an attempt
162
+
163
+ ```sh
164
+ pgnd submit
165
+ pgnd report
166
+ # Outside the workspace:
167
+ pgnd report --session <session-path> --json
168
+ ```
169
+
170
+ Submit captures the checked ordinary files, runs fresh checks and saves an immutable local report.
171
+ It can finalize a failed attempt (exit `1`); missing regression evidence is explicit, not a pass or
172
+ submission gate. Repeating submit returns the same result without rerunning. Further check/reset/hint/run
173
+ operations are rejected; start a new session to practice again. Your workspace remains editable,
174
+ but later edits cannot change the saved report. Report reads need no Docker.
175
+
176
+ For supported conventional pytest tests, regression evidence selects genuinely new/changed test
177
+ definitions and requires them to fail on original code and pass on final code. Pre-existing failures,
178
+ skips, missing tests, collection problems and unsupported/changed harnesses do not count as proof.
179
+ The report also records changed checked files (including ordinary Git-ignored files), expected-path
180
+ scope, structural test removals, elapsed time and practice-check/hint counts. Binary/non-UTF-8 or
181
+ over-1-MiB line counts are unestablished. Assertion-count reductions are facts, not judgments about
182
+ semantic test strength. There is no overall score or AI judgment.
183
+
184
+ Infrastructure errors before finalization leave the attempt open. If a failure occurs after the
185
+ bundle is published, retry submit to verify/reconcile it rather than assuming rollback. This is
186
+ local evidence, not tamper-proof attestation. See [the feedback contract](https://github.com/franndeli/patchground/blob/main/docs/contracts/feedback.md).
187
+
188
+ ## Author a challenge
189
+
190
+ ```sh
191
+ pgnd new my-challenge
192
+ pgnd validate my-challenge
193
+ ```
194
+
195
+ `new` defaults to `--type debug --lang python` and creates a runnable teaching starter in the
196
+ current directory. It refuses any existing destination and never builds or runs automatically.
197
+ Only that template combination is currently supported. The starter includes an intentional defect,
198
+ checks and a reference patch; replace them together rather than changing only the ticket.
199
+ See [the authoring guide](https://github.com/franndeli/patchground/blob/main/docs/authoring.md) for the full workflow and safety limits.
200
+
201
+ The curated challenges live in `examples/` and ship inside the package as the built-in catalog. They
202
+ are Docker-validated, not yet human-rated for difficulty or realism.
203
+
204
+ ## Validate an authored challenge
205
+
206
+ `pgnd validate <path>` builds once, proves the broken state fails and the reference passes, then
207
+ checks determinism across three runs each of visible-only, combined and reference checks (nine
208
+ check containers for this part). Test IDs, statuses, occurrence counts and outcomes must agree; ordering
209
+ and timing may differ. A mismatch fails at `determinism`. Only structured results are reported.
210
+
211
+ Success writes a local `verification.json`; failure removes an earlier Patchground record.
212
+ Use `--no-write` for read-only validation. Three stable runs are bounded evidence, not a guarantee
213
+ that a challenge can never be flaky.
214
+
215
+ For supported Python/pytest layouts, `anti_gaming` then probes an empty diff, deletion of editable
216
+ test modules, and skipping those modules (three more containers, twelve total). Hidden test IDs,
217
+ counts and statuses must match the broken baseline exactly. Mutations use disposable copies and
218
+ never change authored files or hidden checks. Conventional `test_*.py` / `*_test.py` modules with
219
+ static `test_*` functions or methods in `Test*` classes are supported; helper-only modules are kept.
220
+
221
+ Other runners/layouts, absent editable tests, or ineffective mutations are explicitly `not_run`,
222
+ not passed. A `valid` result means all executed gates passed: inspect step statuses before claiming
223
+ anti-gaming coverage. This bounded probe does not prevent malicious test runners, forged results,
224
+ custom discovery/plugin bypasses or local cheating.
225
+
226
+ ## Website
227
+
228
+ The product homepage, [patchground.dev](https://patchground.dev), lives in `web/`. Preview it locally with
229
+ `npm --prefix web run dev`, then open `http://127.0.0.1:4173`. It introduces the CLI, local practice
230
+ workflow and real challenges, with contribution as a secondary path. It does not host execution.
231
+ The challenge catalog is generated from `examples/*/challenge.yaml` (`npm --prefix web run catalog`).
232
+ See the web README for checks, the static build, and the separate deployment approval gate.
233
+
234
+ ## Develop
235
+
236
+ ```sh
237
+ git clone https://github.com/franndeli/patchground && cd patchground
238
+ uv sync
239
+ uv run pgnd validate examples/pagination-last-page
240
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
241
+ uv run pytest # Docker tests are skipped if no engine is reachable
242
+ PATCHGROUND_REQUIRE_DOCKER=1 uv run pytest -m docker # require integration tests to run
243
+ ```
244
+
245
+ One-time setup per clone: `brew install gitleaks && git config core.hooksPath .githooks`.
246
+
247
+ Specs: `goal-and-requirements.md`, `architecture.md`, `docs/contracts/`, `src/patchground/*/SPEC.md`.
248
+
249
+ ## Contributing and security
250
+
251
+ Bug reports, challenge feedback and pull requests are welcome; see
252
+ [CONTRIBUTING](https://github.com/franndeli/patchground/blob/main/.github/CONTRIBUTING.md).
253
+ Please report vulnerabilities privately, as described in the
254
+ [security policy](https://github.com/franndeli/patchground/blob/main/.github/SECURITY.md).
255
+
256
+ ## License
257
+
258
+ Apache-2.0 for the CLI. Each challenge declares its own `content_license`.