opencode-leetcode-realworld 0.4.0 → 0.5.0
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.
- package/README.md +23 -3
- package/package.json +1 -1
- package/src/index.ts +29 -5
- package/src/prompt.ts +87 -75
- package/src/scaffold.ts +121 -39
- package/src/types.ts +28 -4
package/README.md
CHANGED
|
@@ -172,13 +172,33 @@ they tend to produce:
|
|
|
172
172
|
| **Mini project** | A small multi-file app (~3-6 files) with the algorithm behind a single `TODO` (TypeScript, Python, Rust). |
|
|
173
173
|
| **Fuller project** | A multi-file app with more realistic layers (~6-15 files) and the same single `TODO`. |
|
|
174
174
|
|
|
175
|
-
In project mode you get a real little codebase — CLI entry
|
|
176
|
-
layer, wiring — where **everything works except one `TODO`**. You
|
|
177
|
-
piece (the algorithm) and `node tests/runner.mjs` verifies the app
|
|
175
|
+
In project mode you get a real little codebase — CLI entry or web server, domain
|
|
176
|
+
types, service layer, wiring — where **everything works except one `TODO`**. You
|
|
177
|
+
implement that piece (the algorithm) and `node tests/runner.mjs` verifies the app
|
|
178
|
+
end-to-end.
|
|
178
179
|
|
|
179
180
|
Project mode is available for **TypeScript, Python, Rust**; the other languages use
|
|
180
181
|
the quick exercise.
|
|
181
182
|
|
|
183
|
+
**Pick your stack.** In project mode it also asks for a stack:
|
|
184
|
+
|
|
185
|
+
| Stack | Shape | How it's tested |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| Vanilla (no framework) | CLI / library | JSON on stdin → JSON on stdout |
|
|
188
|
+
| Express API (Node) | HTTP server | runner starts it and sends HTTP requests |
|
|
189
|
+
| Next.js (React) | route handlers / API | runner starts it and sends HTTP requests |
|
|
190
|
+
| FastAPI (Python) | HTTP server | runner starts it and sends HTTP requests |
|
|
191
|
+
| Agent decides | whatever fits | inferred from the chosen shape |
|
|
192
|
+
|
|
193
|
+
The folder structure is **not hardcoded** — the agent designs a layout that fits
|
|
194
|
+
the stack and problem (`files`), so it feels like a real repo, not a template.
|
|
195
|
+
|
|
196
|
+
For server projects, test cases are HTTP requests
|
|
197
|
+
(`{ name, method, path, input, expected, status }`); the runner runs your
|
|
198
|
+
`installCommand`, optional `buildCommand`, boots `startCommand`, waits on
|
|
199
|
+
`healthPath`, then checks each request. For CLI/library projects it stays
|
|
200
|
+
stdin/stdout.
|
|
201
|
+
|
|
182
202
|
The generated project looks like this:
|
|
183
203
|
|
|
184
204
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-leetcode-realworld",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "OpenCode plugin that turns a LeetCode problem into a realistic software-engineering assignment, so you practice applying the underlying algorithm instead of solving the puzzle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -8,8 +8,15 @@ import type { ScaffoldSpec } from "./types";
|
|
|
8
8
|
|
|
9
9
|
const testCaseSchema = tool.schema.object({
|
|
10
10
|
name: tool.schema.string().describe("Short description of what the case asserts"),
|
|
11
|
-
input: tool.schema.any().describe("
|
|
12
|
-
expected: tool.schema.any().describe("
|
|
11
|
+
input: tool.schema.any().optional().describe("stdio: JSON payload. http: JSON request body."),
|
|
12
|
+
expected: tool.schema.any().optional().describe("Expected JSON result/response body"),
|
|
13
|
+
method: tool.schema.string().optional().describe("http: method, e.g. POST (default GET)"),
|
|
14
|
+
path: tool.schema.string().optional().describe("http: request path, e.g. /api/events"),
|
|
15
|
+
status: tool.schema.number().optional().describe("http: expected status code (default 200)"),
|
|
16
|
+
headers: tool.schema
|
|
17
|
+
.record(tool.schema.string(), tool.schema.string())
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("http: extra request headers"),
|
|
13
20
|
});
|
|
14
21
|
|
|
15
22
|
export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
@@ -119,11 +126,28 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
|
119
126
|
runCommand: tool.schema
|
|
120
127
|
.string()
|
|
121
128
|
.optional()
|
|
122
|
-
.describe("
|
|
129
|
+
.describe("stdio project: command that runs one case (JSON on stdin, JSON on stdout)"),
|
|
123
130
|
buildCommand: tool.schema
|
|
124
131
|
.string()
|
|
125
132
|
.optional()
|
|
126
|
-
.describe("Project mode: optional one-time build command (
|
|
133
|
+
.describe("Project mode: optional one-time build command (e.g. `cargo build`)"),
|
|
134
|
+
installCommand: tool.schema
|
|
135
|
+
.string()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("Project mode: optional one-time install command (e.g. `npm install`)"),
|
|
138
|
+
startCommand: tool.schema
|
|
139
|
+
.string()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe("http project: command that starts the server (enables HTTP test mode)"),
|
|
142
|
+
port: tool.schema.number().optional().describe("http project: server port (default 3000)"),
|
|
143
|
+
healthPath: tool.schema
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("http project: readiness path the runner polls (default /)"),
|
|
147
|
+
stack: tool.schema
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Free-form stack label, e.g. express, nextjs, fastapi, vanilla"),
|
|
127
151
|
},
|
|
128
152
|
async execute(rawArgs, context) {
|
|
129
153
|
const spec = rawArgs as ScaffoldSpec;
|
|
@@ -153,7 +177,7 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
|
153
177
|
`Created a real-world practice project at ${relative}`,
|
|
154
178
|
"",
|
|
155
179
|
`Title: ${result.title}`,
|
|
156
|
-
`Mode: ${result.mode}${result.mode === "project" ?
|
|
180
|
+
`Mode: ${result.mode}${result.mode === "project" ? ` (${result.kind}${result.stack ? `, ${result.stack}` : ""})` : ""}`,
|
|
157
181
|
`Language: ${result.languageName} (${result.runtime})`,
|
|
158
182
|
`Files (${result.files.length}):`,
|
|
159
183
|
...result.files.map((file) => ` - ${file}`),
|
package/src/prompt.ts
CHANGED
|
@@ -16,6 +16,15 @@ export const SUPPORTED_LANGUAGES = [
|
|
|
16
16
|
/** Languages that can receive a full multi-file project. */
|
|
17
17
|
export const PROJECT_LANGUAGES = ["TypeScript", "Python", "Rust"];
|
|
18
18
|
|
|
19
|
+
/** Stacks offered in project mode. */
|
|
20
|
+
export const STACKS = [
|
|
21
|
+
"Vanilla (no framework)",
|
|
22
|
+
"Express API (Node)",
|
|
23
|
+
"Next.js (React)",
|
|
24
|
+
"FastAPI (Python)",
|
|
25
|
+
"Agent decides",
|
|
26
|
+
];
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* The `/practice` command prompt. It orchestrates the two plugin tools and is
|
|
21
30
|
* deliberately opinionated: the whole value of this plugin is that the learner
|
|
@@ -35,107 +44,110 @@ Recognise any of these (all optional):
|
|
|
35
44
|
- tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
|
|
36
45
|
- a specific problem id or slug, e.g. "two-sum" or "1"
|
|
37
46
|
- language: a supported language name or alias (below)
|
|
38
|
-
- scope: "single" | "mini" | "full"
|
|
47
|
+
- scope: "single" | "mini" | "full"
|
|
48
|
+
- stack: a stack name, e.g. "express", "nextjs", "fastapi", "vanilla"
|
|
39
49
|
|
|
40
50
|
Mapping rules:
|
|
41
51
|
- A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
|
|
42
52
|
sliding-window, two-pointers, greedy, ...) is a TAG. Pass it to \`leetcode_fetch\`
|
|
43
|
-
as \`tags\` (
|
|
44
|
-
- Only pass \`idOrSlug\`
|
|
45
|
-
|
|
46
|
-
- When the user gives a tag, it MUST be respected. After fetching, if the problem's
|
|
47
|
-
topics do not include the requested tag, the fetch is wrong: retry with the tag.
|
|
53
|
+
as \`tags\` (array of slugs).
|
|
54
|
+
- Only pass \`idOrSlug\` for a number or known slug (e.g. "1", "two-sum").
|
|
55
|
+
- When a tag is given it MUST be respected; if the fetched topics lack it, retry.
|
|
48
56
|
|
|
49
57
|
## 2. Ask how they want to start (use the \`question\` tool)
|
|
50
58
|
|
|
51
|
-
Step A — if
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
Step A — if language and/or difficulty are missing, ask in one \`question\` call.
|
|
60
|
+
Languages: ${SUPPORTED_LANGUAGES.join(", ")}. Difficulty options: "Easy",
|
|
61
|
+
"Medium" (recommended), "Hard".
|
|
62
|
+
|
|
63
|
+
Step B — scope. Full projects are only for ${PROJECT_LANGUAGES.join(", ")}.
|
|
64
|
+
- If the language is one of those AND scope was not given, ask a SECOND question
|
|
65
|
+
"How do you want to start?" with: "Quick exercise" (one function),
|
|
66
|
+
"Mini project" (~3-6 files), "Fuller project" (~6-15 files).
|
|
67
|
+
- Otherwise skip it.
|
|
54
68
|
|
|
55
|
-
Step
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- "Mini project" — a small multi-file app (~3-6 files) with the algorithm at its core.
|
|
61
|
-
- "Fuller project" — a multi-file app with more realistic layers (~6-15 files).
|
|
62
|
-
- Otherwise (other language, or scope already given) skip this question.
|
|
69
|
+
Step C — stack. Only when the scope is a project (not "Quick exercise"):
|
|
70
|
+
- If the stack was not given, ask a THIRD \`question\`: "Which stack?" with:
|
|
71
|
+
${STACKS.map((s) => `"${s}"`).join(", ")}.
|
|
72
|
+
- Pick sensible stacks: Express/Next.js for TypeScript, FastAPI for Python,
|
|
73
|
+
"Vanilla" or an HTTP service for Rust.
|
|
63
74
|
|
|
64
|
-
Wait for
|
|
75
|
+
Wait for answers. Do not guess.
|
|
65
76
|
|
|
66
77
|
## 3. Fetch the source problem (private)
|
|
67
|
-
Call
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
it, paraphrase it closely, or name its source anywhere in the generated project.
|
|
78
|
+
Call \`leetcode_fetch\` with the difficulty, passing topics as \`tags\`. Verify the
|
|
79
|
+
returned topics include every requested tag; retry if not. The statement is for
|
|
80
|
+
YOUR reasoning only — never paste it or name its source anywhere.
|
|
71
81
|
|
|
72
82
|
## 4. Derive the principle
|
|
73
|
-
Identify the underlying algorithmic idea
|
|
74
|
-
scheduling, union-find, DP over intervals). Do not carry over the problem's
|
|
75
|
-
fiction (arrays of "nums", "target", etc.).
|
|
83
|
+
Identify the underlying algorithmic idea. Do not carry over the problem's fiction.
|
|
76
84
|
|
|
77
85
|
## 5. Build the assignment
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
86
|
+
When the spec is ready, call the \`leetcode_scaffold\` tool. The two shapes differ:
|
|
87
|
+
|
|
88
|
+
### A) scope = Quick exercise -> mode "single"
|
|
89
|
+
One entry point, JSON-in / JSON-out. Do NOT provide \`starterCode\` (the plugin
|
|
90
|
+
generates typed/documented starters from your test cases). Spec: mode, title,
|
|
91
|
+
scenario, pattern, difficulty, language, requirements, edgeCases, publicTests
|
|
92
|
+
(5-8), hiddenTests (6-12), sourceSlug, sourceTitle.
|
|
93
|
+
|
|
94
|
+
### B) scope = Mini/Fuller project -> mode "project" (${PROJECT_LANGUAGES.join(", ")})
|
|
95
|
+
Build a REAL app in the chosen stack. The folder structure is entirely up to you
|
|
96
|
+
— it is fluid, there is no template. The learner must implement ONE marked TODO
|
|
97
|
+
that contains the algorithm; the rest must build and run.
|
|
98
|
+
|
|
99
|
+
Design:
|
|
100
|
+
- Create a genuine feature the algorithm powers (an endpoint, command, job, or
|
|
101
|
+
module), embedded in a believable app with a few real layers (entry, routing or
|
|
102
|
+
CLI, domain/service, models/types, small helpers). Respect the size: mini ~3-6
|
|
103
|
+
files, fuller ~6-15 files.
|
|
104
|
+
- Leave exactly one \`TODO\` where the algorithm goes. It must be the ONLY thing
|
|
105
|
+
missing. Everything else compiles/runs.
|
|
106
|
+
- Choose the interface based on the stack:
|
|
107
|
+
|
|
108
|
+
* Web/API stacks (Express, Next.js, FastAPI, any HTTP server): provide
|
|
109
|
+
\`startCommand\`, \`port\`, \`healthPath\` (a route that returns 200), and use
|
|
110
|
+
**HTTP test cases**. Every case is: { name, method, path, input (JSON body),
|
|
111
|
+
expected (JSON response body), status? }. Add install/build commands as needed
|
|
112
|
+
(\`installCommand\`, e.g. "npm install" / "python -m pip install -r requirements.txt";
|
|
113
|
+
\`buildCommand\` only if a build is required). The runner starts the server,
|
|
114
|
+
polls the health path, then sends the requests.
|
|
115
|
+
* Non-web stacks (CLI/library): provide \`runCommand\` (reads ONE JSON value on
|
|
116
|
+
stdin, writes ONE JSON value on stdout) and use stdio cases
|
|
117
|
+
{ name, input, expected }.
|
|
118
|
+
|
|
119
|
+
- Include everything needed to build/run you write as \`files\` (manifests, config,
|
|
120
|
+
source). No hidden dependencies beyond \`installCommand\`.
|
|
121
|
+
- Import/runtime correctness:
|
|
122
|
+
- TypeScript under Node ESM type-stripping: relative imports MUST include the
|
|
123
|
+
\`.ts\` extension (\`import { x } from "./service.ts"\`). If you use a bundler/dev
|
|
124
|
+
server (Next.js), follow that stack's conventions instead.
|
|
125
|
+
- Python: make imports work when the entry runs as given by \`startCommand\`/\`runCommand\`.
|
|
126
|
+
- Rust: internal modules via \`mod ...;\` under \`src/\`.
|
|
127
|
+
- Provide \`task\`: a short markdown brief naming the file(s) and function/route to implement.
|
|
128
|
+
- Do NOT implement the TODO and do not reveal the algorithm in comments or commit messages.
|
|
117
129
|
|
|
118
130
|
Both shapes:
|
|
119
|
-
- requirements: concrete
|
|
120
|
-
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
- sourceSlug / sourceTitle
|
|
131
|
+
- requirements / edgeCases: concrete and testable
|
|
132
|
+
- publicTests (5-8) and hiddenTests (6-12) as JSON-serialisable objects. Every
|
|
133
|
+
\`expected\` must be correct; public and hidden must not overlap. Include an
|
|
134
|
+
empty/degenerate case and a larger case.
|
|
135
|
+
- \`sourceSlug\` / \`sourceTitle\`: provenance ONLY, never surfaced.
|
|
124
136
|
|
|
125
137
|
## 6. Rules (non-negotiable)
|
|
126
138
|
- Titles, scenarios, requirements, comments, file contents and test names MUST NOT
|
|
127
139
|
mention LeetCode, its title, its slug, or any coding-practice site. The tool
|
|
128
|
-
rejects leaks
|
|
129
|
-
- NEVER implement the algorithm (single
|
|
130
|
-
|
|
140
|
+
rejects leaks, including inside \`files\`.
|
|
141
|
+
- NEVER implement the algorithm (single: leave the entry unimplemented; project:
|
|
142
|
+
leave the TODO unimplemented).
|
|
131
143
|
- Do not reveal the fetched problem or the original examples.
|
|
132
144
|
|
|
133
145
|
## 7. After scaffolding
|
|
134
146
|
Report back with, in order:
|
|
135
|
-
1. One short paragraph describing the assignment as a real task (no
|
|
136
|
-
2. The project path, mode, and language.
|
|
147
|
+
1. One short paragraph describing the assignment as a real task (no spoilers).
|
|
148
|
+
2. The project path, mode, stack, and language.
|
|
137
149
|
3. The exact command to run the tests (\`node tests/runner.mjs\`).
|
|
138
|
-
4. For project mode:
|
|
150
|
+
4. For project mode: the file(s) and function/route holding the TODO.
|
|
139
151
|
5. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
|
|
140
152
|
Then stop. If the learner asks for help, give guiding hints and ask questions
|
|
141
153
|
rather than writing the algorithm for them.
|
package/src/scaffold.ts
CHANGED
|
@@ -37,7 +37,7 @@ function json(value: unknown, indent = 2): string {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const RUNNER = String.raw`#!/usr/bin/env node
|
|
40
|
-
import { spawnSync } from "node:child_process";
|
|
40
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
41
41
|
import { readFileSync } from "node:fs";
|
|
42
42
|
import path from "node:path";
|
|
43
43
|
import { fileURLToPath } from "node:url";
|
|
@@ -45,6 +45,8 @@ import { fileURLToPath } from "node:url";
|
|
|
45
45
|
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
46
46
|
const root = path.resolve(testsDir, "..");
|
|
47
47
|
const harness = JSON.parse(readFileSync(path.join(root, "harness.json"), "utf8"));
|
|
48
|
+
const kind = harness.kind === "http" ? "http" : "stdio";
|
|
49
|
+
const baseUrl = () => "http://127.0.0.1:" + (harness.port || 3000);
|
|
48
50
|
|
|
49
51
|
function canonical(value) {
|
|
50
52
|
if (Array.isArray(value)) return value.map(canonical);
|
|
@@ -60,12 +62,20 @@ function same(a, b) {
|
|
|
60
62
|
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function
|
|
65
|
+
function runOnce(command) {
|
|
66
|
+
const result = spawnSync(command, { cwd: root, shell: true, stdio: "inherit" });
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
console.error("\nCommand failed: " + command);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runStdio(input) {
|
|
64
74
|
const result = spawnSync(harness.run, {
|
|
65
75
|
cwd: root,
|
|
66
76
|
shell: true,
|
|
67
77
|
encoding: "utf8",
|
|
68
|
-
input: JSON.stringify(input),
|
|
78
|
+
input: JSON.stringify(input === undefined ? null : input),
|
|
69
79
|
timeout: 30000,
|
|
70
80
|
});
|
|
71
81
|
if (result.error) throw result.error;
|
|
@@ -77,46 +87,108 @@ function runCase(input) {
|
|
|
77
87
|
return text === "" ? null : JSON.parse(text);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
async function waitForServer() {
|
|
91
|
+
const health = harness.healthPath || "/";
|
|
92
|
+
const deadline = Date.now() + 40000;
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(baseUrl() + health);
|
|
96
|
+
if (response.status < 500) return;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
/* server not up yet */
|
|
99
|
+
}
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
85
101
|
}
|
|
102
|
+
throw new Error("server did not become ready at " + baseUrl() + health);
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
async function runHttp(testCase) {
|
|
106
|
+
const method = (testCase.method || "GET").toUpperCase();
|
|
107
|
+
const init = {
|
|
108
|
+
method: method,
|
|
109
|
+
headers: Object.assign({ "content-type": "application/json" }, testCase.headers || {}),
|
|
110
|
+
};
|
|
111
|
+
if (testCase.input !== undefined && method !== "GET" && method !== "HEAD") {
|
|
112
|
+
init.body = JSON.stringify(testCase.input);
|
|
113
|
+
}
|
|
114
|
+
const response = await fetch(baseUrl() + (testCase.path || "/"), init);
|
|
115
|
+
const expectedStatus = testCase.status || 200;
|
|
116
|
+
if (response.status !== expectedStatus) {
|
|
117
|
+
throw new Error("expected HTTP " + expectedStatus + " but got " + response.status);
|
|
118
|
+
}
|
|
119
|
+
const text = (await response.text()).trim();
|
|
120
|
+
return text === "" ? null : JSON.parse(text);
|
|
121
|
+
}
|
|
92
122
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
console.log("\n" + suite + " (" + cases.length + " cases)");
|
|
96
|
-
for (const testCase of cases) {
|
|
123
|
+
function stopServer(server) {
|
|
124
|
+
if (server && server.pid) {
|
|
97
125
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
console.log(" FAIL " + testCase.name);
|
|
105
|
-
console.log(" expected: " + JSON.stringify(testCase.expected));
|
|
106
|
-
console.log(" received: " + JSON.stringify(received));
|
|
126
|
+
process.kill(-server.pid, "SIGTERM");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
try {
|
|
129
|
+
server.kill();
|
|
130
|
+
} catch (inner) {
|
|
131
|
+
/* ignore */
|
|
107
132
|
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function main() {
|
|
138
|
+
if (harness.install) runOnce(harness.install);
|
|
139
|
+
if (harness.build) runOnce(harness.build);
|
|
140
|
+
|
|
141
|
+
let server;
|
|
142
|
+
if (kind === "http") {
|
|
143
|
+
server = spawn(harness.start, { cwd: root, shell: true, stdio: "inherit", detached: true });
|
|
144
|
+
try {
|
|
145
|
+
await waitForServer();
|
|
108
146
|
} catch (error) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
console.error(String(error.message));
|
|
148
|
+
stopServer(server);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const onlyPublic = process.argv.includes("--public");
|
|
154
|
+
const suites = onlyPublic ? ["public"] : ["public", "hidden"];
|
|
155
|
+
let passed = 0;
|
|
156
|
+
let failed = 0;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
for (const suite of suites) {
|
|
160
|
+
const cases = JSON.parse(readFileSync(path.join(root, "tests", suite, "cases.json"), "utf8"));
|
|
161
|
+
console.log("\n" + suite + " (" + cases.length + " cases)");
|
|
162
|
+
for (const testCase of cases) {
|
|
163
|
+
try {
|
|
164
|
+
const received = kind === "http" ? await runHttp(testCase) : runStdio(testCase.input);
|
|
165
|
+
if (same(received, testCase.expected)) {
|
|
166
|
+
passed += 1;
|
|
167
|
+
console.log(" PASS " + testCase.name);
|
|
168
|
+
} else {
|
|
169
|
+
failed += 1;
|
|
170
|
+
console.log(" FAIL " + testCase.name);
|
|
171
|
+
console.log(" expected: " + JSON.stringify(testCase.expected));
|
|
172
|
+
console.log(" received: " + JSON.stringify(received));
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
failed += 1;
|
|
176
|
+
console.log(" ERROR " + testCase.name);
|
|
177
|
+
for (const line of String(error.message).split("\n")) {
|
|
178
|
+
console.log(" " + line);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
113
181
|
}
|
|
114
182
|
}
|
|
183
|
+
} finally {
|
|
184
|
+
stopServer(server);
|
|
115
185
|
}
|
|
186
|
+
|
|
187
|
+
console.log("\n" + passed + " passed, " + failed + " failed");
|
|
188
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
116
189
|
}
|
|
117
190
|
|
|
118
|
-
|
|
119
|
-
process.exit(failed === 0 ? 0 : 1);
|
|
191
|
+
main();
|
|
120
192
|
`;
|
|
121
193
|
|
|
122
194
|
function requirementsSection(spec: ScaffoldSpec): string {
|
|
@@ -329,8 +401,7 @@ node tests/runner.mjs --public # visible cases only
|
|
|
329
401
|
|
|
330
402
|
Requires ${adapter.runtime}.
|
|
331
403
|
|
|
332
|
-
The
|
|
333
|
-
on stdin** and must write **one JSON value on stdout**. Debug output goes to stderr.
|
|
404
|
+
The tests ${spec.startCommand ? `start the server (\`${spec.startCommand}\`) and send HTTP requests to port ${spec.port ?? 3000}` : `invoke the app once per case as \`${spec.runCommand}\``}. ${spec.startCommand ? "Each case is an HTTP request; the JSON response body is compared." : "It reads **one JSON value on stdin** and must write **one JSON value on stdout**."} Debug output goes to stderr.
|
|
334
405
|
|
|
335
406
|
## Files
|
|
336
407
|
|
|
@@ -357,9 +428,7 @@ ${task}
|
|
|
357
428
|
|
|
358
429
|
## Interface
|
|
359
430
|
|
|
360
|
-
The application is invoked as \`${spec.runCommand}\`. It reads **one JSON value from
|
|
361
|
-
stdin** and must write **one JSON value to stdout**. Only stdout is compared; use
|
|
362
|
-
stderr for logs.
|
|
431
|
+
${spec.startCommand ? `The application is a server. It is started with \`${spec.startCommand}\` on port ${spec.port ?? 3000}. The tests send HTTP requests (method + path + JSON body) and compare the JSON response body.` : `The application is invoked as \`${spec.runCommand}\`. It reads **one JSON value from stdin** and must write **one JSON value to stdout**.`} Only the JSON is compared; use stderr for logs.
|
|
363
432
|
|
|
364
433
|
## Definition of done
|
|
365
434
|
|
|
@@ -395,7 +464,16 @@ function buildProjectFiles(
|
|
|
395
464
|
{
|
|
396
465
|
relative: "harness.json",
|
|
397
466
|
contents:
|
|
398
|
-
json({
|
|
467
|
+
json({
|
|
468
|
+
kind: spec.startCommand ? "http" : "stdio",
|
|
469
|
+
stack: spec.stack ?? null,
|
|
470
|
+
install: spec.installCommand ?? null,
|
|
471
|
+
build: spec.buildCommand ?? (spec.startCommand ? null : adapter.build(ctx)),
|
|
472
|
+
run: spec.startCommand ? null : spec.runCommand,
|
|
473
|
+
start: spec.startCommand ?? null,
|
|
474
|
+
port: spec.startCommand ? spec.port ?? 3000 : null,
|
|
475
|
+
healthPath: spec.startCommand ? spec.healthPath ?? "/" : null,
|
|
476
|
+
}) + "\n",
|
|
399
477
|
},
|
|
400
478
|
{ relative: "tests/runner.mjs", contents: RUNNER },
|
|
401
479
|
{ relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
|
|
@@ -450,8 +528,10 @@ export async function scaffold(
|
|
|
450
528
|
if (!normalized.files || normalized.files.length === 0) {
|
|
451
529
|
throw new Error("Project mode requires a `files` list (the full file tree).");
|
|
452
530
|
}
|
|
453
|
-
if (!normalized.runCommand) {
|
|
454
|
-
throw new Error(
|
|
531
|
+
if (!normalized.startCommand && !normalized.runCommand) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
"Project mode requires either `runCommand` (stdio) or `startCommand` (http server).",
|
|
534
|
+
);
|
|
455
535
|
}
|
|
456
536
|
}
|
|
457
537
|
|
|
@@ -478,6 +558,8 @@ export async function scaffold(
|
|
|
478
558
|
languageName: adapter.name,
|
|
479
559
|
runtime: adapter.runtime,
|
|
480
560
|
mode,
|
|
561
|
+
kind: normalized.startCommand ? "http" : "stdio",
|
|
562
|
+
stack: normalized.stack,
|
|
481
563
|
files: files.map((file) => file.relative).sort(),
|
|
482
564
|
runCommand: check,
|
|
483
565
|
checkCommand: check,
|
package/src/types.ts
CHANGED
|
@@ -40,10 +40,22 @@ export interface NormalizedProblem {
|
|
|
40
40
|
|
|
41
41
|
export interface TestCase {
|
|
42
42
|
name: string;
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/** stdio: the JSON payload. http: the request body (ignored for GET/HEAD). */
|
|
44
|
+
input?: unknown;
|
|
45
|
+
/** stdio: expected result. http: expected JSON response body. */
|
|
46
|
+
expected?: unknown;
|
|
47
|
+
/** http only: HTTP method (default GET). */
|
|
48
|
+
method?: string;
|
|
49
|
+
/** http only: request path, e.g. "/api/events" (default "/"). */
|
|
50
|
+
path?: string;
|
|
51
|
+
/** http only: expected status code (default 200). */
|
|
52
|
+
status?: number;
|
|
53
|
+
/** http only: extra request headers. */
|
|
54
|
+
headers?: Record<string, string>;
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
export type HarnessKind = "stdio" | "http";
|
|
58
|
+
|
|
47
59
|
export interface FileEntry {
|
|
48
60
|
relative: string;
|
|
49
61
|
contents: string;
|
|
@@ -65,6 +77,8 @@ export type PracticeMode = "single" | "project";
|
|
|
65
77
|
export interface ScaffoldSpec {
|
|
66
78
|
/** `single` = one JSON-in/out function. `project` = a multi-file app. */
|
|
67
79
|
mode?: PracticeMode;
|
|
80
|
+
/** Free-form stack label, e.g. "express", "nextjs", "fastapi", "vanilla". */
|
|
81
|
+
stack?: string;
|
|
68
82
|
/** kebab-case directory name. Derived from `title` when omitted. */
|
|
69
83
|
projectName?: string;
|
|
70
84
|
/** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
|
|
@@ -94,10 +108,18 @@ export interface ScaffoldSpec {
|
|
|
94
108
|
files?: ProjectFile[];
|
|
95
109
|
/** Markdown brief for the learner: what to implement and where. */
|
|
96
110
|
task?: string;
|
|
97
|
-
/**
|
|
111
|
+
/** stdio project: command that runs one case (JSON stdin -> JSON stdout). */
|
|
98
112
|
runCommand?: string;
|
|
99
|
-
/** Optional one-time
|
|
113
|
+
/** Optional one-time install command (e.g. `npm install`, `pip install -r req.txt`). */
|
|
114
|
+
installCommand?: string;
|
|
115
|
+
/** Optional one-time build command (e.g. `cargo build`, `npm run build`). */
|
|
100
116
|
buildCommand?: string;
|
|
117
|
+
/** http project: command that starts the server. */
|
|
118
|
+
startCommand?: string;
|
|
119
|
+
/** http project: port the server listens on (default 3000). */
|
|
120
|
+
port?: number;
|
|
121
|
+
/** http project: path used to poll readiness (default "/"). */
|
|
122
|
+
healthPath?: string;
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
export interface ScaffoldResult {
|
|
@@ -107,6 +129,8 @@ export interface ScaffoldResult {
|
|
|
107
129
|
languageName: string;
|
|
108
130
|
runtime: string;
|
|
109
131
|
mode: PracticeMode;
|
|
132
|
+
kind: HarnessKind;
|
|
133
|
+
stack?: string;
|
|
110
134
|
files: string[];
|
|
111
135
|
runCommand: string;
|
|
112
136
|
checkCommand: string;
|