opencode-leetcode-realworld 0.1.0 → 0.2.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
@@ -15,6 +15,91 @@ and a test suite. The original problem is never shown to you.
15
15
  /practice medium dynamic-programming python
16
16
  ```
17
17
 
18
+ ## Usage
19
+
20
+ ```
21
+ /practice [options]
22
+ ```
23
+
24
+ Options can be given in any order; the command figures out which is which.
25
+
26
+ ### Options
27
+
28
+ | Option | Accepted values | Default | Description |
29
+ | --- | --- | --- | --- |
30
+ | `difficulty` | `easy` \| `medium` \| `hard` | **asks you** (Medium suggested) | Difficulty of the source problem. |
31
+ | `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. |
32
+ | `<id-or-slug>` | A problem number or slug (e.g. `1`, `two-sum`) | random | Derive the assignment from one specific source problem. |
33
+ | `language` | A supported language or alias (see below) | **asks you** | Language of the generated project and tests. |
34
+
35
+ If you leave the language (or difficulty) out, opencode **asks you** with a
36
+ multiple-choice question before generating anything.
37
+
38
+ ### Examples
39
+
40
+ | Command | What you get |
41
+ | --- | --- |
42
+ | `/practice` | Asks for language + difficulty, then builds a random Medium problem. |
43
+ | `/practice easy` | Asks for the language, then an Easy problem. |
44
+ | `/practice hard graph` | Hard problem tagged Graph. |
45
+ | `/practice two-sum` | Asks for the language, then builds from the Two Sum problem. |
46
+ | `/practice medium dynamic-programming python` | Medium DP problem, Python project. |
47
+ | `/practice sliding-window js` | Random sliding-window problem in JavaScript. |
48
+
49
+ ### Supported languages
50
+
51
+ | Language | Aliases | Runtime needed |
52
+ | --- | --- | --- |
53
+ | TypeScript | `ts` | Node.js 22.6+ |
54
+ | JavaScript | `js`, `node` | Node.js 18+ |
55
+ | Python | `py`, `python3` | Python 3.8+ |
56
+ | Ruby | `rb` | Ruby 2.6+ |
57
+ | PHP | — | PHP 7.4+ |
58
+ | Go | `golang` | Go 1.18+ |
59
+ | Rust | `rs`, `cargo` | Rust 1.70+ (fetches `serde_json`) |
60
+ | C# | `cs`, `dotnet` | .NET 8 SDK |
61
+ | Swift | — | Swift 5.5+ |
62
+
63
+ Every language uses the same contract: implement `src/solution.<ext>`, read one
64
+ JSON value from stdin, write one JSON value to stdout. The shared runner verifies
65
+ any language identically.
66
+
67
+ ### Topic tags you can practice
68
+
69
+ Any LeetCode topic tag works. Common ones and the kind of real-world scenario
70
+ they tend to produce:
71
+
72
+ | Tag | Typical generated domain |
73
+ | --- | --- |
74
+ | `array` | batch audit-log processor |
75
+ | `string` | log sanitizer |
76
+ | `hash-table` | event deduplication pipeline |
77
+ | `two-pointers` | order-book matching engine |
78
+ | `sliding-window` | API rate limiter |
79
+ | `prefix-sum` | rolling revenue aggregator |
80
+ | `binary-search` | price-threshold alert engine |
81
+ | `sorting` | leaderboard ranker |
82
+ | `heap-priority-queue` | background job scheduler |
83
+ | `monotonic-stack` | histogram-based capacity planner |
84
+ | `stack` | config-rule expression evaluator |
85
+ | `queue` | async task pipeline |
86
+ | `greedy` | resource bin-packing scheduler |
87
+ | `dynamic-programming` | delivery route cost optimizer |
88
+ | `graph` | microservice dependency resolver |
89
+ | `depth-first-search` | permission inheritance resolver |
90
+ | `breadth-first-search` | service-mesh shortest-path router |
91
+ | `union-find` | account clustering for fraud rings |
92
+ | `topological-sort` | build-order planner |
93
+ | `trie` | autocomplete search index |
94
+ | `tree` | filesystem index builder |
95
+ | `linked-list` | LRU eviction chain |
96
+ | `backtracking` | deployment permutation explorer |
97
+ | `bit-manipulation` | feature-flag bitmask service |
98
+ | `segment-tree` | range-quota enforcement service |
99
+ | `design` | in-memory key-value store |
100
+ | `database` | analytics reconciliation job |
101
+ | `simulation` | tick-based match engine |
102
+
18
103
  ## How it works
19
104
 
20
105
  ```
@@ -35,13 +120,21 @@ The generated project looks like this:
35
120
  .practice/<project>/
36
121
  ├── README.md # quickstart + how to run
37
122
  ├── PROJECT.md # scenario, requirements, definition of done
38
- ├── package.json / tsconfig # or requirements.txt for Python
39
- ├── src/<module>.ts # implement this
40
- ├── tests/public/* # visible behavioural tests
41
- ├── tests/hidden/* # extra acceptance tests (gitignored, guarded)
123
+ ├── harness.json # build + run commands for the chosen language
124
+ ├── src/solution.<ext> # implement this (one JSON in, one JSON out)
125
+ ├── tests/runner.mjs # language-agnostic test harness (Node)
126
+ ├── tests/public/cases.json # visible test cases
127
+ ├── tests/hidden/cases.json # extra acceptance cases (gitignored, guarded)
42
128
  └── .practice-meta.json # provenance only (gitignored, guarded)
43
129
  ```
44
130
 
131
+ Run the checks with:
132
+
133
+ ```bash
134
+ node tests/runner.mjs # all cases
135
+ node tests/runner.mjs --public # visible cases only
136
+ ```
137
+
45
138
  ## Install
46
139
 
47
140
  ### Local (development)
@@ -66,7 +159,7 @@ The plugin registers the `/practice` command itself, so no extra config is neede
66
159
  | Tool | Purpose |
67
160
  | --- | --- |
68
161
  | `leetcode_fetch` | Fetch a problem by `mode` (`random` \| `specific` \| `daily` \| `filter`), `difficulty`, `tags`, `idOrSlug`. Skips premium problems. |
69
- | `leetcode_scaffold` | Create the real-world project from a spec. Rejects specs that mention LeetCode, the source title, or its slug. |
162
+ | `leetcode_scaffold` | Create the real-world project from a spec in any supported language. Rejects specs that mention LeetCode, the source title, or its slug. |
70
163
 
71
164
  ## The honest-practice guard
72
165
 
@@ -106,8 +199,9 @@ command are resolved.
106
199
  src/
