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/README.md +58 -2
- package/package.json +1 -1
- package/src/index.ts +24 -1
- package/src/languages.ts +473 -119
- package/src/patterns.ts +2 -1
- package/src/prompt.ts +73 -49
- package/src/scaffold.ts +157 -4
- package/src/schema.ts +226 -0
- package/src/types.ts +22 -1
package/src/patterns.ts
CHANGED
|
@@ -92,11 +92,12 @@ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
|
|
|
92
92
|
push("scenario", spec.scenario);
|
|
93
93
|
push("pattern", spec.pattern);
|
|
94
94
|
push("functionName", spec.functionName);
|
|
95
|
-
push("
|
|
95
|
+
push("task", spec.task);
|
|
96
96
|
(spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
|
|
97
97
|
(spec.edgeCases ?? []).forEach((edge, i) => push(`edgeCases[${i}]`, edge));
|
|
98
98
|
(spec.publicTests ?? []).forEach((test, i) => push(`publicTests[${i}].name`, test.name));
|
|
99
99
|
(spec.hiddenTests ?? []).forEach((test, i) => push(`hiddenTests[${i}].name`, test.name));
|
|
100
|
+
(spec.files ?? []).forEach((file, i) => push(`files[${i}] (${file.path})`, file.content));
|
|
100
101
|
return entries;
|
|
101
102
|
}
|
|
102
103
|
|
package/src/prompt.ts
CHANGED
|
@@ -13,6 +13,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
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
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
|
-
|
|
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)
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
77
|
+
## 5. Build the assignment
|
|
71
78
|
Design a plausible product/business task that genuinely needs that principle. Good
|
|
72
|
-
domains: observability
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
-
|
|
92
|
-
|
|
93
|
-
-
|
|
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
|
-
-
|
|
103
|
-
mention LeetCode, its title, its slug, or any coding-practice site. The
|
|
104
|
-
|
|
105
|
-
-
|
|
106
|
-
|
|
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
|
|
136
|
+
2. The project path, mode, and language.
|
|
114
137
|
3. The exact command to run the tests (\`node tests/runner.mjs\`).
|
|
115
|
-
4.
|
|
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(
|
|
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
|
|
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 =
|
|
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;
|