opencode-leetcode-realworld 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-leetcode-realworld",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
@@ -38,7 +38,12 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
38
38
  .describe("How to choose the problem"),
39
39
  idOrSlug: tool.schema.string().optional().describe("Problem id or slug (required for mode=specific)"),
40
40
  difficulty: tool.schema.enum(["Easy", "Medium", "Hard"]).optional(),
41
- tags: tool.schema.array(tool.schema.string()).optional().describe("Topic tags, e.g. ['graph']"),
41
+ tags: tool.schema
42
+ .array(tool.schema.string())
43
+ .optional()
44
+ .describe(
45
+ "Topic tag slugs to constrain the problem, e.g. ['array'] or ['dynamic-programming']",
46
+ ),
42
47
  },
43
48
  async execute(args, context) {
44
49
  const problem = await fetchProblem({
package/src/leetcode.ts CHANGED
@@ -157,19 +157,29 @@ export async function getProblem(idOrSlug: string, signal?: AbortSignal): Promis
157
157
  return normalizeProblem(raw, preferredSlug(idOrSlug));
158
158
  }
159
159
 
160
- interface RandomProblem {
160
+ interface ProblemListEntry {
161
161
  title_slug?: string;
162
162
  titleSlug?: string;
163
+ difficulty?: string;
164
+ paid_only?: boolean;
165
+ isPaidOnly?: boolean;
166
+ }
167
+
168
+ export function slugifyTag(input: string): string {
169
+ return input
170
+ .trim()
171
+ .toLowerCase()
172
+ .replace(/[^a-z0-9]+/g, "-")
173
+ .replace(/^-|-$/g, "");
163
174
  }
164
175
 
165
176
  export async function getRandom(
166
- opts: { difficulty?: string; tags?: string[]; signal?: AbortSignal } = {},
177
+ opts: { difficulty?: string; signal?: AbortSignal } = {},
167
178
  ): Promise<NormalizedProblem> {
168
179
  const params = new URLSearchParams();
169
180
  if (opts.difficulty) params.set("difficulty", opts.difficulty);
170
- if (opts.tags?.length) params.set("tags", opts.tags.join(","));
171
181
  const query = params.toString();
172
- const raw = await getJson<RandomProblem>(`/random${query ? `?${query}` : ""}`, opts.signal);
182
+ const raw = await getJson<ProblemListEntry>(`/random${query ? `?${query}` : ""}`, opts.signal);
173
183
  const slug = raw.title_slug ?? raw.titleSlug;
174
184
  if (!slug) throw new Error("LeetCode API /random did not return a problem slug.");
175
185
  return getProblem(slug, opts.signal);
@@ -181,20 +191,102 @@ export async function getDaily(signal?: AbortSignal): Promise<NormalizedProblem>
181
191
  }
182
192
 
183
193
  interface FilterResponse {
184
- problems?: Array<Record<string, unknown>>;
194
+ problems?: ProblemListEntry[];
195
+ }
196
+
197
+ /**
198
+ * IMPORTANT: `/problems/filter` and `/random` silently IGNORE the `tags` query
199
+ * parameter, so the only endpoint that actually filters by topic is
200
+ * `/problems/tag/{slug}`. Difficulty still has to be applied client-side there.
201
+ */
202
+ interface TagResponse {
203
+ tag?: string;
204
+ problems?: ProblemListEntry[];
205
+ }
206
+
207
+ async function listByTag(
208
+ tag: string,
209
+ difficulty: string | undefined,
210
+ signal: AbortSignal | undefined,
211
+ pages = 4,
212
+ ): Promise<ProblemListEntry[]> {
213
+ const results: ProblemListEntry[] = [];
214
+ const seen = new Set<string>();
215
+ for (let page = 0; page < pages; page += 1) {
216
+ const query = new URLSearchParams({ limit: "50", skip: String(page * 50) });
217
+ const raw = await getJson<TagResponse>(
218
+ `/problems/tag/${encodeURIComponent(tag)}?${query.toString()}`,
219
+ signal,
220
+ );
221
+ const problems = raw.problems ?? [];
222
+ if (problems.length === 0) break;
223
+ for (const problem of problems) {
224
+ const slug = String(problem.title_slug ?? problem.titleSlug ?? "");
225
+ if (!slug || seen.has(slug)) continue;
226
+ seen.add(slug);
227
+ if (problem.paid_only || problem.isPaidOnly) continue;
228
+ if (
229
+ difficulty &&
230
+ String(problem.difficulty ?? "").toLowerCase() !== difficulty.toLowerCase()
231
+ ) {
232
+ continue;
233
+ }
234
+ results.push(problem);
235
+ }
236
+ if (problems.length < 50) break;
237
+ }
238
+ return results;
239
+ }
240
+
241
+ function matchesTags(problem: NormalizedProblem, tags: string[]): boolean {
242
+ const present = problem.topics.map(slugifyTag);
243
+ // Some LeetCode tag slugs differ from the topic label, e.g. tag "graph" ->
244
+ // topic "Graph Theory" (slug "graph-theory"). Allow prefix aliases both ways.
245
+ return tags.every((tag) =>
246
+ present.some(
247
+ (topic) => topic === tag || topic.startsWith(`${tag}-`) || tag.startsWith(`${topic}-`),
248
+ ),
249
+ );
250
+ }
251
+
252
+ async function fetchByTags(
253
+ tags: string[],
254
+ difficulty: string | undefined,
255
+ signal?: AbortSignal,
256
+ ): Promise<NormalizedProblem> {
257
+ const candidates = await listByTag(tags[0]!, difficulty, signal);
258
+ if (candidates.length === 0) {
259
+ throw new Error(
260
+ `No ${difficulty ?? "matching"} problems found for tag "${tags[0]}". ` +
261
+ "Check the tag slug (e.g. array, hash-table, dynamic-programming).",
262
+ );
263
+ }
264
+ const strict = tags.length > 1;
265
+ const shuffled = [...candidates].sort(() => Math.random() - 0.5);
266
+ const attempts = Math.min(shuffled.length, 12);
267
+ let fallback: NormalizedProblem | undefined;
268
+ for (let index = 0; index < attempts; index += 1) {
269
+ const slug = String(shuffled[index]!.title_slug ?? shuffled[index]!.titleSlug ?? "");
270
+ if (!slug) continue;
271
+ const problem = await getProblem(slug, signal);
272
+ if (!isUsableProblem(problem)) continue;
273
+ fallback = fallback ?? problem;
274
+ if (!strict || matchesTags(problem, tags)) return problem;
275
+ }
276
+ if (fallback) return fallback;
277
+ throw new Error(`Could not find a usable problem for tag "${tags[0]}".`);
185
278
  }
186
279
 
187
280
  export async function getFiltered(
188
- opts: { difficulty?: string; tags?: string[]; limit?: number; signal?: AbortSignal } = {},
281
+ opts: { difficulty?: string; limit?: number; signal?: AbortSignal } = {},
189
282
  ): Promise<NormalizedProblem> {
190
283
  const params = new URLSearchParams();
191
284
  if (opts.difficulty) params.set("difficulty", opts.difficulty);
192
- if (opts.tags?.length) params.set("tags", opts.tags.join(","));
193
285
  params.set("limit", String(opts.limit ?? 20));
194
286
  const raw = await getJson<FilterResponse>(`/problems/filter?${params.toString()}`, opts.signal);
195
287
  const candidates = raw.problems ?? [];
196
288
  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>;
289
+ const choice = candidates[Math.floor(Math.random() * candidates.length)]!;
198
290
  const slug = String(choice.title_slug ?? choice.titleSlug ?? "");
199
291
  if (!slug) throw new Error("Filtered problem is missing a slug.");
200
292
  return getProblem(slug, opts.signal);
@@ -202,32 +294,27 @@ export async function getFiltered(
202
294
 
203
295
  export async function fetchProblem(options: FetchOptions = {}): Promise<NormalizedProblem> {
204
296
  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
- );
297
+ const tags = (options.tags ?? []).map(slugifyTag).filter((tag) => tag.length > 0);
298
+
299
+ if (mode === "specific") {
300
+ if (!options.idOrSlug) throw new Error("`idOrSlug` is required when mode is `specific`.");
301
+ return getProblem(options.idOrSlug, options.signal);
302
+ }
303
+
304
+ if (mode === "daily" && tags.length === 0) {
305
+ const daily = await getDaily(options.signal);
306
+ if (isUsableProblem(daily)) return daily;
307
+ return pickUsable(() => getRandom({ difficulty: options.difficulty, signal: options.signal }));
308
+ }
309
+
310
+ if (tags.length > 0) {
311
+ return fetchByTags(tags, options.difficulty, options.signal);
312
+ }
313
+
314
+ if (mode === "filter") {
315
+ return pickUsable(() => getFiltered({ difficulty: options.difficulty, signal: options.signal }));
230
316
  }
317
+ return pickUsable(() => getRandom({ difficulty: options.difficulty, signal: options.signal }));
231
318
  }
232
319
 
233
320
  export interface TagInfo {
package/src/prompt.ts CHANGED
@@ -29,10 +29,19 @@ puzzle.
29
29
  Arguments: "$ARGUMENTS"
30
30
  Recognise any of these (all optional):
31
31
  - difficulty: easy | medium | hard
32
- - tags: comma-separated topic tags, e.g. "dynamic-programming,graph"
32
+ - tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
33
33
  - a specific problem id or slug, e.g. "two-sum" or "1"
34
34
  - language: a supported language name or alias (below)
35
35
 
36
+ Mapping rules:
37
+ - A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
38
+ sliding-window, two-pointers, greedy, ...) is a TAG. Pass it to \`leetcode_fetch\`
39
+ as \`tags\` (an array of slugs, e.g. \`["array"]\`).
40
+ - Only pass \`idOrSlug\` when the argument is a number or a known problem slug
41
+ (e.g. "1", "two-sum"). Never pass a topic name as \`idOrSlug\`.
42
+ - When the user gives a tag, it MUST be respected. After fetching, if the problem's
43
+ topics do not include the requested tag, the fetch is wrong: retry with the tag.
44
+
36
45
  ## 2. Ask when unclear (use the \`question\` tool)
37
46
  Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
38
47
 
@@ -47,9 +56,11 @@ If the user did specify values, skip the corresponding question. Only ask at all
47
56
  when something is missing.
48
57
 
49
58
  ## 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.
59
+ Call the \`leetcode_fetch\` tool with the difficulty, and pass any requested topic
60
+ as \`tags\` (array of slugs) — not as \`idOrSlug\`. Verify the returned topics
61
+ include every requested tag; if not, retry. The fetched statement is for YOUR
62
+ reasoning only. Never paste it, paraphrase it closely, or name its source anywhere
63
+ in the generated project.
53
64
 
54
65
  ## 4. Derive the principle
55
66
  Identify the underlying algorithmic idea (e.g. sliding window, heap-based
package/src/scaffold.ts CHANGED
@@ -130,6 +130,29 @@ function edgeCaseSection(spec: ScaffoldSpec): string {
130
130
  return `## Edge cases to handle\n\n${edgeCases.map((edge) => `- ${edge}`).join("\n")}\n`;
131
131
  }
132
132
 
133
+ function exampleSection(spec: ScaffoldSpec): string {
134
+ const tests = spec.publicTests ?? [];
135
+ if (tests.length === 0) return "";
136
+ const blocks = tests.slice(0, 3).map((test, index) => {
137
+ const input = json(test.input);
138
+ const output = json(test.expected);
139
+ return `### Example ${index + 1}: ${test.name}
140
+
141
+ **Input**
142
+
143
+ \`\`\`json
144
+ ${input}
145
+ \`\`\`
146
+
147
+ **Output**
148
+
149
+ \`\`\`json
150
+ ${output}
151
+ \`\`\``;
152
+ });
153
+ return `## Example input / output\n\nUse these to derive the logic.\n\n${blocks.join("\n\n")}\n`;
154
+ }
155
+
133
156
  function readmeFor(names: ResolvedNames, spec: ScaffoldSpec, language: LanguageId): string {
134
157
  const adapter = getLanguage(language);
135
158
  const source = `src/solution.${adapter.extension}`;
@@ -176,6 +199,7 @@ function projectDoc(spec: ScaffoldSpec, names: ResolvedNames, language: Language
176
199
 
177
200
  ${spec.scenario.trim()}
178
201
 
202
+ ${exampleSection(spec)}
179
203
  ${requirementsSection(spec)}
180
204
  ${edgeCaseSection(spec)}
181
205
  ## Interface