107
200
  index.ts plugin entry: tools, guard hooks, command registration
108
201
  leetcode.ts API client + normalizer (handles endpoint shape differences)
202
+ languages.ts per-language adapters: starter, build, run commands
109
203
  patterns.ts topic -> domain seeds, leak detection
110
- scaffold.ts deterministic project writer (TS/JS/Python)
204
+ scaffold.ts deterministic project writer (any supported language)
111
205
  guard.ts protected-path detection
112
206
  prompt.ts the /practice command template
113
207
  types.ts shared types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-leetcode-realworld",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
@@ -75,11 +75,20 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
75
75
  projectName: tool.schema.string().optional().describe("kebab-case directory name"),
76
76
  difficulty: tool.schema.string().optional(),
77
77
  language: tool.schema
78
- .enum(["typescript", "javascript", "python"])
78
+ .enum([
79
+ "typescript",
80
+ "javascript",
81
+ "python",
82
+ "ruby",
83
+ "php",
84
+ "go",
85
+ "rust",
86
+ "csharp",
87
+ "swift",
88
+ ])
79
89
  .default("typescript")
80
- .describe("Target language"),
81
- module: tool.schema.string().optional().describe("Base module/file name"),
82
- functionName: tool.schema.string().optional().describe("Exported entry function name"),
90
+ .describe("Target language for the practice project"),
91
+ functionName: tool.schema.string().optional().describe("Entry function name (default: solve)"),
83
92
  requirements: tool.schema.array(tool.schema.string()).default([]).describe("Testable requirements"),
84
93
  edgeCases: tool.schema.array(tool.schema.string()).default([]),
85
94
  starterCode: tool.schema.string().optional().describe("Optional starter implementation"),
@@ -117,7 +126,7 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
117
126
  `Created a real-world practice project at ${relative}`,
118
127
  "",
119
128
  `Title: ${result.title}`,
120
- `Language: ${result.language}`,
129
+ `Language: ${result.languageName} (${result.runtime})`,
121
130
  `Files (${result.files.length}):`,
122
131
  ...result.files.map((file) => ` - ${file}`),
123
132
  "",
