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,44 @@
1
+ """SQLite persistence for confirmed orders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from pathlib import Path
8
+
9
+ from orders.models import Order, OrderLine
10
+
11
+ _SCHEMA = """
12
+ CREATE TABLE IF NOT EXISTS orders (
13
+ order_id TEXT PRIMARY KEY,
14
+ customer_email TEXT NOT NULL,
15
+ lines TEXT NOT NULL,
16
+ status TEXT NOT NULL
17
+ )
18
+ """
19
+
20
+
21
+ class SqliteOrderRepository:
22
+ def __init__(self, path: str | Path) -> None:
23
+ self._path = str(path)
24
+ with sqlite3.connect(self._path) as connection:
25
+ connection.execute(_SCHEMA)
26
+
27
+ def save_confirmed(self, order: Order) -> None:
28
+ lines = json.dumps([line.__dict__ for line in order.lines])
29
+ with sqlite3.connect(self._path) as connection:
30
+ connection.execute(
31
+ "INSERT OR REPLACE INTO orders VALUES (?, ?, ?, ?)",
32
+ (order.order_id, order.customer_email, lines, "confirmed"),
33
+ )
34
+
35
+ def get(self, order_id: str) -> Order | None:
36
+ with sqlite3.connect(self._path) as connection:
37
+ row = connection.execute(
38
+ "SELECT order_id, customer_email, lines FROM orders WHERE order_id = ?",
39
+ (order_id,),
40
+ ).fetchone()
41
+ if row is None:
42
+ return None
43
+ lines = tuple(OrderLine(**item) for item in json.loads(row[2]))
44
+ return Order(order_id=row[0], customer_email=row[1], lines=lines)
@@ -0,0 +1,44 @@
1
+ # Last page of search results returns "page out of range"
2
+
3
+ **Reported by:** support (via customer ticket)
4
+ **Component:** `catalog.pagination`
5
+
6
+ ## Symptom
7
+
8
+ Customers browsing search results can't reach the last few products. Clicking the final page
9
+ link shows an error instead of the remaining items. The pager also hides the "Next" button one
10
+ page too early.
11
+
12
+ It does not happen for every search: a search for "lamp" (40 results) pages fine, a search for
13
+ "chair" (25 results) does not.
14
+
15
+ ## Reproduction
16
+
17
+ ```python
18
+ from catalog.pagination import paginate
19
+
20
+ results = [f"chair-{i}" for i in range(25)]
21
+
22
+ paginate(results, page=1, per_page=10) # works, 10 items
23
+ paginate(results, page=2, per_page=10) # works, 10 items
24
+ paginate(results, page=3, per_page=10) # raises PageOutOfRange
25
+ ```
26
+
27
+ ## Expected
28
+
29
+ 25 results at 10 per page are 3 pages: 10, 10 and 5 items. Page 3 returns the last 5 items,
30
+ `total_pages` is 3, and page 2 reports `has_next=True`.
31
+
32
+ ## Actual
33
+
34
+ `total_pages` is 2, page 2 reports `has_next=False`, and page 3 raises `PageOutOfRange`.
35
+
36
+ ## Notes
37
+
38
+ - Requesting a page past the real end (e.g. page 4 above) must keep raising `PageOutOfRange`;
39
+ the web layer turns it into a 404.
40
+ - An empty result set is still a single, empty page 1.
41
+ - Run `pgnd check` from the session workspace to check your changes in Docker.
42
+ - Add a regression test covering the reported case, and keep existing behavior intact.
43
+ - You may use your own editor and AI tools. The goal is to explain the cause and verify your fix,
44
+ not merely make the reported error disappear.
@@ -0,0 +1,31 @@
1
+ spec_version: "0.1"
2
+ id: pagination-last-page
3
+ version: 1.0.1
4
+ title: Last page of search results returns "page out of range"
5
+ type: debug
6
+ summary: >-
7
+ The catalog API rejects the last page of results whenever the result count is not
8
+ a multiple of the page size. Find the root cause and fix it.
9
+ languages: [python]
10
+ tags: [pagination, off-by-one]
11
+ difficulty: beginner
12
+ timebox_minutes: 20
13
+ ai_policy: allowed
14
+ environment:
15
+ dockerfile: environment/Dockerfile
16
+ services: []
17
+ test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
18
+ checks:
19
+ visible: checks/visible
20
+ hidden: checks/hidden
21
+ solution: solution/reference.patch
22
+ expected_paths: [src/catalog/, tests/]
23
+ hints:
24
+ - Compare the number of pages the paginator computes with the number you would count by hand for 25 items, 10 per page.
25
+ - Integer division rounds down. What happens to the items that don't fill a whole page?
26
+ debrief:
27
+ questions: debrief/questions.md
28
+ focus: [debugging_method, test_quality, scope_judgment]
29
+ author: {name: Fran Delicado, github: franndeli}
30
+ content_license: CC-BY-4.0
31
+ provenance: handmade
@@ -0,0 +1,32 @@
1
+ import pytest
2
+
3
+ from catalog.pagination import PageOutOfRange, paginate
4
+
5
+ CHAIRS = [f"chair-{i}" for i in range(25)]
6
+
7
+
8
+ def test_partial_last_page_is_reachable():
9
+ page = paginate(CHAIRS, page=3, per_page=10)
10
+ assert page.items == CHAIRS[20:]
11
+ assert page.has_next is False
12
+
13
+
14
+ def test_total_pages_rounds_up():
15
+ assert paginate(CHAIRS, page=1, per_page=10).total_pages == 3
16
+ assert paginate(CHAIRS[:1], page=1, per_page=10).total_pages == 1
17
+ assert paginate(CHAIRS[:11], page=1, per_page=10).total_pages == 2
18
+
19
+
20
+ def test_second_to_last_page_has_next():
21
+ assert paginate(CHAIRS, page=2, per_page=10).has_next is True
22
+
23
+
24
+ def test_page_past_the_end_is_out_of_range():
25
+ with pytest.raises(PageOutOfRange):
26
+ paginate(CHAIRS, page=4, per_page=10)
27
+
28
+
29
+ def test_fewer_items_than_one_page():
30
+ page = paginate(CHAIRS[:3], page=1, per_page=10)
31
+ assert page.items == CHAIRS[:3]
32
+ assert page.total_pages == 1
@@ -0,0 +1,37 @@
1
+ import pytest
2
+
3
+ from catalog.pagination import PageOutOfRange, paginate
4
+
5
+ LAMPS = [f"lamp-{i}" for i in range(40)]
6
+
7
+
8
+ def test_first_page_has_per_page_items():
9
+ page = paginate(LAMPS, page=1, per_page=10)
10
+ assert page.items == LAMPS[:10]
11
+ assert page.number == 1
12
+ assert page.has_previous is False
13
+ assert page.has_next is True
14
+
15
+
16
+ def test_exact_multiple_has_expected_page_count():
17
+ page = paginate(LAMPS, page=4, per_page=10)
18
+ assert page.items == LAMPS[30:]
19
+ assert page.total_pages == 4
20
+ assert page.has_next is False
21
+
22
+
23
+ def test_empty_results_are_one_empty_page():
24
+ page = paginate([], page=1, per_page=10)
25
+ assert page.items == []
26
+ assert page.total_pages == 1
27
+ assert page.has_next is False
28
+
29
+
30
+ def test_page_zero_is_out_of_range():
31
+ with pytest.raises(PageOutOfRange):
32
+ paginate(LAMPS, page=0, per_page=10)
33
+
34
+
35
+ def test_per_page_must_be_positive():
36
+ with pytest.raises(ValueError):
37
+ paginate(LAMPS, page=1, per_page=0)
@@ -0,0 +1,9 @@
1
+ # Debrief questions
2
+
3
+ 1. The visible tests were green before you changed anything. What made you trust (or distrust)
4
+ that baseline, and how did you confirm the bug before touching the code?
5
+ 2. Walk me through the boundary cases you considered for the page count: empty results, fewer
6
+ items than one page, exact multiples, one item over a multiple. Which one would you add as a
7
+ regression test first, and why?
8
+ 3. The web layer turns `PageOutOfRange` into a 404. If your fix had made every page number valid,
9
+ how would that have shown up in production, and how would you have noticed?
@@ -0,0 +1,3 @@
1
+ FROM python:3.12-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ RUN pip install --no-cache-dir pytest==8.3.5
@@ -0,0 +1,22 @@
1
+ diff --git a/src/catalog/pagination.py b/src/catalog/pagination.py
2
+ index 8c0a96e..f09b864 100644
3
+ --- a/src/catalog/pagination.py
4
+ +++ b/src/catalog/pagination.py
5
+ @@ -2,6 +2,7 @@
6
+
7
+ from __future__ import annotations
8
+
9
+ +import math
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Generic, TypeVar
13
+ @@ -39,7 +40,8 @@ def paginate(items: Sequence[T], page: int, per_page: int = 20) -> Page[T]:
14
+ if page < 1:
15
+ raise PageOutOfRange(f"page {page} is before the first page")
16
+
17
+ - total_pages = max(1, len(items) // per_page)
18
+ + # Round up: a partial last page is still a page.
19
+ + total_pages = max(1, math.ceil(len(items) / per_page))
20
+ if page > total_pages:
21
+ raise PageOutOfRange(f"page {page} is past the last page ({total_pages})")
22
+
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "catalog"
3
+ version = "0.3.1"
4
+ requires-python = ">=3.12"
5
+
6
+ [tool.pytest.ini_options]
7
+ pythonpath = ["src"]
8
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Product catalog helpers."""
@@ -0,0 +1,52 @@
1
+ """Split result lists into fixed-size pages for the catalog API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class PageOutOfRange(LookupError):
13
+ """The requested page does not exist. The web layer maps this to a 404."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Page(Generic[T]):
18
+ items: list[T]
19
+ number: int
20
+ total_pages: int
21
+ total_items: int
22
+
23
+ @property
24
+ def has_next(self) -> bool:
25
+ return self.number < self.total_pages
26
+
27
+ @property
28
+ def has_previous(self) -> bool:
29
+ return self.number > 1
30
+
31
+
32
+ def paginate(items: Sequence[T], page: int, per_page: int = 20) -> Page[T]:
33
+ """Return page ``page`` (1-based) of ``items``, ``per_page`` items per page.
34
+
35
+ An empty sequence still has one (empty) page, so page 1 is always valid.
36
+ """
37
+ if per_page < 1:
38
+ raise ValueError("per_page must be at least 1")
39
+ if page < 1:
40
+ raise PageOutOfRange(f"page {page} is before the first page")
41
+
42
+ total_pages = max(1, len(items) // per_page)
43
+ if page > total_pages:
44
+ raise PageOutOfRange(f"page {page} is past the last page ({total_pages})")
45
+
46
+ start = (page - 1) * per_page
47
+ return Page(
48
+ items=list(items[start : start + per_page]),
49
+ number=page,
50
+ total_pages=total_pages,
51
+ total_items=len(items),
52
+ )
@@ -0,0 +1,72 @@
1
+ # The release picker chooses the wrong version
2
+
3
+ Our changelog tool uses the small `releasekit` library in `src/releasekit/` to show release labels
4
+ in ascending order and select the latest release. Support reports that
5
+ `latest(["1.9.0", "1.10.0", "1.2.0"])` chooses `"1.9.0"`, and release candidates appear in surprising
6
+ positions. Fix the ordering behavior and add a regression check. Keep the public API below.
7
+
8
+ ## Public API
9
+
10
+ Import from `src/releasekit/index.ts`:
11
+
12
+ - `compare(a: string, b: string): -1 | 0 | 1` returns the sign of the SemVer precedence comparison.
13
+ - `sortVersions(versions: readonly string[]): string[]` returns a **fresh**, ascending, stable array.
14
+ The input is never mutated, including on error. Equal-precedence strings retain their input order.
15
+ - `latest(versions: readonly string[], options?: {includePrerelease?: boolean}): string | undefined`
16
+ returns the original string with greatest precedence. By default prereleases are excluded;
17
+ `{includePrerelease: true}` includes them. Return `undefined` for an empty input or when no eligible
18
+ versions remain. Equal-precedence candidates keep the **first** eligible input string. Never mutate
19
+ the input.
20
+ - `InvalidVersionError` is an exported `Error` subclass. Every API validates every supplied version,
21
+ including a singleton array and prereleases that `latest` would filter out. Any invalid version
22
+ throws `InvalidVersionError`; message text is unspecified. An empty array is valid.
23
+
24
+ Inputs to this API are strings and arrays of strings; handling other JavaScript types is outside
25
+ this ticket. Runtime checks exercise TypeScript code; they do not establish compiler type correctness.
26
+
27
+ ## Accepted version strings
28
+
29
+ Use strict **SemVer 2.0.0**, with no trimming, normalization, range syntax or optional prefixes:
30
+
31
+ `MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]`
32
+
33
+ - MAJOR, MINOR and PATCH are nonempty ASCII decimal digit strings. Each is either `0` or begins with
34
+ `1`–`9`; leading zeroes are invalid. Exactly three components are required.
35
+ - PRERELEASE and BUILD are nonempty lists of nonempty identifiers separated by `.`. Each identifier
36
+ contains only ASCII letters `A`–`Z`/`a`–`z`, decimal digits `0`–`9`, or `-`.
37
+ - A prerelease identifier containing only decimal digits must be `0` or start with `1`–`9`. Build
38
+ identifiers may have leading zeroes. A nonnumeric prerelease identifier such as `01a` is valid.
39
+ - No whitespace, Unicode letters/digits, leading `v`, signs, wildcards, operators (`^`, `~`, `>=`),
40
+ missing components, empty identifiers or trailing characters are accepted. Hyphens within an
41
+ identifier are allowed, including an identifier consisting only of a hyphen.
42
+ - Numeric components and numeric prerelease identifiers have **arbitrary precision**: their magnitude
43
+ must be compared exactly regardless of length, without floating-point rounding or safe-integer
44
+ rejection. Decimal-string comparison or BigInt is suitable. The ticket imposes no numeric length cap.
45
+
46
+ Parsing already works; keep its validation policy intact.
47
+
48
+ ## Precedence
49
+
50
+ Compare core components MAJOR, then MINOR, then PATCH by numeric magnitude. For equal cores:
51
+
52
+ 1. A version without a prerelease has higher precedence than one with a prerelease.
53
+ 2. Compare prerelease identifiers from left to right. Two numeric identifiers compare by exact numeric
54
+ magnitude. A numeric identifier has lower precedence than a nonnumeric identifier. Two nonnumeric
55
+ identifiers compare by case-sensitive ASCII lexical order, including ordinary prefix ordering.
56
+ 3. If all compared identifiers are equal, the shorter prerelease identifier list has lower precedence.
57
+
58
+ BUILD metadata has **no effect** on precedence, eligibility or tie breaking. Preserve original input
59
+ strings in returned arrays and in `latest` rather than reconstructing or dropping build metadata.
60
+ For example, this is an ascending precedence chain:
61
+
62
+ `1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0`
63
+
64
+ `1.0.0+build.09` and `1.0.0+build.10` compare equal. `latest(["2.0.0-rc.1", "1.0.0"])`
65
+ returns `"1.0.0"` by default, and `"2.0.0-rc.1"` when prereleases are included.
66
+
67
+ ## Scope
68
+
69
+ Fix application code and add focused editable checks. Do not add a semver dependency or change the
70
+ runner, dependency recipe, or hidden checks. No range resolution, filesystem access, clocks, network,
71
+ CLI, or compatibility with a third-party semver package is required. The 45-minute timebox and
72
+ intermediate difficulty are author estimates, pending independent human usability testing.
@@ -0,0 +1,31 @@
1
+ spec_version: "0.1"
2
+ id: release-version-ordering
3
+ version: 1.0.1
4
+ title: Release picker orders version labels incorrectly
5
+ type: debug
6
+ summary: A release-tool library chooses the wrong latest version and misorders prereleases.
7
+ languages: [typescript]
8
+ tags: [semver, ordering, releases]
9
+ difficulty: intermediate
10
+ timebox_minutes: 45
11
+ ai_policy: allowed
12
+ environment:
13
+ dockerfile: environment/Dockerfile
14
+ services: []
15
+ test_command: node /opt/patchground/vitest-runner.mjs
16
+ checks:
17
+ visible: checks/visible
18
+ hidden: checks/hidden
19
+ solution: solution/reference.patch
20
+ expected_paths: [src/releasekit/, tests/]
21
+ hints:
22
+ - Start with two version labels whose numeric components have different digit counts.
23
+ - Compare prerelease identifiers one position at a time, distinguishing numeric and nonnumeric identifiers.
24
+ - A release and its prereleases share the same core version; build metadata never decides precedence.
25
+ debrief:
26
+ questions: debrief/questions.md
27
+ focus: [debugging_method, test_quality, scope_judgment]
28
+ author: {name: Patchground contributors}
29
+ content_license: CC-BY-4.0
30
+ provenance: generated
31
+ generated_by: {model: "Codex configured default (model unverified)", prompt_version: launch-release-order-v1}
@@ -0,0 +1,92 @@
1
+ import {test, expect} from "vitest";
2
+ import {compare, sortVersions, latest, InvalidVersionError} from "../src/releasekit/index";
3
+
4
+ test("core components compare by exact numeric magnitude", () => {
5
+ const pairs = [
6
+ ["9.0.0", "10.0.0"], ["1.9.0", "1.10.0"], ["1.0.9", "1.0.10"],
7
+ ["9007199254740992.0.0", "9007199254740993.0.0"],
8
+ ["1.999999999999999999999999999999.0", "1.1000000000000000000000000000000.0"],
9
+ ["1.0.9007199254740992", "1.0.9007199254740993"],
10
+ ];
11
+ for (const [lower, higher] of pairs) {
12
+ expect(compare(lower, higher)).toBe(-1);
13
+ expect(compare(higher, lower)).toBe(1);
14
+ }
15
+ expect(compare("10.0.0", "9.999.999")).toBe(1);
16
+ });
17
+
18
+ test("release has higher precedence than its prereleases", () => {
19
+ expect(compare("1.0.0", "1.0.0-rc.1")).toBe(1);
20
+ expect(compare("1.0.0-rc.1", "1.0.0")).toBe(-1);
21
+ expect(latest(["1.0.0-rc.1", "1.0.0"], {includePrerelease: true})).toBe("1.0.0");
22
+ });
23
+
24
+ test("prerelease identifiers follow numeric and ASCII rules", () => {
25
+ const pairs = [
26
+ ["1.0.0-beta.2", "1.0.0-beta.11"],
27
+ ["1.0.0-1", "1.0.0--"],
28
+ ["1.0.0-0", "1.0.0-01a"],
29
+ ["1.0.0-A", "1.0.0-a"],
30
+ ["1.0.0-alpha", "1.0.0-alphabet"],
31
+ ["1.0.0-alpha", "1.0.0-alpha.0"],
32
+ ["1.0.0-beta.9007199254740992", "1.0.0-beta.9007199254740993"],
33
+ ["1.0.0-99999999999999999999999999999", "1.0.0-100000000000000000000000000000"],
34
+ ];
35
+ for (const [lower, higher] of pairs) {
36
+ expect(compare(lower, higher)).toBe(-1);
37
+ expect(compare(higher, lower)).toBe(1);
38
+ }
39
+ });
40
+
41
+ test("full SemVer precedence chain sorts independently of input order", () => {
42
+ const chain = ["1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta",
43
+ "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0"];
44
+ expect(sortVersions([...chain].reverse())).toEqual(chain);
45
+ });
46
+
47
+ test("build metadata is ignored and ties preserve original labels", () => {
48
+ const labels = ["1.0.0+build.09", "1.0.0+build.10", "1.0.0", "1.0.0+build.00"];
49
+ for (const value of labels) expect(compare(value, "1.0.0")).toBe(0);
50
+ expect(sortVersions(labels)).toEqual(labels);
51
+ expect(latest(labels)).toBe(labels[0]);
52
+ expect(compare("1.0.0-alpha+Z", "1.0.0-alpha+A")).toBe(0);
53
+ expect(latest(["1.0.0-alpha+first", "1.0.0-alpha+second"],
54
+ {includePrerelease: true})).toBe("1.0.0-alpha+first");
55
+ });
56
+
57
+ test("all entry points reject strict grammar violations including filtered labels", () => {
58
+ const invalid = ["", "1", "1.0", "v1.0.0", "01.0.0", "1.01.0", "1.0.01", "1.0.0-01",
59
+ "1.0.0-alpha.01", "1.0.0-", "1.0.0+", "1.0.0-a..b", "1.0.0+a..b", "1.0.0_a",
60
+ " 1.0.0", "1.0.0 ", "1.0.0\n", "1.0.0+\n", "1.0.0-é", "1.0.0",
61
+ "^1.0.0", "~1.0.0", ">=1.0.0", "1.0.*", "+1.0.0", "1.0.0+a+b"];
62
+ for (const value of invalid) {
63
+ expect(() => compare(value, "1.0.0")).toThrowError(InvalidVersionError);
64
+ expect(() => compare("1.0.0", value)).toThrowError(InvalidVersionError);
65
+ expect(() => sortVersions([value])).toThrowError(InvalidVersionError);
66
+ expect(() => latest(["2.0.0", value])).toThrowError(InvalidVersionError);
67
+ expect(() => latest([value], {includePrerelease: true})).toThrowError(InvalidVersionError);
68
+ }
69
+ });
70
+
71
+ test("valid identifier edges and arbitrary length components remain accepted", () => {
72
+ const labels = ["0.0.0", "1.0.0--", "1.0.0-01a", "1.0.0-a-b.0", "1.0.0+00.-.A",
73
+ "999999999999999999999999999999999999999999999999999999999999.0.0"];
74
+ for (const value of labels) expect(compare(value, value)).toBe(0);
75
+ });
76
+
77
+ test("empty fresh sort and latest never mutate even after invalid input", () => {
78
+ const empty: string[] = [];
79
+ const fresh = sortVersions(empty);
80
+ expect(fresh).toEqual([]);
81
+ expect(fresh).not.toBe(empty);
82
+ expect(latest(empty, {includePrerelease: true})).toBeUndefined();
83
+ const labels = ["1.0.0", "2.0.0-rc.1", "0.1.0"];
84
+ const saved = [...labels];
85
+ expect(latest(labels)).toBe("1.0.0");
86
+ expect(latest(labels, {includePrerelease: true})).toBe("2.0.0-rc.1");
87
+ expect(labels).toEqual(saved);
88
+ const invalid = ["2.0.0", "invalid", "1.0.0"];
89
+ expect(() => sortVersions(invalid)).toThrowError(InvalidVersionError);
90
+ expect(() => latest(invalid)).toThrowError(InvalidVersionError);
91
+ expect(invalid).toEqual(["2.0.0", "invalid", "1.0.0"]);
92
+ });
@@ -0,0 +1,31 @@
1
+ import {test, expect} from "vitest";
2
+ import {compare, sortVersions, latest, InvalidVersionError} from "../src/releasekit/index";
3
+
4
+ test("ordinary labels compare with normalized signs", () => {
5
+ expect(compare("1.2.3", "1.2.3")).toBe(0);
6
+ expect(compare("1.2.3", "2.0.0")).toBe(-1);
7
+ expect(compare("2.0.0", "1.2.3")).toBe(1);
8
+ });
9
+
10
+ test("release picker handles double digit minor versions", () => {
11
+ expect(latest(["1.9.0", "1.10.0", "1.2.0"])).toBe("1.10.0");
12
+ });
13
+
14
+ test("sort returns fresh material and ignores build labels", () => {
15
+ const labels = ["1.0.0+z", "1.0.0+a", "0.9.0"];
16
+ const ordered = sortVersions(labels);
17
+ expect(ordered).toEqual(["0.9.0", "1.0.0+z", "1.0.0+a"]);
18
+ expect(labels).toEqual(["1.0.0+z", "1.0.0+a", "0.9.0"]);
19
+ expect(ordered).not.toBe(labels);
20
+ });
21
+
22
+ test("prerelease opt in and empty selection are explicit", () => {
23
+ expect(latest([])).toBeUndefined();
24
+ expect(latest(["2.0.0-rc.1"])).toBeUndefined();
25
+ expect(latest(["2.0.0-rc.1", "1.0.0"])).toBe("1.0.0");
26
+ expect(latest(["2.0.0-rc.1", "1.0.0"], {includePrerelease: true})).toBe("2.0.0-rc.1");
27
+ });
28
+
29
+ test("strict parsing rejects a leading zero", () => {
30
+ expect(() => compare("01.0.0", "1.0.0")).toThrowError(InvalidVersionError);
31
+ });
@@ -0,0 +1,9 @@
1
+ # Debrief
2
+
3
+ - Which smallest pair of labels reproduced the reported problem? How did you separate parsing from ordering?
4
+ - Which check would catch an implementation that passes ordinary numeric cases but rounds very long numbers?
5
+ - How did you test the prerelease rules independently rather than relying only on one long ordering chain?
6
+ - What did equal-precedence build labels tell you about stable sorting and latest tie behavior?
7
+ - Which parts of the starting library did you leave intact, and what evidence supported that scope?
8
+
9
+ The timebox and difficulty are estimates. No independent human usability trial has been recorded.
@@ -0,0 +1,7 @@
1
+ FROM node:24.15.0-bookworm-slim@sha256:4e6b70dd6cbfc88c8157ba19aa3d9f9cce6ba4703576d55459e45efcbc9c5f5d
2
+ WORKDIR /opt/patchground
3
+ COPY package.json package-lock.json ./
4
+ RUN npm ci --ignore-scripts --no-audit --no-fund && npm cache clean --force
5
+ COPY vitest-runner.mjs ./
6
+ RUN chmod 644 package.json package-lock.json vitest-runner.mjs
7
+ WORKDIR /workspace