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