@@ -0,0 +1,358 @@
1
+ import type { FileEntry, LanguageId } from "./types";
2
+
3
+ export interface LanguageContext {
4
+ projectName: string;
5
+ functionName: string;
6
+ title: string;
7
+ }
8
+
9
+ export interface LanguageAdapter {
10
+ id: LanguageId;
11
+ name: string;
12
+ aliases: string[];
13
+ extension: string;
14
+ /** Human hint shown in the generated README. */
15
+ runtime: string;
16
+ starter(ctx: LanguageContext): string;
17
+ /** Extra project files (build manifests etc). */
18
+ files(ctx: LanguageContext): FileEntry[];
19
+ /** Command run once before the cases, or null when not needed. */
20
+ build(ctx: LanguageContext): string | null;
21
+ /** Command that runs the solution once, reading one JSON value on stdin. */
22
+ run(ctx: LanguageContext): string;
23
+ }
24
+
25
+ function packageJson(projectName: string, script: string): FileEntry {
26
+ return {
27
+ relative: "package.json",
28
+ contents:
29
+ JSON.stringify(
30
+ {
31
+ name: projectName,
32
+ version: "0.1.0",
33
+ private: true,
34
+ type: "module",
35
+ scripts: { test: script, check: script },
36
+ },
37
+ null,
38
+ 2,
39
+ ) + "\n",
40
+ };
41
+ }
42
+
43
+ const adapters: Record<LanguageId, LanguageAdapter> = {
44
+ typescript: {
45
+ id: "typescript",
46
+ name: "TypeScript",
47
+ aliases: ["ts"],
48
+ extension: "ts",
49
+ runtime: "Node.js 22.6+ (uses `--experimental-strip-types`)",
50
+ starter: ({ functionName }) => `import { readFileSync } from "node:fs";
51
+
52
+ function ${functionName}(payload: unknown): unknown {
53
+ throw new Error("Not implemented: ${functionName}");
54
+ }
55
+
56
+ const input = readFileSync(0, "utf8");
57
+ const payload = JSON.parse(input.trim() === "" ? "null" : input);
58
+ process.stdout.write(JSON.stringify(${functionName}(payload) ?? null));
59
+ `,
60
+ files: (ctx) => [
61
+ packageJson(ctx.projectName, "node tests/runner.mjs"),
62
+ {
63
+ relative: "tsconfig.json",
64
+ contents:
65
+ JSON.stringify(
66
+ {
67
+ compilerOptions: {
68
+ target: "ES2022",
69
+ module: "ESNext",
70
+ moduleResolution: "bundler",
71
+ strict: true,
72
+ skipLibCheck: true,
73
+ types: ["node"],
74
+ noEmit: true,
75
+ },
76
+ include: ["src"],
77
+ },
78
+ null,
79
+ 2,
80
+ ) + "\n",
81
+ },
82
+ ],
83
+ build: () => null,
84
+ run: ({ functionName }) => `node --experimental-strip-types src/solution.ts`,
85
+ },
86
+
87
+ javascript: {
88
+ id: "javascript",
89
+ name: "JavaScript",
90
+ aliases: ["js", "node"],
91
+ extension: "mjs",
92
+ runtime: "Node.js 18+",
93
+ starter: ({ functionName }) => `function ${functionName}(payload) {
94
+ throw new Error("Not implemented: ${functionName}");
95
+ }
96
+
97
+ const chunks = [];
98
+ for await (const chunk of process.stdin) chunks.push(chunk);
99
+ const input = Buffer.concat(chunks).toString("utf8");
100
+ const payload = JSON.parse(input.trim() === "" ? "null" : input);
101
+ process.stdout.write(JSON.stringify(${functionName}(payload) ?? null));
102
+ `,
103
+ files: (ctx) => [packageJson(ctx.projectName, "node tests/runner.mjs")],
104
+ build: () => null,
105
+ run: () => "node src/solution.mjs",
106
+ },
107
+
108
+ python: {
109
+ id: "python",
110
+ name: "Python",
111
+ aliases: ["py", "python3"],
112
+ extension: "py",
113
+ runtime: "Python 3.8+",
114
+ starter: ({ functionName }) => `import json
115
+ import sys
116
+
117
+
118
+ def ${functionName}(payload):
119
+ raise NotImplementedError("Not implemented: ${functionName}")
120
+
121
+
122
+ def main():
123
+ raw = sys.stdin.read().strip()
124
+ payload = json.loads(raw) if raw else None
125
+ print(json.dumps(${functionName}(payload)))
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
130
+ `,
131
+ files: () => [],
132
+ build: () => null,
133
+ run: () => "python3 src/solution.py",
134
+ },
135
+
136
+ ruby: {
137
+ id: "ruby",
138
+ name: "Ruby",
139
+ aliases: ["rb"],
140
+ extension: "rb",
141
+ runtime: "Ruby 2.6+ (json is in the standard library)",
142
+ starter: ({ functionName }) => `require "json"
143
+
144
+ def ${functionName}(payload)
145
+ raise NotImplementedError, "Not implemented: ${functionName}"
146
+ end
147
+
148
+ raw = $stdin.read.strip
149
+ payload = raw.empty? ? nil : JSON.parse(raw)
150
+ puts JSON.generate(${functionName}(payload))
151
+ `,
152
+ files: () => [],
153
+ build: () => null,
154
+ run: () => "ruby src/solution.rb",
155
+ },
156
+
157
+ php: {
158
+ id: "php",
159
+ name: "PHP",
160
+ aliases: ["php8"],
161
+ extension: "php",
162
+ runtime: "PHP 7.4+",
163
+ starter: ({ functionName }) => `<?php
164
+
165
+ function ${functionName}($payload) {
166
+ throw new Exception("Not implemented: ${functionName}");
167
+ }
168
+
169
+ $raw = trim(stream_get_contents(STDIN));
170
+ $payload = $raw === "" ? null : json_decode($raw, true);
171
+ echo json_encode(${functionName}($payload));
172
+ `,
173
+ files: () => [],
174
+ build: () => null,
175
+ run: () => "php src/solution.php",
176
+ },
177
+
178
+ go: {
179
+ id: "go",
180
+ name: "Go",
181
+ aliases: ["golang"],
182
+ extension: "go",
183
+ runtime: "Go 1.18+ (uses `any`)",
184
+ starter: ({ functionName }) => `package main
185
+
186
+ import (
187
+ "encoding/json"
188
+ "fmt"
189
+ "io"
190
+ "os"
191
+ )
192
+
193
+ func ${functionName}(payload any) any {
194
+ panic("Not implemented: ${functionName}")
195
+ }
196
+
197
+ func main() {
198
+ raw, _ := io.ReadAll(os.Stdin)
199
+ var payload any
200
+ if len(raw) > 0 {
201
+ if err := json.Unmarshal(raw, &payload); err != nil {
202
+ panic(err)
203
+ }
204
+ }
205
+ out, err := json.Marshal(${functionName}(payload))
206
+ if err != nil {
207
+ panic(err)
208
+ }
209
+ fmt.Print(string(out))
210
+ }
211
+ `,
212
+ files: () => [
213
+ { relative: "go.mod", contents: "module practice\n\ngo 1.21\n" },
214
+ ],
215
+ build: () => "mkdir -p out && go build -o out/solution src/solution.go",
216
+ run: () => "./out/solution",
217
+ },
218
+
219
+ rust: {
220
+ id: "rust",
221
+ name: "Rust",
222
+ aliases: ["rs", "cargo"],
223
+ extension: "rs",
224
+ runtime: "Rust 1.70+ (uses serde_json)",
225
+ starter: ({ functionName }) => `use serde_json::Value;
226
+ use std::io::Read;
227
+
228
+ fn ${functionName}(payload: Value) -> Value {
229
+ let _ = payload;
230
+ panic!("Not implemented: ${functionName}");
231
+ }
232
+
233
+ fn main() {
234
+ let mut raw = String::new();
235
+ std::io::stdin().read_to_string(&mut raw).expect("read stdin");
236
+ let trimmed = raw.trim();
237
+ let payload: Value = if trimmed.is_empty() {
238
+ Value::Null
239
+ } else {
240
+ serde_json::from_str(trimmed).expect("parse json")
241
+ };
242
+ let result = ${functionName}(payload);
243
+ println!("{}", serde_json::to_string(&result).expect("serialize json"));
244
+ }
245
+ `,
246
+ files: (ctx) => [
247
+ {
248
+ relative: "Cargo.toml",
249
+ contents: `[package]
250
+ name = "${ctx.projectName}"
251
+ version = "0.1.0"
252
+ edition = "2021"
253
+
254
+ [dependencies]
255
+ serde_json = "1"
256
+
257
+ [[bin]]
258
+ name = "solution"
259
+ path = "src/solution.rs"
260
+ `,
261
+ },
262
+ ],
263
+ build: () => "cargo build --quiet",
264
+ run: () => "./target/debug/solution",
265
+ },
266
+
267
+ csharp: {
268
+ id: "csharp",
269
+ name: "C#",
270
+ aliases: ["cs", "dotnet"],
271
+ extension: "cs",
272
+ runtime: ".NET 8 SDK",
273
+ starter: ({ functionName }) => `using System;
274
+ using System.Text.Json.Nodes;
275
+
276
+ class Program
277
+ {
278
+ static JsonNode? ${functionName}(JsonNode? payload)
279
+ {
280
+ throw new NotImplementedException("Not implemented: ${functionName}");
281
+ }
282
+
283
+ static void Main()
284
+ {
285
+ var raw = Console.In.ReadToEnd().Trim();
286
+ var payload = raw.Length == 0 ? null : JsonNode.Parse(raw);
287
+ Console.Write(${functionName}(payload)?.ToJsonString() ?? "null");
288
+ }
289
+ }
290
+ `,
291
+ files: (ctx) => [
292
+ {
293
+ relative: `${ctx.projectName}.csproj`,
294
+ contents: `<Project Sdk="Microsoft.NET.Sdk">
295
+ <PropertyGroup>
296
+ <OutputType>Exe</OutputType>
297
+ <TargetFramework>net8.0</TargetFramework>
298
+ <ImplicitUsings>enable</ImplicitUsings>
299
+ <Nullable>enable</Nullable>
300
+ <AssemblyName>solution</AssemblyName>
301
+ </PropertyGroup>
302
+ </Project>
303
+ `,
304
+ },
305
+ ],
306
+ build: () => "dotnet build -c Release -o out --nologo -v quiet",
307
+ run: () => "dotnet out/solution.dll",
308
+ },
309
+
310
+ swift: {
311
+ id: "swift",
312
+ name: "Swift",
313
+ aliases: [],
314
+ extension: "swift",
315
+ runtime: "Swift 5.5+ (Foundation JSONSerialization)",
316
+ starter: ({ functionName }) => `import Foundation
317
+
318
+ func ${functionName}(_ payload: Any) -> Any {
319
+ fatalError("Not implemented: ${functionName}")
320
+ }
321
+
322
+ let raw = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
323
+ let data = raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
324
+ ? Data("null".utf8)
325
+ : Data(raw.utf8)
326
+ let payload = try! JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
327
+ let result = ${functionName}(payload)
328
+ let out = try! JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed])
329
+ FileHandle.standardOutput.write(out)
330
+ `,
331
+ files: () => [],
332
+ build: () => "mkdir -p out && swiftc -O src/solution.swift -o out/solution",
333
+ run: () => "./out/solution",
334
+ },
335
+ };
336
+
337
+ export function getLanguage(id: LanguageId): LanguageAdapter {
338
+ return adapters[id];
339
+ }
340
+
341
+ export function languageIds(): LanguageId[] {
342
+ return Object.keys(adapters) as LanguageId[];
343
+ }
344
+
345
+ export function languageNames(): string {
346
+ return languageIds()
347
+ .map((id) => adapters[id].name)
348
+ .join(", ");
349
+ }
350
+
351
+ export function normalizeLanguage(input: string | undefined): LanguageId | undefined {
352
+ if (!input) return undefined;
353
+ const key = input.trim().toLowerCase();
354
+ for (const adapter of Object.values(adapters)) {
355
+ if (adapter.id === key || adapter.aliases.includes(key)) return adapter.id;
356
+ }
357
+ return undefined;
358
+ }
package/src/patterns.ts CHANGED
@@ -91,7 +91,6 @@ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
91
91
  push("projectName", spec.projectName);
