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,13 @@
1
+ {
2
+ "name": "patchground-vitest-runtime",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "vitest": "5.0.5",
8
+ "vite": "8.3.5"
9
+ },
10
+ "scripts": {
11
+ "test": "node --check vitest-runner.mjs"
12
+ }
13
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, readFile, writeFile, rename, rm, lstat, realpath } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = '/workspace';
8
+ const final = '/results/junit.xml';
9
+ const self = fileURLToPath(import.meta.url);
10
+
11
+ async function child(destination, files) {
12
+ const { startVitest } = await import('vitest/node');
13
+ let completed;
14
+ const reporter = {
15
+ onTestModuleCollected(module) {
16
+ for (const test of module.children.allTests()) {
17
+ const retry = test.options.retry;
18
+ const count = typeof retry === 'number' ? retry : retry?.count ?? 0;
19
+ if (count > 0 || (test.options.repeats ?? 0) > 0) {
20
+ throw Error('Retries and repeats are unsupported');
21
+ }
22
+ }
23
+ },
24
+ onTestRunEnd(modules, errors, reason) {
25
+ const cases = [];
26
+ let trustworthy = errors.length === 0 && reason !== 'interrupted';
27
+ for (const module of modules) {
28
+ if (module.errors().length) trustworthy = false;
29
+ for (const suite of module.children.allSuites()) {
30
+ if (suite.errors().length) trustworthy = false;
31
+ }
32
+ for (const test of module.children.allTests()) {
33
+ const state = test.result().state;
34
+ if (!['passed', 'failed', 'skipped'].includes(state)) trustworthy = false;
35
+ cases.push({ file: module.relativeModuleId, name: test.fullName, state });
36
+ }
37
+ }
38
+ completed = { trustworthy, cases };
39
+ },
40
+ };
41
+ const ctx = await startVitest([], {
42
+ root, config: false, watch: false, run: true,
43
+ include: files.length ? files : ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/.git/**'],
44
+ reporters: [reporter], environment: 'node', pool: 'forks', maxWorkers: 1,
45
+ fileParallelism: false, isolate: true, retry: 0, allowOnly: false, maxConcurrency: 1,
46
+ passWithNoTests: false, testTimeout: 5000, hookTimeout: 5000,
47
+ sequence: { shuffle: false },
48
+ }, {
49
+ configFile: false, envDir: false, cacheDir: '/tmp/patchground-vite',
50
+ resolve: { alias: { vitest: '/opt/patchground/node_modules/vitest/dist/index.js' } },
51
+ });
52
+ await ctx.close();
53
+ if (!completed || ctx.state.getUnhandledErrors().length) return;
54
+ await writeFile(destination, JSON.stringify(completed), { flag: 'wx' });
55
+ }
56
+
57
+ function escape(value) {
58
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
59
+ .replaceAll('"', '&quot;').replaceAll("'", '&apos;').replaceAll("\n", "&#10;")
60
+ .replaceAll("\r", "&#13;").replaceAll("\t", "&#9;");
61
+ }
62
+
63
+ async function parent() {
64
+ await rm(final, { force: true });
65
+ const temporary = await mkdtemp(path.join(tmpdir(), 'patchground-vitest-'));
66
+ try {
67
+ const receipt = path.join(temporary, 'receipt.json');
68
+ const args = process.argv.slice(2);
69
+ const files = args.length ? args.slice(1) : [];
70
+ if (args.length && (args[0] !== '--files' || !files.length)) return;
71
+ for (const file of files) {
72
+ const parts = file.split('/');
73
+ if (!file.endsWith('.test.ts') || parts.some(part =>
74
+ !/^[\p{L}\p{N}_. -]+$/u.test(part) || ['.', '..', '.git', 'node_modules'].includes(part))) return;
75
+ }
76
+ if (new Set(files).size !== files.length) return;
77
+ for (const file of files) {
78
+ const full = path.join(root, file);
79
+ if (!(await lstat(full)).isFile() || !(await realpath(full)).startsWith(root + '/')) return;
80
+ }
81
+ const code = await new Promise((resolve, reject) => {
82
+ const process = spawn(globalThis.process.execPath, [self, '--child', receipt, ...files], {
83
+ stdio: 'ignore', env: { ...globalThis.process.env, CI: 'true' },
84
+ });
85
+ process.once('error', reject);
86
+ process.once('exit', (code, signal) => resolve(signal ? null : code));
87
+ });
88
+ const result = JSON.parse(await readFile(receipt, 'utf8'));
89
+ if (!result.trustworthy || !result.cases.length) return;
90
+ const failed = result.cases.some(test => test.state === 'failed');
91
+ if (code !== (failed ? 1 : 0)) return;
92
+ const seen = new Set();
93
+ result.cases.sort((a, b) => {
94
+ const left = `${a.file}::${a.name}`;
95
+ const right = `${b.file}::${b.name}`;
96
+ return left < right ? -1 : left > right ? 1 : 0;
97
+ });
98
+ const cases = result.cases.map(test => {
99
+ if (!test.file || path.posix.isAbsolute(test.file) || test.file.split('/').includes('..')
100
+ || test.file.includes('\\') || !test.file.endsWith('.test.ts') || !test.name
101
+ || !test.file.isWellFormed() || !test.name.isWellFormed()
102
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(test.file + test.name)) throw Error('unsafe identity');
103
+ const id = `${test.file}::${test.name}`;
104
+ if (seen.has(id)) throw Error('duplicate identity');
105
+ seen.add(id);
106
+ const status = test.state === 'failed' ? '<failure message="test failed"/>'
107
+ : test.state === 'skipped' ? '<skipped/>' : '';
108
+ return `<testcase classname="${escape(test.file)}" name="${escape(test.name)}">${status}</testcase>`;
109
+ });
110
+ const staging = '/results/.patchground-junit.xml';
111
+ await writeFile(staging, `<?xml version="1.0" encoding="UTF-8"?><testsuites><testsuite name="vitest">${cases.join('')}</testsuite></testsuites>`);
112
+ await rename(staging, final);
113
+ process.exitCode = failed ? 1 : 0;
114
+ } finally {
115
+ await rm(temporary, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ try {
120
+ if (process.argv[2] === '--child') await child(process.argv[3], process.argv.slice(4));
121
+ else { process.exitCode = 1; await parent(); }
122
+ } catch {
123
+ process.exitCode = 1;
124
+ }
@@ -0,0 +1,36 @@
1
+ --- a/src/releasekit/ordering.ts
2
+ +++ b/src/releasekit/ordering.ts
3
+ @@ -6,15 +6,29 @@
4
+ return a === b ? 0 : a < b ? -1 : 1;
5
+ }
6
+
7
+ +function numeric(a: string, b: string): Order {
8
+ + return a.length === b.length ? lexical(a, b) : a.length < b.length ? -1 : 1;
9
+ +}
10
+ +
11
+ function precedence(a: Version, b: Version): Order {
12
+ for (let i = 0; i < 3; i += 1) {
13
+ - const order = lexical(a.core[i], b.core[i]);
14
+ + const order = numeric(a.core[i], b.core[i]);
15
+ if (order !== 0) return order;
16
+ }
17
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
18
+ - if (a.prerelease.length === 0) return -1;
19
+ - if (b.prerelease.length === 0) return 1;
20
+ - return lexical(a.prerelease.join("."), b.prerelease.join("."));
21
+ + if (a.prerelease.length === 0) return 1;
22
+ + if (b.prerelease.length === 0) return -1;
23
+ + for (let i = 0; i < Math.min(a.prerelease.length, b.prerelease.length); i += 1) {
24
+ + const left = a.prerelease[i];
25
+ + const right = b.prerelease[i];
26
+ + const leftNumeric = /^[0-9]+$/.test(left);
27
+ + const rightNumeric = /^[0-9]+$/.test(right);
28
+ + const order = leftNumeric && rightNumeric ? numeric(left, right)
29
+ + : leftNumeric !== rightNumeric ? (leftNumeric ? -1 : 1) : lexical(left, right);
30
+ + if (order !== 0) return order;
31
+ + }
32
+ + return a.prerelease.length === b.prerelease.length ? 0
33
+ + : a.prerelease.length < b.prerelease.length ? -1 : 1;
34
+ }
35
+
36
+ export function compare(a: string, b: string): Order {
@@ -0,0 +1,7 @@
1
+ # releasekit
2
+
3
+ A small pure TypeScript library for ordering strict SemVer release labels. Public exports live in
4
+ `src/releasekit/index.ts`; the challenge ticket specifies their contract. Editable checks are merged
5
+ into `tests/` when a session starts. Use Patchground's container-backed `check` command to run them;
6
+ all runner dependencies are preinstalled in the challenge image. No package install is needed in the
7
+ workspace. Application code is original generated challenge material, licensed under Apache-2.0.
@@ -0,0 +1,2 @@
1
+ export {InvalidVersionError} from "./parse";
2
+ export {compare, sortVersions, latest} from "./ordering";
@@ -0,0 +1,41 @@
1
+ import {parse, type Version} from "./parse";
2
+
3
+ type Order = -1 | 0 | 1;
4
+
5
+ function lexical(a: string, b: string): Order {
6
+ return a === b ? 0 : a < b ? -1 : 1;
7
+ }
8
+
9
+ function precedence(a: Version, b: Version): Order {
10
+ for (let i = 0; i < 3; i += 1) {
11
+ const order = lexical(a.core[i], b.core[i]);
12
+ if (order !== 0) return order;
13
+ }
14
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
15
+ if (a.prerelease.length === 0) return -1;
16
+ if (b.prerelease.length === 0) return 1;
17
+ return lexical(a.prerelease.join("."), b.prerelease.join("."));
18
+ }
19
+
20
+ export function compare(a: string, b: string): Order {
21
+ return precedence(parse(a), parse(b));
22
+ }
23
+
24
+ export function sortVersions(versions: readonly string[]): string[] {
25
+ const parsed = versions.map(value => ({value, parsed: parse(value)}));
26
+ parsed.sort((a, b) => precedence(a.parsed, b.parsed));
27
+ return parsed.map(item => item.value);
28
+ }
29
+
30
+ export function latest(
31
+ versions: readonly string[],
32
+ options: {includePrerelease?: boolean} = {},
33
+ ): string | undefined {
34
+ const parsed = versions.map(value => ({value, parsed: parse(value)}));
35
+ const eligible = parsed.filter(item => options.includePrerelease || item.parsed.prerelease.length === 0);
36
+ let best: typeof eligible[number] | undefined;
37
+ for (const item of eligible) {
38
+ if (best === undefined || precedence(item.parsed, best.parsed) > 0) best = item;
39
+ }
40
+ return best?.value;
41
+ }
@@ -0,0 +1,24 @@
1
+ /** Strict label parsing; numeric components stay as strings to preserve precision. */
2
+ export class InvalidVersionError extends Error {
3
+ constructor() {
4
+ super("Invalid semantic version");
5
+ this.name = "InvalidVersionError";
6
+ }
7
+ }
8
+
9
+ export interface Version {
10
+ readonly core: readonly string[];
11
+ readonly prerelease: readonly string[];
12
+ }
13
+
14
+ const grammar = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
15
+
16
+ export function parse(value: string): Version {
17
+ const match = grammar.exec(value);
18
+ if (match === null || match[0] !== value) throw new InvalidVersionError();
19
+ const prerelease = match[4] === undefined ? [] : match[4].split(".");
20
+ if (prerelease.some(part => /^[0-9]+$/.test(part) && part.length > 1 && part[0] === "0")) {
21
+ throw new InvalidVersionError();
22
+ }
23
+ return {core: [match[1], match[2], match[3]], prerelease};
24
+ }
@@ -0,0 +1 @@
1
+ """patchground.cli — see SPEC.md in this folder."""
patchground/cli/app.py ADDED
@@ -0,0 +1,59 @@
1
+ """Typer application behind `patchground` / `pgnd`."""
2
+
3
+ import typer
4
+
5
+ from patchground import __version__
6
+ from patchground.cli.authoring import new
7
+ from patchground.cli.discovery import DiscoveryCommand, list_challenges, show
8
+ from patchground.cli.doctor import doctor
9
+ from patchground.cli.packs import app as packs_app
10
+ from patchground.cli.practice import RunCommand, hint, history, run, sessions
11
+ from patchground.cli.reports import report, submit
12
+ from patchground.cli.sessions import SessionCommand, check, reset, start, status
13
+ from patchground.cli.validate import validate
14
+
15
+ app = typer.Typer(name="patchground", no_args_is_help=True, add_completion=False)
16
+
17
+
18
+ def _version(value: bool) -> None:
19
+ if value:
20
+ typer.echo(__version__)
21
+ raise typer.Exit()
22
+
23
+
24
+ @app.callback()
25
+ def root(
26
+ version: bool = typer.Option(
27
+ False, "--version", callback=_version, is_eager=True, help="Show the version and exit."
28
+ ),
29
+ ) -> None:
30
+ """Practice real software engineering on broken repositories. Checks run in Docker.
31
+
32
+ `pgnd` is a short alias of `patchground`. New here? Run `pgnd doctor`, then
33
+ `pgnd list --builtin` and `pgnd start <id>`.
34
+
35
+ Sessions are kept in ~/patchground/sessions. To keep them elsewhere, set
36
+ PATCHGROUND_SESSIONS_DIR to an absolute path.
37
+ """
38
+
39
+
40
+ app.command("validate")(validate)
41
+ app.command("start", cls=SessionCommand)(start)
42
+ app.command("check", cls=SessionCommand)(check)
43
+ app.command("status", cls=SessionCommand)(status)
44
+ app.command("reset", cls=SessionCommand)(reset)
45
+ app.command("doctor", cls=DiscoveryCommand)(doctor)
46
+ app.command("list", cls=DiscoveryCommand)(list_challenges)
47
+ app.command("show", cls=DiscoveryCommand)(show)
48
+ app.command("new", cls=DiscoveryCommand)(new)
49
+ app.command("submit", cls=SessionCommand)(submit)
50
+ app.command("report", cls=SessionCommand)(report)
51
+ app.command("hint", cls=SessionCommand)(hint)
52
+ app.command("sessions", cls=SessionCommand)(sessions)
53
+ app.command("history", cls=SessionCommand)(history)
54
+ app.command("run", cls=RunCommand)(run)
55
+ app.add_typer(packs_app, name="pack")
56
+
57
+
58
+ def main() -> None:
59
+ app()
@@ -0,0 +1,41 @@
1
+ """Thin authoring command; no build or run side effects."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+
9
+ from patchground.authoring import AuthoringError, CreatedChallenge, create_challenge
10
+ from patchground.cli.render import EXIT_ERROR, EXIT_INTERRUPTED, emit, emit_error
11
+
12
+
13
+ def _human(console: Console, result: CreatedChallenge) -> None:
14
+ console.print(Text(f"created {result.path} ({result.type}, {result.language})"))
15
+ console.print(Text("Edit the starter, then run:"))
16
+ console.print(Text(f" pgnd validate {result.path}"))
17
+
18
+
19
+ def new(
20
+ challenge_id: Annotated[str, typer.Argument(help="New challenge id and folder name.")],
21
+ type_: Annotated[
22
+ str, typer.Option("--type", help="Template type (debug or feature).")
23
+ ] = "debug",
24
+ language: Annotated[
25
+ str, typer.Option("--lang", help="Template language (python or typescript).")
26
+ ] = "python",
27
+ json_mode: Annotated[bool, typer.Option("--json", help="Print one JSON document.")] = False,
28
+ ) -> None:
29
+ """Create a runnable authored challenge without building or running it."""
30
+ try:
31
+ result = create_challenge(challenge_id, type_=type_, language=language)
32
+ except AuthoringError as exc:
33
+ emit_error("environment", str(exc), json_mode)
34
+ raise typer.Exit(EXIT_ERROR) from exc
35
+ except KeyboardInterrupt as exc:
36
+ emit_error("interrupted", "interrupted", json_mode)
37
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
38
+ except Exception as exc:
39
+ emit_error("internal", "scaffold creation failed unexpectedly", json_mode)
40
+ raise typer.Exit(EXIT_ERROR) from exc
41
+ emit(result.model_dump(mode="json"), lambda console: _human(console, result), json_mode)
@@ -0,0 +1,77 @@
1
+ """Explicit pack-review prompting, shared by start, validate and update."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+
9
+ from patchground.cli.render import emit, stdout_console
10
+ from patchground.packs.models import ConsentRequired, PackReview
11
+
12
+
13
+ def literal(value: str, *, multiline: bool = False) -> str:
14
+ return "".join(
15
+ char if char.isprintable() or (multiline and char in "\n\t") else f"\\u{ord(char):04x}"
16
+ for char in value
17
+ )
18
+
19
+
20
+ def show_review(console: Console, review: PackReview) -> None:
21
+ pack = review.pack
22
+ console.print(Text(f"UNOFFICIAL pack: {pack.name} {pack.version}"))
23
+ console.print(Text(f"commit: {pack.commit}"))
24
+ console.print(Text(f"source: {literal(pack.source)}"))
25
+ console.print(Text(f"approval identity: {pack.identity}"))
26
+ if review.previous_commit:
27
+ console.print(Text(f"previous commit: {review.previous_commit}"))
28
+ for label, names in (
29
+ ("added", review.added),
30
+ ("removed", review.removed),
31
+ ("changed", review.changed),
32
+ ):
33
+ console.print(Text(f"{label}: {', '.join(literal(name) for name in names) or 'none'}"))
34
+ console.print(Text("All indexed build/run recipes follow; control characters are escaped."))
35
+ for recipe in review.recipes:
36
+ console.print(Text(f"Challenge: {recipe.challenge_id}"))
37
+ console.print(Text(f"Dockerfile: {literal(recipe.dockerfile_path)}"))
38
+ for line in literal(recipe.dockerfile, multiline=True).split("\n"):
39
+ console.print(Text(" " + line))
40
+ console.print(Text(f"Services: {', '.join(literal(s) for s in recipe.services) or 'none'}"))
41
+ console.print(Text("Command:"))
42
+ for line in literal(recipe.command, multiline=True).split("\n"):
43
+ console.print(Text(" " + line))
44
+ console.print(
45
+ Text(
46
+ "Builds may use the network. Approval covers this exact whole pack revision, "
47
+ "not future updates. Opening its workspace in your editor can run tooling "
48
+ "outside Docker."
49
+ )
50
+ )
51
+
52
+
53
+ def with_consent[T](operation: Callable[[bool, str | None], T], *, yes: bool, json_mode: bool) -> T:
54
+ try:
55
+ return operation(yes, None)
56
+ except ConsentRequired as required:
57
+ if json_mode:
58
+ raise
59
+ show_review(stdout_console(), required.review)
60
+ try:
61
+ accepted = typer.confirm("Approve this exact pack revision?", default=False)
62
+ except (EOFError, typer.Abort) as exc:
63
+ raise KeyboardInterrupt from exc
64
+ if not accepted:
65
+ raise KeyboardInterrupt from None
66
+ return operation(True, required.review.pack.identity)
67
+
68
+
69
+ def emit_required(required: ConsentRequired, json_mode: bool) -> None:
70
+ emit(
71
+ {
72
+ "error": {"kind": "consent_required", "message": str(required)},
73
+ "review": required.review.model_dump(mode="json"),
74
+ },
75
+ lambda console: show_review(console, required.review),
76
+ json_mode,
77
+ )
@@ -0,0 +1,189 @@
1
+ """`pgnd list` and `pgnd show`: render local challenge discovery; never build or run anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+ from typer.core import TyperCommand
14
+
15
+ from patchground.cli.render import (
16
+ EXIT_ERROR,
17
+ EXIT_FAILED,
18
+ EXIT_INTERRUPTED,
19
+ EXIT_OK,
20
+ emit,
21
+ emit_error,
22
+ )
23
+ from patchground.packs import (
24
+ ChallengeDetails,
25
+ DiscoveryError,
26
+ DiscoveryFilters,
27
+ DiscoveryResult,
28
+ discover_challenges,
29
+ )
30
+ from patchground.packs.builtin import builtin_root, is_builtin_path
31
+ from patchground.packs.execution import InstalledDiscoveryResult
32
+ from patchground.packs.models import PackError
33
+ from patchground.packs.registry import discover_installed, show_reference
34
+ from patchground.spec import ChallengeType, Difficulty
35
+
36
+ Result = DiscoveryResult | ChallengeDetails
37
+ JsonOption = Annotated[bool, typer.Option("--json", help="Print one JSON document.")]
38
+
39
+ # Challenge text is untrusted: drop terminal control sequences before it reaches a terminal.
40
+ _CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
41
+
42
+
43
+ def _clean(value: str) -> str:
44
+ return _CONTROL.sub("", value)
45
+
46
+
47
+ class DiscoveryCommand(TyperCommand):
48
+ """Keep argument-parsing failures inside the one-JSON-document contract under ``--json``."""
49
+
50
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
51
+ json_mode = "--json" in args
52
+ try:
53
+ return super().parse_args(ctx, args)
54
+ except typer.Exit:
55
+ raise
56
+ except Exception as exc:
57
+ if not json_mode:
58
+ raise
59
+ emit_error("environment", "invalid command arguments", True)
60
+ raise typer.Exit(EXIT_ERROR) from exc
61
+
62
+
63
+ def _human_list(console: Console, result: DiscoveryResult) -> None:
64
+ if result.challenges and is_builtin_path(Path(result.directory)):
65
+ console.print(Text("Built-in challenges. Start one with: pgnd start <id>", style="dim"))
66
+ if not result.challenges and not result.invalid:
67
+ console.print(Text(f"no challenges found in {_clean(result.directory)}"))
68
+ if not isinstance(result, InstalledDiscoveryResult) and builtin_root() is not None:
69
+ console.print(
70
+ Text("Challenges ship with Patchground. To see them: pgnd list --builtin")
71
+ )
72
+ console.print(Text("Start one by ID, e.g.: pgnd start pagination-last-page"))
73
+ console.print(Text("Or write your own: pgnd new my-challenge"))
74
+ for item in result.challenges:
75
+ line = Text()
76
+ line.append(_clean(getattr(item, "reference", item.id)), style="bold")
77
+ line.append(f" {item.type.value} {item.difficulty.value} {item.timebox_minutes}m")
78
+ line.append(f" {_clean(', '.join(item.languages))}")
79
+ console.print(line)
80
+ console.print(Text(f" {_clean(item.title)}"))
81
+ if not is_builtin_path(Path(item.path)):
82
+ console.print(Text(f" {_clean(item.path)}", style="dim"))
83
+ for bad in result.invalid:
84
+ console.print(Text(f"invalid: {_clean(bad.path)}", style="bold red"))
85
+ for issue in bad.issues:
86
+ console.print(Text(f" {_clean(issue)}"))
87
+
88
+
89
+ def _human_show(console: Console, result: ChallengeDetails) -> None:
90
+ if not result.valid or result.challenge is None:
91
+ console.print(Text(f"invalid: {_clean(result.path)}", style="bold red"))
92
+ for issue in result.issues:
93
+ console.print(Text(f" {_clean(issue)}"))
94
+ return
95
+ item = result.challenge
96
+ console.print(Text(f"{_clean(item.title)} ({_clean(item.id)} {_clean(item.version)})"))
97
+ rows = [
98
+ ("type", item.type.value),
99
+ ("difficulty", item.difficulty.value),
100
+ ("timebox", f"{item.timebox_minutes} minutes"),
101
+ ("ai policy", item.ai_policy.value),
102
+ ("languages", ", ".join(item.languages)),
103
+ ("tags", ", ".join(item.tags) or "-"),
104
+ ("hints", str(result.hint_count or 0)),
105
+ ("author", result.author or "-"),
106
+ ("license", result.content_license or "-"),
107
+ ("path", item.path),
108
+ ]
109
+ for label, value in rows:
110
+ console.print(Text(f"{label:<11} {_clean(value)}"))
111
+ console.print(Text(_clean(item.summary)))
112
+ console.print(Text(""))
113
+ console.print(Text(_clean(result.ticket or "")))
114
+
115
+
116
+ def _human(console: Console, result: Result) -> None:
117
+ if isinstance(result, DiscoveryResult):
118
+ _human_list(console, result)
119
+ else:
120
+ _human_show(console, result)
121
+
122
+
123
+ def _invoke(call: Callable[[], Result], json_mode: bool) -> None:
124
+ try:
125
+ result = call()
126
+ except (DiscoveryError, PackError) as exc:
127
+ emit_error("environment", str(exc), json_mode)
128
+ raise typer.Exit(EXIT_ERROR) from exc
129
+ except KeyboardInterrupt as exc:
130
+ emit_error("interrupted", "interrupted", json_mode)
131
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
132
+ except Exception as exc:
133
+ # Exception text could quote challenge content; keep it out of the output.
134
+ emit_error("internal", "discovery failed unexpectedly", json_mode)
135
+ raise typer.Exit(EXIT_ERROR) from exc
136
+ emit(result.model_dump(mode="json"), lambda console: _human(console, result), json_mode)
137
+ failed = bool(result.invalid) if isinstance(result, DiscoveryResult) else not result.valid
138
+ raise typer.Exit(EXIT_FAILED if failed else EXIT_OK)
139
+
140
+
141
+ def list_challenges(
142
+ directory: Annotated[
143
+ Path | None,
144
+ typer.Argument(help="Folder whose immediate subfolders are challenges (default: here)."),
145
+ ] = None,
146
+ installed: Annotated[
147
+ bool, typer.Option("--installed", help="List installed pack challenges.")
148
+ ] = False,
149
+ builtin: Annotated[
150
+ bool,
151
+ typer.Option("--builtin", help="List the curated challenges shipped with Patchground."),
152
+ ] = False,
153
+ type_: Annotated[
154
+ ChallengeType | None, typer.Option("--type", help="Only this challenge type.")
155
+ ] = None,
156
+ lang: Annotated[str | None, typer.Option("--lang", help="Only this language.")] = None,
157
+ difficulty: Annotated[
158
+ Difficulty | None, typer.Option("--difficulty", help="Only this difficulty.")
159
+ ] = None,
160
+ tag: Annotated[str | None, typer.Option("--tag", help="Only challenges with this tag.")] = None,
161
+ json_mode: JsonOption = False,
162
+ ) -> None:
163
+ """List challenges (here, built-in or installed) and report invalid ones; nothing runs."""
164
+ filters = DiscoveryFilters(type=type_, lang=lang, difficulty=difficulty, tag=tag)
165
+ if sum((installed, builtin, directory is not None)) > 1:
166
+ emit_error("environment", "choose one of a directory, --builtin or --installed", json_mode)
167
+ raise typer.Exit(EXIT_ERROR)
168
+ bundled = builtin_root()
169
+ if builtin and bundled is None:
170
+ emit_error("environment", "this installation has no built-in challenges", json_mode)
171
+ raise typer.Exit(EXIT_ERROR)
172
+
173
+ def discover() -> Result:
174
+ if installed:
175
+ return discover_installed(filters)
176
+ return discover_challenges(bundled if builtin else directory, filters)
177
+
178
+ _invoke(discover, json_mode)
179
+
180
+
181
+ def show(
182
+ path: Annotated[
183
+ str,
184
+ typer.Argument(help="Local folder, built-in ID, installed ID or pack-name:challenge-id."),
185
+ ],
186
+ json_mode: JsonOption = False,
187
+ ) -> None:
188
+ """Show a local challenge's metadata and ticket; never the solution or hidden checks."""
189
+ _invoke(lambda: show_reference(path), json_mode)