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,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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
59
|
+
.replaceAll('"', '"').replaceAll("'", ''').replaceAll("\n", " ")
|
|
60
|
+
.replaceAll("\r", " ").replaceAll("\t", "	");
|
|
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,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)
|