opencode-leetcode-realworld 0.3.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 +40 -2
- package/package.json +1 -1
- package/src/index.ts +50 -3
- package/src/patterns.ts +2 -1
- package/src/prompt.ts +99 -68
- package/src/scaffold.ts +257 -33
- package/src/types.ts +48 -3
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Options can be given in any order; the command figures out which is which.
|
|
|
76
76
|
| `tags` | One or more topic tags, comma-separated (e.g. `graph` or `dynamic-programming,sliding-window`) | any | Nudges which underlying algorithm and real-world domain you get. |
|
|
77
77
|
| `<id-or-slug>` | A problem number or slug (e.g. `1`, `two-sum`) | random | Derive the assignment from one specific source problem. |
|
|
78
78
|
| `language` | A supported language or alias (see below) | **asks you** | Language of the generated project and tests. |
|
|
79
|
+
| `scope` | `single` \| `mini` \| `full` | **asks you** | How big the assignment is (see Two modes). |
|
|
79
80
|
|
|
80
81
|
If you leave the language (or difficulty) out, opencode **asks you** with a
|
|
81
82
|
multiple-choice question before generating anything.
|
|
@@ -150,17 +151,54 @@ they tend to produce:
|
|
|
150
151
|
```
|
|
151
152
|
/practice
|
|
152
153
|
│
|
|
153
|
-
├─ (agent)
|
|
154
|
+
├─ (agent) ask you for language/difficulty, then how you want to start
|
|
154
155
|
│
|
|
155
156
|
├─ leetcode_fetch fetch a problem from the LeetCode API (private reasoning input)
|
|
156
157
|
│
|
|
157
158
|
├─ (agent) derive the principle -> invent a real-world scenario
|
|
158
159
|
│
|
|
159
|
-
├─ leetcode_scaffold write
|
|
160
|
+
├─ leetcode_scaffold write the exercises(s): one function, or a multi-file app
|
|
160
161
|
│
|
|
161
162
|
└─ guard hooks block reading hidden tests + provenance, keep the exercise honest
|
|
162
163
|
```
|
|
163
164
|
|
|
165
|
+
## Two modes
|
|
166
|
+
|
|
167
|
+
`/practice` asks **how you want to start**, and the answer changes what gets built:
|
|
168
|
+
|
|
169
|
+
| Scope | What you get |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| **Quick exercise** | One entry point with a JSON-in / JSON-out contract (any language). |
|
|
172
|
+
| **Mini project** | A small multi-file app (~3-6 files) with the algorithm behind a single `TODO` (TypeScript, Python, Rust). |
|
|
173
|
+
| **Fuller project** | A multi-file app with more realistic layers (~6-15 files) and the same single `TODO`. |
|
|
174
|
+
|
|
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.
|
|
179
|
+
|
|
180
|
+
Project mode is available for **TypeScript, Python, Rust**; the other languages use
|
|
181
|
+
the quick exercise.
|
|
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
|
+
|
|
164
202
|
The generated project looks like this:
|
|
165
203
|
|
|
166
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 }) => {
|
|
@@ -71,9 +78,15 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
|
71
78
|
|
|
72
79
|
const scaffoldTool = tool({
|
|
73
80
|
description:
|
|
74
|
-
"Create a real-world practice project from a spec
|
|
81
|
+
"Create a real-world practice project from a spec. " +
|
|
82
|
+
"Use mode \"single\" for one JSON-in/out function (any language) or mode \"project\" " +
|
|
83
|
+
"(typescript, python, rust) for a multi-file app where one TODO must be implemented. " +
|
|
75
84
|
"Rejects any spec that leaks the original problem, its title, slug, or a coding-practice site name.",
|
|
76
85
|
args: {
|
|
86
|
+
mode: tool.schema
|
|
87
|
+
.enum(["single", "project"])
|
|
88
|
+
.default("single")
|
|
89
|
+
.describe("single = one function; project = multi-file app (ts/python/rust)"),
|
|
77
90
|
title: tool.schema.string().describe("Real-world project title (no coding-practice words)"),
|
|
78
91
|
scenario: tool.schema.string().describe("Business context, written like a real ticket"),
|
|
79
92
|
pattern: tool.schema.string().describe("Underlying algorithmic principle (internal only)"),
|
|
@@ -102,6 +115,39 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
|
102
115
|
outDir: tool.schema.string().optional().describe("Target directory, relative to the session directory"),
|
|
103
116
|
sourceSlug: tool.schema.string().optional().describe("Provenance only; never surfaced"),
|
|
104
117
|
sourceTitle: tool.schema.string().optional().describe("Provenance only; never surfaced"),
|
|
118
|
+
task: tool.schema
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Project mode: markdown brief telling the learner what to implement and where"),
|
|
122
|
+
files: tool.schema
|
|
123
|
+
.array(tool.schema.object({ path: tool.schema.string(), content: tool.schema.string() }))
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Project mode: the full file tree, leaving one TODO to implement"),
|
|
126
|
+
runCommand: tool.schema
|
|
127
|
+
.string()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("stdio project: command that runs one case (JSON on stdin, JSON on stdout)"),
|
|
130
|
+
buildCommand: tool.schema
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
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"),
|
|
105
151
|
},
|
|
106
152
|
async execute(rawArgs, context) {
|
|
107
153
|
const spec = rawArgs as ScaffoldSpec;
|
|
@@ -131,6 +177,7 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
|
|
|
131
177
|
`Created a real-world practice project at ${relative}`,
|
|
132
178
|
"",
|
|
133
179
|
`Title: ${result.title}`,
|
|
180
|
+
`Mode: ${result.mode}${result.mode === "project" ? ` (${result.kind}${result.stack ? `, ${result.stack}` : ""})` : ""}`,
|
|
134
181
|
`Language: ${result.languageName} (${result.runtime})`,
|
|
135
182
|
`Files (${result.files.length}):`,
|
|
136
183
|
...result.files.map((file) => ` - ${file}`),
|
package/src/patterns.ts
CHANGED
|
@@ -92,11 +92,12 @@ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
|
|
|
92
92
|
push("scenario", spec.scenario);
|
|
93
93
|
push("pattern", spec.pattern);
|
|
94
94
|
push("functionName", spec.functionName);
|
|
95
|
-
push("
|
|
95
|
+
push("task", spec.task);
|
|
96
96
|
(spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
|
|
97
97
|
(spec.edgeCases ?? []).forEach((edge, i) => push(`edgeCases[${i}]`, edge));
|
|
98
98
|
(spec.publicTests ?? []).forEach((test, i) => push(`publicTests[${i}].name`, test.name));
|
|
99
99
|
(spec.hiddenTests ?? []).forEach((test, i) => push(`hiddenTests[${i}].name`, test.name));
|
|
100
|
+
(spec.files ?? []).forEach((file, i) => push(`files[${i}] (${file.path})`, file.content));
|
|
100
101
|
return entries;
|
|
101
102
|
}
|
|
102
103
|
|
package/src/prompt.ts
CHANGED
|
@@ -13,6 +13,18 @@ export const SUPPORTED_LANGUAGES = [
|
|
|
13
13
|
"Swift",
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
+
/** Languages that can receive a full multi-file project. */
|
|
17
|
+
export const PROJECT_LANGUAGES = ["TypeScript", "Python", "Rust"];
|
|
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
|
+
|
|
16
28
|
/**
|
|
17
29
|
* The `/practice` command prompt. It orchestrates the two plugin tools and is
|
|
18
30
|
* deliberately opinionated: the whole value of this plugin is that the learner
|
|
@@ -32,92 +44,111 @@ Recognise any of these (all optional):
|
|
|
32
44
|
- tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
|
|
33
45
|
- a specific problem id or slug, e.g. "two-sum" or "1"
|
|
34
46
|
- language: a supported language name or alias (below)
|
|
47
|
+
- scope: "single" | "mini" | "full"
|
|
48
|
+
- stack: a stack name, e.g. "express", "nextjs", "fastapi", "vanilla"
|
|
35
49
|
|
|
36
50
|
Mapping rules:
|
|
37
51
|
- A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
|
|
38
52
|
sliding-window, two-pointers, greedy, ...) is a TAG. Pass it to \`leetcode_fetch\`
|
|
39
|
-
as \`tags\` (
|
|
40
|
-
- Only pass \`idOrSlug\`
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
56
|
+
|
|
57
|
+
## 2. Ask how they want to start (use the \`question\` tool)
|
|
58
|
+
|
|
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".
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
${
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
- Wait for the answers. Do not guess and do not default silently.
|
|
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.
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
when something is missing.
|
|
75
|
+
Wait for answers. Do not guess.
|
|
57
76
|
|
|
58
77
|
## 3. Fetch the source problem (private)
|
|
59
|
-
Call
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
reasoning only. Never paste it, paraphrase it closely, or name its source anywhere
|
|
63
|
-
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.
|
|
64
81
|
|
|
65
82
|
## 4. Derive the principle
|
|
66
|
-
Identify the underlying algorithmic idea
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
+
Identify the underlying algorithmic idea. Do not carry over the problem's fiction.
|
|
84
|
+
|
|
85
|
+
## 5. Build the assignment
|
|
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.
|
|
129
|
+
|
|
130
|
+
Both shapes:
|
|
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.
|
|
105
136
|
|
|
106
137
|
## 6. Rules (non-negotiable)
|
|
107
|
-
-
|
|
108
|
-
mention LeetCode, its title, its slug, or any coding-practice site. The
|
|
109
|
-
|
|
110
|
-
-
|
|
111
|
-
|
|
138
|
+
- Titles, scenarios, requirements, comments, file contents and test names MUST NOT
|
|
139
|
+
mention LeetCode, its title, its slug, or any coding-practice site. The tool
|
|
140
|
+
rejects leaks, including inside \`files\`.
|
|
141
|
+
- NEVER implement the algorithm (single: leave the entry unimplemented; project:
|
|
142
|
+
leave the TODO unimplemented).
|
|
112
143
|
- Do not reveal the fetched problem or the original examples.
|
|
113
|
-
- If the tool reports a leak, rewrite the offending fields and retry.
|
|
114
144
|
|
|
115
145
|
## 7. After scaffolding
|
|
116
146
|
Report back with, in order:
|
|
117
|
-
1. One short paragraph describing the assignment as a real task (no
|
|
118
|
-
2. The project path and
|
|
147
|
+
1. One short paragraph describing the assignment as a real task (no spoilers).
|
|
148
|
+
2. The project path, mode, stack, and language.
|
|
119
149
|
3. The exact command to run the tests (\`node tests/runner.mjs\`).
|
|
120
|
-
4.
|
|
150
|
+
4. For project mode: the file(s) and function/route holding the TODO.
|
|
151
|
+
5. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
|
|
121
152
|
Then stop. If the learner asks for help, give guiding hints and ask questions
|
|
122
153
|
rather than writing the algorithm for them.
|
|
123
154
|
`;
|
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 {
|
|
@@ -232,9 +304,15 @@ tests/hidden/
|
|
|
232
304
|
.practice-meta.json
|
|
233
305
|
`;
|
|
234
306
|
|
|
235
|
-
function metaFor(
|
|
307
|
+
function metaFor(
|
|
308
|
+
spec: ScaffoldSpec,
|
|
309
|
+
names: ResolvedNames,
|
|
310
|
+
language: LanguageId,
|
|
311
|
+
mode: "single" | "project" = "single",
|
|
312
|
+
): string {
|
|
236
313
|
return (
|
|
237
314
|
json({
|
|
315
|
+
mode,
|
|
238
316
|
title: names.title,
|
|
239
317
|
functionName: names.functionName,
|
|
240
318
|
language,
|
|
@@ -289,6 +367,125 @@ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: Language
|
|
|
289
367
|
return files;
|
|
290
368
|
}
|
|
291
369
|
|
|
370
|
+
/** Languages that support full multi-file project mode. */
|
|
371
|
+
export const PROJECT_LANGUAGES = new Set<LanguageId>(["typescript", "python", "rust"]);
|
|
372
|
+
|
|
373
|
+
function sanitizeRelative(value: string): string {
|
|
374
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
375
|
+
if (normalized.includes("..") || /^[a-zA-Z]:/.test(normalized)) {
|
|
376
|
+
throw new Error(`Refusing to write outside the project directory: "${value}"`);
|
|
377
|
+
}
|
|
378
|
+
return normalized;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function projectReadme(
|
|
382
|
+
names: ResolvedNames,
|
|
383
|
+
spec: ScaffoldSpec,
|
|
384
|
+
language: LanguageId,
|
|
385
|
+
): string {
|
|
386
|
+
const adapter = getLanguage(language);
|
|
387
|
+
const fileList = (spec.files ?? [])
|
|
388
|
+
.map((file) => ` ${sanitizeRelative(file.path)}`)
|
|
389
|
+
.join("\n");
|
|
390
|
+
return `# ${names.title}
|
|
391
|
+
|
|
392
|
+
This is a small application, not a single function. Read \`PROJECT.md\` for the
|
|
393
|
+
brief, find the marked \`TODO\`, and implement the behaviour it needs.
|
|
394
|
+
|
|
395
|
+
## Run the checks
|
|
396
|
+
|
|
397
|
+
\`\`\`bash
|
|
398
|
+
node tests/runner.mjs # all cases
|
|
399
|
+
node tests/runner.mjs --public # visible cases only
|
|
400
|
+
\`\`\`
|
|
401
|
+
|
|
402
|
+
Requires ${adapter.runtime}.
|
|
403
|
+
|
|
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.
|
|
405
|
+
|
|
406
|
+
## Files
|
|
407
|
+
|
|
408
|
+
\`\`\`
|
|
409
|
+
${fileList}
|
|
410
|
+
\`\`\`
|
|
411
|
+
`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function projectModeDoc(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
|
|
415
|
+
const task = spec.task?.trim() || "Find the `TODO` in the source and implement it.";
|
|
416
|
+
return `# ${names.title}
|
|
417
|
+
|
|
418
|
+
## Scenario
|
|
419
|
+
|
|
420
|
+
${spec.scenario.trim()}
|
|
421
|
+
|
|
422
|
+
${exampleSection(spec)}
|
|
423
|
+
${requirementsSection(spec)}
|
|
424
|
+
${edgeCaseSection(spec)}
|
|
425
|
+
## What to build
|
|
426
|
+
|
|
427
|
+
${task}
|
|
428
|
+
|
|
429
|
+
## Interface
|
|
430
|
+
|
|
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.
|
|
432
|
+
|
|
433
|
+
## Definition of done
|
|
434
|
+
|
|
435
|
+
- The full test suite passes: \`node tests/runner.mjs\`.
|
|
436
|
+
- The provided code is understood and reused, not bypassed.
|
|
437
|
+
- Edge cases above are handled, not just the happy path.
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function buildProjectFiles(
|
|
442
|
+
spec: ScaffoldSpec,
|
|
443
|
+
names: ResolvedNames,
|
|
444
|
+
language: LanguageId,
|
|
445
|
+
): FileEntry[] {
|
|
446
|
+
const adapter = getLanguage(language);
|
|
447
|
+
const cases = [...(spec.publicTests ?? []), ...(spec.hiddenTests ?? [])];
|
|
448
|
+
const first = cases[0];
|
|
449
|
+
const ctx: LanguageContext = {
|
|
450
|
+
projectName: names.projectName,
|
|
451
|
+
functionName: names.functionName,
|
|
452
|
+
title: names.title,
|
|
453
|
+
input: schemaFromSamples(cases.map((test) => test.input)),
|
|
454
|
+
output: schemaFromSamples(cases.map((test) => test.expected)),
|
|
455
|
+
inputExample: first?.input ?? null,
|
|
456
|
+
outputExample: first?.expected ?? null,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const files: FileEntry[] = [
|
|
460
|
+
{ relative: "README.md", contents: projectReadme(names, spec, language) },
|
|
461
|
+
{ relative: "PROJECT.md", contents: projectModeDoc(spec, names, language) },
|
|
462
|
+
{ relative: ".gitignore", contents: PROJECT_GITIGNORE },
|
|
463
|
+
{ relative: ".practice-meta.json", contents: metaFor(spec, names, language, "project") },
|
|
464
|
+
{
|
|
465
|
+
relative: "harness.json",
|
|
466
|
+
contents:
|
|
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",
|
|
477
|
+
},
|
|
478
|
+
{ relative: "tests/runner.mjs", contents: RUNNER },
|
|
479
|
+
{ relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
|
|
480
|
+
{ relative: "tests/hidden/cases.json", contents: casesFile(spec.hiddenTests ?? []) },
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
for (const file of spec.files ?? []) {
|
|
484
|
+
files.push({ relative: sanitizeRelative(file.path), contents: file.content });
|
|
485
|
+
}
|
|
486
|
+
return files;
|
|
487
|
+
}
|
|
488
|
+
|
|
292
489
|
export interface ScaffoldOptions {
|
|
293
490
|
/** Base directory to resolve relative paths against (the session directory). */
|
|
294
491
|
directory: string;
|
|
@@ -316,13 +513,37 @@ export async function scaffold(
|
|
|
316
513
|
|
|
317
514
|
assertNoLeak(normalized);
|
|
318
515
|
|
|
516
|
+
const mode = normalized.mode === "project" ? "project" : "single";
|
|
517
|
+
|
|
319
518
|
const adapter = getLanguage(language);
|
|
320
519
|
const names = resolveNames(normalized);
|
|
520
|
+
|
|
521
|
+
if (mode === "project") {
|
|
522
|
+
if (!PROJECT_LANGUAGES.has(language)) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`Project mode is only supported for: ${[...PROJECT_LANGUAGES].join(", ")}. ` +
|
|
525
|
+
`Use mode "single" for ${adapter.name}.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (!normalized.files || normalized.files.length === 0) {
|
|
529
|
+
throw new Error("Project mode requires a `files` list (the full file tree).");
|
|
530
|
+
}
|
|
531
|
+
if (!normalized.startCommand && !normalized.runCommand) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
"Project mode requires either `runCommand` (stdio) or `startCommand` (http server).",
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
321
538
|
const target = normalized.outDir
|
|
322
539
|
? path.resolve(options.directory, normalized.outDir)
|
|
323
540
|
: path.resolve(options.directory, ".practice", names.projectName);
|
|
324
541
|
|
|
325
|
-
const files =
|
|
542
|
+
const files =
|
|
543
|
+
mode === "project"
|
|
544
|
+
? buildProjectFiles(normalized, names, language)
|
|
545
|
+
: buildFiles(normalized, names, language);
|
|
546
|
+
|
|
326
547
|
for (const file of files) {
|
|
327
548
|
const destination = path.join(target, file.relative);
|
|
328
549
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
@@ -336,6 +557,9 @@ export async function scaffold(
|
|
|
336
557
|
language,
|
|
337
558
|
languageName: adapter.name,
|
|
338
559
|
runtime: adapter.runtime,
|
|
560
|
+
mode,
|
|
561
|
+
kind: normalized.startCommand ? "http" : "stdio",
|
|
562
|
+
stack: normalized.stack,
|
|
339
563
|
files: files.map((file) => file.relative).sort(),
|
|
340
564
|
runCommand: check,
|
|
341
565
|
checkCommand: check,
|
package/src/types.ts
CHANGED
|
@@ -40,21 +40,45 @@ 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;
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
/** A file the agent authored for a multi-file project. */
|
|
65
|
+
export interface ProjectFile {
|
|
66
|
+
path: string;
|
|
67
|
+
content: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PracticeMode = "single" | "project";
|
|
71
|
+
|
|
52
72
|
/**
|
|
53
73
|
* The contract the agent fills in after it has decided on a real-world scenario.
|
|
54
74
|
* None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
|
|
55
75
|
* before writing anything to disk.
|
|
56
76
|
*/
|
|
57
77
|
export interface ScaffoldSpec {
|
|
78
|
+
/** `single` = one JSON-in/out function. `project` = a multi-file app. */
|
|
79
|
+
mode?: PracticeMode;
|
|
80
|
+
/** Free-form stack label, e.g. "express", "nextjs", "fastapi", "vanilla". */
|
|
81
|
+
stack?: string;
|
|
58
82
|
/** kebab-case directory name. Derived from `title` when omitted. */
|
|
59
83
|
projectName?: string;
|
|
60
84
|
/** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
|
|
@@ -69,7 +93,7 @@ export interface ScaffoldSpec {
|
|
|
69
93
|
functionName?: string;
|
|
70
94
|
requirements?: string[];
|
|
71
95
|
edgeCases?: string[];
|
|
72
|
-
/** Optional starter implementation. A TODO stub is generated when omitted. */
|
|
96
|
+
/** Optional starter implementation (single mode). A TODO stub is generated when omitted. */
|
|
73
97
|
starterCode?: string;
|
|
74
98
|
publicTests?: TestCase[];
|
|
75
99
|
hiddenTests?: TestCase[];
|
|
@@ -78,6 +102,24 @@ export interface ScaffoldSpec {
|
|
|
78
102
|
/** Internal provenance, stored in `.practice-meta.json` only. Never leaked. */
|
|
79
103
|
sourceSlug?: string;
|
|
80
104
|
sourceTitle?: string;
|
|
105
|
+
|
|
106
|
+
// --- project mode -------------------------------------------------------
|
|
107
|
+
/** The full file tree for a project-mode assignment. */
|
|
108
|
+
files?: ProjectFile[];
|
|
109
|
+
/** Markdown brief for the learner: what to implement and where. */
|
|
110
|
+
task?: string;
|
|
111
|
+
/** stdio project: command that runs one case (JSON stdin -> JSON stdout). */
|
|
112
|
+
runCommand?: string;
|
|
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`). */
|
|
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;
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
export interface ScaffoldResult {
|
|
@@ -86,6 +128,9 @@ export interface ScaffoldResult {
|
|
|
86
128
|
language: LanguageId;
|
|
87
129
|
languageName: string;
|
|
88
130
|
runtime: string;
|
|
131
|
+
mode: PracticeMode;
|
|
132
|
+
kind: HarnessKind;
|
|
133
|
+
stack?: string;
|
|
89
134
|
files: string[];
|
|
90
135
|
runCommand: string;
|
|
91
136
|
checkCommand: string;
|