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 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, domain types, service
176
- layer, wiring — where **everything works except one `TODO`**. You implement that
177
- piece (the algorithm) and `node tests/runner.mjs` verifies the app end-to-end.
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.4.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("The single JSON-shaped payload passed to the entry function"),
12
- expected: tool.schema.any().describe("The JSON-shaped value the entry function must return"),
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("Project mode: command that runs one case (JSON on stdin, JSON on stdout)"),
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 (compiled languages)"),
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" ? " (implement the marked TODO)" : ""}`,
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" (how they want to start)
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\` (an array of slugs, e.g. \`["array"]\`).
44
- - Only pass \`idOrSlug\` when the argument is a number or a known problem slug
45
- (e.g. "1", "two-sum"). Never pass a topic name as \`idOrSlug\`.
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 the language and/or difficulty are missing, ask for them in one
52
- \`question\` call. Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
53
- Offer difficulty options "Easy", "Medium" (recommended), "Hard".
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 B — decide the *scope*. Full multi-file projects are only available for
56
- ${PROJECT_LANGUAGES.join(", ")}.
57
- - If the chosen language is one of those AND scope was not given, make a SECOND
58
- \`question\` call titled "How do you want to start?" with these options:
59
- - "Quick exercise" — one focused function with a JSON-in/JSON-out contract.
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 the answers. Do not guess.
75
+ Wait for answers. Do not guess.
65
76
 
66
77
  ## 3. Fetch the source problem (private)
67
- Call the \`leetcode_fetch\` tool with the difficulty, and pass any requested topic
68
- as \`tags\` (array of slugs). Verify the returned topics include every requested
69
- tag; if not, retry. The fetched statement is for YOUR reasoning only. Never paste
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 (e.g. sliding window, heap-based
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
- Design a plausible product/business task that genuinely needs that principle. Good
79
- domains: observability, payments/ledgering, logistics, rate limiting, access
80
- control, search, feature flags, data reconciliation, scheduling.
81
-
82
- Then call \`leetcode_scaffold\`. There are two shapes:
83
-
84
- ### A) mode = "single" (Quick exercise)
85
- - One entry point with a JSON-in / JSON-out contract.
86
- - Do NOT provide \`starterCode\`: the plugin generates a typed starter (type-safe
87
- languages) or a documented one (dynamic languages) inferred from your test cases,
88
- so make the first few cases representative (vary fields to expose optionals).
89
- - Spec: mode, title, scenario, pattern, difficulty, language, requirements,
90
- edgeCases, publicTests (5-8), hiddenTests (6-12 incl. empty + large), task optional,
91
- sourceSlug, sourceTitle.
92
-
93
- ### B) mode = "project" (only ${PROJECT_LANGUAGES.join(", ")})
94
- Build a small but real application where the algorithm is the piece the learner
95
- implements. You author the whole tree and pass it as \`files\`.
96
- - Provide a working CLI entry (e.g. \`src/cli.ts\`, \`src/cli.py\`, or \`src/main.rs\`)
97
- that reads ONE JSON value on stdin and writes ONE JSON value on stdout. Tests
98
- drive this entry end-to-end.
99
- - Put the algorithm behind a single, clearly marked \`TODO\` in a domain/service file
100
- (e.g. \`// TODO: implement ...\`). Everything else — argument/stdin parsing, domain
101
- models/types, wiring, config, a repository or helper — must be complete and runnable
102
- so that the ONLY thing missing is that TODO.
103
- - Keep the surrounding code real but not algorithm-heavy: parsing, types, plumbing.
104
- - Respect the requested size: mini ~3-6 files, fuller ~6-15 files.
105
- - Provide \`runCommand\` (runs one case) and, for compiled languages, \`buildCommand\`
106
- (e.g. Rust: \`cargo build\`; then \`runCommand: ./target/debug/<bin>\`).
107
- - Provide \`task\`: a short markdown brief naming the file and function to implement.
108
- - Include everything needed to build/run (package.json/tsconfig, Cargo.toml, etc.).
109
- - Import/runtime correctness (the tests run your \`runCommand\`):
110
- - TypeScript: relative imports MUST include the \`.ts\` extension
111
- (\`import { x } from "./service.ts"\`) because the app runs under Node ESM type
112
- stripping, which does not resolve extensionless specifiers.
113
- - Python: make imports work when the entry runs directly (\`python3 src/cli.py\`);
114
- same-directory modules or a small \`sys.path\` adjustment are both fine.
115
- - Rust: declare internal modules with \`mod ...;\` and keep them under \`src/\`.
116
- - Do NOT implement the TODO and do not reveal the algorithm in comments.
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, testable bullet points
120
- - edgeCases: tricky situations the solution must handle
121
- - publicTests / hiddenTests: JSON-serialisable \`{ "name": string, "input": any, "expected": any }\`.
122
- Every \`expected\` must be correct for the described rules; public and hidden must not overlap.
123
- - sourceSlug / sourceTitle: provenance ONLY, never surfaced.
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 (including inside \`files\`).
129
- - NEVER implement the algorithm (single mode: leave the entry unimplemented;
130
- project mode: leave the TODO unimplemented). The learner does that.
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 source spoilers).
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: which file/function holds the TODO.
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 runCase(input) {
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
- if (harness.build) {
81
- const build = spawnSync(harness.build, { cwd: root, shell: true, stdio: "inherit" });
82
- if (build.status !== 0) {
83
- console.error("\nBuild failed.");
84
- process.exit(1);
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
- const onlyPublic = process.argv.includes("--public");
89
- const suites = onlyPublic ? ["public"] : ["public", "hidden"];
90
- let passed = 0;
91
- let failed = 0;
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
- for (const suite of suites) {
94
- const cases = JSON.parse(readFileSync(path.join(root, "tests", suite, "cases.json"), "utf8"));
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
- const received = runCase(testCase.input);
99
- if (same(received, testCase.expected)) {
100
- passed += 1;
101
- console.log(" PASS " + testCase.name);
102
- } else {
103
- failed += 1;
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
- failed += 1;
110
- console.log(" ERROR " + testCase.name);
111
- for (const line of String(error.message).split("\n")) {
112
- console.log(" " + line);
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
- console.log("\n" + passed + " passed, " + failed + " failed");
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 app is invoked once per case as \`${spec.runCommand}\`: it reads **one JSON value
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({ build: spec.buildCommand ?? adapter.build(ctx), run: spec.runCommand }) + "\n",
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("Project mode requires a `runCommand` (run one case, JSON on stdin/stdout).");
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
- input: unknown;
44
- expected: unknown;
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
- /** Command that runs one test case (JSON on stdin, JSON on stdout). */
111
+ /** stdio project: command that runs one case (JSON stdin -> JSON stdout). */
98
112
  runCommand?: string;
99
- /** Optional one-time build command (compiled languages). */
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;