opencode-leetcode-realworld 0.0.0-stage → 0.1.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
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # opencode-leetcode-realworld
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [opencode](https://opencode.ai) plugin that turns a LeetCode problem into a
4
+ **realistic software-engineering assignment**.
5
+
6
+ Solving the puzzle and shipping the principle are different skills. This plugin
7
+ fetches a problem, extracts the *underlying algorithm*, and generates a project
8
+ that actually needs that algorithm — a scenario, requirements, a starter stub,
9
+ and a test suite. The original problem is never shown to you.
10
+
11
+ ```
12
+ /practice # random Medium problem
13
+ /practice hard graph # difficulty + tags
14
+ /practice two-sum # a specific problem
15
+ /practice medium dynamic-programming python
16
+ ```
17
+
18
+ ## How it works
19
+
20
+ ```
21
+ /practice
22
+ │
23
+ ├─ leetcode_fetch fetch a problem from the LeetCode API (private reasoning input)
24
+ │
25
+ ├─ (agent) derive the principle -> invent a real-world scenario
26
+ │
27
+ ├─ leetcode_scaffold write README / PROJECT / starter / tests, reject any leak
28
+ │
29
+ └─ guard hooks block reading hidden tests + provenance, keep the exercise honest
30
+ ```
31
+
32
+ The generated project looks like this:
33
+
34
+ ```
35
+ .practice/<project>/
36
+ ├── README.md # quickstart + how to run
37
+ ├── 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)
42
+ └── .practice-meta.json # provenance only (gitignored, guarded)
43
+ ```
44
+
45
+ ## Install
46
+
47
+ ### Local (development)
48
+
49
+ Clone/copy this repo, then open opencode inside it. Files under
50
+ `.opencode/plugins/` and `.opencode/commands/` are auto-loaded.
51
+
52
+ ### From npm (once published)
53
+
54
+ ```jsonc
55
+ // opencode.json
56
+ {
57
+ "$schema": "https://opencode.ai/config.json",
58
+ "plugin": ["opencode-leetcode-realworld"]
59
+ }
60
+ ```
61
+
62
+ The plugin registers the `/practice` command itself, so no extra config is needed.
63
+
64
+ ## Tools
65
+
66
+ | Tool | Purpose |
67
+ | --- | --- |
68
+ | `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. |
70
+
71
+ ## The honest-practice guard
72
+
73
+ Two plugin hooks keep the exercise meaningful:
74
+
75
+ - **Leak rejection** — `leetcode_scaffold` scans every learner-visible string for
76
+ the source title/slug and practice-site names (`leetcode`, `neetcode`, ...). If
77
+ found, it refuses and tells the agent to rewrite.
78
+ - **Access block** — `tool.execute.before` blocks `read`/`write`/`edit`/`glob`/
79
+ `grep`/`bash` from touching `tests/hidden/**`, `.practice-meta.json`, and
80
+ `.practice/meta.json`. You implement against the visible spec; the oracle stays
81
+ out of reach.
82
+
83
+ ## Configuration
84
+
85
+ | Env var | Default | Description |
86
+ | --- | --- | --- |
87
+ | `LEETCODE_API_BASE` | `https://leetcode-api-pied.vercel.app` | Base URL of the LeetCode API. |
88
+
89
+ Data comes from the community [leetcode-api](https://github.com/noworneverev/leetcode-api)
90
+ project. This plugin is not affiliated with LeetCode.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ bun install
96
+ bun test # unit + integration tests
97
+ bun run typecheck # tsc --noEmit
98
+ ```
99
+
100
+ Run `opencode debug config` inside the repo to confirm the plugin and `/practice`
101
+ command are resolved.
102
+
103
+ ## Project layout
104
+
105
+ ```
106
+ src/
107
+ index.ts plugin entry: tools, guard hooks, command registration
108
+ leetcode.ts API client + normalizer (handles endpoint shape differences)
109
+ patterns.ts topic -> domain seeds, leak detection
110
+ scaffold.ts deterministic project writer (TS/JS/Python)
111
+ guard.ts protected-path detection
112
+ prompt.ts the /practice command template
113
+ types.ts shared types
114
+ .opencode/ local wiring so this repo is usable immediately
115
+ tests/ unit + integration tests
116
+ ```
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "opencode-leetcode-realworld",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "leetcode",
19
+ "practice",
20
+ "interview",
21
+ "algorithms"
22
+ ],
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "bun test"
26
+ },
27
+ "dependencies": {
28
+ "@opencode-ai/plugin": "^1.4.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "^1.4.2",
32
+ "@types/node": "^22.13.9",
33
+ "typescript": "^5.8.2"
34
+ }
35
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The generated oracle tests and provenance file are the only things keeping the
3
+ * practice exercise honest. If the learner (or a helpful agent) can just read the
4
+ * hidden tests or the `.practice-meta.json` that points back to the original
5
+ * problem, the exercise collapses into "solve the LeetCode problem".
6
+ *
7
+ * These helpers detect tool calls that would touch those files so the plugin can
8
+ * block them.
9
+ */
10
+
11
+ const GUARDED_TOOLS = new Set([
12
+ "read",
13
+ "write",
14
+ "edit",
15
+ "glob",
16
+ "grep",
17
+ "list",
18
+ "find",
19
+ "bash",
20
+ ]);
21
+
22
+ const HIDDEN_SEGMENT = /(^|[\/\s"'])tests\/hidden(\/|[\s"']|$)/;
23
+ const META_FILE = /(^|[\/\s"'])\.practice-meta\.json/;
24
+ const LEGACY_META = /(^|[\/\s"'])\.practice\/meta\.json/;
25
+
26
+ export function normalizeReference(value: string): string {
27
+ return value.replace(/\\/g, "/");
28
+ }
29
+
30
+ export function isProtectedReference(value: string): boolean {
31
+ const normalized = normalizeReference(value);
32
+ return HIDDEN_SEGMENT.test(normalized) || META_FILE.test(normalized) || LEGACY_META.test(normalized);
33
+ }
34
+
35
+ export function isGuardedTool(toolName: string): boolean {
36
+ return GUARDED_TOOLS.has(toolName);
37
+ }
38
+
39
+ /**
40
+ * Pull the path-like arguments out of a tool call. Deliberately conservative:
41
+ * we only inspect fields that name a file, directory, or shell command, so we
42
+ * never false-positive on prose or search patterns.
43
+ */
44
+ export function collectPathReferences(toolName: string, args: Record<string, unknown>): string[] {
45
+ const references: string[] = [];
46
+ const add = (value: unknown) => {
47
+ if (typeof value === "string" && value.length > 0) references.push(value);
48
+ };
49
+
50
+ add(args.filePath);
51
+ add(args.path);
52
+ add(args.pattern);
53
+ add(args.command);
54
+ add(args.include);
55
+
56
+ if (toolName === "bash" && typeof args.command === "string") {
57
+ references.push(args.command);
58
+ }
59
+
60
+ return references;
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ import path from "node:path";
2
+ import { type Plugin, tool } from "@opencode-ai/plugin";
3
+ import { collectPathReferences, isGuardedTool, isProtectedReference } from "./guard";
4
+ import { fetchProblem, renderProblemForAgent } from "./leetcode";
5
+ import { PRACTICE_DESCRIPTION, PRACTICE_TEMPLATE } from "./prompt";
6
+ import { scaffold } from "./scaffold";
7
+ import type { ScaffoldSpec } from "./types";
8
+
9
+ const testCaseSchema = tool.schema.object({
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"),
13
+ });
14
+
15
+ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
16
+ const log = async (
17
+ level: "debug" | "info" | "warn" | "error",
18
+ message: string,
19
+ extra?: Record<string, unknown>,
20
+ ) => {
21
+ try {
22
+ await client.app.log({
23
+ body: { service: "leetcode-realworld", level, message, extra },
24
+ });
25
+ } catch {
26
+ /* logging must never break a session */
27
+ }
28
+ };
29
+
30
+ const fetchTool = tool({
31
+ description:
32
+ "Fetch a LeetCode problem (random, by id/slug, the daily challenge, or filtered by difficulty/tags). " +
33
+ "Use the returned algorithmic principle to design a real-world assignment. Never surface the raw problem to the learner.",
34
+ args: {
35
+ mode: tool.schema
36
+ .enum(["random", "specific", "daily", "filter"])
37
+ .default("random")
38
+ .describe("How to choose the problem"),
39
+ idOrSlug: tool.schema.string().optional().describe("Problem id or slug (required for mode=specific)"),
40
+ difficulty: tool.schema.enum(["Easy", "Medium", "Hard"]).optional(),
41
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Topic tags, e.g. ['graph']"),
42
+ },
43
+ async execute(args, context) {
44
+ const problem = await fetchProblem({
45
+ mode: args.mode,
46
+ idOrSlug: args.idOrSlug,
47
+ difficulty: args.difficulty,
48
+ tags: args.tags,
49
+ signal: context.abort,
50
+ });
51
+ context.metadata({ title: `${problem.title} (${problem.difficulty})` });
52
+ await log("info", "Fetched problem", { slug: problem.slug, difficulty: problem.difficulty });
53
+ const metadata = {
54
+ id: problem.frontendId || problem.id,
55
+ slug: problem.slug,
56
+ title: problem.title,
57
+ difficulty: problem.difficulty,
58
+ topics: problem.topics,
59
+ url: problem.url,
60
+ };
61
+ return `${renderProblemForAgent(problem)}\n\n<!-- source-metadata ${JSON.stringify(
62
+ metadata,
63
+ )} -->`;
64
+ },
65
+ });
66
+
67
+ const scaffoldTool = tool({
68
+ description:
69
+ "Create a real-world practice project from a spec (scenario, requirements, starter, tests). " +
70
+ "Rejects any spec that leaks the original problem, its title, slug, or a coding-practice site name.",
71
+ args: {
72
+ title: tool.schema.string().describe("Real-world project title (no coding-practice words)"),
73
+ scenario: tool.schema.string().describe("Business context, written like a real ticket"),
74
+ pattern: tool.schema.string().describe("Underlying algorithmic principle (internal only)"),
75
+ projectName: tool.schema.string().optional().describe("kebab-case directory name"),
76
+ difficulty: tool.schema.string().optional(),
77
+ language: tool.schema
78
+ .enum(["typescript", "javascript", "python"])
79
+ .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"),
83
+ requirements: tool.schema.array(tool.schema.string()).default([]).describe("Testable requirements"),
84
+ edgeCases: tool.schema.array(tool.schema.string()).default([]),
85
+ starterCode: tool.schema.string().optional().describe("Optional starter implementation"),
86
+ publicTests: tool.schema.array(testCaseSchema).default([]).describe("Visible test cases"),
87
+ hiddenTests: tool.schema.array(testCaseSchema).default([]).describe("Extra acceptance test cases"),
88
+ outDir: tool.schema.string().optional().describe("Target directory, relative to the session directory"),
89
+ sourceSlug: tool.schema.string().optional().describe("Provenance only; never surfaced"),
90
+ sourceTitle: tool.schema.string().optional().describe("Provenance only; never surfaced"),
91
+ },
92
+ async execute(rawArgs, context) {
93
+ const spec = rawArgs as ScaffoldSpec;
94
+ const result = await scaffold(spec, { directory: context.directory });
95
+ context.metadata({ title: result.title });
96
+
97
+ const relative = path.relative(context.directory, result.outDir) || ".";
98
+ await log("info", "Scaffolded practice project", {
99
+ outDir: relative,
100
+ language: result.language,
101
+ files: result.files.length,
102
+ });
103
+ try {
104
+ await client.tui.showToast({
105
+ body: {
106
+ title: "Practice project ready",
107
+ message: `${result.title} -> ${relative}`,
108
+ variant: "success",
109
+ duration: 5000,
110
+ },
111
+ });
112
+ } catch {
113
+ /* toast is best-effort */
114
+ }
115
+
116
+ return [
117
+ `Created a real-world practice project at ${relative}`,
118
+ "",
119
+ `Title: ${result.title}`,
120
+ `Language: ${result.language}`,
121
+ `Files (${result.files.length}):`,
122
+ ...result.files.map((file) => ` - ${file}`),
123
+ "",
124
+ `Check your work with: ${result.checkCommand}`,
125
+ "The solution is intentionally left unimplemented. Do not write it for the learner.",
126
+ ].join("\n");
127
+ },
128
+ });
129
+
130
+ return {
131
+ config: async (config) => {
132
+ const commands = (config.command ??= {});
133
+ if (!commands.practice) {
134
+ commands.practice = {
135
+ template: PRACTICE_TEMPLATE,
136
+ description: PRACTICE_DESCRIPTION,
137
+ };
138
+ }
139
+ },
140
+
141
+ tool: {
142
+ leetcode_fetch: fetchTool,
143
+ leetcode_scaffold: scaffoldTool,
144
+ },
145
+
146
+ "tool.execute.before": async (input, output) => {
147
+ if (!isGuardedTool(input.tool)) return;
148
+ const args = (output.args ?? {}) as Record<string, unknown>;
149
+ const reference = collectPathReferences(input.tool, args).find(isProtectedReference);
150
+ if (reference) {
151
+ throw new Error(
152
+ `[leetcode-realworld] "${reference}" is protected practice material and cannot be accessed. ` +
153
+ "Hidden acceptance tests and problem provenance are off-limits so the exercise stays honest.",
154
+ );
155
+ }
156
+ },
157
+
158
+ event: async ({ event }) => {
159
+ if (event.type === "session.idle") {
160
+ await log("debug", "Session idle", { cwd: directory });
161
+ }
162
+ },
163
+ };
164
+ };
@@ -0,0 +1,257 @@
1
+ import type { CodeSnippet, NormalizedProblem } from "./types";
2
+
3
+ const DEFAULT_BASE_URL = "https://leetcode-api-pied.vercel.app";
4
+
5
+ export function apiBaseUrl(): string {
6
+ return (process.env.LEETCODE_API_BASE ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
7
+ }
8
+
9
+ export interface FetchOptions {
10
+ /** How to choose the problem. Defaults to `random`. */
11
+ mode?: "random" | "specific" | "daily" | "filter";
12
+ /** Required when `mode` is `specific`. Accepts an id ("1") or a slug ("two-sum"). */
13
+ idOrSlug?: string;
14
+ difficulty?: "Easy" | "Medium" | "Hard";
15
+ tags?: string[];
16
+ signal?: AbortSignal;
17
+ }
18
+
19
+ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
20
+ const url = `${apiBaseUrl()}${path}`;
21
+ let res: Response;
22
+ try {
23
+ res = await fetch(url, {
24
+ signal,
25
+ headers: { accept: "application/json", "user-agent": "opencode-leetcode-realworld" },
26
+ });
27
+ } catch (error) {
28
+ throw new Error(`Failed to reach LeetCode API at ${url}: ${(error as Error).message}`);
29
+ }
30
+ if (!res.ok) {
31
+ throw new Error(`LeetCode API responded ${res.status} ${res.statusText} for ${url}`);
32
+ }
33
+ return (await res.json()) as T;
34
+ }
35
+
36
+ function htmlToText(html: string): string {
37
+ if (!html) return "";
38
+ return html
39
+ .replace(/<sup>\s*([^<]*)\s*<\/sup>/gi, "^$1")
40
+ .replace(/<br\s*\/?>/gi, "\n")
41
+ .replace(/<\/(p|div|pre|li|ul|ol|h[1-6])>/gi, "\n")
42
+ .replace(/<[^>]+>/g, "")
43
+ .replace(/&nbsp;/g, " ")
44
+ .replace(/&lt;/g, "<")
45
+ .replace(/&gt;/g, ">")
46
+ .replace(/&quot;/g, '"')
47
+ .replace(/&#39;/g, "'")
48
+ .replace(/&amp;/g, "&")
49
+ .replace(/[ \t]+\n/g, "\n")
50
+ .replace(/\n{3,}/g, "\n\n")
51
+ .trim();
52
+ }
53
+
54
+ function normalizeSnippets(raw: unknown): CodeSnippet[] {
55
+ if (!Array.isArray(raw)) return [];
56
+ return raw
57
+ .map((entry) => {
58
+ const record = entry as Record<string, unknown>;
59
+ return {
60
+ lang: String(record.lang ?? record.langSlug ?? ""),
61
+ langSlug: String(record.langSlug ?? record.langsSlug ?? record.lang ?? ""),
62
+ code: String(record.code ?? ""),
63
+ };
64
+ })
65
+ .filter((snippet) => snippet.code.length > 0);
66
+ }
67
+
68
+ function normalizeTopics(raw: unknown): string[] {
69
+ if (!Array.isArray(raw)) return [];
70
+ return raw
71
+ .map((entry) => {
72
+ if (typeof entry === "string") return entry;
73
+ const record = entry as Record<string, unknown>;
74
+ return String(record.name ?? record.slug ?? "");
75
+ })
76
+ .filter((name) => name.length > 0);
77
+ }
78
+
79
+ function extractAcRate(raw: Record<string, unknown>): number | undefined {
80
+ if (typeof raw.acRate === "number") return raw.acRate;
81
+ if (typeof raw.stats === "string") {
82
+ try {
83
+ const parsed = JSON.parse(raw.stats) as { acRate?: string | number };
84
+ if (parsed.acRate !== undefined) return Number.parseFloat(String(parsed.acRate));
85
+ } catch {
86
+ /* ignore malformed stats */
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ export function normalizeProblem(
93
+ raw: Record<string, unknown>,
94
+ fallbackSlug?: string,
95
+ ): NormalizedProblem {
96
+ const nested = (raw.question ?? {}) as Record<string, unknown>;
97
+ const contentHtml = String(raw.content ?? nested.content ?? "");
98
+ const slug = String(
99
+ raw.titleSlug ?? raw.title_slug ?? raw.slug ?? nested.titleSlug ?? fallbackSlug ?? "",
100
+ );
101
+ const acRate = extractAcRate(raw);
102
+ return {
103
+ id: String(raw.questionId ?? nested.questionId ?? raw.id ?? ""),
104
+ frontendId: String(
105
+ raw.questionFrontendId ?? nested.questionFrontendId ?? raw.frontend_id ?? "",
106
+ ),
107
+ slug,
108
+ title: String(raw.title ?? nested.title ?? ""),
109
+ difficulty: String(raw.difficulty ?? nested.difficulty ?? "Unknown"),
110
+ url: String(raw.url ?? (slug ? `https://leetcode.com/problems/${slug}/` : "")),
111
+ topics: normalizeTopics(raw.topicTags ?? nested.topicTags),
112
+ contentHtml,
113
+ contentText: htmlToText(contentHtml),
114
+ hints: Array.isArray(raw.hints)
115
+ ? raw.hints.map((hint) => htmlToText(String(hint)))
116
+ : [],
117
+ snippets: normalizeSnippets(raw.codeSnippets ?? nested.codeSnippets),
118
+ acRate,
119
+ paidOnly: Boolean(raw.isPaidOnly ?? raw.paid_only ?? nested.isPaidOnly ?? false),
120
+ date: typeof raw.date === "string" ? raw.date : undefined,
121
+ };
122
+ }
123
+
124
+ function preferredSlug(idOrSlug: string | undefined): string | undefined {
125
+ if (!idOrSlug) return undefined;
126
+ return /^\d+$/.test(idOrSlug.trim()) ? undefined : idOrSlug.trim();
127
+ }
128
+
129
+ /**
130
+ * A problem is usable for practice generation only if it is free and actually
131
+ * carries a statement. The API returns `content: null` (with `isPaidOnly: true`)
132
+ * for premium problems.
133
+ */
134
+ export function isUsableProblem(problem: NormalizedProblem): boolean {
135
+ return !problem.paidOnly && problem.contentText.trim().length > 40;
136
+ }
137
+
138
+ async function pickUsable(
139
+ make: () => Promise<NormalizedProblem>,
140
+ attempts = 6,
141
+ ): Promise<NormalizedProblem> {
142
+ let last: NormalizedProblem | undefined;
143
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
144
+ const candidate = await make();
145
+ last = candidate;
146
+ if (isUsableProblem(candidate)) return candidate;
147
+ }
148
+ if (!last) throw new Error("LeetCode API returned no problems.");
149
+ return last;
150
+ }
151
+
152
+ export async function getProblem(idOrSlug: string, signal?: AbortSignal): Promise<NormalizedProblem> {
153
+ const raw = await getJson<Record<string, unknown>>(
154
+ `/problem/${encodeURIComponent(idOrSlug)}`,
155
+ signal,
156
+ );
157
+ return normalizeProblem(raw, preferredSlug(idOrSlug));
158
+ }
159
+
160
+ interface RandomProblem {
161
+ title_slug?: string;
162
+ titleSlug?: string;
163
+ }
164
+
165
+ export async function getRandom(
166
+ opts: { difficulty?: string; tags?: string[]; signal?: AbortSignal } = {},
167
+ ): Promise<NormalizedProblem> {
168
+ const params = new URLSearchParams();
169
+ if (opts.difficulty) params.set("difficulty", opts.difficulty);
170
+ if (opts.tags?.length) params.set("tags", opts.tags.join(","));
171
+ const query = params.toString();
172
+ const raw = await getJson<RandomProblem>(`/random${query ? `?${query}` : ""}`, opts.signal);
173
+ const slug = raw.title_slug ?? raw.titleSlug;
174
+ if (!slug) throw new Error("LeetCode API /random did not return a problem slug.");
175
+ return getProblem(slug, opts.signal);
176
+ }
177
+
178
+ export async function getDaily(signal?: AbortSignal): Promise<NormalizedProblem> {
179
+ const raw = await getJson<Record<string, unknown>>("/daily", signal);
180
+ return normalizeProblem(raw);
181
+ }
182
+
183
+ interface FilterResponse {
184
+ problems?: Array<Record<string, unknown>>;
185
+ }
186
+
187
+ export async function getFiltered(
188
+ opts: { difficulty?: string; tags?: string[]; limit?: number; signal?: AbortSignal } = {},
189
+ ): Promise<NormalizedProblem> {
190
+ const params = new URLSearchParams();
191
+ if (opts.difficulty) params.set("difficulty", opts.difficulty);
192
+ if (opts.tags?.length) params.set("tags", opts.tags.join(","));
193
+ params.set("limit", String(opts.limit ?? 20));
194
+ const raw = await getJson<FilterResponse>(`/problems/filter?${params.toString()}`, opts.signal);
195
+ const candidates = raw.problems ?? [];
196
+ if (candidates.length === 0) throw new Error("LeetCode API returned no problems for the given filter.");
197
+ const choice = candidates[Math.floor(Math.random() * candidates.length)] as Record<string, unknown>;
198
+ const slug = String(choice.title_slug ?? choice.titleSlug ?? "");
199
+ if (!slug) throw new Error("Filtered problem is missing a slug.");
200
+ return getProblem(slug, opts.signal);
201
+ }
202
+
203
+ export async function fetchProblem(options: FetchOptions = {}): Promise<NormalizedProblem> {
204
+ const mode = options.mode ?? "random";
205
+ switch (mode) {
206
+ case "specific": {
207
+ if (!options.idOrSlug) throw new Error("`idOrSlug` is required when mode is `specific`.");
208
+ return getProblem(options.idOrSlug, options.signal);
209
+ }
210
+ case "daily": {
211
+ const daily = await getDaily(options.signal);
212
+ if (isUsableProblem(daily)) return daily;
213
+ return pickUsable(() =>
214
+ getRandom({ difficulty: options.difficulty, tags: options.tags, signal: options.signal }),
215
+ );
216
+ }
217
+ case "filter":
218
+ return pickUsable(() =>
219
+ getFiltered({
220
+ difficulty: options.difficulty,
221
+ tags: options.tags,
222
+ signal: options.signal,
223
+ }),
224
+ );
225
+ case "random":
226
+ default:
227
+ return pickUsable(() =>
228
+ getRandom({ difficulty: options.difficulty, tags: options.tags, signal: options.signal }),
229
+ );
230
+ }
231
+ }
232
+
233
+ export interface TagInfo {
234
+ name: string;
235
+ slug: string;
236
+ problem_count: number;
237
+ }
238
+
239
+ export async function getTags(signal?: AbortSignal): Promise<TagInfo[]> {
240
+ return getJson<TagInfo[]>("/tags", signal);
241
+ }
242
+
243
+ /** Render a compact, agent-friendly view of the problem. Never write this to disk. */
244
+ export function renderProblemForAgent(problem: NormalizedProblem): string {
245
+ const lines: string[] = [];
246
+ lines.push(`# ${problem.title} (${problem.difficulty})`);
247
+ if (problem.topics.length) lines.push(`Topics: ${problem.topics.join(", ")}`);
248
+ if (problem.acRate !== undefined) lines.push(`Acceptance: ${problem.acRate}%`);
249
+ lines.push("");
250
+ lines.push(problem.contentText || "(no statement returned)");
251
+ if (problem.hints.length) {
252
+ lines.push("");
253
+ lines.push("## Hints (do not reveal verbatim)");
254
+ for (const hint of problem.hints) lines.push(`- ${hint}`);
255
+ }
256
+ return lines.join("\n");
257
+ }
@@ -0,0 +1,142 @@
1
+ import type { ScaffoldSpec } from "./types";
2
+
3
+ /**
4
+ * Deterministic fallback inspiration. Maps a normalized topic tag to plausible
5
+ * real-world domains that exercise the same underlying principle. The agent is
6
+ * free to invent better scenarios; this exists so a sensible seed is always
7
+ * available even with a weak model.
8
+ */
9
+ export const DOMAIN_SEEDS: Record<string, string[]> = {
10
+ array: ["batch audit-log processor", "telemetry windowing service"],
11
+ string: ["log sanitizer", "template renderer"],
12
+ "hash-table": ["event deduplication pipeline", "feature-flag lookup cache"],
13
+ "two-pointers": ["order-book matching engine", "sorted log-stream merger"],
14
+ "sliding-window": ["API rate limiter", "rolling p95 latency tracker"],
15
+ "binary-search": ["price-threshold alert engine", "pagination cursor resolver"],
16
+ "binary-search-tree": ["in-memory index range scanner", "time-series retention policy"],
17
+ "heap-priority-queue": ["background job scheduler", "incident severity triage"],
18
+ "priority-queue": ["background job scheduler", "incident severity triage"],
19
+ "dynamic-programming": ["delivery route cost optimizer", "budget allocation planner"],
20
+ greedy: ["resource bin-packing scheduler", "ad-slot allocator"],
21
+ graph: ["microservice dependency resolver", "network topology mapper"],
22
+ "depth-first-search": ["permission inheritance resolver", "nested category walker"],
23
+ "breadth-first-search": ["service-mesh shortest-path router", "org-chart traversal"],
24
+ "union-find": ["account clustering for fraud rings", "network segment merger"],
25
+ trie: ["autocomplete search index", "routing prefix matcher"],
26
+ stack: ["config-rule expression evaluator", "undo/redo command stack"],
27
+ queue: ["async task pipeline", "event buffer"],
28
+ "monotonic-stack": ["stock-span style sidebar calculator", "histogram-based capacity planner"],
29
+ sorting: ["leaderboard ranker", "changelog assembly job"],
30
+ backtracking: ["deployment permutation explorer", "seat-assignment planner"],
31
+ "bit-manipulation": ["feature-flag bitmask service", "compact permission encoder"],
32
+ math: ["billing proration engine", "metrics aggregation service"],
33
+ design: ["in-memory key-value store", "rate-limited API gateway"],
34
+ "linked-list": ["LRU eviction chain", "streaming ring buffer"],
35
+ tree: ["filesystem index builder", "org hierarchy aggregator"],
36
+ "prefix-sum": ["rolling revenue aggregator", "subnet traffic accounting"],
37
+ "segment-tree": ["range-quota enforcement service", "metrics rollup store"],
38
+ database: ["analytics reconciliation job", "query result differ"],
39
+ simulation: ["tick-based match engine", "capacity planning simulator"],
40
+ "ordered-set": ["leaderboard with rank lookup", "scheduler keyed by priority"],
41
+ "topological-sort": ["build-order planner", "migration dependency sequencer"],
42
+ };
43
+
44
+ /** Tags that are too generic to drive a scenario on their own. */
45
+ const WEAK_TAGS = new Set(["algorithms", "database", "math"]);
46
+
47
+ function normalizeTag(tag: string): string {
48
+ return tag.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
49
+ }
50
+
51
+ export function domainSeedsFor(topics: string[]): string[] {
52
+ const seeds: string[] = [];
53
+ for (const topic of topics) {
54
+ const key = normalizeTag(topic);
55
+ const matches = DOMAIN_SEEDS[key];
56
+ if (matches && !WEAK_TAGS.has(key)) seeds.push(...matches);
57
+ }
58
+ if (seeds.length === 0) {
59
+ seeds.push("internal developer-tooling service", "operations workflow automation");
60
+ }
61
+ return Array.from(new Set(seeds));
62
+ }
63
+
64
+ export function pickDomainSeed(topics: string[], random: () => number = Math.random): string {
65
+ const seeds = domainSeedsFor(topics);
66
+ return seeds[Math.floor(random() * seeds.length)] ?? seeds[0]!;
67
+ }
68
+
69
+ const GENERIC_BANNED_TERMS = [
70
+ "leetcode",
71
+ "leet code",
72
+ "geeksforgeeks",
73
+ "hackerrank",
74
+ "codewars",
75
+ "neetcode",
76
+ "blind 75",
77
+ "grind 75",
78
+ ];
79
+
80
+ export interface LeakReport {
81
+ term: string;
82
+ field: string;
83
+ }
84
+
85
+ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
86
+ const entries: Array<[string, string]> = [];
87
+ const push = (field: string, value: unknown) => {
88
+ if (typeof value === "string" && value.length > 0) entries.push([field, value]);
89
+ };
90
+ push("title", spec.title);
91
+ push("projectName", spec.projectName);
92
+ push("scenario", spec.scenario);
93
+ push("pattern", spec.pattern);
94
+ push("module", spec.module);
95
+ push("functionName", spec.functionName);
96
+ push("starterCode", spec.starterCode);
97
+ (spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
98
+ (spec.edgeCases ?? []).forEach((edge, i) => push(`edgeCases[${i}]`, edge));
99
+ (spec.publicTests ?? []).forEach((test, i) => push(`publicTests[${i}].name`, test.name));
100
+ (spec.hiddenTests ?? []).forEach((test, i) => push(`hiddenTests[${i}].name`, test.name));
101
+ return entries;
102
+ }
103
+
104
+ /**
105
+ * Guards the core promise of the plugin: the assignment must not be the LeetCode
106
+ * problem in disguise. Scans every learner-visible string for the source problem
107
+ * and generic problem-site branding.
108
+ */
109
+ export function findLeaks(spec: ScaffoldSpec): LeakReport[] {
110
+ const lowered = spec.sourceTitle?.toLowerCase().trim();
111
+ const sourceSlug = spec.sourceSlug?.toLowerCase().trim();
112
+ const reports: LeakReport[] = [];
113
+
114
+ for (const [field, value] of textsFromSpec(spec)) {
115
+ const haystack = value.toLowerCase();
116
+ for (const term of GENERIC_BANNED_TERMS) {
117
+ if (haystack.includes(term)) reports.push({ term, field });
118
+ }
119
+ if (lowered && lowered.length > 3 && haystack.includes(lowered)) {
120
+ reports.push({ term: `source title "${spec.sourceTitle}"`, field });
121
+ }
122
+ if (sourceSlug && sourceSlug.length > 2 && haystack.includes(sourceSlug)) {
123
+ reports.push({ term: `source slug "${spec.sourceSlug}"`, field });
124
+ }
125
+ }
126
+
127
+ const unique = new Map<string, LeakReport>();
128
+ for (const report of reports) unique.set(`${report.term}::${report.field}`, report);
129
+ return Array.from(unique.values());
130
+ }
131
+
132
+ export function assertNoLeak(spec: ScaffoldSpec): void {
133
+ const leaks = findLeaks(spec);
134
+ if (leaks.length === 0) return;
135
+ const details = leaks.map((l) => ` - "${l.term}" in ${l.field}`).join("\n");
136
+ throw new Error(
137
+ "The scaffold spec leaks the original problem, which breaks the whole point of this plugin.\n" +
138
+ "Rewrite the flagged fields so the assignment stands on its own as real software work:\n" +
139
+ `${details}\n` +
140
+ "Never mention the problem title, its slug, or any coding-practice site.",
141
+ );
142
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,80 @@
1
+ export const PRACTICE_DESCRIPTION =
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
+
4
+ /**
5
+ * The `/practice` command prompt. It orchestrates the two plugin tools and is
6
+ * deliberately opinionated: the whole value of this plugin is that the learner
7
+ * never sees the original puzzle.
8
+ */
9
+ export const PRACTICE_TEMPLATE = `You are running the "LeetCode -> Real World" practice generator.
10
+
11
+ Goal: fetch a LeetCode problem, extract the *algorithmic principle* behind it, and
12
+ produce a realistic software-engineering assignment that requires the same
13
+ principle. The learner must solve a genuine engineering task, never the original
14
+ puzzle.
15
+
16
+ ## 1. Parse arguments
17
+ Arguments: "$ARGUMENTS"
18
+ Recognise any of these (all optional):
19
+ - difficulty: easy | medium | hard
20
+ - tags: comma-separated topic tags, e.g. "dynamic-programming,graph"
21
+ - 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.
24
+
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.
29
+
30
+ ## 3. Derive the principle
31
+ Identify the underlying algorithmic idea (e.g. sliding window, heap-based
32
+ scheduling, union-find, DP over intervals). Do not carry over the problem's
33
+ fiction (arrays of "nums", "target", etc.).
34
+
35
+ ## 4. Invent a real engineering scenario
36
+ Design a plausible product/business task that genuinely needs that principle. Good
37
+ domains: observability pipelines, payments/ledgering, logistics, rate limiting,
38
+ access control, search, feature flags, data reconciliation, scheduling.
39
+ 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.
41
+
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
+ Call the tool \`leetcode_scaffold\` with a spec containing:
46
+ - title: real-world project title (no coding-practice words)
47
+ - scenario: 1-3 paragraphs of business context
48
+ - pattern: the underlying principle (internal only)
49
+ - language: chosen language
50
+ - requirements: concrete, testable bullet points
51
+ - edgeCases: tricky situations the solution must handle
52
+ - 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)
56
+
57
+ Test cases must be JSON-serialisable: \`{ "name": string, "input": any, "expected": any }\`.
58
+
59
+ ## 5. Rules (non-negotiable)
60
+ - The generated title, scenario, requirements, comments, and test names MUST NOT
61
+ mention LeetCode, its title, its slug, or any coding-practice site. The
62
+ \`leetcode_scaffold\` tool will reject the spec if it does.
63
+ - DO NOT implement the solution. Your job ends when the scaffold is written. The
64
+ learner implements \`src/...\` themselves.
65
+ - Do not reveal the fetched problem or the original examples.
66
+ - If the tool reports a leak, rewrite the offending fields and retry.
67
+
68
+ ## 6. After scaffolding
69
+ Report back with, in order:
70
+ 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.
74
+ Then stop. If the learner asks for help, give guiding hints and ask questions
75
+ rather than writing the algorithm for them.
76
+ `;
77
+
78
+ export function practiceTemplate(): string {
79
+ return PRACTICE_TEMPLATE;
80
+ }
@@ -0,0 +1,417 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertNoLeak } from "./patterns";
4
+ import type { PracticeLanguage, ScaffoldResult, ScaffoldSpec, TestCase } from "./types";
5
+
6
+ function kebab(input: string): string {
7
+ return input
8
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
9
+ .replace(/[^a-zA-Z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .toLowerCase();
12
+ }
13
+
14
+ function camel(input: string): string {
15
+ const parts = kebab(input).split("-").filter(Boolean);
16
+ return parts
17
+ .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
18
+ .join("");
19
+ }
20
+
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
+ interface ResolvedNames {
31
+ projectName: string;
32
+ module: string;
33
+ functionName: string;
34
+ title: string;
35
+ }
36
+
37
+ function resolveNames(spec: ScaffoldSpec): ResolvedNames {
38
+ 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 };
44
+ }
45
+
46
+ function json(value: unknown, indent = 2): string {
47
+ return JSON.stringify(value, null, indent);
48
+ }
49
+
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}]`;
61
+ }
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}}`;
69
+ }
70
+ return "None";
71
+ }
72
+
73
+ function requirementsSection(spec: ScaffoldSpec): string {
74
+ const requirements = spec.requirements ?? [];
75
+ if (requirements.length === 0) return "";
76
+ const lines = requirements.map((req, i) => `${i + 1}. ${req}`);
77
+ return `## Requirements\n\n${lines.join("\n")}\n`;
78
+ }
79
+
80
+ function edgeCaseSection(spec: ScaffoldSpec): string {
81
+ const edgeCases = spec.edgeCases ?? [];
82
+ if (edgeCases.length === 0) return "";
83
+ const lines = edgeCases.map((edge) => `- ${edge}`);
84
+ return `## Edge cases to handle\n\n${lines.join("\n")}\n`;
85
+ }
86
+
87
+ function readmeFor(names: ResolvedNames, language: PracticeLanguage): string {
88
+ const run = runCommands(language);
89
+ return `# ${names.title}
90
+
91
+ ${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.
94
+
95
+ ## Getting started
96
+
97
+ \`\`\`bash
98
+ ${run.install ? `${run.install}\n` : ""}${run.check}
99
+ \`\`\`
100
+
101
+ ## Layout
102
+
103
+ \`\`\`
104
+ ${sourcePath(names, language)} # implement your solution here
105
+ tests/public/ # visible behavioural tests
106
+ tests/hidden/ # additional acceptance tests (do not edit)
107
+ PROJECT.md # scenario, requirements, definition of done
108
+ \`\`\`
109
+
110
+ Implement until \`${run.check}\` is green. Prefer clarity over cleverness: the goal is
111
+ production-quality code, not the shortest possible snippet.
112
+ `;
113
+ }
114
+
115
+ function projectDoc(spec: ScaffoldSpec, names: ResolvedNames): string {
116
+ return `# ${names.title}
117
+
118
+ ## Scenario
119
+
120
+ ${spec.scenario.trim()}
121
+
122
+ ${requirementsSection(spec)}
123
+ ${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
187
+
188
+ from src.${names.module} import ${names.functionName}
189
+
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};
219
+
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
+ });
227
+ `;
228
+ }
229
+
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/
309
+ __pycache__/
310
+ .pytest_cache/
311
+ .venv/
312
+ venv/
313
+ # Acceptance tests are meant to stay local; don't commit the oracle.
314
+ tests/hidden/
315
+ .practice-meta.json
316
+ `;
317
+
318
+ function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: PracticeLanguage): string {
319
+ 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"
336
+ );
337
+ }
338
+
339
+ interface FileEntry {
340
+ relative: string;
341
+ contents: string;
342
+ }
343
+
344
+ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames): FileEntry[] {
345
+ const language = spec.language ?? "typescript";
346
+ const files: FileEntry[] = [
347
+ { relative: "README.md", contents: readmeFor(names, language) },
348
+ { relative: "PROJECT.md", contents: projectDoc(spec, names) },
349
+ { relative: ".gitignore", contents: PROJECT_GITIGNORE },
350
+ { 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) },
354
+ ];
355
+
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
+
370
+ return files;
371
+ }
372
+
373
+ export interface ScaffoldOptions {
374
+ /** Base directory to resolve relative paths against (the session directory). */
375
+ directory: string;
376
+ }
377
+
378
+ export async function scaffold(
379
+ spec: ScaffoldSpec,
380
+ options: ScaffoldOptions,
381
+ ): Promise<ScaffoldResult> {
382
+ const language = spec.language ?? "typescript";
383
+ const normalized: ScaffoldSpec = {
384
+ ...spec,
385
+ language,
386
+ requirements: spec.requirements ?? [],
387
+ edgeCases: spec.edgeCases ?? [],
388
+ publicTests: spec.publicTests ?? [],
389
+ hiddenTests: spec.hiddenTests ?? [],
390
+ };
391
+
392
+ assertNoLeak(normalized);
393
+
394
+ const names = resolveNames(normalized);
395
+ const target = normalized.outDir
396
+ ? path.resolve(options.directory, normalized.outDir)
397
+ : path.resolve(options.directory, ".practice", names.projectName);
398
+
399
+ const files = buildFiles(normalized, names);
400
+ for (const file of files) {
401
+ const destination = path.join(target, file.relative);
402
+ await mkdir(path.dirname(destination), { recursive: true });
403
+ await writeFile(destination, file.contents, "utf8");
404
+ }
405
+
406
+ const commands = runCommands(language);
407
+ return {
408
+ outDir: target,
409
+ title: names.title,
410
+ language,
411
+ files: files.map((file) => file.relative).sort(),
412
+ runCommand: commands.run,
413
+ checkCommand: commands.check,
414
+ };
415
+ }
416
+
417
+ export { resolveNames, kebab, camel, snake, pascal };
package/src/types.ts ADDED
@@ -0,0 +1,78 @@
1
+ export type PracticeLanguage = "typescript" | "javascript" | "python";
2
+
3
+ export interface CodeSnippet {
4
+ lang: string;
5
+ langSlug: string;
6
+ code: string;
7
+ }
8
+
9
+ /**
10
+ * A LeetCode problem reduced to a stable, API-shape-agnostic structure.
11
+ * The upstream API is not perfectly consistent between endpoints, so everything
12
+ * is funneled through `normalizeProblem` before it reaches the rest of the plugin.
13
+ */
14
+ export interface NormalizedProblem {
15
+ id: string;
16
+ frontendId: string;
17
+ slug: string;
18
+ title: string;
19
+ difficulty: string;
20
+ url: string;
21
+ topics: string[];
22
+ contentHtml: string;
23
+ contentText: string;
24
+ hints: string[];
25
+ snippets: CodeSnippet[];
26
+ acRate?: number;
27
+ paidOnly: boolean;
28
+ /** Only present for the `/daily` endpoint. */
29
+ date?: string;
30
+ }
31
+
32
+ export interface TestCase {
33
+ name: string;
34
+ input: unknown;
35
+ expected: unknown;
36
+ }
37
+
38
+ /**
39
+ * The contract the agent fills in after it has decided on a real-world scenario.
40
+ * None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
41
+ * before writing anything to disk.
42
+ */
43
+ export interface ScaffoldSpec {
44
+ /** kebab-case directory name. Derived from `title` when omitted. */
45
+ projectName?: string;
46
+ /** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
47
+ title: string;
48
+ /** Business context. Explains why the project matters and what it must do. */
49
+ scenario: string;
50
+ /** Underlying algorithmic principle, e.g. "sliding window". Internal only. */
51
+ pattern: string;
52
+ 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. */
57
+ functionName?: string;
58
+ requirements?: string[];
59
+ edgeCases?: string[];
60
+ /** Optional starter implementation. A TODO stub is generated when omitted. */
61
+ starterCode?: string;
62
+ publicTests?: TestCase[];
63
+ hiddenTests?: TestCase[];
64
+ /** Directory to create the project in, relative to the session directory. */
65
+ outDir?: string;
66
+ /** Internal provenance, stored in `.practice-meta.json` only. Never leaked. */
67
+ sourceSlug?: string;
68
+ sourceTitle?: string;
69
+ }
70
+
71
+ export interface ScaffoldResult {
72
+ outDir: string;
73
+ title: string;
74
+ language: PracticeLanguage;
75
+ files: string[];
76
+ runCommand: string;
77
+ checkCommand: string;
78
+ }