92
92
  push("scenario", spec.scenario);
93
93
  push("pattern", spec.pattern);
94
- push("module", spec.module);
95
94
  push("functionName", spec.functionName);
96
95
  push("starterCode", spec.starterCode);
97
96
  (spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
package/src/prompt.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export const PRACTICE_DESCRIPTION =
2
2
  "Turn a LeetCode problem into a realistic software-engineering assignment (scenario, starter, tests) so you practice applying the underlying algorithm, not solving the puzzle.";
3
3
 
4
+ export const SUPPORTED_LANGUAGES = [
5
+ "TypeScript",
6
+ "JavaScript",
7
+ "Python",
8
+ "Ruby",
9
+ "PHP",
10
+ "Go",
11
+ "Rust",
12
+ "C#",
13
+ "Swift",
14
+ ];
15
+
4
16
  /**
5
17
  * The `/practice` command prompt. It orchestrates the two plugin tools and is
6
18
  * deliberately opinionated: the whole value of this plugin is that the learner
@@ -19,58 +31,77 @@ Recognise any of these (all optional):
19
31
  - difficulty: easy | medium | hard
20
32
  - tags: comma-separated topic tags, e.g. "dynamic-programming,graph"
21
33
  - a specific problem id or slug, e.g. "two-sum" or "1"
22
- - language: ts | js | python (default ts)
23
- If nothing is provided, pick a random Medium problem.
34
+ - language: a supported language name or alias (below)
35
+
36
+ ## 2. Ask when unclear (use the \`question\` tool)
37
+ Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
38
+
39
+ - If the user did NOT specify a language, call the \`question\` tool and ask which
40
+ language they want to practice, offering these as options:
41
+ ${SUPPORTED_LANGUAGES.map((name) => `"${name}"`).join(", ")}.
42
+ - If the user did NOT specify a difficulty, include a second question in the SAME
43
+ \`question\` call offering "Easy", "Medium" (recommended), and "Hard".
44
+ - Wait for the answers. Do not guess and do not default silently.
24
45
 
25
- ## 2. Fetch the source problem (private)
26
- Call the \`leetcode_fetch\` tool with the parsed options. The fetched statement is
27
- for YOUR reasoning only. Never paste it, paraphrase it closely, or name its source
28
- anywhere in the generated project.
46
+ If the user did specify values, skip the corresponding question. Only ask at all
47
+ when something is missing.
29
48
 
30
- ## 3. Derive the principle
49
+ ## 3. Fetch the source problem (private)
50
+ Call the \`leetcode_fetch\` tool with the difficulty/tags/slug. The fetched
51
+ statement is for YOUR reasoning only. Never paste it, paraphrase it closely, or
52
+ name its source anywhere in the generated project.
53
+
54
+ ## 4. Derive the principle
31
55
  Identify the underlying algorithmic idea (e.g. sliding window, heap-based
32
56
  scheduling, union-find, DP over intervals). Do not carry over the problem's
33
57
  fiction (arrays of "nums", "target", etc.).
34
58
 
35
- ## 4. Invent a real engineering scenario
59
+ ## 5. Invent a real engineering scenario
36
60
  Design a plausible product/business task that genuinely needs that principle. Good
37
61
  domains: observability pipelines, payments/ledgering, logistics, rate limiting,
38
62
  access control, search, feature flags, data reconciliation, scheduling.
39
63
  The scenario must read like a ticket from a real team: who needs it, why, and what
40
- "correct" means. Pick ONE clean entry point.
64
+ "correct" means.
65
+
66
+ The assignment has ONE entry point with a JSON-in / JSON-out contract:
67
+ - It receives a single JSON value (usually an object describing a request).
68
+ - It returns a single JSON value (a response or result).
69
+ This is how real services, CLIs and jobs exchange data, and it lets the same test
70
+ harness verify any language.
41
71
 
42
- Design the assignment so a single exported function is the contract:
43
- - It receives one JSON-shaped \`payload\` value.
44
- - It returns one JSON-shaped result.
45
72
  Call the tool \`leetcode_scaffold\` with a spec containing:
46
73
  - title: real-world project title (no coding-practice words)
47
74
  - scenario: 1-3 paragraphs of business context
48
75
  - pattern: the underlying principle (internal only)
49
- - language: chosen language
76
+ - language: the language the user chose (use the canonical id, e.g. "typescript",
77
+ "javascript", "python", "ruby", "php", "go", "rust", "csharp", "swift")
50
78
  - requirements: concrete, testable bullet points
51
79
  - edgeCases: tricky situations the solution must handle
52
80
  - starterCode: optional; otherwise a TODO stub is generated
53
- - publicTests: 3-5 visible cases that illustrate the contract
54
- - hiddenTests: 5-8 acceptance cases, including edge cases and one larger input
55
- - sourceSlug / sourceTitle: for internal provenance ONLY (never surfaced to the learner)
81
+ - publicTests: 5-8 visible cases that illustrate the contract
82
+ - hiddenTests: 6-12 acceptance cases, including edge cases, boundary values, an
83
+ empty/degenerate input, and at least one larger input
84
+ - sourceSlug / sourceTitle: for internal provenance ONLY (never surfaced)
56
85
 
57
86
  Test cases must be JSON-serialisable: \`{ "name": string, "input": any, "expected": any }\`.
87
+ Make sure every \`expected\` value is actually correct for the described rules, and
88
+ that public and hidden cases do not overlap.
58
89
 
59
- ## 5. Rules (non-negotiable)
90
+ ## 6. Rules (non-negotiable)
60
91
  - The generated title, scenario, requirements, comments, and test names MUST NOT
61
92
  mention LeetCode, its title, its slug, or any coding-practice site. The
62
93
  \`leetcode_scaffold\` tool will reject the spec if it does.
63
94
  - DO NOT implement the solution. Your job ends when the scaffold is written. The
64
- learner implements \`src/...\` themselves.
95
+ learner implements the solution file themselves.
65
96
  - Do not reveal the fetched problem or the original examples.
66
97
  - If the tool reports a leak, rewrite the offending fields and retry.
67
98
 
68
- ## 6. After scaffolding
99
+ ## 7. After scaffolding
69
100
  Report back with, in order:
70
101
  1. One short paragraph describing the assignment as a real task (no source spoilers).
71
- 2. The project path reported by the tool.
72
- 3. The exact commands to install dependencies and run the tests.
73
- 4. Note that \`tests/hidden\` holds extra acceptance tests they should not edit.
102
+ 2. The project path and chosen language.
103
+ 3. The exact command to run the tests (\`node tests/runner.mjs\`).
104
+ 4. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
74
105
  Then stop. If the learner asks for help, give guiding hints and ask questions
75
106
  rather than writing the algorithm for them.
76
107
  `;
package/src/scaffold.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { getLanguage, normalizeLanguage } from "./languages";
3
4
  import { assertNoLeak } from "./patterns";
4
- import type { PracticeLanguage, ScaffoldResult, ScaffoldSpec, TestCase } from "./types";
5
+ import type { FileEntry, LanguageId, ScaffoldResult, ScaffoldSpec, TestCase } from "./types";
5
6
 
6
7
  function kebab(input: string): string {
7
8
  return input
@@ -18,101 +19,157 @@ function camel(input: string): string {
18
19
  .join("");
19
20
  }
20
21
 
21
- function snake(input: string): string {
22
- return kebab(input).replace(/-/g, "_");
23
- }
24
-
25
- function pascal(input: string): string {
26
- const value = camel(input);
27
- return value.charAt(0).toUpperCase() + value.slice(1);
28
- }
29
-
30
22
  interface ResolvedNames {
31
23
  projectName: string;
32
- module: string;
33
24
  functionName: string;
34
25
  title: string;
35
26
  }
36
27
 
37
28
  function resolveNames(spec: ScaffoldSpec): ResolvedNames {
38
29
  const projectName = kebab(spec.projectName ?? spec.title) || "practice-project";
39
- const module = kebab(spec.module ?? spec.title) || "core";
40
- const language = spec.language ?? "typescript";
41
- const rawFunction = spec.functionName ?? (camel(module) || "solve");
42
- const functionName = language === "python" ? snake(rawFunction) : camel(rawFunction);
43
- return { projectName, module, functionName, title: spec.title };
30
+ const functionName = spec.functionName?.trim() || "solve";
31
+ return { projectName, functionName, title: spec.title };
44
32
  }
45
33
 
46
34
  function json(value: unknown, indent = 2): string {
47
35
  return JSON.stringify(value, null, indent);
48
36
  }
49
37
 
50
- function toPythonLiteral(value: unknown, depth = 0): string {
51
- const pad = " ".repeat(depth + 1);
52
- const closePad = " ".repeat(depth);
53
- if (value === null) return "None";
54
- if (value === true) return "True";
55
- if (value === false) return "False";
56
- if (typeof value === "number" || typeof value === "string") return JSON.stringify(value);
57
- if (Array.isArray(value)) {
58
- if (value.length === 0) return "[]";
59
- const items = value.map((item) => `${pad}${toPythonLiteral(item, depth + 1)}`);
60
- return `[\n${items.join(",\n")},\n${closePad}]`;
38
+ const RUNNER = String.raw`#!/usr/bin/env node
39
+ import { spawnSync } from "node:child_process";
40
+ import { readFileSync } from "node:fs";
41
+ import path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+
44
+ const testsDir = path.dirname(fileURLToPath(import.meta.url));
45
+ const root = path.resolve(testsDir, "..");
46
+ const harness = JSON.parse(readFileSync(path.join(root, "harness.json"), "utf8"));
47
+
48
+ function canonical(value) {
49
+ if (Array.isArray(value)) return value.map(canonical);
50
+ if (value && typeof value === "object") {
51
+ const out = {};
52
+ for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
53
+ return out;
61
54
  }
62
- if (typeof value === "object") {
63
- const entries = Object.entries(value as Record<string, unknown>);
64
- if (entries.length === 0) return "{}";
65
- const items = entries.map(
66
- ([key, val]) => `${pad}${JSON.stringify(key)}: ${toPythonLiteral(val, depth + 1)}`,
67
- );
68
- return `{\n${items.join(",\n")},\n${closePad}}`;
55
+ return value;
56
+ }
57
+
58
+ function same(a, b) {
59
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
60
+ }
61
+
62
+ function runCase(input) {
63
+ const result = spawnSync(harness.run, {
64
+ cwd: root,
65
+ shell: true,
66
+ encoding: "utf8",
67
+ input: JSON.stringify(input),
68
+ timeout: 30000,
69
+ });
70
+ if (result.error) throw result.error;
71
+ if (result.status !== 0) {
72
+ const detail = (result.stderr || result.stdout || "").trim().split("\n").slice(-6).join("\n");
73
+ throw new Error("process exited with code " + result.status + (detail ? "\n" + detail : ""));
69
74
  }
70
- return "None";
75
+ const text = (result.stdout || "").trim();
76
+ return text === "" ? null : JSON.parse(text);
71
77
  }
72
78
 
79
+ if (harness.build) {
80
+ const build = spawnSync(harness.build, { cwd: root, shell: true, stdio: "inherit" });
81
+ if (build.status !== 0) {
82
+ console.error("\nBuild failed.");
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ const onlyPublic = process.argv.includes("--public");
88
+ const suites = onlyPublic ? ["public"] : ["public", "hidden"];
89
+ let passed = 0;
90
+ let failed = 0;
91
+
92
+ for (const suite of suites) {
93
+ const cases = JSON.parse(readFileSync(path.join(root, "tests", suite, "cases.json"), "utf8"));
94
+ console.log("\n" + suite + " (" + cases.length + " cases)");
95
+ for (const testCase of cases) {
96
+ try {
97
+ const received = runCase(testCase.input);
98
+ if (same(received, testCase.expected)) {
99
+ passed += 1;
100
+ console.log(" PASS " + testCase.name);
101
+ } else {
102
+ failed += 1;
103
+ console.log(" FAIL " + testCase.name);
104
+ console.log(" expected: " + JSON.stringify(testCase.expected));
105
+ console.log(" received: " + JSON.stringify(received));
106
+ }
107
+ } catch (error) {
108
+ failed += 1;
109
+ console.log(" ERROR " + testCase.name);
110
+ for (const line of String(error.message).split("\n")) {
111
+ console.log(" " + line);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ console.log("\n" + passed + " passed, " + failed + " failed");
118
+ process.exit(failed === 0 ? 0 : 1);
119
+ `;
120
+
73
121
  function requirementsSection(spec: ScaffoldSpec): string {
74
122
  const requirements = spec.requirements ?? [];
75
123
  if (requirements.length === 0) return "";
76
- const lines = requirements.map((req, i) => `${i + 1}. ${req}`);
77
- return `## Requirements\n\n${lines.join("\n")}\n`;
124
+ return `## Requirements\n\n${requirements.map((req, i) => `${i + 1}. ${req}`).join("\n")}\n`;
78
125
  }
79
126
 
80
127
  function edgeCaseSection(spec: ScaffoldSpec): string {
81
128
  const edgeCases = spec.edgeCases ?? [];
82
129
  if (edgeCases.length === 0) return "";
83
- const lines = edgeCases.map((edge) => `- ${edge}`);
84
- return `## Edge cases to handle\n\n${lines.join("\n")}\n`;
130
+ return `## Edge cases to handle\n\n${edgeCases.map((edge) => `- ${edge}`).join("\n")}\n`;
85
131
  }
86
132
 
87
- function readmeFor(names: ResolvedNames, language: PracticeLanguage): string {
88
- const run = runCommands(language);
133
+ function readmeFor(names: ResolvedNames, spec: ScaffoldSpec, language: LanguageId): string {
134
+ const adapter = getLanguage(language);
135
+ const source = `src/solution.${adapter.extension}`;
136
+ const publicCount = (spec.publicTests ?? []).length;
137
+ const hiddenCount = (spec.hiddenTests ?? []).length;
89
138
  return `# ${names.title}
90
139
 
91
140
  ${names.title} is a self-contained engineering exercise. Read \`PROJECT.md\` for the
92
- business context and requirements, implement the entry point in \`${sourcePath(names, language)}\`,
93
- and make the test suite pass.
141
+ brief, implement the entry point in \`${source}\`, and make the test suite pass.
94
142
 
95
- ## Getting started
143
+ ## Run the tests
96
144
 
97
145
  \`\`\`bash
98
- ${run.install ? `${run.install}\n` : ""}${run.check}
146
+ node tests/runner.mjs
99
147
  \`\`\`
100
148
 
149
+ Requires ${adapter.runtime}.
150
+
151
+ - \`node tests/runner.mjs --public\` runs only the visible cases.
152
+ - Your program reads **one JSON value on stdin** and writes **one JSON value on
153
+ stdout**. The starter file shows the exact shape.
154
+
101
155
  ## Layout
102
156
 
103
157
  \`\`\`
104
- ${sourcePath(names, language)} # implement your solution here
105
- tests/public/ # visible behavioural tests
106
- tests/hidden/ # additional acceptance tests (do not edit)
158
+ ${source} # implement your solution here
159
+ tests/public/cases.json # ${publicCount} visible cases
160
+ tests/hidden/cases.json # ${hiddenCount} acceptance cases (do not edit)
161
+ tests/runner.mjs # test harness
162
+ harness.json # build/run commands
107
163
  PROJECT.md # scenario, requirements, definition of done
108
164
  \`\`\`
109
165
 
110
- Implement until \`${run.check}\` is green. Prefer clarity over cleverness: the goal is
111
- production-quality code, not the shortest possible snippet.
166
+ Prefer clarity over cleverness: the goal is production-quality code, not the
167
+ shortest possible snippet.
112
168
  `;
113
169
  }
114
170
 
115
- function projectDoc(spec: ScaffoldSpec, names: ResolvedNames): string {
171
+ function projectDoc(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
172
+ const adapter = getLanguage(language);
116
173
  return `# ${names.title}
117
174
 
118
175
  ## Scenario
@@ -121,252 +178,79 @@ ${spec.scenario.trim()}
121
178
 
122
179
  ${requirementsSection(spec)}
123
180
  ${edgeCaseSection(spec)}
124
- ## Definition of done
125
-
126
- - The entry point is implemented and exported as documented.
127
- - The full test suite passes: \`check\` command in the README.
128
- - No external service calls are required; input arrives as a plain value and the result is returned.
129
- - Handle the edge cases above rather than only the happy path.
130
- `;
131
- }
132
-
133
- function starterFor(names: ResolvedNames, spec: ScaffoldSpec, language: PracticeLanguage): string {
134
- if (spec.starterCode && spec.starterCode.trim().length > 0) return spec.starterCode.trimEnd() + "\n";
135
- const summary = spec.scenario.trim().split(/\n/)[0] ?? "";
136
- if (language === "python") {
137
- return `"""${names.title}.
138
-
139
- ${summary}
140
- """
141
-
142
-
143
- def ${names.functionName}(payload):
144
- """Implement the behaviour described in PROJECT.md."""
145
- raise NotImplementedError("${names.functionName} is not implemented yet")
146
- `;
147
- }
148
- const doc = `/**\n * ${names.title}\n *\n * ${summary}\n */`;
149
- if (language === "javascript") {
150
- return `${doc}\nexport function ${names.functionName}(payload) {\n throw new Error("${names.functionName} is not implemented yet");\n}\n`;
151
- }
152
- const signature = `export function ${names.functionName}(payload: unknown): unknown {`;
153
- return `${doc}\n${signature}\n throw new Error("${names.functionName} is not implemented yet");\n}\n`;
154
- }
155
-
156
- function testCasesArray(cases: TestCase[]): string {
157
- return json(cases, 2);
158
- }
159
-
160
- function publicTestFor(
161
- names: ResolvedNames,
162
- spec: ScaffoldSpec,
163
- language: PracticeLanguage,
164
- ): string {
165
- const cases = spec.publicTests ?? [];
166
- return testFile(names, cases, language, "public");
167
- }
168
-
169
- function hiddenTestFor(
170
- names: ResolvedNames,
171
- spec: ScaffoldSpec,
172
- language: PracticeLanguage,
173
- ): string {
174
- const cases = spec.hiddenTests ?? [];
175
- return testFile(names, cases, language, "hidden");
176
- }
177
-
178
- function testFile(
179
- names: ResolvedNames,
180
- cases: TestCase[],
181
- language: PracticeLanguage,
182
- kind: "public" | "hidden",
183
- ): string {
184
- const title = `${names.title} (${kind} acceptance)`;
185
- if (language === "python") {
186
- return `import pytest
181
+ ## Interface
187
182
 
188
- from src.${names.module} import ${names.functionName}
183
+ Implement \`src/solution.${adapter.extension}\`. Your program is invoked once per test
184
+ case: it reads **one JSON value from stdin** and must write **one JSON value to
185
+ stdout**. Nothing else may be printed to stdout (use stderr for debugging).
189
186
 
190
- CASES = ${toPythonLiteral(cases)}
191
-
192
-
193
- @pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"])
194
- def test_${names.functionName}(case):
195
- assert ${names.functionName}(case["input"]) == case["expected"]
196
- `;
197
- }
198
- const casesLiteral = testCasesArray(cases);
199
- if (language === "javascript") {
200
- return `import test from "node:test";
201
- import assert from "node:assert/strict";
202
- import { ${names.functionName} } from "../../src/${names.module}.js";
203
-
204
- // ${title}
205
- const cases = ${casesLiteral};
206
-
207
- for (const testCase of cases) {
208
- test(testCase.name, () => {
209
- assert.deepStrictEqual(${names.functionName}(testCase.input), testCase.expected);
210
- });
211
- }
212
- `;
213
- }
214
- return `import { describe, expect, it } from "vitest";
215
- import { ${names.functionName} } from "../../src/${names.module}";
216
-
217
- // ${title}
218
- const cases: Array<{ name: string; input: unknown; expected: unknown }> = ${casesLiteral};
187
+ ## Definition of done
219
188
 
220
- describe(${JSON.stringify(names.title)}, () => {
221
- for (const testCase of cases) {
222
- it(testCase.name, () => {
223
- expect(${names.functionName}(testCase.input)).toEqual(testCase.expected);
224
- });
225
- }
226
- });
189
+ - The full test suite passes: \`node tests/runner.mjs\`.
190
+ - Edge cases above are handled, not just the happy path.
191
+ - No network or external services are required.
227
192
  `;
