opencode-leetcode-realworld 0.2.3 → 0.4.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/src/patterns.ts CHANGED
@@ -92,11 +92,12 @@ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
92
92
  push("scenario", spec.scenario);
93
93
  push("pattern", spec.pattern);
94
94
  push("functionName", spec.functionName);
95
- push("starterCode", spec.starterCode);
95
+ push("task", spec.task);
96
96
  (spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
97
97
  (spec.edgeCases ?? []).forEach((edge, i) => push(`edgeCases[${i}]`, edge));
98
98
  (spec.publicTests ?? []).forEach((test, i) => push(`publicTests[${i}].name`, test.name));
99
99
  (spec.hiddenTests ?? []).forEach((test, i) => push(`hiddenTests[${i}].name`, test.name));
100
+ (spec.files ?? []).forEach((file, i) => push(`files[${i}] (${file.path})`, file.content));
100
101
  return entries;
101
102
  }
102
103
 
package/src/prompt.ts CHANGED
@@ -13,6 +13,9 @@ export const SUPPORTED_LANGUAGES = [
13
13
  "Swift",
14
14
  ];
15
15
 
16
+ /** Languages that can receive a full multi-file project. */
17
+ export const PROJECT_LANGUAGES = ["TypeScript", "Python", "Rust"];
18
+
16
19
  /**
17
20
  * The `/practice` command prompt. It orchestrates the two plugin tools and is
18
21
  * deliberately opinionated: the whole value of this plugin is that the learner
@@ -32,6 +35,7 @@ Recognise any of these (all optional):
32
35
  - tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
33
36
  - a specific problem id or slug, e.g. "two-sum" or "1"
34
37
  - language: a supported language name or alias (below)
38
+ - scope: "single" | "mini" | "full" (how they want to start)
35
39
 
36
40
  Mapping rules:
37
41
  - A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
@@ -42,77 +46,97 @@ Mapping rules:
42
46
  - When the user gives a tag, it MUST be respected. After fetching, if the problem's
43
47
  topics do not include the requested tag, the fetch is wrong: retry with the tag.
44
48
 
45
- ## 2. Ask when unclear (use the \`question\` tool)
46
- Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
49
+ ## 2. Ask how they want to start (use the \`question\` tool)
50
+
51
+ Step A — if the language and/or difficulty are missing, ask for them in one
52
+ \`question\` call. Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
53
+ Offer difficulty options "Easy", "Medium" (recommended), "Hard".
47
54
 
48
- - If the user did NOT specify a language, call the \`question\` tool and ask which
49
- language they want to practice, offering these as options:
50
- ${SUPPORTED_LANGUAGES.map((name) => `"${name}"`).join(", ")}.
51
- - If the user did NOT specify a difficulty, include a second question in the SAME
52
- \`question\` call offering "Easy", "Medium" (recommended), and "Hard".
53
- - Wait for the answers. Do not guess and do not default silently.
55
+ Step B — decide the *scope*. Full multi-file projects are only available for
56
+ ${PROJECT_LANGUAGES.join(", ")}.
57
+ - If the chosen language is one of those AND scope was not given, make a SECOND
58
+ \`question\` call titled "How do you want to start?" with these options:
59
+ - "Quick exercise" — one focused function with a JSON-in/JSON-out contract.
60
+ - "Mini project" — a small multi-file app (~3-6 files) with the algorithm at its core.
61
+ - "Fuller project" — a multi-file app with more realistic layers (~6-15 files).
62
+ - Otherwise (other language, or scope already given) skip this question.
54
63
 
55
- If the user did specify values, skip the corresponding question. Only ask at all
56
- when something is missing.
64
+ Wait for the answers. Do not guess.
57
65
 
58
66
  ## 3. Fetch the source problem (private)
59
67
  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.
68
+ as \`tags\` (array of slugs). Verify the returned topics include every requested
69
+ tag; if not, retry. The fetched statement is for YOUR reasoning only. Never paste
70
+ it, paraphrase it closely, or name its source anywhere in the generated project.
64
71
 
65
72
  ## 4. Derive the principle
66
73
  Identify the underlying algorithmic idea (e.g. sliding window, heap-based
67
74
  scheduling, union-find, DP over intervals). Do not carry over the problem's
68
75
  fiction (arrays of "nums", "target", etc.).
69
76
 
70
- ## 5. Invent a real engineering scenario
77
+ ## 5. Build the assignment
71
78
  Design a plausible product/business task that genuinely needs that principle. Good
72
- domains: observability pipelines, payments/ledgering, logistics, rate limiting,
73
- access control, search, feature flags, data reconciliation, scheduling.
74
- The scenario must read like a ticket from a real team: who needs it, why, and what
75
- "correct" means.
76
-
77
- The assignment has ONE entry point with a JSON-in / JSON-out contract:
78
- - It receives a single JSON value (usually an object describing a request).
79
- - It returns a single JSON value (a response or result).
80
- This is how real services, CLIs and jobs exchange data, and it lets the same test
81
- harness verify any language.
82
-
83
- Call the tool \`leetcode_scaffold\` with a spec containing:
84
- - title: real-world project title (no coding-practice words)
85
- - scenario: 1-3 paragraphs of business context
86
- - pattern: the underlying principle (internal only)
87
- - language: the language the user chose (use the canonical id, e.g. "typescript",
88
- "javascript", "python", "ruby", "php", "go", "rust", "csharp", "swift")
79
+ domains: observability, payments/ledgering, logistics, rate limiting, access
80
+ control, search, feature flags, data reconciliation, scheduling.
81
+
82
+ Then call \`leetcode_scaffold\`. There are two shapes:
83
+
84
+ ### A) mode = "single" (Quick exercise)
85
+ - One entry point with a JSON-in / JSON-out contract.
86
+ - Do NOT provide \`starterCode\`: the plugin generates a typed starter (type-safe
87
+ languages) or a documented one (dynamic languages) inferred from your test cases,
88
+ so make the first few cases representative (vary fields to expose optionals).
89
+ - Spec: mode, title, scenario, pattern, difficulty, language, requirements,
90
+ edgeCases, publicTests (5-8), hiddenTests (6-12 incl. empty + large), task optional,
91
+ sourceSlug, sourceTitle.
92
+
93
+ ### B) mode = "project" (only ${PROJECT_LANGUAGES.join(", ")})
94
+ Build a small but real application where the algorithm is the piece the learner
95
+ implements. You author the whole tree and pass it as \`files\`.
96
+ - Provide a working CLI entry (e.g. \`src/cli.ts\`, \`src/cli.py\`, or \`src/main.rs\`)
97
+ that reads ONE JSON value on stdin and writes ONE JSON value on stdout. Tests
98
+ drive this entry end-to-end.
99
+ - Put the algorithm behind a single, clearly marked \`TODO\` in a domain/service file
100
+ (e.g. \`// TODO: implement ...\`). Everything else — argument/stdin parsing, domain
101
+ models/types, wiring, config, a repository or helper — must be complete and runnable
102
+ so that the ONLY thing missing is that TODO.
103
+ - Keep the surrounding code real but not algorithm-heavy: parsing, types, plumbing.
104
+ - Respect the requested size: mini ~3-6 files, fuller ~6-15 files.
105
+ - Provide \`runCommand\` (runs one case) and, for compiled languages, \`buildCommand\`
106
+ (e.g. Rust: \`cargo build\`; then \`runCommand: ./target/debug/<bin>\`).
107
+ - Provide \`task\`: a short markdown brief naming the file and function to implement.
108
+ - Include everything needed to build/run (package.json/tsconfig, Cargo.toml, etc.).
109
+ - Import/runtime correctness (the tests run your \`runCommand\`):
110
+ - TypeScript: relative imports MUST include the \`.ts\` extension
111
+ (\`import { x } from "./service.ts"\`) because the app runs under Node ESM type
112
+ stripping, which does not resolve extensionless specifiers.
113
+ - Python: make imports work when the entry runs directly (\`python3 src/cli.py\`);
114
+ same-directory modules or a small \`sys.path\` adjustment are both fine.
115
+ - Rust: declare internal modules with \`mod ...;\` and keep them under \`src/\`.
116
+ - Do NOT implement the TODO and do not reveal the algorithm in comments.
117
+
118
+ Both shapes:
89
119
  - requirements: concrete, testable bullet points
90
120
  - edgeCases: tricky situations the solution must handle
91
- - starterCode: optional; otherwise a TODO stub is generated
92
- - publicTests: 5-8 visible cases that illustrate the contract
93
- - hiddenTests: 6-12 acceptance cases, including edge cases, boundary values, an
94
- empty/degenerate input, and at least one larger input
95
- - sourceSlug / sourceTitle: for internal provenance ONLY (never surfaced)
96
-
97
- Test cases must be JSON-serialisable: \`{ "name": string, "input": any, "expected": any }\`.
98
- Make sure every \`expected\` value is actually correct for the described rules, and
99
- that public and hidden cases do not overlap.
121
+ - publicTests / hiddenTests: JSON-serialisable \`{ "name": string, "input": any, "expected": any }\`.
122
+ Every \`expected\` must be correct for the described rules; public and hidden must not overlap.
123
+ - sourceSlug / sourceTitle: provenance ONLY, never surfaced.
100
124
 
101
125
  ## 6. Rules (non-negotiable)
102
- - The generated title, scenario, requirements, comments, and test names MUST NOT
103
- mention LeetCode, its title, its slug, or any coding-practice site. The
104
- \`leetcode_scaffold\` tool will reject the spec if it does.
105
- - DO NOT implement the solution. Your job ends when the scaffold is written. The
106
- learner implements the solution file themselves.
126
+ - Titles, scenarios, requirements, comments, file contents and test names MUST NOT
127
+ mention LeetCode, its title, its slug, or any coding-practice site. The tool
128
+ rejects leaks (including inside \`files\`).
129
+ - NEVER implement the algorithm (single mode: leave the entry unimplemented;
130
+ project mode: leave the TODO unimplemented). The learner does that.
107
131
  - Do not reveal the fetched problem or the original examples.
108
- - If the tool reports a leak, rewrite the offending fields and retry.
109
132
 
110
133
  ## 7. After scaffolding
111
134
  Report back with, in order:
112
135
  1. One short paragraph describing the assignment as a real task (no source spoilers).
113
- 2. The project path and chosen language.
136
+ 2. The project path, mode, and language.
114
137
  3. The exact command to run the tests (\`node tests/runner.mjs\`).
115
- 4. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
138
+ 4. For project mode: which file/function holds the TODO.
139
+ 5. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
116
140
  Then stop. If the learner asks for help, give guiding hints and ask questions
117
141
  rather than writing the algorithm for them.
118
142
  `;
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
+ import { getLanguage, normalizeLanguage, type LanguageContext } from "./languages";
4
4
  import { assertNoLeak } from "./patterns";
5
+ import { schemaFromSamples } from "./schema";
5
6
  import type { FileEntry, LanguageId, ScaffoldResult, ScaffoldSpec, TestCase } from "./types";
6
7
 
7
8
  function kebab(input: string): string {
@@ -231,9 +232,15 @@ tests/hidden/
231
232
  .practice-meta.json
232
233
  `;
233
234
 
234
- function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
235
+ function metaFor(
236
+ spec: ScaffoldSpec,
237
+ names: ResolvedNames,
238
+ language: LanguageId,
239
+ mode: "single" | "project" = "single",
240
+ ): string {
235
241
  return (
236
242
  json({
243
+ mode,
237
244
  title: names.title,
238
245
  functionName: names.functionName,
239
246
  language,
@@ -251,7 +258,17 @@ function casesFile(cases: TestCase[]): string {
251
258
 
252
259
  function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): FileEntry[] {
253
260
  const adapter = getLanguage(language);
254
- const ctx = { projectName: names.projectName, functionName: names.functionName, title: names.title };
261
+ const cases = [...(spec.publicTests ?? []), ...(spec.hiddenTests ?? [])];
262
+ const first = cases[0];
263
+ const ctx: LanguageContext = {
264
+ projectName: names.projectName,
265
+ functionName: names.functionName,
266
+ title: names.title,
267
+ input: schemaFromSamples(cases.map((test) => test.input)),
268
+ output: schemaFromSamples(cases.map((test) => test.expected)),
269
+ inputExample: first?.input ?? null,
270
+ outputExample: first?.expected ?? null,
271
+ };
255
272
 
256
273
  const files: FileEntry[] = [
257
274
  { relative: "README.md", contents: readmeFor(names, spec, language) },
@@ -278,6 +295,119 @@ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: Language
278
295
  return files;
279
296
  }
280
297
 
298
+ /** Languages that support full multi-file project mode. */
299
+ export const PROJECT_LANGUAGES = new Set<LanguageId>(["typescript", "python", "rust"]);
300
+
301
+ function sanitizeRelative(value: string): string {
302
+ const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
303
+ if (normalized.includes("..") || /^[a-zA-Z]:/.test(normalized)) {
304
+ throw new Error(`Refusing to write outside the project directory: "${value}"`);
305
+ }
306
+ return normalized;
307
+ }
308
+
309
+ function projectReadme(
310
+ names: ResolvedNames,
311
+ spec: ScaffoldSpec,
312
+ language: LanguageId,
313
+ ): string {
314
+ const adapter = getLanguage(language);
315
+ const fileList = (spec.files ?? [])
316
+ .map((file) => ` ${sanitizeRelative(file.path)}`)
317
+ .join("\n");
318
+ return `# ${names.title}
319
+
320
+ This is a small application, not a single function. Read \`PROJECT.md\` for the
321
+ brief, find the marked \`TODO\`, and implement the behaviour it needs.
322
+
323
+ ## Run the checks
324
+
325
+ \`\`\`bash
326
+ node tests/runner.mjs # all cases
327
+ node tests/runner.mjs --public # visible cases only
328
+ \`\`\`
329
+
330
+ Requires ${adapter.runtime}.
331
+
332
+ The app is invoked once per case as \`${spec.runCommand}\`: it reads **one JSON value
333
+ on stdin** and must write **one JSON value on stdout**. Debug output goes to stderr.
334
+
335
+ ## Files
336
+
337
+ \`\`\`
338
+ ${fileList}
339
+ \`\`\`
340
+ `;
341
+ }
342
+
343
+ function projectModeDoc(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
344
+ const task = spec.task?.trim() || "Find the `TODO` in the source and implement it.";
345
+ return `# ${names.title}
346
+
347
+ ## Scenario
348
+
349
+ ${spec.scenario.trim()}
350
+
351
+ ${exampleSection(spec)}
352
+ ${requirementsSection(spec)}
353
+ ${edgeCaseSection(spec)}
354
+ ## What to build
355
+
356
+ ${task}
357
+
358
+ ## Interface
359
+
360
+ The application is invoked as \`${spec.runCommand}\`. It reads **one JSON value from
361
+ stdin** and must write **one JSON value to stdout**. Only stdout is compared; use
362
+ stderr for logs.
363
+
364
+ ## Definition of done
365
+
366
+ - The full test suite passes: \`node tests/runner.mjs\`.
367
+ - The provided code is understood and reused, not bypassed.
368
+ - Edge cases above are handled, not just the happy path.
369
+ `;
370
+ }
371
+
372
+ function buildProjectFiles(
373
+ spec: ScaffoldSpec,
374
+ names: ResolvedNames,
375
+ language: LanguageId,
376
+ ): FileEntry[] {
377
+ const adapter = getLanguage(language);
378
+ const cases = [...(spec.publicTests ?? []), ...(spec.hiddenTests ?? [])];
379
+ const first = cases[0];
380
+ const ctx: LanguageContext = {
381
+ projectName: names.projectName,
382
+ functionName: names.functionName,
383
+ title: names.title,
384
+ input: schemaFromSamples(cases.map((test) => test.input)),
385
+ output: schemaFromSamples(cases.map((test) => test.expected)),
386
+ inputExample: first?.input ?? null,
387
+ outputExample: first?.expected ?? null,
388
+ };
389
+
390
+ const files: FileEntry[] = [
391
+ { relative: "README.md", contents: projectReadme(names, spec, language) },
392
+ { relative: "PROJECT.md", contents: projectModeDoc(spec, names, language) },
393
+ { relative: ".gitignore", contents: PROJECT_GITIGNORE },
394
+ { relative: ".practice-meta.json", contents: metaFor(spec, names, language, "project") },
395
+ {
396
+ relative: "harness.json",
397
+ contents:
398
+ json({ build: spec.buildCommand ?? adapter.build(ctx), run: spec.runCommand }) + "\n",
399
+ },
400
+ { relative: "tests/runner.mjs", contents: RUNNER },
401
+ { relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
402
+ { relative: "tests/hidden/cases.json", contents: casesFile(spec.hiddenTests ?? []) },
403
+ ];
404
+
405
+ for (const file of spec.files ?? []) {
406
+ files.push({ relative: sanitizeRelative(file.path), contents: file.content });
407
+ }
408
+ return files;
409
+ }
410
+
281
411
  export interface ScaffoldOptions {
282
412
  /** Base directory to resolve relative paths against (the session directory). */
283
413
  directory: string;
@@ -305,13 +435,35 @@ export async function scaffold(
305
435
 
306
436
  assertNoLeak(normalized);
307
437
 
438
+ const mode = normalized.mode === "project" ? "project" : "single";
439
+
308
440
  const adapter = getLanguage(language);
309
441
  const names = resolveNames(normalized);
442
+
443
+ if (mode === "project") {
444
+ if (!PROJECT_LANGUAGES.has(language)) {
445
+ throw new Error(
446
+ `Project mode is only supported for: ${[...PROJECT_LANGUAGES].join(", ")}. ` +
447
+ `Use mode "single" for ${adapter.name}.`,
448
+ );
449
+ }
450
+ if (!normalized.files || normalized.files.length === 0) {
451
+ throw new Error("Project mode requires a `files` list (the full file tree).");
452
+ }
453
+ if (!normalized.runCommand) {
454
+ throw new Error("Project mode requires a `runCommand` (run one case, JSON on stdin/stdout).");
455
+ }
456
+ }
457
+
310
458
  const target = normalized.outDir
311
459
  ? path.resolve(options.directory, normalized.outDir)
312
460
  : path.resolve(options.directory, ".practice", names.projectName);
313
461
 
314
- const files = buildFiles(normalized, names, language);
462
+ const files =
463
+ mode === "project"
464
+ ? buildProjectFiles(normalized, names, language)
465
+ : buildFiles(normalized, names, language);
466
+
315
467
  for (const file of files) {
316
468
  const destination = path.join(target, file.relative);
317
469
  await mkdir(path.dirname(destination), { recursive: true });
@@ -325,6 +477,7 @@ export async function scaffold(
325
477
  language,
326
478
  languageName: adapter.name,
327
479
  runtime: adapter.runtime,
480
+ mode,
328
481
  files: files.map((file) => file.relative).sort(),
329
482
  runCommand: check,
330
483
  checkCommand: check,
package/src/schema.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Infers a small structural schema from the sample test cases so the generated
3
+ * starter files can carry real types (type-safe languages) or a documented
4
+ * format (dynamic languages).
5
+ *
6
+ * This is intentionally schema-light: it only needs enough fidelity to describe
7
+ * JSON input/output shapes (objects, arrays, primitives, optionality).
8
+ */
9
+
10
+ export type SchemaNode =
11
+ | { kind: "string" }
12
+ | { kind: "number"; integer: boolean }
13
+ | { kind: "boolean" }
14
+ | { kind: "null" }
15
+ | { kind: "unknown" }
16
+ | { kind: "array"; element: SchemaNode }
17
+ | { kind: "object"; fields: FieldSchema[] };
18
+
19
+ export interface FieldSchema {
20
+ name: string;
21
+ schema: SchemaNode;
22
+ optional: boolean;
23
+ }
24
+
25
+ export function inferSchema(value: unknown): SchemaNode {
26
+ if (value === null || value === undefined) return { kind: "null" };
27
+ if (Array.isArray(value)) {
28
+ return { kind: "array", element: mergeMany(value.map(inferSchema)) };
29
+ }
30
+ switch (typeof value) {
31
+ case "string":
32
+ return { kind: "string" };
33
+ case "boolean":
34
+ return { kind: "boolean" };
35
+ case "number":
36
+ return { kind: "number", integer: Number.isInteger(value) };
37
+ case "object": {
38
+ const fields = Object.entries(value as Record<string, unknown>).map(([name, entry]) => ({
39
+ name,
40
+ schema: inferSchema(entry),
41
+ optional: false,
42
+ }));
43
+ return { kind: "object", fields };
44
+ }
45
+ default:
46
+ return { kind: "unknown" };
47
+ }
48
+ }
49
+
50
+ export function mergeSchemas(a: SchemaNode, b: SchemaNode): SchemaNode {
51
+ if (a.kind === "unknown") return b;
52
+ if (b.kind === "unknown") return a;
53
+ if (a.kind === "null") return b;
54
+ if (b.kind === "null") return a;
55
+
56
+ if (a.kind === "object" && b.kind === "object") {
57
+ const names = new Set([...a.fields.map((f) => f.name), ...b.fields.map((f) => f.name)]);
58
+ const fields: FieldSchema[] = [];
59
+ for (const name of names) {
60
+ const left = a.fields.find((f) => f.name === name);
61
+ const right = b.fields.find((f) => f.name === name);
62
+ if (left && right) {
63
+ fields.push({
64
+ name,
65
+ schema: mergeSchemas(left.schema, right.schema),
66
+ optional: left.optional && right.optional,
67
+ });
68
+ } else {
69
+ const only = (left ?? right)!;
70
+ fields.push({ name, schema: only.schema, optional: true });
71
+ }
72
+ }
73
+ return { kind: "object", fields };
74
+ }
75
+
76
+ if (a.kind === "array" && b.kind === "array") {
77
+ return { kind: "array", element: mergeSchemas(a.element, b.element) };
78
+ }
79
+
80
+ if (a.kind === "number" && b.kind === "number") {
81
+ return { kind: "number", integer: a.integer && b.integer };
82
+ }
83
+ if (a.kind === b.kind) return a;
84
+ return { kind: "unknown" };
85
+ }
86
+
87
+ function mergeMany(nodes: SchemaNode[]): SchemaNode {
88
+ if (nodes.length === 0) return { kind: "unknown" };
89
+ return nodes.reduce((acc, node) => mergeSchemas(acc, node));
90
+ }
91
+
92
+ export function schemaFromSamples(values: unknown[]): SchemaNode {
93
+ return mergeMany(values.map(inferSchema));
94
+ }
95
+
96
+ /** True when the schema contains only types representable as static types. */
97
+ export function isFullyTyped(node: SchemaNode): boolean {
98
+ switch (node.kind) {
99
+ case "unknown":
100
+ case "null":
101
+ return false;
102
+ case "array":
103
+ return isFullyTyped(node.element);
104
+ case "object":
105
+ return node.fields.every((field) => isFullyTyped(field.schema));
106
+ default:
107
+ return true;
108
+ }
109
+ }
110
+
111
+ /** Human-readable signature, e.g. `{ amounts: number[]; target: number }`. */
112
+ export function schemaSignature(node: SchemaNode): string {
113
+ switch (node.kind) {
114
+ case "string":
115
+ return "string";
116
+ case "number":
117
+ return "number";
118
+ case "boolean":
119
+ return "boolean";
120
+ case "null":
121
+ return "null";
122
+ case "unknown":
123
+ return "any";
124
+ case "array": {
125
+ const inner = schemaSignature(node.element);
126
+ return inner.includes(" ") ? `(${inner})[]` : `${inner}[]`;
127
+ }
128
+ case "object":
129
+ if (node.fields.length === 0) return "{}";
130
+ return `{ ${node.fields
131
+ .map((field) => `${field.name}${field.optional ? "?" : ""}: ${schemaSignature(field.schema)}`)
132
+ .join("; ")} }`;
133
+ }
134
+ }
135
+
136
+ export type TypeRef =
137
+ | { t: "string" }
138
+ | { t: "int" }
139
+ | { t: "double" }
140
+ | { t: "bool" }
141
+ | { t: "any" }
142
+ | { t: "null" }
143
+ | { t: "array"; of: TypeRef }
144
+ | { t: "named"; name: string };
145
+
146
+ export interface TypeDecl {
147
+ name: string;
148
+ fields: { name: string; ref: TypeRef; optional: boolean }[];
149
+ }
150
+
151
+ export function buildTypes(
152
+ rootName: string,
153
+ node: SchemaNode,
154
+ ): { root: TypeRef; decls: TypeDecl[] } {
155
+ const decls: TypeDecl[] = [];
156
+ const root = refFor(rootName, node, decls);
157
+ return { root, decls };
158
+ }
159
+
160
+ function refFor(name: string, node: SchemaNode, decls: TypeDecl[]): TypeRef {
161
+ switch (node.kind) {
162
+ case "object": {
163
+ const decl: TypeDecl = { name, fields: [] };
164
+ decls.push(decl);
165
+ for (const field of node.fields) {
166
+ decl.fields.push({
167
+ name: field.name,
168
+ optional: field.optional,
169
+ ref: refFor(`${name}${pascal(field.name)}`, field.schema, decls),
170
+ });
171
+ }
172
+ return { t: "named", name };
173
+ }
174
+ case "array":
175
+ return { t: "array", of: refFor(`${name}Item`, node.element, decls) };
176
+ case "string":
177
+ return { t: "string" };
178
+ case "boolean":
179
+ return { t: "bool" };
180
+ case "null":
181
+ return { t: "null" };
182
+ case "unknown":
183
+ return { t: "any" };
184
+ case "number":
185
+ return { t: node.integer ? "int" : "double" };
186
+ }
187
+ }
188
+
189
+ export function pascal(input: string): string {
190
+ const parts = input
191
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
192
+ .split(/[^a-zA-Z0-9]+/)
193
+ .filter(Boolean);
194
+ return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
195
+ }
196
+
197
+ export function snake(input: string): string {
198
+ const value = input
199
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
200
+ .replace(/[^a-zA-Z0-9]+/g, "_")
201
+ .replace(/^_+|_+$/g, "")
202
+ .toLowerCase();
203
+ return /^[0-9]/.test(value) ? `_${value}` : value || "field";
204
+ }
205
+
206
+ const RESERVED = new Set([
207
+ "abstract", "as", "async", "await", "base", "bool", "break", "byte", "case", "catch",
208
+ "class", "const", "continue", "crate", "default", "delegate", "do", "double", "dyn",
209
+ "else", "enum", "event", "explicit", "extern", "false", "final", "finally", "fixed",
210
+ "float", "fn", "for", "foreach", "func", "goto", "if", "impl", "implements", "import",
211
+ "in", "int", "interface", "internal", "is", "let", "lock", "long", "loop", "match",
212
+ "mod", "move", "mut", "namespace", "new", "null", "object", "operator", "out",
213
+ "override", "package", "params", "private", "protected", "pub", "public", "readonly",
214
+ "ref", "return", "self", "short", "sizeof", "static", "string", "struct", "super",
215
+ "switch", "this", "throw", "trait", "true", "try", "type", "typeof", "uint", "ulong",
216
+ "unchecked", "unsafe", "use", "ushort", "var", "virtual", "void", "volatile", "where",
217
+ "while", "with", "yield",
218
+ ]);
219
+
220
+ export function isReserved(word: string): boolean {
221
+ return RESERVED.has(word.toLowerCase());
222
+ }
223
+
224
+ export function isIdentifier(word: string): boolean {
225
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word);
226
+ }
package/src/types.ts CHANGED
@@ -49,12 +49,22 @@ export interface FileEntry {
49
49
  contents: string;
50
50
  }
51
51
 
52
+ /** A file the agent authored for a multi-file project. */
53
+ export interface ProjectFile {
54
+ path: string;
55
+ content: string;
56
+ }
57
+
58
+ export type PracticeMode = "single" | "project";
59
+
52
60
  /**
53
61
  * The contract the agent fills in after it has decided on a real-world scenario.
54
62
  * None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
55
63
  * before writing anything to disk.
56
64
  */
57
65
  export interface ScaffoldSpec {
66
+ /** `single` = one JSON-in/out function. `project` = a multi-file app. */
67
+ mode?: PracticeMode;
58
68
  /** kebab-case directory name. Derived from `title` when omitted. */
59
69
  projectName?: string;
60
70
  /** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
@@ -69,7 +79,7 @@ export interface ScaffoldSpec {
69
79
  functionName?: string;
70
80
  requirements?: string[];
71
81
  edgeCases?: string[];
72
- /** Optional starter implementation. A TODO stub is generated when omitted. */
82
+ /** Optional starter implementation (single mode). A TODO stub is generated when omitted. */
73
83
  starterCode?: string;
74
84
  publicTests?: TestCase[];
75
85
  hiddenTests?: TestCase[];
@@ -78,6 +88,16 @@ export interface ScaffoldSpec {
78
88
  /** Internal provenance, stored in `.practice-meta.json` only. Never leaked. */
79
89
  sourceSlug?: string;
80
90
  sourceTitle?: string;
91
+
92
+ // --- project mode -------------------------------------------------------
93
+ /** The full file tree for a project-mode assignment. */
94
+ files?: ProjectFile[];
95
+ /** Markdown brief for the learner: what to implement and where. */
96
+ task?: string;
97
+ /** Command that runs one test case (JSON on stdin, JSON on stdout). */
98
+ runCommand?: string;
99
+ /** Optional one-time build command (compiled languages). */
100
+ buildCommand?: string;
81
101
  }
82
102
 
83
103
  export interface ScaffoldResult {
@@ -86,6 +106,7 @@ export interface ScaffoldResult {
86
106
  language: LanguageId;
87
107
  languageName: string;
88
108
  runtime: string;
109
+ mode: PracticeMode;
89
110
  files: string[];
90
111
  runCommand: string;
91
112
  checkCommand: string;