228
193
  }
229
194
 
230
- function sourcePath(names: ResolvedNames, language: PracticeLanguage): string {
231
- const ext = language === "python" ? "py" : language === "javascript" ? "js" : "ts";
232
- return `src/${names.module}.${ext}`;
233
- }
234
-
235
- interface RunCommands {
236
- install: string;
237
- check: string;
238
- run: string;
239
- }
240
-
241
- function runCommands(language: PracticeLanguage): RunCommands {
242
- if (language === "python") {
243
- return {
244
- install: "python -m pip install -r requirements.txt",
245
- check: "python -m pytest -q",
246
- run: "python -m pytest -q",
247
- };
248
- }
249
- if (language === "javascript") {
250
- return { install: "npm install", check: "node --test", run: "node --test" };
251
- }
252
- return { install: "npm install", check: "npm test", run: "npm test" };
253
- }
254
-
255
- function packageJsonFor(
256
- names: ResolvedNames,
257
- language: PracticeLanguage,
258
- ): string {
259
- if (language === "javascript") {
260
- return (
261
- json(
262
- {
263
- name: names.projectName,
264
- version: "0.1.0",
265
- private: true,
266
- type: "module",
267
- scripts: { test: "node --test", check: "node --test" },
268
- },
269
- 2,
270
- ) + "\n"
271
- );
272
- }
273
- return (
274
- json(
275
- {
276
- name: names.projectName,
277
- version: "0.1.0",
278
- private: true,
279
- type: "module",
280
- scripts: { test: "vitest run", check: "vitest run" },
281
- devDependencies: { "@types/node": "^22.13.9", typescript: "^5.8.2", vitest: "^3.0.0" },
282
- },
283
- 2,
284
- ) + "\n"
285
- );
286
- }
287
-
288
- function tsconfigFor(): string {
289
- return (
290
- json(
291
- {
292
- compilerOptions: {
293
- target: "ES2022",
294
- module: "ESNext",
295
- moduleResolution: "bundler",
296
- strict: true,
297
- skipLibCheck: true,
298
- types: ["node"],
299
- noEmit: true,
300
- },
301
- include: ["src", "tests"],
302
- },
303
- 2,
304
- ) + "\n"
305
- );
306
- }
307
-
308
- const PROJECT_GITIGNORE = `node_modules/
195
+ const PROJECT_GITIGNORE = `# build artifacts
196
+ out/
197
+ target/
198
+ bin/
199
+ obj/
200
+ node_modules/
309
201
  __pycache__/
202
+ *.pyc
310
203
  .pytest_cache/
311
- .venv/
312
- venv/
313
- # Acceptance tests are meant to stay local; don't commit the oracle.
204
+
205
+ # acceptance tests + provenance stay local
314
206
  tests/hidden/
315
207
  .practice-meta.json
316
208
  `;
317
209
 
318
- function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: PracticeLanguage): string {
210
+ function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
319
211
  return (
320
- json(
321
- {
322
- title: names.title,
323
- module: names.module,
324
- functionName: names.functionName,
325
- language,
326
- pattern: spec.pattern,
327
- difficulty: spec.difficulty ?? null,
328
- source: {
329
- slug: spec.sourceSlug ?? null,
330
- title: spec.sourceTitle ?? null,
331
- },
332
- generatedAt: new Date().toISOString(),
333
- },
334
- 2,
335
- ) + "\n"
212
+ json({
213
+ title: names.title,
214
+ functionName: names.functionName,
215
+ language,
216
+ pattern: spec.pattern,
217
+ difficulty: spec.difficulty ?? null,
218
+ source: { slug: spec.sourceSlug ?? null, title: spec.sourceTitle ?? null },
219
+ generatedAt: new Date().toISOString(),
220
+ }) + "\n"
336
221
  );
337
222
  }
338
223
 
339
- interface FileEntry {
340
- relative: string;
341
- contents: string;
224
+ function casesFile(cases: TestCase[]): string {
225
+ return json(cases) + "\n";
342
226
  }
343
227
 
344
- function buildFiles(spec: ScaffoldSpec, names: ResolvedNames): FileEntry[] {
345
- const language = spec.language ?? "typescript";
228
+ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): FileEntry[] {
229
+ const adapter = getLanguage(language);
230
+ const ctx = { projectName: names.projectName, functionName: names.functionName, title: names.title };
231
+
346
232
  const files: FileEntry[] = [
347
- { relative: "README.md", contents: readmeFor(names, language) },
348
- { relative: "PROJECT.md", contents: projectDoc(spec, names) },
233
+ { relative: "README.md", contents: readmeFor(names, spec, language) },
234
+ { relative: "PROJECT.md", contents: projectDoc(spec, names, language) },
349
235
  { relative: ".gitignore", contents: PROJECT_GITIGNORE },
350
236
  { relative: ".practice-meta.json", contents: metaFor(spec, names, language) },
351
- { relative: sourcePath(names, language), contents: starterFor(names, spec, language) },
352
- { relative: `tests/public/${names.module}.test.${language === "python" ? "py" : language === "javascript" ? "js" : "ts"}`, contents: publicTestFor(names, spec, language) },
353
- { relative: `tests/hidden/${names.module}.test.${language === "python" ? "py" : language === "javascript" ? "js" : "ts"}`, contents: hiddenTestFor(names, spec, language) },
237
+ {
238
+ relative: "harness.json",
239
+ contents:
240
+ json({ build: adapter.build(ctx), run: adapter.run(ctx) }) + "\n",
241
+ },
242
+ { relative: "tests/runner.mjs", contents: RUNNER },
243
+ { relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
244
+ { relative: "tests/hidden/cases.json", contents: casesFile(spec.hiddenTests ?? []) },
245
+ {
246
+ relative: `src/solution.${adapter.extension}`,
247
+ contents: spec.starterCode?.trimEnd()
248
+ ? spec.starterCode.trimEnd() + "\n"
249
+ : adapter.starter(ctx),
250
+ },
354
251
  ];
355
252
 
356
- if (language === "python") {
357
- files.push(
358
- { relative: "requirements.txt", contents: "pytest>=8.0\n" },
359
- { relative: "conftest.py", contents: "import sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).parent))\n" },
360
- { relative: "src/__init__.py", contents: "" },
361
- { relative: "tests/__init__.py", contents: "" },
362
- { relative: "tests/public/__init__.py", contents: "" },
363
- { relative: "tests/hidden/__init__.py", contents: "" },
364
- );
365
- } else {
366
- files.push({ relative: "package.json", contents: packageJsonFor(names, language) });
367
- if (language === "typescript") files.push({ relative: "tsconfig.json", contents: tsconfigFor() });
368
- }
369
-
253
+ files.push(...adapter.files(ctx));
370
254
  return files;
371
255
  }
372
256
 
@@ -379,7 +263,13 @@ export async function scaffold(
379
263
  spec: ScaffoldSpec,
380
264
  options: ScaffoldOptions,
381
265
  ): Promise<ScaffoldResult> {
382
- const language = spec.language ?? "typescript";
266
+ const language = normalizeLanguage(spec.language) ?? (spec.language ? undefined : "typescript");
267
+ if (!language) {
268
+ throw new Error(
269
+ `Unsupported language "${spec.language}". Supported: typescript, javascript, python, ruby, php, go, rust, csharp, swift.`,
270
+ );
271
+ }
272
+
383
273
  const normalized: ScaffoldSpec = {
384
274
  ...spec,
385
275
  language,
@@ -391,27 +281,30 @@ export async function scaffold(
391
281
 
392
282
  assertNoLeak(normalized);
393
283
 
284
+ const adapter = getLanguage(language);
394
285
  const names = resolveNames(normalized);
395
286
  const target = normalized.outDir
396
287
  ? path.resolve(options.directory, normalized.outDir)
397
288
  : path.resolve(options.directory, ".practice", names.projectName);
398
289
 
399
- const files = buildFiles(normalized, names);
290
+ const files = buildFiles(normalized, names, language);
400
291
  for (const file of files) {
401
292
  const destination = path.join(target, file.relative);
402
293
  await mkdir(path.dirname(destination), { recursive: true });
403
294
  await writeFile(destination, file.contents, "utf8");
404
295
  }
405
296
 
406
- const commands = runCommands(language);
297
+ const check = "node tests/runner.mjs";
407
298
  return {
408
299
  outDir: target,
409
300
  title: names.title,
410
301
  language,
302
+ languageName: adapter.name,
303
+ runtime: adapter.runtime,
411
304
  files: files.map((file) => file.relative).sort(),
412
- runCommand: commands.run,
413
- checkCommand: commands.check,
305
+ runCommand: check,
306
+ checkCommand: check,
414
307
  };
415
308
  }
416
309
 
417
- export { resolveNames, kebab, camel, snake, pascal };
310
+ export { resolveNames, kebab, camel };
package/src/types.ts CHANGED
@@ -1,4 +1,13 @@
1
- export type PracticeLanguage = "typescript" | "javascript" | "python";
1
+ export type LanguageId =
2
+ | "typescript"
3
+ | "javascript"
4
+ | "python"
5
+ | "ruby"
6
+ | "php"
7
+ | "go"
8
+ | "rust"
9
+ | "csharp"
10
+ | "swift";
2
11
 
3
12
  export interface CodeSnippet {
4
13
  lang: string;
@@ -35,6 +44,11 @@ export interface TestCase {
35
44
  expected: unknown;
36
45
  }
37
46
 
47
+ export interface FileEntry {
48
+ relative: string;
49
+ contents: string;
50
+ }
51
+
38
52
  /**
39
53
  * The contract the agent fills in after it has decided on a real-world scenario.
40
54
  * None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
@@ -50,10 +64,8 @@ export interface ScaffoldSpec {
50
64
  /** Underlying algorithmic principle, e.g. "sliding window". Internal only. */
51
65
  pattern: string;
52
66
  difficulty?: string;
53
- language?: PracticeLanguage;
54
- /** Base module/file name, e.g. "deduper". Derived from title when omitted. */
55
- module?: string;
56
- /** Exported entry function, e.g. "dedupe". Derived from module when omitted. */
67
+ language?: LanguageId;
68
+ /** Name of the entry function inside the solution file. Defaults to `solve`. */
57
69
  functionName?: string;
58
70
  requirements?: string[];
59
71
  edgeCases?: string[];
@@ -71,7 +83,9 @@ export interface ScaffoldSpec {
71
83
  export interface ScaffoldResult {
72
84
  outDir: string;
73
85
  title: string;
74
- language: PracticeLanguage;
86
+ language: LanguageId;
87
+ languageName: string;
88
+ runtime: string;
75
89
  files: string[];
76
90
  runCommand: string;
77
91
  checkCommand: string;