From 053aeff71d7cf05663dc573942398133d42e3798 Mon Sep 17 00:00:00 2001 From: mbn <66748817+mbn-code@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:56:58 +0200 Subject: [PATCH 1/7] feat(engine): compile cache, diagnostics, checkers, statement parser, hardened routes - cache compiled binaries by compiler/std/flags/source with LRU eviction and in-flight coalescing; report cached, diagnostics and rejectedFlags on every compile - parse gcc/clang stderr into structured line/column diagnostics - add tokens and float checkers next to the lines checker and flag presentation-only mismatches - parse pasted Codeforces/AtCoder statements into samples and limits (POST /api/samples) - validate compiler flags against an allowlist; reject path traversal in /api/solution and /api/problem-text; 400 on malformed JSON everywhere - store: atomic writes, whole-workspace records, duplicate, export/import - /api/test: checker, epsilon, stopOnFirstFailure, SKIPPED, timing summary - /api/stress: compilerFlags, checker, seedBase, elapsed and timing stats, 60s budget - /api/config: version, compiler version, limits, cache size and reset - vitest unit suite covering the engine against the real compiler --- web/app/api/_engine/compare.test.ts | 118 ++ web/app/api/_engine/compare.ts | 157 +- web/app/api/_engine/cpp.test.ts | 138 ++ web/app/api/_engine/cpp.ts | 348 ++++- web/app/api/_engine/diagnostics.test.ts | 67 + web/app/api/_engine/diagnostics.ts | 71 + web/app/api/_engine/flags.test.ts | 74 + web/app/api/_engine/flags.ts | 131 ++ web/app/api/_engine/request.ts | 71 + web/app/api/_engine/statement.test.ts | 80 + web/app/api/_engine/statement.ts | 206 +++ web/app/api/_engine/store.test.ts | 146 ++ web/app/api/_engine/store.ts | 294 +++- web/app/api/config/route.ts | 89 +- web/app/api/problem-text/route.ts | 26 +- web/app/api/problems/route.ts | 113 +- web/app/api/run/route.ts | 87 +- web/app/api/samples/route.ts | 31 + web/app/api/solution/route.ts | 24 +- web/app/api/stress/route.ts | 240 +-- web/app/api/template/route.ts | 21 +- web/app/api/test/route.ts | 192 ++- web/package-lock.json | 1907 ++++++++++++++++++++--- web/package.json | 15 +- web/vitest.config.ts | 21 + 25 files changed, 4017 insertions(+), 650 deletions(-) create mode 100644 web/app/api/_engine/compare.test.ts create mode 100644 web/app/api/_engine/cpp.test.ts create mode 100644 web/app/api/_engine/diagnostics.test.ts create mode 100644 web/app/api/_engine/diagnostics.ts create mode 100644 web/app/api/_engine/flags.test.ts create mode 100644 web/app/api/_engine/flags.ts create mode 100644 web/app/api/_engine/request.ts create mode 100644 web/app/api/_engine/statement.test.ts create mode 100644 web/app/api/_engine/statement.ts create mode 100644 web/app/api/_engine/store.test.ts create mode 100644 web/app/api/samples/route.ts create mode 100644 web/vitest.config.ts diff --git a/web/app/api/_engine/compare.test.ts b/web/app/api/_engine/compare.test.ts new file mode 100644 index 0000000..0179a41 --- /dev/null +++ b/web/app/api/_engine/compare.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + compareOutputs, + normalize, + tokenize, + tokensEqual, + parseCheckerMode, + parseEpsilon, +} from "./compare"; + +describe("normalize", () => { + it("strips CRLF, trailing spaces and trailing blank lines", () => { + expect(normalize("1 2 \r\n3\t\r\n\r\n\r\n")).toBe("1 2\n3"); + }); + it("keeps leading whitespace and interior blank lines", () => { + expect(normalize(" a\n\nb\n")).toBe(" a\n\nb"); + }); +}); + +describe("compareOutputs (lines)", () => { + it("matches identical output modulo trailing whitespace", () => { + const r = compareOutputs("5\n", "5 \r\n\n"); + expect(r.match).toBe(true); + expect(r.diff).toEqual([]); + expect(r.firstMismatch).toBe(-1); + }); + it("reports differing lines with 1-based numbers", () => { + const r = compareOutputs("1\n2\n3\n", "1\n9\n3\n"); + expect(r.match).toBe(false); + expect(r.firstMismatch).toBe(2); + expect(r.diff).toEqual([ + { line: 2, expected: "2", actual: "9", same: false }, + ]); + }); + it("marks missing / extra lines with null", () => { + const r = compareOutputs("1\n2\n", "1\n"); + expect(r.diff).toEqual([ + { line: 2, expected: "2", actual: null, same: false }, + ]); + }); + it("caps the diff", () => { + const exp = Array.from({ length: 500 }, (_, i) => String(i)).join("\n"); + const act = Array.from({ length: 500 }, () => "x").join("\n"); + expect(compareOutputs(exp, act, 10).diff).toHaveLength(10); + expect(compareOutputs(exp, act, {}, 7).diff).toHaveLength(7); + }); + it("flags presentation-only mismatches", () => { + const r = compareOutputs("1 2 3\n", "1\n2\n3\n"); + expect(r.match).toBe(false); + expect(r.presentationOnly).toBe(true); + }); + it("does not flag presentation-only when tokens differ", () => { + const r = compareOutputs("1 2 3\n", "1\n2\n4\n"); + expect(r.presentationOnly).toBeUndefined(); + }); +}); + +describe("compareOutputs (tokens)", () => { + it("ignores whitespace layout", () => { + expect( + compareOutputs("1 2 3\n", "1\n2\n 3\n", { mode: "tokens" }).match, + ).toBe(true); + }); + it("still rejects different tokens", () => { + expect(compareOutputs("1 2 3", "1 2 4", { mode: "tokens" }).match).toBe( + false, + ); + }); + it("rejects a different token count", () => { + expect(compareOutputs("1 2 3", "1 2", { mode: "tokens" }).match).toBe( + false, + ); + }); +}); + +describe("compareOutputs (float)", () => { + it("accepts values within epsilon", () => { + expect( + compareOutputs("3.1415926\n", "3.1415927\n", { mode: "float" }).match, + ).toBe(true); + }); + it("uses relative tolerance for large magnitudes", () => { + expect( + compareOutputs("1000000.0", "1000000.5", { mode: "float", epsilon: 1e-6 }) + .match, + ).toBe(true); + }); + it("rejects values outside epsilon", () => { + expect( + compareOutputs("1.0", "1.1", { mode: "float", epsilon: 1e-6 }).match, + ).toBe(false); + }); + it("compares non-numeric tokens exactly", () => { + expect(compareOutputs("YES", "yes", { mode: "float" }).match).toBe(false); + expect(compareOutputs("YES", "YES", { mode: "float" }).match).toBe(true); + }); +}); + +describe("helpers", () => { + it("tokenize splits on any whitespace", () => { + expect(tokenize(" a\tb\n\nc ")).toEqual(["a", "b", "c"]); + }); + it("tokensEqual handles scientific notation", () => { + expect(tokensEqual("1e3", "1000", 1e-9)).toBe(true); + expect(tokensEqual("abc", "1000", 1e-9)).toBe(false); + }); + it("parseCheckerMode falls back to lines", () => { + expect(parseCheckerMode("tokens")).toBe("tokens"); + expect(parseCheckerMode("nonsense")).toBe("lines"); + expect(parseCheckerMode(undefined)).toBe("lines"); + }); + it("parseEpsilon rejects garbage", () => { + expect(parseEpsilon(1e-9)).toBe(1e-9); + expect(parseEpsilon(-1)).toBe(1e-6); + expect(parseEpsilon("x")).toBe(1e-6); + expect(parseEpsilon(5)).toBe(1); + }); +}); diff --git a/web/app/api/_engine/compare.ts b/web/app/api/_engine/compare.ts index 75b0fae..71e1f96 100644 --- a/web/app/api/_engine/compare.ts +++ b/web/app/api/_engine/compare.ts @@ -3,12 +3,51 @@ * * Output comparison helpers shared by /api/test and /api/stress. * - * Competitive-programming judges accept output that differs only in trailing - * whitespace, trailing blank lines, and line-ending style. `normalize` encodes - * that tolerance; `compareOutputs` reports a match plus a compact line diff that - * the UI can render. + * Three checker modes are supported, mirroring what online judges do: + * + * - `lines` (default) — trailing whitespace, trailing blank lines and CRLF + * differences are ignored; everything else must match line for line. + * - `tokens` — outputs are split on any whitespace and compared token by + * token. This is the Codeforces "wcmp" checker and is what most problems + * with "print the numbers in any order/format" actually use. + * - `float` — like `tokens`, but numeric tokens are compared with an + * absolute-or-relative tolerance (`epsilon`, default 1e-6), matching the + * classic "answer accepted if |a-b| / max(1,|b|) <= eps" checker. + * + * Every mode reports a match flag plus a compact line diff the UI can render. */ +export type CheckerMode = "lines" | "tokens" | "float"; + +export const CHECKER_MODES: readonly CheckerMode[] = [ + "lines", + "tokens", + "float", +]; + +export const DEFAULT_EPSILON = 1e-6; + +export type CheckerOptions = { + mode?: CheckerMode; + /** Tolerance used by the `float` checker. */ + epsilon?: number; +}; + +/** Coerce request input into a valid checker mode. */ +export function parseCheckerMode(value: unknown): CheckerMode { + return typeof value === "string" && + (CHECKER_MODES as string[]).includes(value) + ? (value as CheckerMode) + : "lines"; +} + +/** Coerce request input into a usable epsilon. */ +export function parseEpsilon(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) + return DEFAULT_EPSILON; + return Math.min(1, value); +} + /** * Canonicalise output for tolerant comparison: * - CRLF / CR are converted to LF @@ -24,6 +63,28 @@ export function normalize(s: string): string { return lines.join("\n"); } +/** Split output into whitespace-separated tokens. */ +export function tokenize(s: string): string[] { + return s.split(/\s+/).filter(Boolean); +} + +const NUMERIC = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; + +/** True when two tokens are equal under the float checker. */ +export function tokensEqual( + expected: string, + actual: string, + epsilon: number, +): boolean { + if (expected === actual) return true; + if (!NUMERIC.test(expected) || !NUMERIC.test(actual)) return false; + const e = Number(expected); + const a = Number(actual); + if (!Number.isFinite(e) || !Number.isFinite(a)) return false; + const diff = Math.abs(e - a); + return diff <= epsilon || diff <= epsilon * Math.max(1, Math.abs(e)); +} + export type DiffLine = { /** 1-based line number. */ line: number; @@ -38,23 +99,24 @@ export type CompareResult = { diff: DiffLine[]; /** First differing line number, or -1 when outputs match. */ firstMismatch: number; + /** The checker mode that produced this verdict. */ + mode: CheckerMode; + /** + * Set when the `lines` checker rejected the output but the `tokens` checker + * would have accepted it — i.e. the answer is right, only the whitespace + * layout differs. The UI surfaces this as a hint. + */ + presentationOnly?: boolean; }; -/** - * Compare two outputs with trailing-whitespace tolerance and return a per-line - * diff of the lines that differ. The diff is capped so a pathological mismatch - * can't produce an unbounded payload. - */ -export function compareOutputs( +/** Build a per-line diff of two normalised outputs (capped at maxDiff rows). */ +function lineDiff( expected: string, actual: string, - maxDiff = 200, -): CompareResult { - const exp = normalize(expected).split("\n"); - const act = normalize(actual).split("\n"); - const match = normalize(expected) === normalize(actual); - if (match) return { match: true, diff: [], firstMismatch: -1 }; - + maxDiff: number, +): { diff: DiffLine[]; firstMismatch: number } { + const exp = expected.split("\n"); + const act = actual.split("\n"); const diff: DiffLine[] = []; let firstMismatch = -1; const max = Math.max(exp.length, act.length); @@ -67,5 +129,64 @@ export function compareOutputs( diff.push({ line: i + 1, expected: e, actual: a, same: false }); } } - return { match: false, diff, firstMismatch }; + return { diff, firstMismatch }; +} + +/** Token-wise comparison; returns true on match. */ +function tokensMatch( + expected: string, + actual: string, + epsilon: number | null, +): boolean { + const e = tokenize(expected); + const a = tokenize(actual); + if (e.length !== a.length) return false; + for (let i = 0; i < e.length; i++) { + const ok = + epsilon === null ? e[i] === a[i] : tokensEqual(e[i], a[i], epsilon); + if (!ok) return false; + } + return true; +} + +/** + * Compare two outputs under the chosen checker and return a per-line diff of + * the lines that differ. The diff is capped so a pathological mismatch can't + * produce an unbounded payload. + */ +export function compareOutputs( + expected: string, + actual: string, + options: CheckerOptions | number = {}, + maxDiff = 200, +): CompareResult { + // Backwards-compatible signature: compareOutputs(exp, act, maxDiff). + const opts: CheckerOptions = typeof options === "number" ? {} : options; + if (typeof options === "number") maxDiff = options; + const mode = opts.mode ?? "lines"; + const epsilon = opts.epsilon ?? DEFAULT_EPSILON; + + const normExp = normalize(expected); + const normAct = normalize(actual); + + let match: boolean; + switch (mode) { + case "tokens": + match = tokensMatch(normExp, normAct, null); + break; + case "float": + match = tokensMatch(normExp, normAct, epsilon); + break; + default: + match = normExp === normAct; + } + + if (match) return { match: true, diff: [], firstMismatch: -1, mode }; + + const { diff, firstMismatch } = lineDiff(normExp, normAct, maxDiff); + const result: CompareResult = { match: false, diff, firstMismatch, mode }; + if (mode === "lines" && tokensMatch(normExp, normAct, null)) { + result.presentationOnly = true; + } + return result; } diff --git a/web/app/api/_engine/cpp.test.ts b/web/app/api/_engine/cpp.test.ts new file mode 100644 index 0000000..2fc5665 --- /dev/null +++ b/web/app/api/_engine/cpp.test.ts @@ -0,0 +1,138 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { + compile, + runBinary, + cleanup, + detectCompiler, + compilerVersion, + clearCompileCache, + compileCacheSize, + runVerdict, + clampTimeLimit, + includeDir, +} from "./cpp"; + +/** + * These tests exercise the real toolchain. They are skipped (not failed) on a + * machine without a C++ compiler so the rest of the unit suite still runs. + */ +const hasCompiler = detectCompiler() !== null; +const maybe = hasCompiler ? describe : describe.skip; + +const AB = `#include +using namespace std; +int main(){ int a,b; cin>>a>>b; cout< { + await clearCompileCache(); +}); + +describe("clampTimeLimit", () => { + it("clamps into [100, 60000] and defaults", () => { + expect(clampTimeLimit(undefined)).toBe(5000); + expect(clampTimeLimit(1)).toBe(100); + expect(clampTimeLimit(1e9)).toBe(60000); + expect(clampTimeLimit(NaN)).toBe(5000); + expect(clampTimeLimit(1234.9)).toBe(1234); + }); +}); + +maybe("compile + run", () => { + it("reports the compiler version", () => { + expect(compilerVersion()).toBeTruthy(); + expect(includeDir()).toMatch(/include$/); + }); + + it("compiles and runs A+B, then serves the second compile from cache", async () => { + await clearCompileCache(); + const first = await compile(AB, { std: "gnu++17" }); + expect(first.ok).toBe(true); + expect(first.cached).toBe(false); + expect(first.binPath).toBeTruthy(); + expect(compileCacheSize()).toBe(1); + + const run = await runBinary(first.binPath!, { input: "2 3\n" }); + expect(run.stdout.trim()).toBe("5"); + expect(runVerdict(run)).toBe("OK"); + expect(run.timeMs).toBeGreaterThan(0); + + const second = await compile(AB, { std: "gnu++17" }); + expect(second.cached).toBe(true); + expect(second.ms).toBe(0); + expect(second.binPath).toBe(first.binPath); + expect(compileCacheSize()).toBe(1); + + // A different standard is a different cache entry. + const third = await compile(AB, { std: "gnu++20" }); + expect(third.cached).toBe(false); + expect(compileCacheSize()).toBe(2); + }); + + it("coalesces identical concurrent compiles", async () => { + await clearCompileCache(); + const src = `${AB}// concurrent\n`; + const [a, b] = await Promise.all([compile(src), compile(src)]); + expect(a.ok && b.ok).toBe(true); + expect(a.binPath).toBe(b.binPath); + expect(compileCacheSize()).toBe(1); + }); + + it("returns structured diagnostics on a compile error", async () => { + const r = await compile("int main() { return x; }\n"); + expect(r.ok).toBe(false); + expect(r.error).toBe("compile-error"); + expect(r.stderr).toContain("main.cpp:1"); + expect( + r.diagnostics.some((d) => d.severity === "error" && d.line === 1), + ).toBe(true); + expect(r.workDir).toBeTruthy(); + await cleanup(r.workDir); + }); + + it("drops disallowed flags and reports them", async () => { + const r = await compile(AB, { extraFlags: ["-Wall", "-o", "/tmp/evil"] }); + expect(r.ok).toBe(true); + expect(r.rejectedFlags.map((f) => f.flag)).toEqual(["-o", "/tmp/evil"]); + }); + + it("classifies a timeout as TLE", async () => { + const r = await compile("int main(){ for(;;){} }\n"); + expect(r.ok).toBe(true); + const run = await runBinary(r.binPath!, { timeLimitMs: 300 }); + expect(run.timedOut).toBe(true); + expect(runVerdict(run)).toBe("TLE"); + expect(run.timeMs).toBeGreaterThanOrEqual(250); + }); + + it("classifies a non-zero exit as RE", async () => { + const r = await compile("int main(){ return 3; }\n"); + const run = await runBinary(r.binPath!); + expect(run.exitCode).toBe(3); + expect(runVerdict(run)).toBe("RE"); + }); + + it("truncates runaway output and reports it", async () => { + const r = await compile( + '#include \nint main(){ for(;;) puts("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); }\n', + ); + const run = await runBinary(r.binPath!, { + timeLimitMs: 5000, + maxOutputBytes: 64 * 1024, + }); + expect(run.truncated).toBe(true); + expect(run.stdout.length).toBeLessThanOrEqual(64 * 1024); + }); + + it("rejects oversized sources without invoking the compiler", async () => { + const r = await compile("x".repeat(2 * 1024 * 1024)); + expect(r.ok).toBe(false); + expect(r.error).toBe("source-too-large"); + }); + + it("surfaces a spawn error for a missing binary", async () => { + const run = await runBinary("/definitely/not/here/prog"); + expect(run.spawnError).toBeTruthy(); + expect(runVerdict(run)).toBe("RE"); + }); +}); diff --git a/web/app/api/_engine/cpp.ts b/web/app/api/_engine/cpp.ts index 456f248..9741f20 100644 --- a/web/app/api/_engine/cpp.ts +++ b/web/app/api/_engine/cpp.ts @@ -3,24 +3,33 @@ * * Self-contained C++ compile/run engine for the cf workbench API routes. * - * This module depends ONLY on Node.js built-ins (node:child_process, node:fs, - * node:os, node:path) so the `web/app/api` tree never reaches into other - * workspaces (e.g. web/lib). It powers /api/run, /api/test and /api/stress. + * This module depends ONLY on Node.js built-ins so the `web/app/api` tree + * never reaches into other workspaces (e.g. web/lib). It powers /api/run, + * /api/test and /api/stress. * * Design notes: * - The bundled portable `` lives at /include, so every * compile passes `-I /include` and the idiom resolves on macOS - * (clang/libc++) as well as Linux (g++/libstdc++). + * (clang/libc++) as well as Linux (g++/libstdc++) and MinGW on Windows. * - Wall-clock time is measured inside Node with process.hrtime.bigint() around * the spawn — never the shell `time` builtin (which is unreliable on macOS). - * - Every compilation happens in a unique os.tmpdir() directory so concurrent - * requests never collide, and temp dirs are always cleaned up by callers. + * - Compiled binaries are cached by a hash of (compiler, std, flags, source). + * Re-running unchanged code, or running the same source against many test + * cases, skips the multi-second `` instantiation entirely. + * Cache entries live under a per-process temp directory and are evicted + * least-recently-used once the cap is reached. + * - Cache misses compile in a unique temp directory so concurrent requests + * never collide; identical concurrent compiles are coalesced. */ import { spawn, spawnSync } from "node:child_process"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { rmSync } from "node:fs"; +import { mkdtemp, mkdir, writeFile, rm, rename, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { validateFlags, validateStd } from "./flags"; +import { parseDiagnostics, type Diagnostic } from "./diagnostics"; // ==================== Tunables ==================== @@ -32,14 +41,22 @@ export const DEFAULT_TIME_LIMIT_MS = 5000; export const MAX_TIME_LIMIT_MS = 60_000; /** Compilation is killed if it exceeds this (runaway template instantiation). */ export const COMPILE_TIMEOUT_MS = 30_000; +/** Largest source accepted for compilation. */ +export const MAX_SOURCE_BYTES = 1024 * 1024; // 1 MiB /** Largest stdin payload forwarded to a program. */ export const MAX_INPUT_BYTES = 4 * 1024 * 1024; // 4 MiB /** Largest stdout/stderr captured per stream; excess is truncated. */ export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; // 4 MiB +/** Number of compiled binaries kept in the cache before LRU eviction. */ +export const COMPILE_CACHE_ENTRIES = 48; /** Compiler candidates, in preference order: clang++/c++ (macOS) then g++. */ const COMPILER_CANDIDATES = ["clang++", "c++", "g++"]; +const IS_WINDOWS = process.platform === "win32"; +const BIN_NAME = IS_WINDOWS ? "prog.exe" : "prog"; +const SRC_NAME = "main.cpp"; + // ==================== Paths ==================== /** Absolute path to the repository root (the parent of the Next.js `web` dir). */ @@ -56,6 +73,7 @@ export function includeDir(): string { // ==================== Compiler detection ==================== let cachedCompiler: string | null = null; +let cachedVersion: string | null = null; /** * Return the first usable C++ compiler, or null if none is installed. @@ -70,9 +88,10 @@ export function detectCompiler(): string | null { : COMPILER_CANDIDATES; for (const cand of candidates) { try { - const probe = spawnSync(cand, ["--version"], { stdio: "ignore" }); + const probe = spawnSync(cand, ["--version"], { encoding: "utf8" }); if (!probe.error && probe.status === 0) { cachedCompiler = cand; + cachedVersion = (probe.stdout || "").split(/\r?\n/)[0]?.trim() || null; return cand; } } catch { @@ -82,6 +101,12 @@ export function detectCompiler(): string | null { return null; } +/** First line of ` --version`, or null when no compiler is found. */ +export function compilerVersion(): string | null { + if (!detectCompiler()) return null; + return cachedVersion; +} + // ==================== Compilation ==================== export type CompileResult = { @@ -91,27 +116,131 @@ export type CompileResult = { compiler: string | null; /** Compiler diagnostics (the "CE" text shown to the user). */ stderr: string; + /** Structured diagnostics parsed from `stderr`. */ + diagnostics: Diagnostic[]; /** Absolute path to the produced executable, or null on failure. */ binPath: string | null; - /** The unique temp directory holding the sources/binary; clean it up. */ + /** + * The unique temp directory holding the sources/binary for an uncached + * failure; null for cache-backed results. Always pass it to cleanup(). + */ workDir: string | null; - /** Compile wall-clock time in milliseconds. */ + /** Compile wall-clock time in milliseconds (0 on a cache hit). */ ms: number; + /** True when the binary came from the compile cache. */ + cached: boolean; + /** Flags that were dropped by validation, with a reason each. */ + rejectedFlags: { flag: string; reason: string }[]; /** Machine-readable failure cause, when ok === false. */ - error?: "no-compiler" | "compile-timeout" | "compile-error" | "spawn-error"; + error?: + | "no-compiler" + | "compile-timeout" + | "compile-error" + | "spawn-error" + | "source-too-large"; }; export type CompileOptions = { /** C++ standard without the `-std=` prefix (default `gnu++17`). */ std?: string; - /** Extra compiler flags appended verbatim. */ + /** Extra compiler flags; validated against an allowlist before use. */ extraFlags?: string[]; /** Short label used in the temp directory name. */ label?: string; }; +type CacheEntry = { binPath: string; stderr: string; lastUsed: number }; + +const cache = new Map(); +const inflight = new Map>(); +let cacheRootPromise: Promise | null = null; + +function cacheRoot(): Promise { + if (!cacheRootPromise) { + cacheRootPromise = mkdtemp(path.join(os.tmpdir(), "cf-cache-")).then( + (dir) => { + // Reclaim the whole cache when the server exits normally. + process.once("exit", () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + }); + return dir; + }, + ); + } + return cacheRootPromise; +} + +function cacheKey( + compiler: string, + std: string, + flags: string[], + source: string, +): string { + return createHash("sha256") + .update(compiler) + .update("\0") + .update(std) + .update("\0") + .update(flags.join(" ")) + .update("\0") + .update(source) + .digest("hex"); +} + +async function evictIfNeeded(): Promise { + while (cache.size > COMPILE_CACHE_ENTRIES) { + let oldestKey: string | null = null; + let oldest = Infinity; + for (const [k, v] of cache) { + if (v.lastUsed < oldest) { + oldest = v.lastUsed; + oldestKey = k; + } + } + if (!oldestKey) break; + const entry = cache.get(oldestKey); + cache.delete(oldestKey); + if (entry) await cleanup(path.dirname(entry.binPath)); + } +} + +/** Drop every cached binary. Exposed for tests and for /api/config resets. */ +export async function clearCompileCache(): Promise { + const entries = [...cache.values()]; + cache.clear(); + await Promise.all(entries.map((e) => cleanup(path.dirname(e.binPath)))); +} + +/** Number of binaries currently held in the compile cache. */ +export function compileCacheSize(): number { + return cache.size; +} + +function noCompilerResult(): CompileResult { + return { + ok: false, + compiler: null, + stderr: + "No C++ compiler found. Tried clang++, c++ and g++. Install the Xcode " + + "Command Line Tools (`xcode-select --install`), a g++ toolchain, or " + + "set CF_CXX to the compiler you want to use.", + diagnostics: [], + binPath: null, + workDir: null, + ms: 0, + cached: false, + rejectedFlags: [], + error: "no-compiler", + }; +} + /** - * Compile a single C++ translation unit into a unique temp directory. + * Compile a single C++ translation unit, serving from the compile cache when + * the exact same (compiler, std, flags, source) tuple was built before. * Never throws — failures are reported through the returned CompileResult. * Callers MUST cleanup(result.workDir) when finished with the binary. */ @@ -120,36 +249,83 @@ export async function compile( opts: CompileOptions = {}, ): Promise { const compiler = detectCompiler(); - if (!compiler) { + if (!compiler) return noCompilerResult(); + + const { accepted: flags, rejected } = validateFlags(opts.extraFlags ?? []); + const std = validateStd(opts.std, DEFAULT_STD); + + if (Buffer.byteLength(source, "utf8") > MAX_SOURCE_BYTES) { return { ok: false, - compiler: null, - stderr: - "No C++ compiler found. Tried clang++, c++ and g++. Install the Xcode " + - "Command Line Tools (`xcode-select --install`) or a g++ toolchain.", + compiler, + stderr: `Source exceeds the ${MAX_SOURCE_BYTES / 1024} KiB limit.`, + diagnostics: [], binPath: null, workDir: null, ms: 0, - error: "no-compiler", + cached: false, + rejectedFlags: rejected, + error: "source-too-large", }; } + const key = cacheKey(compiler, std, flags, source); + const hit = cache.get(key); + if (hit) { + hit.lastUsed = Date.now(); + return { + ok: true, + compiler, + stderr: hit.stderr, + diagnostics: parseDiagnostics(hit.stderr, SRC_NAME), + binPath: hit.binPath, + workDir: null, + ms: 0, + cached: true, + rejectedFlags: rejected, + }; + } + + const pending = inflight.get(key); + if (pending) { + const r = await pending; + return { ...r, rejectedFlags: rejected, cached: r.ok ? true : r.cached }; + } + + const job = compileUncached(source, compiler, std, flags, key, opts.label); + inflight.set(key, job); + try { + const r = await job; + return { ...r, rejectedFlags: rejected }; + } finally { + inflight.delete(key); + } +} + +async function compileUncached( + source: string, + compiler: string, + std: string, + flags: string[], + key: string, + label?: string, +): Promise { const workDir = await mkdtemp( - path.join(os.tmpdir(), `cf-${opts.label ?? "build"}-`), + path.join(os.tmpdir(), `cf-${label ?? "build"}-`), ); - const srcPath = path.join(workDir, "main.cpp"); - const binPath = path.join(workDir, "prog"); + const srcPath = path.join(workDir, SRC_NAME); + const binPath = path.join(workDir, BIN_NAME); await writeFile(srcPath, source, "utf8"); const args = [ - `-std=${opts.std ?? DEFAULT_STD}`, + `-std=${std}`, "-O2", "-I", includeDir(), srcPath, "-o", binPath, - ...(opts.extraFlags ?? []), + ...flags, ]; const start = process.hrtime.bigint(); @@ -157,6 +333,7 @@ export async function compile( encoding: "utf8", timeout: COMPILE_TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, + cwd: workDir, }); const ms = Number(process.hrtime.bigint() - start) / 1e6; @@ -164,27 +341,65 @@ export async function compile( !!res.error && (res.error as NodeJS.ErrnoException).code === "ETIMEDOUT"; const ok = !res.error && res.status === 0; - let stderr = res.stderr ?? ""; + // Present the temp path as plain `main.cpp` so diagnostics read naturally. + let stderr = (res.stderr ?? "").split(srcPath).join(SRC_NAME); + if (IS_WINDOWS) + stderr = stderr.split(srcPath.replace(/\\/g, "/")).join(SRC_NAME); if (res.error && !timedOut) stderr = `${stderr}\n${res.error.message}`.trim(); if (timedOut) { stderr = `${stderr}\nCompilation timed out after ${COMPILE_TIMEOUT_MS} ms.`.trim(); } + const diagnostics = parseDiagnostics(stderr, SRC_NAME); + + if (!ok) { + return { + ok, + compiler, + stderr, + diagnostics, + binPath: null, + workDir, + ms, + cached: false, + rejectedFlags: [], + error: timedOut + ? "compile-timeout" + : res.error + ? "spawn-error" + : "compile-error", + }; + } + + // Move the finished build into the cache. On a rename race the other copy + // wins and ours is discarded. + const root = await cacheRoot(); + const target = path.join(root, key); + let finalBin = binPath; + try { + await mkdir(root, { recursive: true }); + await rename(workDir, target); + finalBin = path.join(target, BIN_NAME); + } catch { + const existing = await stat(path.join(target, BIN_NAME)).catch(() => null); + if (existing) { + await cleanup(workDir); + finalBin = path.join(target, BIN_NAME); + } + } + cache.set(key, { binPath: finalBin, stderr, lastUsed: Date.now() }); + await evictIfNeeded(); return { ok, compiler, stderr, - binPath: ok ? binPath : null, - workDir, + diagnostics, + binPath: finalBin, + workDir: null, ms, - error: ok - ? undefined - : timedOut - ? "compile-timeout" - : res.error - ? "spawn-error" - : "compile-error", + cached: false, + rejectedFlags: [], }; } @@ -218,9 +433,10 @@ export type RunOptions = { maxOutputBytes?: number; }; -function clamp(n: number, lo: number, hi: number): number { - if (!Number.isFinite(n)) return lo; - return Math.min(hi, Math.max(lo, n)); +export function clampTimeLimit(n: number | undefined): number { + const v = n ?? DEFAULT_TIME_LIMIT_MS; + if (!Number.isFinite(v)) return DEFAULT_TIME_LIMIT_MS; + return Math.min(MAX_TIME_LIMIT_MS, Math.max(100, Math.floor(v))); } /** @@ -232,17 +448,15 @@ export function runBinary( binPath: string, opts: RunOptions = {}, ): Promise { - const timeLimitMs = clamp( - opts.timeLimitMs ?? DEFAULT_TIME_LIMIT_MS, - 100, - MAX_TIME_LIMIT_MS, - ); + const timeLimitMs = clampTimeLimit(opts.timeLimitMs); const maxOut = opts.maxOutputBytes ?? MAX_OUTPUT_BYTES; return new Promise((resolve) => { const start = process.hrtime.bigint(); - let stdout = ""; - let stderr = ""; + const outChunks: Buffer[] = []; + const errChunks: Buffer[] = []; + let outLen = 0; + let errLen = 0; let truncated = false; let timedOut = false; let settled = false; @@ -254,6 +468,8 @@ export function runBinary( try { child = spawn(binPath, opts.args ?? [], { stdio: ["pipe", "pipe", "pipe"], + cwd: path.dirname(binPath), + windowsHide: true, }); } catch (e) { const ms = Number(process.hrtime.bigint() - start) / 1e6; @@ -281,12 +497,12 @@ export function runBinary( clearTimeout(timer); const timeMs = Number(process.hrtime.bigint() - start) / 1e6; resolve({ - stdout, - stderr, + stdout: Buffer.concat(outChunks).toString("utf8"), + stderr: Buffer.concat(errChunks).toString("utf8"), exitCode, signal, timedOut, - timeMs, + timeMs: Math.round(timeMs * 10) / 10, truncated, spawnError, }); @@ -298,21 +514,29 @@ export function runBinary( }); child.stdout.on("data", (d: Buffer) => { - if (stdout.length >= maxOut) return; - stdout += d.toString("utf8"); - if (stdout.length >= maxOut) { - stdout = stdout.slice(0, maxOut); + if (outLen >= maxOut) return; + const room = maxOut - outLen; + if (d.length > room) { + outChunks.push(d.subarray(0, room)); + outLen = maxOut; truncated = true; child.kill("SIGKILL"); + } else { + outChunks.push(d); + outLen += d.length; } }); child.stderr.on("data", (d: Buffer) => { - if (stderr.length >= maxOut) return; - stderr += d.toString("utf8"); - if (stderr.length >= maxOut) { - stderr = stderr.slice(0, maxOut); + if (errLen >= maxOut) return; + const room = maxOut - errLen; + if (d.length > room) { + errChunks.push(d.subarray(0, room)); + errLen = maxOut; truncated = true; + } else { + errChunks.push(d); + errLen += d.length; } }); @@ -334,6 +558,20 @@ export function runBinary( }); } +/** Classify a run as a judge verdict (without output comparison). */ +export function runVerdict(run: RunResult): "OK" | "TLE" | "RE" { + if (run.timedOut) return "TLE"; + if (run.spawnError || run.exitCode !== 0 || run.signal) return "RE"; + return "OK"; +} + +/** Program stderr plus any spawn error, for display. */ +export function runStderr(run: RunResult): string { + return run.spawnError + ? `${run.stderr}\n${run.spawnError}`.trim() + : run.stderr; +} + // ==================== Cleanup ==================== /** Best-effort recursive removal of a temp work directory. Never throws. */ diff --git a/web/app/api/_engine/diagnostics.test.ts b/web/app/api/_engine/diagnostics.test.ts new file mode 100644 index 0000000..d4822ee --- /dev/null +++ b/web/app/api/_engine/diagnostics.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { parseDiagnostics, summarizeDiagnostics } from "./diagnostics"; + +const GCC = `main.cpp: In function 'int main()': +main.cpp:5:9: error: 'x' was not declared in this scope + 5 | int y = x; + | ^ +main.cpp:7:5: warning: unused variable 'y' [-Wunused-variable] +/usr/include/c++/13/bits/stl_vector.h:100:3: note: declared here +`; + +const CLANG = `main.cpp:3:12: error: use of undeclared identifier 'foo' + return foo; + ^ +1 error generated. +`; + +describe("parseDiagnostics", () => { + it("parses gcc output with line and column", () => { + const d = parseDiagnostics(GCC); + expect(d).toHaveLength(3); + expect(d[0]).toEqual({ + severity: "error", + line: 5, + column: 9, + message: "'x' was not declared in this scope", + }); + expect(d[1].severity).toBe("warning"); + expect(d[1].line).toBe(7); + }); + + it("drops line numbers for diagnostics in other files", () => { + const d = parseDiagnostics(GCC); + expect(d[2]).toEqual({ + severity: "note", + line: null, + column: null, + message: "declared here", + }); + }); + + it("parses clang output", () => { + const d = parseDiagnostics(CLANG); + expect(d).toHaveLength(1); + expect(d[0].line).toBe(3); + expect(d[0].column).toBe(12); + }); + + it("handles fatal errors and windows paths", () => { + const d = parseDiagnostics( + "C:\\tmp\\cf-x\\main.cpp:1:10: fatal error: foo.h: No such file or directory", + ); + expect(d[0].severity).toBe("error"); + expect(d[0].line).toBe(1); + }); + + it("returns nothing for empty stderr", () => { + expect(parseDiagnostics("")).toEqual([]); + }); + + it("summarizes counts", () => { + expect(summarizeDiagnostics(parseDiagnostics(GCC))).toEqual({ + errors: 1, + warnings: 1, + }); + }); +}); diff --git a/web/app/api/_engine/diagnostics.ts b/web/app/api/_engine/diagnostics.ts new file mode 100644 index 0000000..8591c6a --- /dev/null +++ b/web/app/api/_engine/diagnostics.ts @@ -0,0 +1,71 @@ +/** + * web/app/api/_engine/diagnostics.ts + * + * Turn raw compiler stderr into structured diagnostics the UI can render as + * gutter markers and a clickable problem list. Both GCC and clang emit the + * same `file:line:col: severity: message` shape, which is all this parses. + */ + +export type DiagnosticSeverity = "error" | "warning" | "note"; + +export type Diagnostic = { + severity: DiagnosticSeverity; + /** 1-based source line, or null when the diagnostic has no location. */ + line: number | null; + /** 1-based column, or null when unknown. */ + column: number | null; + message: string; +}; + +const LINE_RE = + /^(?:(.+?):(\d+):(?:(\d+):)?\s+)?(fatal error|error|warning|note):\s+(.*)$/; + +/** + * Parse compiler output into diagnostics. Only lines that follow the + * `path:line:col: severity: message` convention are picked up; everything + * else (code excerpts, caret lines, "In function" context) is ignored. + * + * When `sourceName` is given, diagnostics that point at a different file (for + * example the bundled `` shim) keep their message but lose the + * line number, so the editor never marks a line that is not in the user's + * source. + */ +export function parseDiagnostics( + stderr: string, + sourceName = "main.cpp", +): Diagnostic[] { + const out: Diagnostic[] = []; + for (const rawLine of stderr.split(/\r?\n/)) { + const m = LINE_RE.exec(rawLine.trim()); + if (!m) continue; + const [, file, lineStr, colStr, sev, message] = m; + const inSource = + !file || + file === sourceName || + file.endsWith(`/${sourceName}`) || + file.endsWith(`\\${sourceName}`); + const line = inSource && lineStr ? Number(lineStr) : null; + const column = inSource && colStr ? Number(colStr) : null; + out.push({ + severity: sev === "fatal error" ? "error" : (sev as DiagnosticSeverity), + line: Number.isFinite(line) ? line : null, + column: Number.isFinite(column) ? column : null, + message: message.trim(), + }); + } + return out; +} + +/** Count errors and warnings in a diagnostics list. */ +export function summarizeDiagnostics(diags: Diagnostic[]): { + errors: number; + warnings: number; +} { + let errors = 0; + let warnings = 0; + for (const d of diags) { + if (d.severity === "error") errors++; + else if (d.severity === "warning") warnings++; + } + return { errors, warnings }; +} diff --git a/web/app/api/_engine/flags.test.ts b/web/app/api/_engine/flags.test.ts new file mode 100644 index 0000000..06ec6f3 --- /dev/null +++ b/web/app/api/_engine/flags.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { validateFlags, validateStd, tokenizeFlags } from "./flags"; + +describe("validateFlags", () => { + it("accepts the everyday competitive-programming flags", () => { + const { accepted, rejected } = validateFlags([ + "-Wall", + "-Wextra", + "-O3", + "-DLOCAL", + "-DDEBUG=1", + "-g", + "-fsanitize=address,undefined", + "-march=native", + "-std=gnu++20", + "-pedantic", + ]); + expect(rejected).toEqual([]); + expect(accepted).toHaveLength(10); + }); + + it("rejects flags that write files or load code", () => { + const { accepted, rejected } = validateFlags([ + "-o", + "/tmp/x", + "-include", + "/etc/passwd", + "@resp.txt", + "-fplugin=evil.so", + "-Wl,-rpath,/x", + "-I/etc", + "-lfoo", + "-fprofile-generate", + ]); + expect(accepted).toEqual([]); + expect(rejected.map((r) => r.flag)).toContain("-fplugin=evil.so"); + expect(rejected.map((r) => r.flag)).toContain("-Wl,-rpath,/x"); + expect(rejected.map((r) => r.flag)).toContain("-o"); + }); + + it("rejects shell metacharacters and bogus standards", () => { + const { accepted, rejected } = validateFlags([ + "-DX=$(rm -rf /)", + "-std=c++99", + "-Wall;ls", + ]); + expect(accepted).toEqual([]); + expect(rejected).toHaveLength(3); + }); + + it("dedupes and accepts a raw string", () => { + expect(validateFlags("-Wall -Wall -O2").accepted).toEqual(["-Wall", "-O2"]); + }); + + it("treats non-list input as empty", () => { + expect(validateFlags(42)).toEqual({ accepted: [], rejected: [] }); + }); +}); + +describe("validateStd", () => { + it("accepts known standards and falls back otherwise", () => { + expect(validateStd("gnu++17", "x")).toBe("gnu++17"); + expect(validateStd("c++23", "x")).toBe("c++23"); + expect(validateStd("c++98", "gnu++17")).toBe("gnu++17"); + expect(validateStd("gnu++17; rm", "gnu++17")).toBe("gnu++17"); + expect(validateStd(undefined, "gnu++17")).toBe("gnu++17"); + }); +}); + +describe("tokenizeFlags", () => { + it("splits on whitespace", () => { + expect(tokenizeFlags(" -Wall\t-O2\n")).toEqual(["-Wall", "-O2"]); + }); +}); diff --git a/web/app/api/_engine/flags.ts b/web/app/api/_engine/flags.ts new file mode 100644 index 0000000..ca0f3bc --- /dev/null +++ b/web/app/api/_engine/flags.ts @@ -0,0 +1,131 @@ +/** + * web/app/api/_engine/flags.ts + * + * Validation for user-supplied compiler flags and language standards. + * + * The workbench is a local tool, but every /api/* route accepts flags straight + * from the browser. A flag such as `-o /some/path`, `-include /etc/passwd`, + * `@response-file` or `-fplugin=` turns the compiler into a file writer or a + * code loader, so only the flag families a competitive programmer actually + * needs are let through. Anything else is reported back as rejected so the UI + * can explain why a setting was ignored instead of silently dropping it. + */ + +/** Flag prefixes that are safe to forward verbatim. */ +const ALLOWED_PREFIXES = [ + "-O", // optimisation level + "-W", // warnings (incl. -Werror, -Wno-*) + "-w", // silence warnings + "-D", // macro definitions + "-U", // macro undefinitions + "-f", // codegen / sanitizer switches (-fsanitize=..., -fno-omit-frame-pointer) + "-g", // debug info + "-m", // machine flags (-march=native, -m64) + "-std=", // language standard + "-pedantic", + "-static", + "-pthread", + "-pipe", + "-ansi", + "-trigraphs", +]; + +/** Flags that are dangerous even though they share an allowed prefix. */ +const DENIED_PREFIXES = [ + "-fplugin", // loads arbitrary shared objects into the compiler + "-fpass-plugin", + "-fuse-ld", + "-fprofile", // writes files outside the work dir + "-fdump", + "-fsave", + "-frandom-seed", + "-fmodule", + "-fpch", + "-fdebug-prefix-map", + "-ffile-prefix-map", + "-Wl,", // linker pass-through + "-Wa,", // assembler pass-through + "-Wp,", // preprocessor pass-through +]; + +/** Only the standards the toolchains actually understand. */ +const STD_PATTERN = /^(gnu|c)\+\+(0x|11|14|17|1z|2a|20|2b|23|2c|26)$/; + +export type FlagValidation = { + /** Flags that passed validation, in their original order. */ + accepted: string[]; + /** Flags that were dropped, each paired with a short reason. */ + rejected: { flag: string; reason: string }[]; +}; + +/** Split a raw flags string into argv-style tokens (whitespace separated). */ +export function tokenizeFlags(raw: string): string[] { + return raw + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Filter a list of compiler flags down to the safe subset. + * Never throws; unknown input types are treated as an empty list. + */ +export function validateFlags(input: unknown): FlagValidation { + const list: string[] = Array.isArray(input) + ? input.filter((f): f is string => typeof f === "string") + : typeof input === "string" + ? tokenizeFlags(input) + : []; + + const accepted: string[] = []; + const rejected: { flag: string; reason: string }[] = []; + const seen = new Set(); + + for (const raw of list) { + const flag = raw.trim(); + if (!flag) continue; + if (seen.has(flag)) continue; + seen.add(flag); + + if (flag.length > 128) { + rejected.push({ flag, reason: "flag is too long" }); + continue; + } + if (!flag.startsWith("-")) { + rejected.push({ flag, reason: "not a flag (must start with '-')" }); + continue; + } + if (/[\s"'`$\\;|&<>]/.test(flag)) { + rejected.push({ flag, reason: "contains shell metacharacters" }); + continue; + } + if (flag === "-std" || flag.startsWith("-std=")) { + const std = flag.slice("-std=".length); + if (!STD_PATTERN.test(std)) { + rejected.push({ flag, reason: "unsupported language standard" }); + continue; + } + } + if (DENIED_PREFIXES.some((p) => flag.startsWith(p))) { + rejected.push({ flag, reason: "flag family is not allowed" }); + continue; + } + if (!ALLOWED_PREFIXES.some((p) => flag === p || flag.startsWith(p))) { + rejected.push({ flag, reason: "flag family is not allowed" }); + continue; + } + accepted.push(flag); + } + + return { accepted, rejected }; +} + +/** + * Validate a `std` value coming from a request. Returns the value when it is a + * known standard, otherwise `fallback`. + */ +export function validateStd(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const std = value.trim(); + return STD_PATTERN.test(std) ? std : fallback; +} diff --git a/web/app/api/_engine/request.ts b/web/app/api/_engine/request.ts new file mode 100644 index 0000000..486543e --- /dev/null +++ b/web/app/api/_engine/request.ts @@ -0,0 +1,71 @@ +/** + * web/app/api/_engine/request.ts + * + * Request-parsing helpers shared by every API route so the compile/run + * options (`std`, `timeLimitMs`, `compilerFlags`, `checker`, `epsilon`) are + * interpreted identically everywhere. + */ + +import { NextResponse } from "next/server"; +import { clampTimeLimit, DEFAULT_STD } from "./cpp"; +import { validateStd } from "./flags"; +import { parseCheckerMode, parseEpsilon, type CheckerMode } from "./compare"; + +export type Body = Record; + +/** Parse a JSON body, or return a 400 response when it is not an object. */ +export async function readJson( + request: Request, +): Promise< + { body: Body; error?: undefined } | { body?: undefined; error: NextResponse } +> { + try { + const parsed = await request.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + error: NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 }, + ), + }; + } + return { body: parsed as Body }; + } catch { + return { + error: NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }), + }; + } +} + +export function badRequest(message: string): NextResponse { + return NextResponse.json({ error: message }, { status: 400 }); +} + +export type RunOptionsParsed = { + std: string; + timeLimitMs: number; + extraFlags: string[]; + checker: CheckerMode; + epsilon: number; +}; + +/** Read the shared compile/run options off a request body. */ +export function parseRunOptions(body: Body): RunOptionsParsed { + return { + std: validateStd(body.std, DEFAULT_STD), + timeLimitMs: clampTimeLimit( + typeof body.timeLimitMs === "number" ? body.timeLimitMs : undefined, + ), + extraFlags: Array.isArray(body.compilerFlags) + ? body.compilerFlags.filter((f): f is string => typeof f === "string") + : [], + checker: parseCheckerMode(body.checker), + epsilon: parseEpsilon(body.epsilon), + }; +} + +/** Read a string field, or "" when absent / not a string. */ +export function str(body: Body, key: string): string { + const v = body[key]; + return typeof v === "string" ? v : ""; +} diff --git a/web/app/api/_engine/statement.test.ts b/web/app/api/_engine/statement.test.ts new file mode 100644 index 0000000..331a5ae --- /dev/null +++ b/web/app/api/_engine/statement.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseStatement, parseTimeLimit, parseMemoryLimit } from "./statement"; + +const fixture = readFileSync( + path.resolve(__dirname, "../../../../tests/fixtures/231A/problem.txt"), + "utf8", +); + +describe("parseStatement", () => { + it("extracts every sample from a Codeforces statement", () => { + const r = parseStatement(fixture); + expect(r.title).toBe("A. Team"); + expect(r.timeLimitMs).toBe(2000); + expect(r.memoryLimitMb).toBe(256); + expect(r.tests).toEqual([ + { input: "3\n1 1 0\n1 1 1\n1 0 0\n", expected: "2\n" }, + { input: "2\n1 0 0\n0 1 1\n", expected: "1\n" }, + ]); + }); + + it("stops at the Note section", () => { + const r = parseStatement(fixture); + expect(r.tests[1].expected).not.toContain("In the first sample"); + }); + + it("accepts the CLI template skeleton with empty samples", () => { + const r = parseStatement( + "# Paste the problem statement here\n\nExamples\nInput\n\n\nOutput\n\n\n", + ); + expect(r.tests).toEqual([]); + }); + + it("handles AtCoder-style 'Sample Input 1' headings", () => { + const r = parseStatement( + "Problem\nSample Input 1\n1 2\nSample Output 1\n3\nSample Input 2\n5 5\nSample Output 2\n10\n", + ); + expect(r.tests).toEqual([ + { input: "1 2\n", expected: "3\n" }, + { input: "5 5\n", expected: "10\n" }, + ]); + }); + + it("handles a statement without an Examples heading", () => { + const r = parseStatement("Sample Input\n4\nSample Output\n16\n"); + expect(r.tests).toEqual([{ input: "4\n", expected: "16\n" }]); + }); + + it("does not mistake the input-format section for a sample", () => { + const r = parseStatement( + "A. X\nInput\nThe first line contains n.\nOutput\nPrint n.\n", + ); + expect(r.tests).toEqual([]); + }); + + it("ignores 'Copy' button text and CRLF", () => { + const r = parseStatement( + "Examples\r\nInput\r\nCopy\r\n1\r\nOutput\r\nCopy\r\n2\r\n", + ); + expect(r.tests).toEqual([{ input: "1\n", expected: "2\n" }]); + }); + + it("returns no tests for prose", () => { + expect(parseStatement("just some text").tests).toEqual([]); + }); +}); + +describe("limits", () => { + it("parses seconds and milliseconds", () => { + expect(parseTimeLimit("time limit per test\n1.5 seconds")).toBe(1500); + expect(parseTimeLimit("time limit: 500 ms")).toBe(500); + expect(parseTimeLimit("no limit here")).toBeNull(); + }); + it("parses megabytes and gigabytes", () => { + expect(parseMemoryLimit("memory limit per test\n256 megabytes")).toBe(256); + expect(parseMemoryLimit("memory limit 1 GB")).toBe(1024); + expect(parseMemoryLimit("")).toBeNull(); + }); +}); diff --git a/web/app/api/_engine/statement.ts b/web/app/api/_engine/statement.ts new file mode 100644 index 0000000..e42566f --- /dev/null +++ b/web/app/api/_engine/statement.ts @@ -0,0 +1,206 @@ +/** + * web/app/api/_engine/statement.ts + * + * Parse a problem statement pasted from Codeforces (or a similar judge) into + * sample test cases plus the limits the statement declares. This is the same + * heuristic the `cf` CLI applies to `problem.txt`, ported to TypeScript so the + * web workbench can turn a pasted statement into test cases in one click. + * + * Recognised layout (headings are case-insensitive, a trailing colon and the + * "Copy" button text Codeforces adds are ignored): + * + * A. Title + * time limit per test + * 2 seconds + * memory limit per test + * 256 megabytes + * ... + * Examples (or "Example", "Sample 1", "Examples:") + * Input (or "Sample Input", "Sample Input 1", "inputCopy") + * + * Output (or "Sample Output", "outputCopy") + * + * Input ... Output ... (repeated) + * Note (ends the examples section) + */ + +export type ParsedStatement = { + /** First non-empty line of the statement, typically "A. Title". */ + title: string | null; + /** Time limit in milliseconds when the statement declares one. */ + timeLimitMs: number | null; + /** Memory limit in megabytes when the statement declares one. */ + memoryLimitMb: number | null; + /** Extracted samples, each with a trailing newline. */ + tests: { input: string; expected: string }[]; +}; + +function normHead(line: string): string { + return line + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/[::]\s*$/, "") + .trim(); +} + +function isExamplesHead(line: string): boolean { + const h = normHead(line); + return ( + h === "examples" || + h === "example" || + /^example\s*\d+$/.test(h) || + /^samples?$/.test(h) || + /^sample\s*\d+$/.test(h) || + /^sample (tests?|cases?)$/.test(h) + ); +} + +function isInputHead(line: string): boolean { + const h = normHead(line).replace(/copy$/, "").trim(); + return ( + h === "input" || + h === "sample input" || + /^sample input\s*\d+$/.test(h) || + /^input\s*\d+$/.test(h) || + h === "standard input" + ); +} + +function isOutputHead(line: string): boolean { + const h = normHead(line).replace(/copy$/, "").trim(); + return ( + h === "output" || + h === "sample output" || + /^sample output\s*\d+$/.test(h) || + /^output\s*\d+$/.test(h) || + h === "standard output" + ); +} + +function isStopHead(line: string): boolean { + const h = normHead(line); + return ( + h === "note" || + h === "notes" || + h === "explanation" || + h === "constraints" || + h === "scoring" || + h === "interaction" + ); +} + +function trimBlock(lines: string[]): string { + const copy = [...lines]; + while (copy.length && copy[0].trim() === "") copy.shift(); + while (copy.length && copy[copy.length - 1].trim() === "") copy.pop(); + if (copy.length === 0) return ""; + return `${copy.map((l) => l.replace(/[ \t]+$/, "")).join("\n")}\n`; +} + +const TIME_RE = + /time limit(?: per test)?[^\d]*(\d+(?:\.\d+)?)\s*(seconds?|s\b|ms|milliseconds?)/i; +const MEM_RE = + /memory limit(?: per test)?[^\d]*(\d+(?:\.\d+)?)\s*(megabytes?|mb|gigabytes?|gb|kilobytes?|kb)/i; + +/** Parse "2 seconds" / "1.5 s" / "500 ms" style limits into milliseconds. */ +export function parseTimeLimit(text: string): number | null { + const m = TIME_RE.exec(text); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + const unit = m[2].toLowerCase(); + return unit.startsWith("ms") || unit.startsWith("milli") + ? Math.round(n) + : Math.round(n * 1000); +} + +/** Parse "256 megabytes" / "1 GB" style limits into megabytes. */ +export function parseMemoryLimit(text: string): number | null { + const m = MEM_RE.exec(text); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + const unit = m[2].toLowerCase(); + if (unit.startsWith("g")) return Math.round(n * 1024); + if (unit.startsWith("k")) return Math.round(n / 1024); + return Math.round(n); +} + +/** + * Extract samples and limits from a statement. Never throws; a statement + * without an examples section yields `tests: []`. + */ +export function parseStatement(text: string): ParsedStatement { + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + + const firstLine = lines.find((l) => l.trim() !== "") ?? null; + const title = firstLine ? firstLine.trim().slice(0, 200) : null; + + // Limits appear in the header; only scan the first few hundred characters + // so a "time limit" phrase inside a note is not misread. + const head = lines.slice(0, 12).join("\n"); + const timeLimitMs = parseTimeLimit(head); + const memoryLimitMb = parseMemoryLimit(head); + + const tests: { input: string; expected: string }[] = []; + let inExamples = false; + let block: "input" | "output" | null = null; + let curInput: string[] = []; + let curOutput: string[] = []; + let haveInput = false; + + const flush = () => { + if (haveInput) { + tests.push({ + input: trimBlock(curInput), + expected: trimBlock(curOutput), + }); + } + curInput = []; + curOutput = []; + haveInput = false; + }; + + for (const raw of lines) { + const line = raw.replace(/\s+$/, ""); + if (!inExamples) { + if (isExamplesHead(line)) inExamples = true; + // AtCoder-style statements have no "Examples" heading; their samples + // start with an unambiguous "Sample Input N". A bare "Input" is the + // input-format section of the statement and must not count. + else if (/^sample input/.test(normHead(line))) { + inExamples = true; + block = "input"; + haveInput = true; + } + continue; + } + + if (isInputHead(line)) { + flush(); + block = "input"; + haveInput = true; + continue; + } + if (isOutputHead(line)) { + block = "output"; + continue; + } + if (isStopHead(line) || (block === "output" && isExamplesHead(line))) { + flush(); + break; + } + if (block === null) continue; + if (normHead(line) === "copy") continue; + if (block === "input") curInput.push(line); + else curOutput.push(line); + } + flush(); + + return { + title, + timeLimitMs, + memoryLimitMb, + tests: tests.filter((t) => t.input !== "" || t.expected !== ""), + }; +} diff --git a/web/app/api/_engine/store.test.ts b/web/app/api/_engine/store.test.ts new file mode 100644 index 0000000..1161ee1 --- /dev/null +++ b/web/app/api/_engine/store.test.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, rm, readdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + saveProblem, + getProblem, + listProblems, + listProblemsFull, + renameProblem, + duplicateProblem, + deleteProblem, + importProblems, + slugify, + isSafeProblemName, + normalizeProblem, + problemsDataDir, +} from "./store"; + +let dataDir: string; + +beforeAll(async () => { + dataDir = await mkdtemp(path.join(os.tmpdir(), "cf-store-test-")); + process.env.CF_DATA_DIR = dataDir; +}); + +afterAll(async () => { + delete process.env.CF_DATA_DIR; + await rm(dataDir, { recursive: true, force: true }); +}); + +describe("slugify", () => { + it("normalises names", () => { + expect(slugify("1620C - Bad Sums!")).toBe("1620c-bad-sums"); + expect(slugify(" ")).toBe("problem"); + expect(slugify("x".repeat(200))).toHaveLength(80); + }); +}); + +describe("isSafeProblemName", () => { + it("accepts plain segments and rejects traversal", () => { + expect(isSafeProblemName("1000A")).toBe(true); + expect(isSafeProblemName("bear-and-big_brother.v2")).toBe(true); + expect(isSafeProblemName("../etc")).toBe(false); + expect(isSafeProblemName("a/b")).toBe(false); + expect(isSafeProblemName("a\\b")).toBe(false); + expect(isSafeProblemName(".hidden")).toBe(false); + expect(isSafeProblemName("")).toBe(false); + expect(isSafeProblemName(null)).toBe(false); + }); +}); + +describe("problems store", () => { + it("uses the CF_DATA_DIR override", () => { + expect(problemsDataDir()).toBe(path.join(dataDir, "problems")); + }); + + it("saves and loads a full workspace", async () => { + const saved = await saveProblem({ + name: "Test A", + code: "int main(){}", + tests: [{ input: "1\n", expected: "2\n" }, { bogus: true }], + stdin: "1\n", + brute: "// brute", + generator: "// gen", + settings: { std: "gnu++20", timeLimitMs: 1000, junk: 1 }, + }); + expect(saved.slug).toBe("test-a"); + expect(saved.tests).toEqual([ + { input: "1\n", expected: "2\n" }, + { input: "", expected: "" }, + ]); + expect(saved.settings).toEqual({ std: "gnu++20", timeLimitMs: 1000 }); + + const loaded = await getProblem("test-a"); + expect(loaded).toEqual(saved); + }); + + it("merges partial updates over the existing record", async () => { + const updated = await saveProblem({ name: "Test A", stdin: "9\n" }); + expect(updated.code).toBe("int main(){}"); + expect(updated.stdin).toBe("9\n"); + expect(updated.brute).toBe("// brute"); + expect(updated.createdAt).toBeLessThanOrEqual(updated.updatedAt); + }); + + it("writes atomically (no temp files left behind)", async () => { + const files = await readdir(problemsDataDir()); + expect(files.filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); + + it("lists summaries newest first", async () => { + await saveProblem({ name: "Test B", code: "b" }); + const list = await listProblems(); + expect(list.map((p) => p.slug)).toEqual(["test-b", "test-a"]); + expect(list[1].testCount).toBe(2); + }); + + it("renames, duplicates and deletes", async () => { + const renamed = await renameProblem("test-b", "Test C"); + expect(renamed?.slug).toBe("test-c"); + expect(await getProblem("test-b")).toBeNull(); + + const dup = await duplicateProblem("test-c", "Test D"); + expect(dup?.code).toBe("b"); + expect((await listProblems()).map((p) => p.slug).sort()).toEqual([ + "test-a", + "test-c", + "test-d", + ]); + + expect(await deleteProblem("test-d")).toBe(true); + expect(await deleteProblem("test-d")).toBe(false); + expect(await deleteProblem("../evil")).toBe(false); + }); + + it("imports a bundle and respects overwrite", async () => { + const bundle = (await listProblemsFull()).map((p) => ({ + ...p, + code: "imported", + })); + const skipped = await importProblems(bundle, false); + expect(skipped).toEqual({ imported: 0, skipped: bundle.length }); + expect((await getProblem("test-a"))?.code).toBe("int main(){}"); + + const over = await importProblems([...bundle, null, { name: "" }], true); + expect(over).toEqual({ imported: bundle.length, skipped: 2 }); + expect((await getProblem("test-a"))?.code).toBe("imported"); + }); + + it("tolerates records written by older versions", () => { + const p = normalizeProblem( + { name: "Old", code: "c", tests: "nope" }, + "old", + ); + expect(p).toMatchObject({ + name: "Old", + slug: "old", + code: "c", + tests: [], + stdin: "", + settings: null, + }); + expect(normalizeProblem("garbage", "x")).toBeNull(); + }); +}); diff --git a/web/app/api/_engine/store.ts b/web/app/api/_engine/store.ts index 8ba605f..f3fe17a 100644 --- a/web/app/api/_engine/store.ts +++ b/web/app/api/_engine/store.ts @@ -4,12 +4,14 @@ * Persistence helpers for the API routes, using only Node built-ins. * * Two concerns live here: - * 1. Problems store — saved problems and their test cases, persisted as JSON - * under /data/problems/.json (the canonical store the workbench - * reads/writes via /api/problems). + * 1. Problems store — saved problems (source, statement, sample tests, the + * custom stdin and the stress sources) persisted as JSON under + * /data/problems/.json. Writes are atomic (temp file + rename) + * so a crash mid-write never leaves a half-written record behind. * 2. Legacy file helpers — solution.cpp / problem.txt persistence under the * repo `src//` tree, kept so the /api/solution, /api/problem-text * and /api/template routes stay self-contained (no dependency on web/lib). + * Problem names are validated so a request can never escape the tree. */ import { @@ -19,6 +21,7 @@ import { readdir, stat, rm, + rename, } from "node:fs/promises"; import path from "node:path"; import { repoRoot } from "./cpp"; @@ -27,12 +30,28 @@ import { repoRoot } from "./cpp"; export type TestCase = { input: string; expected: string }; +export type ProblemSettings = { + std?: string; + timeLimitMs?: number; + compilerFlags?: string[]; + checker?: string; + epsilon?: number; +}; + export type Problem = { name: string; slug: string; code: string; statement: string; tests: TestCase[]; + /** Custom stdin from the Run panel. */ + stdin: string; + /** Brute-force source from the Stress panel. */ + brute: string; + /** Generator source from the Stress panel. */ + generator: string; + /** Per-problem run settings snapshot (optional, merged over defaults). */ + settings: ProblemSettings | null; createdAt: number; updatedAt: number; }; @@ -71,7 +90,19 @@ async function ensureDataDir(): Promise { await mkdir(problemsDataDir(), { recursive: true }); } -function normalizeTests(tests: unknown): TestCase[] { +/** Write a file atomically: write to a sibling temp file, then rename. */ +async function atomicWrite(file: string, data: string): Promise { + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, data, "utf8"); + try { + await rename(tmp, file); + } catch (e) { + await rm(tmp, { force: true }).catch(() => {}); + throw e; + } +} + +export function normalizeTests(tests: unknown): TestCase[] { if (!Array.isArray(tests)) return []; return tests .filter((t): t is Record => !!t && typeof t === "object") @@ -81,33 +112,79 @@ function normalizeTests(tests: unknown): TestCase[] { })); } +function normalizeSettings(value: unknown): ProblemSettings | null { + if (!value || typeof value !== "object") return null; + const v = value as Record; + const out: ProblemSettings = {}; + if (typeof v.std === "string") out.std = v.std; + if (typeof v.timeLimitMs === "number" && Number.isFinite(v.timeLimitMs)) + out.timeLimitMs = v.timeLimitMs; + if (Array.isArray(v.compilerFlags)) + out.compilerFlags = v.compilerFlags.filter( + (f): f is string => typeof f === "string", + ); + if (typeof v.checker === "string") out.checker = v.checker; + if (typeof v.epsilon === "number" && Number.isFinite(v.epsilon)) + out.epsilon = v.epsilon; + return Object.keys(out).length ? out : null; +} + +/** Coerce a raw JSON record (possibly from an older version) into a Problem. */ +export function normalizeProblem( + raw: unknown, + fallbackSlug: string, +): Problem | null { + if (!raw || typeof raw !== "object") return null; + const p = raw as Record; + const name = + typeof p.name === "string" && p.name.trim() ? p.name.trim() : fallbackSlug; + return { + name, + slug: typeof p.slug === "string" && p.slug ? p.slug : slugify(name), + code: typeof p.code === "string" ? p.code : "", + statement: typeof p.statement === "string" ? p.statement : "", + tests: normalizeTests(p.tests), + stdin: typeof p.stdin === "string" ? p.stdin : "", + brute: typeof p.brute === "string" ? p.brute : "", + generator: typeof p.generator === "string" ? p.generator : "", + settings: normalizeSettings(p.settings), + createdAt: typeof p.createdAt === "number" ? p.createdAt : 0, + updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : 0, + }; +} + /** List saved problems (summaries only), newest first. */ export async function listProblems(): Promise { + const full = await listProblemsFull(); + return full.map((p) => ({ + name: p.name, + slug: p.slug, + testCount: p.tests.length, + updatedAt: p.updatedAt, + })); +} + +/** Load every saved problem in full, newest first. */ +export async function listProblemsFull(): Promise { try { const entries = await readdir(problemsDataDir(), { withFileTypes: true }); - const summaries: ProblemSummary[] = []; + const out: Problem[] = []; for (const e of entries) { if (!e.isFile() || !e.name.endsWith(".json")) continue; + const slug = e.name.replace(/\.json$/, ""); try { const raw = await readFile( path.join(problemsDataDir(), e.name), "utf8", ); - const p = JSON.parse(raw) as Problem; - summaries.push({ - name: - typeof p.name === "string" ? p.name : e.name.replace(/\.json$/, ""), - slug: - typeof p.slug === "string" ? p.slug : e.name.replace(/\.json$/, ""), - testCount: Array.isArray(p.tests) ? p.tests.length : 0, - updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : 0, - }); + const p = normalizeProblem(JSON.parse(raw), slug); + if (p) out.push(p); } catch { // skip unreadable/corrupt files } } - summaries.sort((a, b) => b.updatedAt - a.updatedAt); - return summaries; + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; } catch { return []; } @@ -115,50 +192,64 @@ export async function listProblems(): Promise { /** Load one problem by slug, or null when it does not exist. */ export async function getProblem(slug: string): Promise { + if (!/^[a-z0-9-]+$/.test(slug)) return null; try { const raw = await readFile(problemPath(slug), "utf8"); - return JSON.parse(raw) as Problem; + return normalizeProblem(JSON.parse(raw), slug); } catch { return null; } } -/** Create or update a problem keyed by its slug. Returns the stored record. */ -export async function saveProblem(input: { +export type SaveProblemInput = { name: string; code?: string; statement?: string; tests?: unknown; -}): Promise { + stdin?: string; + brute?: string; + generator?: string; + settings?: unknown; + /** Preserve these timestamps (used by import). */ + createdAt?: number; + updatedAt?: number; +}; + +/** Create or update a problem keyed by its slug. Returns the stored record. */ +export async function saveProblem(input: SaveProblemInput): Promise { await ensureDataDir(); const slug = slugify(input.name); const now = Date.now(); const existing = await getProblem(slug); + const pick = (v: T | undefined, prev: T | undefined, def: T): T => + v !== undefined ? v : prev !== undefined ? prev : def; + const problem: Problem = { - name: input.name, + name: input.name.trim(), slug, - code: typeof input.code === "string" ? input.code : (existing?.code ?? ""), - statement: - typeof input.statement === "string" - ? input.statement - : (existing?.statement ?? ""), + code: pick(input.code, existing?.code, ""), + statement: pick(input.statement, existing?.statement, ""), tests: input.tests !== undefined ? normalizeTests(input.tests) : (existing?.tests ?? []), - createdAt: existing?.createdAt ?? now, - updatedAt: now, + stdin: pick(input.stdin, existing?.stdin, ""), + brute: pick(input.brute, existing?.brute, ""), + generator: pick(input.generator, existing?.generator, ""), + settings: + input.settings !== undefined + ? normalizeSettings(input.settings) + : (existing?.settings ?? null), + createdAt: input.createdAt ?? existing?.createdAt ?? now, + updatedAt: input.updatedAt ?? now, }; - await writeFile( - problemPath(slug), - `${JSON.stringify(problem, null, 2)}\n`, - "utf8", - ); + await atomicWrite(problemPath(slug), `${JSON.stringify(problem, null, 2)}\n`); return problem; } /** Delete a problem by slug. Returns true when a file was removed. */ export async function deleteProblem(slug: string): Promise { + if (!/^[a-z0-9-]+$/.test(slug)) return false; try { await rm(problemPath(slug)); return true; @@ -175,15 +266,64 @@ export async function renameProblem( const existing = await getProblem(from); if (!existing) return null; const saved = await saveProblem({ + ...existing, name: to, - code: existing.code, - statement: existing.statement, - tests: existing.tests, + createdAt: existing.createdAt, + updatedAt: undefined, }); if (saved.slug !== from) await deleteProblem(from); return saved; } +/** Duplicate a problem under a new name. Returns null when `from` is absent. */ +export async function duplicateProblem( + from: string, + to: string, +): Promise { + const existing = await getProblem(from); + if (!existing) return null; + return saveProblem({ + ...existing, + name: to, + createdAt: undefined, + updatedAt: undefined, + }); +} + +export type ImportResult = { imported: number; skipped: number }; + +/** + * Import problems from an exported bundle. Existing records are overwritten + * only when `overwrite` is true; otherwise they are skipped. + */ +export async function importProblems( + records: unknown, + overwrite: boolean, +): Promise { + if (!Array.isArray(records)) return { imported: 0, skipped: 0 }; + let imported = 0; + let skipped = 0; + for (const raw of records) { + const p = normalizeProblem(raw, ""); + if (!p || !p.name) { + skipped++; + continue; + } + const slug = slugify(p.name); + if (!overwrite && (await getProblem(slug))) { + skipped++; + continue; + } + await saveProblem({ + ...p, + createdAt: p.createdAt || undefined, + updatedAt: p.updatedAt || undefined, + }); + imported++; + } + return { imported, skipped }; +} + // ==================== Legacy file helpers (repo src tree) ==================== /** Directory holding per-problem source folders (overridable with env). */ @@ -193,67 +333,83 @@ export function srcProblemsDir(): string { : path.join(repoRoot(), "src"); } +/** + * A problem directory name is a single path segment of safe characters. This + * is what keeps `../` (or an absolute path) out of the file helpers below. + */ +export function isSafeProblemName(name: unknown): name is string { + return ( + typeof name === "string" && + name.length > 0 && + name.length <= 128 && + /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) && + !name.includes("..") + ); +} + +function problemFile(problem: string, file: string): string { + if (!isSafeProblemName(problem)) throw new Error("Invalid problem name"); + return path.join(srcProblemsDir(), problem, file); +} + async function ensureDir(dir: string): Promise { if (!(await stat(dir).catch(() => null))) await mkdir(dir, { recursive: true }); } -export async function getSolution(problem: string): Promise { +async function readOrEmpty(file: string): Promise { try { - return await readFile( - path.join(srcProblemsDir(), problem, "solution.cpp"), - "utf8", - ); + return await readFile(file, "utf8"); } catch { return ""; } } +async function writeIfChanged(file: string, content: string): Promise { + await ensureDir(path.dirname(file)); + const current = await readFile(file, "utf8").catch(() => null); + if (current === content) return; // avoid churning mtimes + await atomicWrite(file, content); +} + +export async function getSolution(problem: string): Promise { + return readOrEmpty(problemFile(problem, "solution.cpp")); +} + export async function saveSolution( problem: string, code: string, ): Promise { - const dir = path.join(srcProblemsDir(), problem); - await ensureDir(dir); - const file = path.join(dir, "solution.cpp"); - const current = await readFile(file, "utf8").catch(() => null); - if (current === code) return; // avoid churning mtimes - await writeFile(file, code, "utf8"); + await writeIfChanged(problemFile(problem, "solution.cpp"), code); } export async function getProblemText(problem: string): Promise { - try { - return await readFile( - path.join(srcProblemsDir(), problem, "problem.txt"), - "utf8", - ); - } catch { - return ""; - } + return readOrEmpty(problemFile(problem, "problem.txt")); } export async function saveProblemText( problem: string, text: string, ): Promise { - const dir = path.join(srcProblemsDir(), problem); - await ensureDir(dir); - const file = path.join(dir, "problem.txt"); - const current = await readFile(file, "utf8").catch(() => null); - if (current === text) return; - await writeFile(file, text, "utf8"); + await writeIfChanged(problemFile(problem, "problem.txt"), text); +} + +/** Names of the starter templates shipped under the repo `templates/` dir. */ +export async function listTemplates(): Promise { + try { + const entries = await readdir(path.join(repoRoot(), "templates")); + return entries + .filter((f) => f.endsWith(".cpp")) + .map((f) => f.replace(/\.cpp$/, "")) + .sort(); + } catch { + return []; + } } /** Read a named starter template from the repo `templates/` directory. */ export async function readTemplate(name: string): Promise { const safe = path.basename(String(name || "").replace(/[^a-zA-Z0-9_-]/g, "")); if (!safe) return ""; - try { - return await readFile( - path.join(repoRoot(), "templates", `${safe}.cpp`), - "utf8", - ); - } catch { - return ""; - } + return readOrEmpty(path.join(repoRoot(), "templates", `${safe}.cpp`)); } diff --git a/web/app/api/config/route.ts b/web/app/api/config/route.ts index cd97ac9..c6ae589 100644 --- a/web/app/api/config/route.ts +++ b/web/app/api/config/route.ts @@ -1,8 +1,95 @@ -import { NextResponse } from 'next/server'; +/** + * GET /api/config — environment and toolchain facts the UI displays. + * + * Response JSON: + * { + * version: string, // workbench version (package.json) + * platform: string, // process.platform + * compiler: string|null, // detected compiler command + * compilerVersion: string|null, // first line of ` --version` + * includeDir: string, // where the bits/stdc++.h shim lives + * defaults: { std, timeLimitMs, maxTimeLimitMs, checker }, + * limits: { maxSourceBytes, maxInputBytes, maxOutputBytes, compileTimeoutMs }, + * cache: { entries: number }, // compiled binaries currently cached + * startProblem: string|null, // CF_START_PROBLEM hint from `cf serve` + * problemsDir: string|null // CF_PROBLEMS_DIR hint from `cf serve` + * } + * + * DELETE /api/config?cache=1 — drop every cached binary → { ok, cleared }. + */ + +import { NextResponse } from "next/server"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + detectCompiler, + compilerVersion, + includeDir, + compileCacheSize, + clearCompileCache, + DEFAULT_STD, + DEFAULT_TIME_LIMIT_MS, + MAX_TIME_LIMIT_MS, + MAX_SOURCE_BYTES, + MAX_INPUT_BYTES, + MAX_OUTPUT_BYTES, + COMPILE_TIMEOUT_MS, +} from "../_engine/cpp"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +let cachedVersion: string | null = null; + +async function packageVersion(): Promise { + if (cachedVersion) return cachedVersion; + try { + const raw = await readFile( + path.join(process.cwd(), "package.json"), + "utf8", + ); + const v = (JSON.parse(raw) as { version?: unknown }).version; + cachedVersion = typeof v === "string" ? v : "0.0.0"; + } catch { + cachedVersion = "0.0.0"; + } + return cachedVersion; +} export async function GET() { return NextResponse.json({ + version: await packageVersion(), + platform: process.platform, + compiler: detectCompiler(), + compilerVersion: compilerVersion(), + includeDir: includeDir(), + defaults: { + std: DEFAULT_STD, + timeLimitMs: DEFAULT_TIME_LIMIT_MS, + maxTimeLimitMs: MAX_TIME_LIMIT_MS, + checker: "lines", + }, + limits: { + maxSourceBytes: MAX_SOURCE_BYTES, + maxInputBytes: MAX_INPUT_BYTES, + maxOutputBytes: MAX_OUTPUT_BYTES, + compileTimeoutMs: COMPILE_TIMEOUT_MS, + }, + cache: { entries: compileCacheSize() }, startProblem: process.env.CF_START_PROBLEM || null, problemsDir: process.env.CF_PROBLEMS_DIR || null, }); } + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + if (!searchParams.get("cache")) { + return NextResponse.json( + { error: "Nothing to delete; pass ?cache=1 to clear the compile cache" }, + { status: 400 }, + ); + } + const cleared = compileCacheSize(); + await clearCompileCache(); + return NextResponse.json({ ok: true, cleared }); +} diff --git a/web/app/api/problem-text/route.ts b/web/app/api/problem-text/route.ts index 6ceb38c..d9a4d7a 100644 --- a/web/app/api/problem-text/route.ts +++ b/web/app/api/problem-text/route.ts @@ -4,28 +4,38 @@ * GET /api/problem-text?problem= → { text: string } ("" when absent) * POST /api/problem-text { problem, text } → { success: true } * - * Self-contained (Node built-ins only) so the api tree never imports web/lib. + * `problem` must be a single safe path segment; see /api/solution. */ import { NextResponse } from "next/server"; -import { getProblemText, saveProblemText } from "../_engine/store"; +import { + getProblemText, + saveProblemText, + isSafeProblemName, +} from "../_engine/store"; +import { readJson, badRequest, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const MAX_TEXT_BYTES = 1024 * 1024; + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const problem = searchParams.get("problem"); - if (!problem) - return NextResponse.json({ error: "Missing problem" }, { status: 400 }); + if (!isSafeProblemName(problem)) return badRequest("Invalid problem name"); const text = await getProblemText(problem); return NextResponse.json({ text }); } export async function POST(request: Request) { - const { problem, text } = await request.json(); - if (!problem) - return NextResponse.json({ error: "Missing problem" }, { status: 400 }); - await saveProblemText(problem, typeof text === "string" ? text : ""); + const { body, error } = await readJson(request); + if (error) return error; + const problem = str(body, "problem"); + if (!isSafeProblemName(problem)) return badRequest("Invalid problem name"); + const text = str(body, "text"); + if (Buffer.byteLength(text, "utf8") > MAX_TEXT_BYTES) + return badRequest("Statement is too large"); + await saveProblemText(problem, text); return NextResponse.json({ success: true }); } diff --git a/web/app/api/problems/route.ts b/web/app/api/problems/route.ts index 51d3ce0..2a882bf 100644 --- a/web/app/api/problems/route.ts +++ b/web/app/api/problems/route.ts @@ -1,18 +1,23 @@ /** - * /api/problems — persist saved problems and their test cases as JSON under + * /api/problems — persist saved problems as JSON under * /data/problems/.json. Uses only Node built-ins. * - * GET /api/problems → { problems: ProblemSummary[] } (newest first) - * GET /api/problems?name=→ { problem: Problem } | 404 { error } + * GET /api/problems → { problems: ProblemSummary[] } (newest first) + * GET /api/problems?name= → { problem: Problem } | 404 { error } + * GET /api/problems?export=1 → { version: 1, exportedAt, problems: Problem[] } * POST /api/problems - * { name, code?, statement?, tests? } → { ok, problem } (upsert) - * { op: "rename", from, to } → { ok, problem } (rename) - * DELETE /api/problems?name= → { ok, deleted } + * { name, code?, statement?, tests?, stdin?, brute?, generator?, settings? } + * → { ok, problem } (upsert) + * { op: "rename", from, to } → { ok, problem } (rename) + * { op: "duplicate", from, to }→ { ok, problem } (copy) + * { op: "import", problems: Problem[], overwrite?: boolean } + * → { ok, imported, skipped } + * DELETE /api/problems?name= → { ok, deleted } * * Types: * TestCase = { input: string, expected: string } - * Problem = { name, slug, code, statement, tests: TestCase[], - * createdAt, updatedAt } + * Problem = { name, slug, code, statement, tests: TestCase[], stdin, + * brute, generator, settings|null, createdAt, updatedAt } * ProblemSummary = { name, slug, testCount, updatedAt } * * `slug` is derived from `name` (lowercase, non-alphanumerics → "-") and is the @@ -22,21 +27,34 @@ import { NextResponse } from "next/server"; import { listProblems, + listProblemsFull, getProblem, saveProblem, deleteProblem, renameProblem, + duplicateProblem, + importProblems, slugify, } from "../_engine/store"; +import { readJson, badRequest, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const MAX_NAME = 120; + function resolveSlug(key: string): string { // Accept either an exact slug or a human name; slugify is idempotent on slugs. return /^[a-z0-9-]+$/.test(key) ? key : slugify(key); } +function notFound(name: string) { + return NextResponse.json( + { error: `Problem not found: ${name}` }, + { status: 404 }, + ); +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const name = searchParams.get("name"); @@ -44,58 +62,60 @@ export async function GET(request: Request) { const problem = (await getProblem(resolveSlug(name))) ?? (await getProblem(slugify(name))); - if (!problem) { - return NextResponse.json( - { error: `Problem not found: ${name}` }, - { status: 404 }, - ); - } + if (!problem) return notFound(name); return NextResponse.json({ problem }); } + if (searchParams.get("export")) { + const problems = await listProblemsFull(); + return NextResponse.json({ + version: 1, + exportedAt: new Date().toISOString(), + problems, + }); + } const problems = await listProblems(); return NextResponse.json({ problems }); } export async function POST(request: Request) { - let body: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } + const { body, error } = await readJson(request); + if (error) return error; - if (body.op === "rename") { - const from = typeof body.from === "string" ? body.from : ""; - const to = typeof body.to === "string" ? body.to : ""; - if (!from || !to.trim()) { - return NextResponse.json( - { error: "rename requires `from` and `to`" }, - { status: 400 }, - ); - } - const problem = await renameProblem(resolveSlug(from), to.trim()); - if (!problem) { - return NextResponse.json( - { error: `Problem not found: ${from}` }, - { status: 404 }, - ); - } + const op = str(body, "op"); + + if (op === "rename" || op === "duplicate") { + const from = str(body, "from"); + const to = str(body, "to").trim(); + if (!from || !to) return badRequest(`${op} requires \`from\` and \`to\``); + if (to.length > MAX_NAME) return badRequest("Problem name is too long"); + const problem = + op === "rename" + ? await renameProblem(resolveSlug(from), to) + : await duplicateProblem(resolveSlug(from), to); + if (!problem) return notFound(from); return NextResponse.json({ ok: true, problem }); } - const name = typeof body.name === "string" ? body.name : ""; - if (!name.trim()) { - return NextResponse.json( - { error: "Missing required field: name" }, - { status: 400 }, - ); + if (op === "import") { + const result = await importProblems(body.problems, body.overwrite === true); + return NextResponse.json({ ok: true, ...result }); } + if (op) return badRequest(`Unknown op: ${op}`); + + const name = str(body, "name").trim(); + if (!name) return badRequest("Missing required field: name"); + if (name.length > MAX_NAME) return badRequest("Problem name is too long"); + const problem = await saveProblem({ - name: name.trim(), + name, code: typeof body.code === "string" ? body.code : undefined, statement: typeof body.statement === "string" ? body.statement : undefined, tests: body.tests, + stdin: typeof body.stdin === "string" ? body.stdin : undefined, + brute: typeof body.brute === "string" ? body.brute : undefined, + generator: typeof body.generator === "string" ? body.generator : undefined, + settings: body.settings, }); return NextResponse.json({ ok: true, problem }); } @@ -103,12 +123,7 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const name = searchParams.get("name"); - if (!name) { - return NextResponse.json( - { error: "Missing required query param: name" }, - { status: 400 }, - ); - } + if (!name) return badRequest("Missing required query param: name"); const deleted = (await deleteProblem(resolveSlug(name))) || (await deleteProblem(slugify(name))); diff --git a/web/app/api/run/route.ts b/web/app/api/run/route.ts index f844980..4f776bb 100644 --- a/web/app/api/run/route.ts +++ b/web/app/api/run/route.ts @@ -8,47 +8,49 @@ * stdin?: string, // alias for `input` * timeLimitMs?: number, // wall-clock limit, default 5000, max 60000 * std?: string, // C++ standard sans -std=, default "gnu++17" - * compilerFlags?: string[] // extra flags appended to the compile command + * compilerFlags?: string[] // extra flags (validated against an allowlist) * } * * Response JSON (200): * { * ok: boolean, // true when verdict === "OK" * verdict: "OK"|"CE"|"RE"|"TLE", - * stdout: string, // program stdout (backward-compatible field) - * stderr: string, // program stderr (backward-compatible field) - * exitCode: number|null, // program exit code (backward-compatible field) + * stdout: string, // program stdout + * stderr: string, // program stderr + * exitCode: number|null, // program exit code * signal: string|null, // terminating signal, when killed * timedOut: boolean, * timeMs: number, // run wall-clock time, measured in Node * truncated: boolean, // output hit the size cap * compiler: string|null, - * compile: { ok: boolean, stderr: string, ms: number } + * compile: { + * ok: boolean, stderr: string, ms: number, cached: boolean, + * diagnostics: { severity, line, column, message }[], + * rejectedFlags: { flag, reason }[] + * } * } * On a 400 the body is { error: string }. */ import { NextResponse } from "next/server"; -import { compile, runBinary, cleanup, type RunResult } from "../_engine/cpp"; +import { + compile, + runBinary, + cleanup, + runVerdict, + runStderr, +} from "../_engine/cpp"; +import { readJson, badRequest, parseRunOptions, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: Request) { - let body: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } + const { body, error } = await readJson(request); + if (error) return error; - const code = typeof body.code === "string" ? body.code : ""; - if (!code.trim()) { - return NextResponse.json( - { error: "Missing required field: code" }, - { status: 400 }, - ); - } + const code = str(body, "code"); + if (!code.trim()) return badRequest("Missing required field: code"); const input = typeof body.input === "string" @@ -56,14 +58,22 @@ export async function POST(request: Request) { : typeof body.stdin === "string" ? body.stdin : undefined; - const timeLimitMs = - typeof body.timeLimitMs === "number" ? body.timeLimitMs : undefined; - const std = typeof body.std === "string" ? body.std : undefined; - const extraFlags = Array.isArray(body.compilerFlags) - ? body.compilerFlags.filter((f): f is string => typeof f === "string") - : undefined; + const opts = parseRunOptions(body); + + const compiled = await compile(code, { + std: opts.std, + extraFlags: opts.extraFlags, + label: "run", + }); + const compileBlock = { + ok: compiled.ok, + stderr: compiled.stderr, + ms: Math.round(compiled.ms), + cached: compiled.cached, + diagnostics: compiled.diagnostics, + rejectedFlags: compiled.rejectedFlags, + }; - const compiled = await compile(code, { std, extraFlags, label: "run" }); if (!compiled.ok || !compiled.binPath) { await cleanup(compiled.workDir); return NextResponse.json({ @@ -77,36 +87,27 @@ export async function POST(request: Request) { timeMs: 0, truncated: false, compiler: compiled.compiler, - compile: { ok: false, stderr: compiled.stderr, ms: compiled.ms }, + compile: compileBlock, }); } - let run: RunResult; - try { - run = await runBinary(compiled.binPath, { input, timeLimitMs }); - } finally { - await cleanup(compiled.workDir); - } - - const verdict = run.timedOut - ? "TLE" - : run.spawnError || run.exitCode !== 0 || run.signal - ? "RE" - : "OK"; + const run = await runBinary(compiled.binPath, { + input, + timeLimitMs: opts.timeLimitMs, + }); + const verdict = runVerdict(run); return NextResponse.json({ ok: verdict === "OK", verdict, stdout: run.stdout, - stderr: run.spawnError - ? `${run.stderr}\n${run.spawnError}`.trim() - : run.stderr, + stderr: runStderr(run), exitCode: run.exitCode, signal: run.signal, timedOut: run.timedOut, timeMs: run.timeMs, truncated: run.truncated, compiler: compiled.compiler, - compile: { ok: true, stderr: compiled.stderr, ms: compiled.ms }, + compile: compileBlock, }); } diff --git a/web/app/api/samples/route.ts b/web/app/api/samples/route.ts new file mode 100644 index 0000000..2108c59 --- /dev/null +++ b/web/app/api/samples/route.ts @@ -0,0 +1,31 @@ +/** + * POST /api/samples — parse a pasted problem statement into sample tests. + * + * Request JSON: { statement: string } + * Response JSON: { + * title: string|null, // first non-empty line ("A. Team") + * timeLimitMs: number|null, // from "time limit per test 2 seconds" + * memoryLimitMb: number|null, // from "memory limit per test 256 megabytes" + * tests: { input: string, expected: string }[] + * } + * On a 400 the body is { error: string }. + */ + +import { NextResponse } from "next/server"; +import { parseStatement } from "../_engine/statement"; +import { readJson, badRequest, str } from "../_engine/request"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_STATEMENT_BYTES = 1024 * 1024; + +export async function POST(request: Request) { + const { body, error } = await readJson(request); + if (error) return error; + const statement = str(body, "statement"); + if (!statement.trim()) return badRequest("Missing required field: statement"); + if (Buffer.byteLength(statement, "utf8") > MAX_STATEMENT_BYTES) + return badRequest("Statement is too large"); + return NextResponse.json(parseStatement(statement)); +} diff --git a/web/app/api/solution/route.ts b/web/app/api/solution/route.ts index 54a98b1..fad7595 100644 --- a/web/app/api/solution/route.ts +++ b/web/app/api/solution/route.ts @@ -4,28 +4,36 @@ * GET /api/solution?problem= → { code: string } ("" when absent) * POST /api/solution { problem, code } → { success: true } * - * Self-contained (Node built-ins only) so the api tree never imports web/lib. + * `problem` must be a single safe path segment (letters, digits, `_`, `-`, + * `.`); anything else is rejected with 400 so the route can never read or + * write outside the problems directory. */ import { NextResponse } from "next/server"; -import { getSolution, saveSolution } from "../_engine/store"; +import { getSolution, saveSolution, isSafeProblemName } from "../_engine/store"; +import { readJson, badRequest, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const MAX_CODE_BYTES = 1024 * 1024; + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const problem = searchParams.get("problem"); - if (!problem) - return NextResponse.json({ error: "Missing problem" }, { status: 400 }); + if (!isSafeProblemName(problem)) return badRequest("Invalid problem name"); const code = await getSolution(problem); return NextResponse.json({ code }); } export async function POST(request: Request) { - const { problem, code } = await request.json(); - if (!problem) - return NextResponse.json({ error: "Missing problem" }, { status: 400 }); - await saveSolution(problem, typeof code === "string" ? code : ""); + const { body, error } = await readJson(request); + if (error) return error; + const problem = str(body, "problem"); + if (!isSafeProblemName(problem)) return badRequest("Invalid problem name"); + const code = str(body, "code"); + if (Buffer.byteLength(code, "utf8") > MAX_CODE_BYTES) + return badRequest("Source is too large"); + await saveSolution(problem, code); return NextResponse.json({ success: true }); } diff --git a/web/app/api/stress/route.ts b/web/app/api/stress/route.ts index 0222224..4303056 100644 --- a/web/app/api/stress/route.ts +++ b/web/app/api/stress/route.ts @@ -3,7 +3,7 @@ * * Compiles three C++ sources, then repeatedly: runs `generator ` to make * an input, feeds that input to both `solution` and `brute`, and compares their - * outputs (trailing-whitespace tolerant). Returns the first input on which they + * outputs under the chosen checker. Returns the first input on which they * disagree (or on which a program crashes / times out). * * The generator receives the iteration seed as argv[1] so runs are reproducible. @@ -16,7 +16,10 @@ * iterations?: number, // rounds to run, default 100, max 5000 * timeLimitMs?: number, // per-program limit, default 5000 * seedBase?: number, // first seed, default 1 - * std?: string // C++ standard, default gnu++17 + * std?: string, // C++ standard, default gnu++17 + * compilerFlags?: string[], + * checker?: "lines"|"tokens"|"float", + * epsilon?: number * } * * Response JSON (200): @@ -24,10 +27,13 @@ * ok: boolean, // compiled & ran (not whether a bug was found) * failed: boolean, // true when a counter-example was found * iterationsRun: number, + * elapsedMs: number, // wall-clock time spent iterating + * deadlineHit: boolean, // true when the request budget cut the search short + * stats: { maxSolutionMs, maxBruteMs, avgSolutionMs }, * compile: { // per-source compile status - * solution: { ok, stderr, ms }, - * brute: { ok, stderr, ms }, - * generator:{ ok, stderr, ms } + * solution: { ok, stderr, ms, cached, diagnostics }, + * brute: { ok, stderr, ms, cached, diagnostics }, + * generator:{ ok, stderr, ms, cached, diagnostics } * }, * firstFailure?: { * iteration: number, @@ -49,47 +55,41 @@ import { compile, runBinary, cleanup, + runVerdict, type CompileResult, } from "../_engine/cpp"; import { compareOutputs } from "../_engine/compare"; +import { readJson, badRequest, parseRunOptions, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const MAX_ITERATIONS = 5000; // Overall wall-clock budget so a slow brute force can't hang a request forever. -const TOTAL_DEADLINE_MS = 30_000; +const TOTAL_DEADLINE_MS = 60_000; export async function POST(request: Request) { - let body: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } + const { body, error } = await readJson(request); + if (error) return error; - const solution = typeof body.solution === "string" ? body.solution : ""; - const brute = typeof body.brute === "string" ? body.brute : ""; - const generator = typeof body.generator === "string" ? body.generator : ""; + const solution = str(body, "solution"); + const brute = str(body, "brute"); + const generator = str(body, "generator"); if (!solution.trim() || !brute.trim() || !generator.trim()) { - return NextResponse.json( - { - error: "Provide non-empty `solution`, `brute` and `generator` sources", - }, - { status: 400 }, + return badRequest( + "Provide non-empty `solution`, `brute` and `generator` sources", ); } const iterations = clampInt(body.iterations, 100, 1, MAX_ITERATIONS); - const timeLimitMs = - typeof body.timeLimitMs === "number" ? body.timeLimitMs : undefined; const seedBase = clampInt(body.seedBase, 1, 0, Number.MAX_SAFE_INTEGER); - const std = typeof body.std === "string" ? body.std : undefined; + const opts = parseRunOptions(body); + const compileOpts = { std: opts.std, extraFlags: opts.extraFlags }; const [cs, cb, cg] = await Promise.all([ - compile(solution, { std, label: "sol" }), - compile(brute, { std, label: "brute" }), - compile(generator, { std, label: "gen" }), + compile(solution, { ...compileOpts, label: "sol" }), + compile(brute, { ...compileOpts, label: "brute" }), + compile(generator, { ...compileOpts, label: "gen" }), ]); const compileBlock = { @@ -109,115 +109,137 @@ export async function POST(request: Request) { ok: false, failed: false, iterationsRun: 0, + elapsedMs: 0, + deadlineHit: false, + stats: { maxSolutionMs: 0, maxBruteMs: 0, avgSolutionMs: 0 }, compile: compileBlock, message: `Compilation failed for ${which}.`, }); } const start = process.hrtime.bigint(); + const elapsed = () => Number(process.hrtime.bigint() - start) / 1e6; let iterationsRun = 0; + let deadlineHit = false; + let maxSolutionMs = 0; + let maxBruteMs = 0; + let sumSolutionMs = 0; let firstFailure: Record | undefined; - try { - for (let i = 0; i < iterations; i++) { - if (Number(process.hrtime.bigint() - start) / 1e6 > TOTAL_DEADLINE_MS) - break; - iterationsRun = i + 1; - const seed = seedBase + i; - - const gen = await runBinary(cg.binPath, { - args: [String(seed)], - timeLimitMs, - }); - if (gen.timedOut || gen.spawnError || gen.exitCode !== 0 || gen.signal) { - firstFailure = { - iteration: i + 1, - seed, - reason: "generator-error", - input: gen.stdout, - solutionOutput: "", - bruteOutput: "", - diff: [], - generatorStderr: gen.stderr, - }; - break; - } - const input = gen.stdout; - - const [solRun, bruteRun] = await Promise.all([ - runBinary(cs.binPath, { input, timeLimitMs }), - runBinary(cb.binPath, { input, timeLimitMs }), - ]); - - const solBad = - solRun.timedOut || - solRun.spawnError || - solRun.exitCode !== 0 || - solRun.signal; - const bruteBad = - bruteRun.timedOut || - bruteRun.spawnError || - bruteRun.exitCode !== 0 || - bruteRun.signal; - - if (solBad || bruteBad) { - const reason = solRun.timedOut + for (let i = 0; i < iterations; i++) { + if (elapsed() > TOTAL_DEADLINE_MS) { + deadlineHit = true; + break; + } + iterationsRun = i + 1; + const seed = seedBase + i; + + const gen = await runBinary(cg.binPath, { + args: [String(seed)], + timeLimitMs: opts.timeLimitMs, + }); + if (runVerdict(gen) !== "OK") { + firstFailure = { + iteration: i + 1, + seed, + reason: "generator-error", + input: gen.stdout, + solutionOutput: "", + bruteOutput: "", + diff: [], + generatorStderr: gen.stderr, + }; + break; + } + const input = gen.stdout; + + const [solRun, bruteRun] = await Promise.all([ + runBinary(cs.binPath, { input, timeLimitMs: opts.timeLimitMs }), + runBinary(cb.binPath, { input, timeLimitMs: opts.timeLimitMs }), + ]); + maxSolutionMs = Math.max(maxSolutionMs, solRun.timeMs); + maxBruteMs = Math.max(maxBruteMs, bruteRun.timeMs); + sumSolutionMs += solRun.timeMs; + + const solV = runVerdict(solRun); + const bruteV = runVerdict(bruteRun); + if (solV !== "OK" || bruteV !== "OK") { + const reason = + solV === "TLE" ? "solution-tle" - : bruteRun.timedOut + : bruteV === "TLE" ? "brute-tle" - : solBad + : solV !== "OK" ? "solution-error" : "brute-error"; - firstFailure = { - iteration: i + 1, - seed, - reason, - input, - solutionOutput: solRun.stdout, - bruteOutput: bruteRun.stdout, - solutionStderr: solRun.stderr, - bruteStderr: bruteRun.stderr, - diff: [], - }; - break; - } - - const cmp = compareOutputs(bruteRun.stdout, solRun.stdout); - if (!cmp.match) { - firstFailure = { - iteration: i + 1, - seed, - reason: "mismatch", - input, - solutionOutput: solRun.stdout, - bruteOutput: bruteRun.stdout, - diff: cmp.diff, - }; - break; - } + firstFailure = { + iteration: i + 1, + seed, + reason, + input, + solutionOutput: solRun.stdout, + bruteOutput: bruteRun.stdout, + solutionStderr: solRun.stderr, + bruteStderr: bruteRun.stderr, + diff: [], + }; + break; + } + + const cmp = compareOutputs(bruteRun.stdout, solRun.stdout, { + mode: opts.checker, + epsilon: opts.epsilon, + }); + if (!cmp.match) { + firstFailure = { + iteration: i + 1, + seed, + reason: "mismatch", + input, + solutionOutput: solRun.stdout, + bruteOutput: bruteRun.stdout, + diff: cmp.diff, + }; + break; } - } finally { - await Promise.all([ - cleanup(cs.workDir), - cleanup(cb.workDir), - cleanup(cg.workDir), - ]); } + const elapsedMs = Math.round(elapsed()); + const message = firstFailure + ? `Found a counter-example on iteration ${firstFailure.iteration}.` + : deadlineHit + ? `No counter-example in ${iterationsRun} iteration(s) before the ${TOTAL_DEADLINE_MS / 1000}s request budget ran out.` + : `No counter-example found in ${iterationsRun} iteration(s).`; + return NextResponse.json({ ok: true, failed: !!firstFailure, iterationsRun, + elapsedMs, + deadlineHit, + stats: { + maxSolutionMs: round1(maxSolutionMs), + maxBruteMs: round1(maxBruteMs), + avgSolutionMs: round1(iterationsRun ? sumSolutionMs / iterationsRun : 0), + }, compile: compileBlock, firstFailure, - message: firstFailure - ? `Found a counter-example on iteration ${firstFailure.iteration}.` - : `No counter-example found in ${iterationsRun} iteration(s).`, + message, }); } function toCompileStatus(c: CompileResult) { - return { ok: c.ok, stderr: c.stderr, ms: c.ms }; + return { + ok: c.ok, + stderr: c.stderr, + ms: Math.round(c.ms), + cached: c.cached, + diagnostics: c.diagnostics, + }; +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; } function clampInt(v: unknown, def: number, lo: number, hi: number): number { diff --git a/web/app/api/template/route.ts b/web/app/api/template/route.ts index 3e1924e..7f0aed8 100644 --- a/web/app/api/template/route.ts +++ b/web/app/api/template/route.ts @@ -1,23 +1,28 @@ /** - * POST /api/template — fetch a named starter template from `templates/.cpp`. + * /api/template — starter templates shipped under the repo `templates/` dir. * - * Request: { name: string } // e.g. "dp", "graph", "math" - * Response: { stdout: string, stderr: string, exitCode: number, content: string } + * GET /api/template → { templates: string[] } // e.g. ["dp","graph","math"] + * POST /api/template { name } → { stdout, stderr, exitCode, content } * `stdout` and `content` both hold the template text (stdout kept for * backward compatibility). On a miss: exitCode 1 and an error stderr. - * - * Self-contained (Node built-ins only) so the api tree never imports web/lib. */ import { NextResponse } from "next/server"; -import { readTemplate } from "../_engine/store"; +import { readTemplate, listTemplates } from "../_engine/store"; +import { readJson, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +export async function GET() { + return NextResponse.json({ templates: await listTemplates() }); +} + export async function POST(request: Request) { - const { name } = await request.json(); - const content = await readTemplate(typeof name === "string" ? name : ""); + const { body, error } = await readJson(request); + if (error) return error; + const name = str(body, "name"); + const content = await readTemplate(name); if (!content) { return NextResponse.json({ stdout: "", diff --git a/web/app/api/test/route.ts b/web/app/api/test/route.ts index a5751e7..c273bca 100644 --- a/web/app/api/test/route.ts +++ b/web/app/api/test/route.ts @@ -7,18 +7,22 @@ * tests: { input: string, expected: string }[], // cases (required) * timeLimitMs?: number, // per-case limit, default 5000 * std?: string, // C++ standard, default gnu++17 - * compilerFlags?: string[] + * compilerFlags?: string[], // validated against an allowlist + * checker?: "lines"|"tokens"|"float", // comparison mode, default lines + * epsilon?: number, // float checker tolerance + * stopOnFirstFailure?: boolean // skip remaining cases after a non-AC * } * * Response JSON (200): * { * ok: boolean, // true when every case is AC * compiler: string|null, - * compile: { ok: boolean, stderr: string, ms: number }, - * summary: { total, passed, failed, verdict }, // verdict is worst-of + * compile: { ok, stderr, ms, cached, diagnostics, rejectedFlags }, + * checker: "lines"|"tokens"|"float", + * summary: { total, passed, failed, skipped, verdict, maxTimeMs, totalTimeMs }, * results: Array<{ * index: number, - * verdict: "AC"|"WA"|"TLE"|"RE"|"CE", + * verdict: "AC"|"WA"|"TLE"|"RE"|"CE"|"SKIPPED", * input: string, * expected: string, * actual: string, // program stdout @@ -27,74 +31,71 @@ * signal: string|null, * timeMs: number, * truncated: boolean, + * presentationOnly: boolean, // WA only: tokens match, layout differs * diff: Array<{ line, expected, actual, same }> // differing lines, WA only * }> * } * On a 400 the body is { error: string }. - * - * Comparison is trailing-whitespace tolerant (see _engine/compare). */ import { NextResponse } from "next/server"; -import { compile, runBinary, cleanup } from "../_engine/cpp"; +import { + compile, + runBinary, + cleanup, + runVerdict, + runStderr, +} from "../_engine/cpp"; import { compareOutputs } from "../_engine/compare"; +import { normalizeTests } from "../_engine/store"; +import { readJson, badRequest, parseRunOptions, str } from "../_engine/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -type Verdict = "AC" | "WA" | "TLE" | "RE" | "CE"; +type Verdict = "AC" | "WA" | "TLE" | "RE" | "CE" | "SKIPPED"; // Worst-of ordering for the overall summary verdict. const SEVERITY: Record = { AC: 0, + SKIPPED: 0, WA: 1, TLE: 2, RE: 3, CE: 4, }; +const MAX_TESTS = 200; + export async function POST(request: Request) { - let body: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } + const { body, error } = await readJson(request); + if (error) return error; - const code = typeof body.code === "string" ? body.code : ""; - if (!code.trim()) { - return NextResponse.json( - { error: "Missing required field: code" }, - { status: 400 }, - ); - } + const code = str(body, "code"); + if (!code.trim()) return badRequest("Missing required field: code"); - const tests = Array.isArray(body.tests) - ? body.tests - .filter( - (t): t is Record => !!t && typeof t === "object", - ) - .map((t) => ({ - input: typeof t.input === "string" ? t.input : "", - expected: typeof t.expected === "string" ? t.expected : "", - })) - : []; - - if (tests.length === 0) { - return NextResponse.json( - { error: "Provide at least one test case in `tests`" }, - { status: 400 }, - ); - } + const tests = normalizeTests(body.tests); + if (tests.length === 0) + return badRequest("Provide at least one test case in `tests`"); + if (tests.length > MAX_TESTS) + return badRequest(`At most ${MAX_TESTS} test cases per request`); - const timeLimitMs = - typeof body.timeLimitMs === "number" ? body.timeLimitMs : undefined; - const std = typeof body.std === "string" ? body.std : undefined; - const extraFlags = Array.isArray(body.compilerFlags) - ? body.compilerFlags.filter((f): f is string => typeof f === "string") - : undefined; + const opts = parseRunOptions(body); + const stopOnFirstFailure = body.stopOnFirstFailure === true; - const compiled = await compile(code, { std, extraFlags, label: "test" }); + const compiled = await compile(code, { + std: opts.std, + extraFlags: opts.extraFlags, + label: "test", + }); + const compileBlock = { + ok: compiled.ok, + stderr: compiled.stderr, + ms: Math.round(compiled.ms), + cached: compiled.cached, + diagnostics: compiled.diagnostics, + rejectedFlags: compiled.rejectedFlags, + }; // Compile error → every case reports CE so the UI can badge each row. if (!compiled.ok || !compiled.binPath) { @@ -110,78 +111,109 @@ export async function POST(request: Request) { signal: null, timeMs: 0, truncated: false, + presentationOnly: false, diff: [], })); return NextResponse.json({ ok: false, compiler: compiled.compiler, - compile: { ok: false, stderr: compiled.stderr, ms: compiled.ms }, + compile: compileBlock, + checker: opts.checker, summary: { total: tests.length, passed: 0, failed: tests.length, + skipped: 0, verdict: "CE", + maxTimeMs: 0, + totalTimeMs: 0, }, results, }); } const results = []; - try { - for (let index = 0; index < tests.length; index++) { - const t = tests[index]; - const run = await runBinary(compiled.binPath, { - input: t.input, - timeLimitMs, - }); - - let verdict: Verdict; - let diff: ReturnType["diff"] = []; - if (run.timedOut) { - verdict = "TLE"; - } else if (run.spawnError || run.exitCode !== 0 || run.signal) { - verdict = "RE"; - } else { - const cmp = compareOutputs(t.expected, run.stdout); - verdict = cmp.match ? "AC" : "WA"; - diff = cmp.diff; - } - + let failedSoFar = false; + for (let index = 0; index < tests.length; index++) { + const t = tests[index]; + if (stopOnFirstFailure && failedSoFar) { results.push({ index, - verdict, + verdict: "SKIPPED" as Verdict, input: t.input, expected: t.expected, - actual: run.stdout, - stderr: run.spawnError - ? `${run.stderr}\n${run.spawnError}`.trim() - : run.stderr, - exitCode: run.exitCode, - signal: run.signal, - timeMs: run.timeMs, - truncated: run.truncated, - diff, + actual: "", + stderr: "", + exitCode: null, + signal: null, + timeMs: 0, + truncated: false, + presentationOnly: false, + diff: [], }); + continue; } - } finally { - await cleanup(compiled.workDir); + + const run = await runBinary(compiled.binPath, { + input: t.input, + timeLimitMs: opts.timeLimitMs, + }); + + let verdict: Verdict; + let diff: ReturnType["diff"] = []; + let presentationOnly = false; + const rv = runVerdict(run); + if (rv !== "OK") { + verdict = rv; + } else { + const cmp = compareOutputs(t.expected, run.stdout, { + mode: opts.checker, + epsilon: opts.epsilon, + }); + verdict = cmp.match ? "AC" : "WA"; + diff = cmp.diff; + presentationOnly = !!cmp.presentationOnly; + } + if (verdict !== "AC") failedSoFar = true; + + results.push({ + index, + verdict, + input: t.input, + expected: t.expected, + actual: run.stdout, + stderr: runStderr(run), + exitCode: run.exitCode, + signal: run.signal, + timeMs: run.timeMs, + truncated: run.truncated, + presentationOnly, + diff, + }); } const passed = results.filter((r) => r.verdict === "AC").length; + const skipped = results.filter((r) => r.verdict === "SKIPPED").length; const worst = results.reduce( (acc, r) => (SEVERITY[r.verdict] > SEVERITY[acc] ? r.verdict : acc), "AC", ); + const maxTimeMs = results.reduce((m, r) => Math.max(m, r.timeMs), 0); + const totalTimeMs = results.reduce((s, r) => s + r.timeMs, 0); return NextResponse.json({ ok: passed === results.length, compiler: compiled.compiler, - compile: { ok: true, stderr: compiled.stderr, ms: compiled.ms }, + compile: compileBlock, + checker: opts.checker, summary: { total: results.length, passed, - failed: results.length - passed, + failed: results.length - passed - skipped, + skipped, verdict: worst, + maxTimeMs: Math.round(maxTimeMs * 10) / 10, + totalTimeMs: Math.round(totalTimeMs * 10) / 10, }, results, }); diff --git a/web/package-lock.json b/web/package-lock.json index bcbc3ff..4d91476 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,7 +32,8 @@ "typescript": "5.9.3" }, "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "vitest": "^3.2.7" } }, "node_modules/@alloc/quick-lru": { @@ -701,6 +702,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1627,6 +2070,26 @@ "node": ">=18" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3142,87 +3605,226 @@ }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", - "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.11" - }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-use-callback-ref": { + "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3233,173 +3835,423 @@ } } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -3754,10 +4606,28 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4317,10 +5187,125 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/accepts": { "version": "2.0.0", @@ -4635,6 +5620,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -4813,6 +5808,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4889,6 +5894,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4905,6 +5927,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5297,6 +6329,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5645,6 +6687,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5701,6 +6750,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6135,6 +7226,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6200,6 +7301,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -8099,6 +9210,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8902,6 +10020,23 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9499,6 +10634,52 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9978,6 +11159,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10030,6 +11218,13 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10039,6 +11234,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -10265,6 +11467,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -10359,6 +11581,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -10413,6 +11642,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", @@ -10888,6 +12147,243 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -10997,6 +12493,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 57cdeff..dc17b5c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,14 +1,18 @@ { - "name": "web", - "version": "0.1.0", + "name": "cf-workbench", + "version": "1.0.0", "private": true, - "//": "Build/lint tooling (typescript, eslint, eslint-config-next, tailwind, shadcn, @types/*) is kept in dependencies rather than devDependencies on purpose: the validation/CI install runs with NODE_ENV=production, which makes `npm install` omit devDependencies. Keeping these as runtime dependencies guarantees `npm run lint` and `npm run build` succeed regardless of NODE_ENV. Only @playwright/test (e2e harness) stays a devDependency.", + "//": "Build/lint tooling (typescript, eslint, eslint-config-next, tailwind, shadcn, @types/*) is kept in dependencies rather than devDependencies on purpose: the validation/CI install runs with NODE_ENV=production, which makes `npm install` omit devDependencies. Keeping these as runtime dependencies guarantees `npm run lint` and `npm run build` succeed regardless of NODE_ENV. Only the test harnesses (@playwright/test, vitest) stay devDependencies.", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", - "e2e": "playwright test" + "typecheck": "tsc --noEmit -p .", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "playwright test", + "check": "npm run lint && npm run typecheck && npm test && npm run build" }, "dependencies": { "@tailwindcss/postcss": "^4", @@ -35,6 +39,7 @@ "typescript": "5.9.3" }, "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "vitest": "^3.2.7" } } diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..5360823 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +/** + * Unit tests for the execution engine and the client-side helpers. They run + * in Node against the real C++ toolchain where a compiler is available; the + * Playwright suite under ./e2e is separate and is NOT picked up here. + */ +export default defineConfig({ + test: { + include: ["app/**/*.test.ts", "lib/**/*.test.ts"], + exclude: ["e2e/**", "node_modules/**", ".next/**"], + environment: "node", + testTimeout: 60_000, + hookTimeout: 60_000, + reporters: process.env.CI ? ["dot"] : ["default"], + }, + resolve: { + alias: { "@": path.resolve(__dirname) }, + }, +}); From adbe071d0ec1e74c33e77a1b3fb7687a1072b034 Mon Sep 17 00:00:00 2001 From: mbn <66748817+mbn-code@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:57:41 +0200 Subject: [PATCH 2/7] feat(web): diagnostics, statement import, checkers, stress promotion, full-workspace library - editor: gutter error/warning markers, clickable diagnostics that jump to the line, error/warning counts, cursor status bar, Ctrl+/ comment toggle - run panel: cached-compile marker, diagnostics list, ignored-flag notice, copy and clear, persisted stdin - tests panel: import samples from a pasted statement, run a single case, duplicate, use as stdin, expand/collapse all, stop on first failure, whitespace-only hints, SKIPPED verdicts, max time - stress panel: seed base, timing stats, budget notice, add the failing input as a test case or as stdin - settings: checker mode and epsilon, reset, clear compile cache, server info - sidebar: whole-workspace save (code, tests, stdin, stress, settings), duplicate, filter, relative timestamps, dirty indicator, new workspace, export/import; constrain width so long names cannot overflow into the editor - resizable editor/panel split, remembered tab, Ctrl+1..4 and Ctrl+B shortcuts, compiler shown in the header, offline-aware API client - five new Playwright specs; remove the unused lib/cf.ts and scaffold SVGs --- web/app/page.tsx | 549 ++++++++++++++++++++++++++-- web/components/CodeEditor.tsx | 186 +++++++++- web/components/DiagnosticsList.tsx | 105 ++++++ web/components/ProblemsSidebar.tsx | 222 ++++++++--- web/components/Resizer.tsx | 76 ++++ web/components/RunPanel.tsx | 164 +++++++-- web/components/SettingsPanel.tsx | 220 +++++++++-- web/components/StressPanel.tsx | 278 ++++++++++++-- web/components/TestsPanel.tsx | 384 ++++++++++++++++--- web/components/VerdictBadge.tsx | 17 +- web/e2e/06-diagnostics.spec.ts | 49 +++ web/e2e/07-import-statement.spec.ts | 89 +++++ web/e2e/08-checker.spec.ts | 69 ++++ web/e2e/09-stress-promote.spec.ts | 49 +++ web/e2e/10-problems-library.spec.ts | 68 ++++ web/lib/api.ts | 216 ++++++++--- web/lib/cf.ts | 208 ----------- web/lib/storage.ts | 92 ++++- web/public/file.svg | 1 - web/public/globe.svg | 1 - web/public/next.svg | 1 - web/public/vercel.svg | 1 - web/public/window.svg | 1 - 23 files changed, 2546 insertions(+), 500 deletions(-) create mode 100644 web/components/DiagnosticsList.tsx create mode 100644 web/components/Resizer.tsx create mode 100644 web/e2e/06-diagnostics.spec.ts create mode 100644 web/e2e/07-import-statement.spec.ts create mode 100644 web/e2e/08-checker.spec.ts create mode 100644 web/e2e/09-stress-promote.spec.ts create mode 100644 web/e2e/10-problems-library.spec.ts delete mode 100644 web/lib/cf.ts delete mode 100644 web/public/file.svg delete mode 100644 web/public/globe.svg delete mode 100644 web/public/next.svg delete mode 100644 web/public/vercel.svg delete mode 100644 web/public/window.svg diff --git a/web/app/page.tsx b/web/app/page.tsx index dd63a66..e1f5c89 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -19,6 +19,7 @@ import { FileCode, Minus, Plus, + Keyboard, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -30,11 +31,20 @@ import { getProblem, saveProblem, renameProblem, + duplicateProblem, deleteProblem, + exportProblems, + importProblems, + parseSamples, + getConfig, type RunResponse, type TestResponse, + type TestCaseResult, type RunSettings, type ProblemSummary, + type Diagnostic, + type WorkbenchConfig, + type ParsedStatement, } from "@/lib/api"; import { STORAGE_KEYS, @@ -43,11 +53,17 @@ import { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_TESTS, + DEFAULT_STDIN, + DEFAULT_LAYOUT, + MIN_PANEL_FRACTION, + MAX_PANEL_FRACTION, loadJSON, loadString, saveJSON, saveString, normalizeSettings, + normalizeLayout, + clampTimeLimit, toTestCases, type SampleTest, } from "@/lib/storage"; @@ -58,20 +74,30 @@ import { GENERATOR_TEMPLATE, } from "@/lib/templates"; -import { CodeEditor } from "@/components/CodeEditor"; +import { + CodeEditor, + type LineMarkers, + type CursorPosition, +} from "@/components/CodeEditor"; import { RunPanel } from "@/components/RunPanel"; import { TestsPanel } from "@/components/TestsPanel"; import { StressPanel } from "@/components/StressPanel"; import { SettingsPanel } from "@/components/SettingsPanel"; import { ProblemsSidebar } from "@/components/ProblemsSidebar"; +import { Resizer } from "@/components/Resizer"; type PanelTab = "run" | "tests" | "stress" | "settings"; -const PANEL_TABS: { id: PanelTab; label: string; icon: ElementType }[] = [ - { id: "run", label: "Run", icon: TerminalIcon }, - { id: "tests", label: "Tests", icon: FlaskConical }, - { id: "stress", label: "Stress", icon: Swords }, - { id: "settings", label: "Settings", icon: SettingsIcon }, +const PANEL_TABS: { + id: PanelTab; + label: string; + icon: ElementType; + key: string; +}[] = [ + { id: "run", label: "Run", icon: TerminalIcon, key: "1" }, + { id: "tests", label: "Tests", icon: FlaskConical, key: "2" }, + { id: "stress", label: "Stress", icon: Swords, key: "3" }, + { id: "settings", label: "Settings", icon: SettingsIcon, key: "4" }, ]; type StressState = { brute: string; generator: string; iterations: number }; @@ -90,6 +116,37 @@ function withIds(cases: { input: string; expected: string }[], start: number) { })); } +function isPanelTab(v: string): v is PanelTab { + return PANEL_TABS.some((t) => t.id === v); +} + +/** Everything that is persisted with a problem, for dirty tracking. */ +function snapshot( + code: string, + tests: SampleTest[], + stdin: string, + stress: StressState, + settings: RunSettings, +): string { + return JSON.stringify({ + code, + tests: toTestCases(tests), + stdin, + brute: stress.brute, + generator: stress.generator, + settings, + }); +} + +function markersFrom(diags: Diagnostic[] | undefined): LineMarkers { + const m: LineMarkers = {}; + for (const d of diags ?? []) { + if (d.line === null || d.severity === "note") continue; + if (d.severity === "error" || !m[d.line]) m[d.line] = d.severity; + } + return m; +} + export default function Workbench() { const [mounted, setMounted] = useState(false); @@ -101,23 +158,35 @@ export default function Workbench() { withIds(DEFAULT_TESTS, 1), ); const [stress, setStress] = useState(DEFAULT_STRESS); - const [stdin, setStdin] = useState("2 3\n"); + const [stdin, setStdin] = useState(DEFAULT_STDIN); + const [layout, setLayout] = useState(DEFAULT_LAYOUT); // Execution results. const [runResult, setRunResult] = useState(null); const [testResult, setTestResult] = useState(null); const [runLoading, setRunLoading] = useState(false); const [testLoading, setTestLoading] = useState(false); + const [stopOnFirstFailure, setStopOnFirstFailure] = useState(false); + const [diagnostics, setDiagnostics] = useState([]); // Problems library. const [problems, setProblems] = useState([]); const [activeSlug, setActiveSlug] = useState(null); const [saveName, setSaveName] = useState(""); const [problemsBusy, setProblemsBusy] = useState(false); + const [savedSnapshot, setSavedSnapshot] = useState(null); + const [config, setConfig] = useState(null); // Layout. const [activeTab, setActiveTab] = useState("run"); const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarHidden, setSidebarHidden] = useState(false); + const [cursor, setCursor] = useState({ line: 1, column: 1 }); + const [gotoLine, setGotoLine] = useState<{ + line: number; + nonce: number; + } | null>(null); + const mainRef = useRef(null); const idRef = useRef(1); const nextId = useCallback(() => { @@ -140,6 +209,7 @@ export default function Workbench() { // ---- Hydration from localStorage (runs once on mount) ---- useEffect(() => { setCode(loadString(STORAGE_KEYS.code, DEFAULT_CODE)); + setStdin(loadString(STORAGE_KEYS.stdin, DEFAULT_STDIN)); const storedFont = loadJSON( STORAGE_KEYS.fontSize, DEFAULT_FONT_SIZE, @@ -148,6 +218,9 @@ export default function Workbench() { setSettings( normalizeSettings(loadJSON(STORAGE_KEYS.settings, DEFAULT_SETTINGS)), ); + setLayout(normalizeLayout(loadJSON(STORAGE_KEYS.layout, DEFAULT_LAYOUT))); + const tab = loadString(STORAGE_KEYS.activeTab, "run"); + if (isPanelTab(tab)) setActiveTab(tab); const storedTests = loadJSON(STORAGE_KEYS.tests, []); const hydrated = @@ -168,6 +241,9 @@ export default function Workbench() { setMounted(true); refreshProblems(); + getConfig() + .then(setConfig) + .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -175,6 +251,9 @@ export default function Workbench() { useEffect(() => { if (mounted) saveString(STORAGE_KEYS.code, code); }, [code, mounted]); + useEffect(() => { + if (mounted) saveString(STORAGE_KEYS.stdin, stdin); + }, [stdin, mounted]); useEffect(() => { if (mounted) saveJSON(STORAGE_KEYS.fontSize, fontSize); }, [fontSize, mounted]); @@ -187,10 +266,23 @@ export default function Workbench() { useEffect(() => { if (mounted) saveJSON(STORAGE_KEYS.stress, stress); }, [stress, mounted]); + useEffect(() => { + if (mounted) saveJSON(STORAGE_KEYS.layout, layout); + }, [layout, mounted]); + useEffect(() => { + if (mounted) saveString(STORAGE_KEYS.activeTab, activeTab); + }, [activeTab, mounted]); useEffect(() => { if (mounted) saveString(STORAGE_KEYS.activeProblem, activeSlug ?? ""); }, [activeSlug, mounted]); + const dirty = useMemo( + () => + savedSnapshot !== null && + savedSnapshot !== snapshot(code, tests, stdin, stress, settings), + [savedSnapshot, code, tests, stdin, stress, settings], + ); + // ---- Execution ---- const handleRun = useCallback(async () => { if (!code.trim()) { @@ -203,7 +295,10 @@ export default function Workbench() { try { const res = await runCode(code, stdin, settings); setRunResult(res); + setDiagnostics(res.compile.diagnostics ?? []); if (res.verdict === "CE") toast.error("Compilation error"); + else if (res.verdict === "TLE") toast.warning("Time limit exceeded"); + else if (res.verdict === "RE") toast.warning("Runtime error"); } catch (e) { toast.error(e instanceof Error ? e.message : "Run failed"); } finally { @@ -224,16 +319,84 @@ export default function Workbench() { setActiveTab("tests"); setTestResult(null); try { - const res = await runTests(code, toTestCases(tests), settings); + const res = await runTests(code, toTestCases(tests), settings, { + stopOnFirstFailure, + }); setTestResult(res); + setDiagnostics(res.compile.diagnostics ?? []); + if (res.summary.verdict === "CE") toast.error("Compilation error"); + else if (res.ok) toast.success(`All ${res.summary.total} cases passed`); + else + toast.error( + `${res.summary.failed} of ${res.summary.total} case${res.summary.total === 1 ? "" : "s"} failed`, + ); } catch (e) { toast.error(e instanceof Error ? e.message : "Tests failed"); } finally { setTestLoading(false); } - }, [code, tests, settings]); + }, [code, tests, settings, stopOnFirstFailure]); + + const handleRunOne = useCallback( + async (id: number) => { + const idx = tests.findIndex((t) => t.id === id); + if (idx === -1) return; + if (!code.trim()) { + toast.error("The editor is empty"); + return; + } + setTestLoading(true); + try { + const res = await runTests(code, toTestCases([tests[idx]]), settings); + setDiagnostics(res.compile.diagnostics ?? []); + const single: TestCaseResult = { ...res.results[0], index: idx }; + setTestResult((prev) => { + const results = (prev?.results ?? []).filter((r) => r.index !== idx); + results.push(single); + results.sort((a, b) => a.index - b.index); + const passed = results.filter((r) => r.verdict === "AC").length; + const worst = results.reduce( + (acc, r) => (rank(r.verdict) > rank(acc) ? r.verdict : acc), + single.verdict, + ); + return { + ...res, + summary: { + ...res.summary, + total: results.length, + passed, + failed: results.length - passed, + verdict: worst, + maxTimeMs: results.reduce((m, r) => Math.max(m, r.timeMs), 0), + }, + results, + }; + }); + if (single.verdict === "AC") toast.success(`Case ${idx + 1}: AC`); + else toast.error(`Case ${idx + 1}: ${single.verdict}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Test failed"); + } finally { + setTestLoading(false); + } + }, + [code, tests, settings], + ); // ---- Problems library ---- + const currentProblemPayload = useCallback( + (name: string) => ({ + name, + code, + tests: toTestCases(tests), + stdin, + brute: stress.brute, + generator: stress.generator, + settings, + }), + [code, tests, stdin, stress, settings], + ); + const handleSave = useCallback(async () => { const name = saveName.trim(); if (!name) { @@ -241,42 +404,65 @@ export default function Workbench() { return; } try { - const saved = await saveProblem({ - name, - code, - tests: toTestCases(tests), - }); + const saved = await saveProblem(currentProblemPayload(name)); setActiveSlug(saved.slug); setSaveName(saved.name); + setSavedSnapshot(snapshot(code, tests, stdin, stress, settings)); await refreshProblems(); toast.success(`Saved ${saved.name}`); } catch (e) { toast.error(e instanceof Error ? e.message : "Save failed"); } - }, [saveName, code, tests, refreshProblems]); + }, [ + saveName, + currentProblemPayload, + code, + tests, + stdin, + stress, + settings, + refreshProblems, + ]); const handleLoad = useCallback( async (slug: string) => { try { const problem = await getProblem(slug); - setCode(problem.code || DEFAULT_CODE); + const nextCode = problem.code || DEFAULT_CODE; const loaded = problem.tests.map((t) => ({ id: nextId(), input: t.input, expected: t.expected, })); + const nextStress: StressState = { + brute: problem.brute || DEFAULT_STRESS.brute, + generator: problem.generator || DEFAULT_STRESS.generator, + iterations: stress.iterations, + }; + const nextStdin = problem.stdin || problem.tests[0]?.input || ""; + const nextSettings = problem.settings + ? normalizeSettings({ ...settings, ...problem.settings }) + : settings; + setCode(nextCode); setTests(loaded); + setStdin(nextStdin); + setStress(nextStress); + setSettings(nextSettings); setActiveSlug(problem.slug); setSaveName(problem.name); setRunResult(null); setTestResult(null); + setDiagnostics([]); setSidebarOpen(false); + setSavedSnapshot( + snapshot(nextCode, loaded, nextStdin, nextStress, nextSettings), + ); toast.success(`Loaded ${problem.name}`); } catch (e) { toast.error(e instanceof Error ? e.message : "Load failed"); } }, - [nextId], + [nextId, stress.iterations, settings], ); const handleRename = useCallback( @@ -296,11 +482,27 @@ export default function Workbench() { [activeSlug, refreshProblems], ); + const handleDuplicate = useCallback( + async (slug: string, to: string) => { + try { + const copy = await duplicateProblem(slug, to); + await refreshProblems(); + toast.success(`Duplicated as ${copy.name}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Duplicate failed"); + } + }, + [refreshProblems], + ); + const handleDelete = useCallback( async (slug: string) => { try { await deleteProblem(slug); - if (activeSlug === slug) setActiveSlug(null); + if (activeSlug === slug) { + setActiveSlug(null); + setSavedSnapshot(null); + } await refreshProblems(); toast.success("Deleted"); } catch (e) { @@ -310,12 +512,133 @@ export default function Workbench() { [activeSlug, refreshProblems], ); + const handleNew = useCallback(() => { + if ( + dirty && + !window.confirm("Discard unsaved changes and start a new workspace?") + ) + return; + setCode(DEFAULT_CODE); + setTests(withIds(DEFAULT_TESTS, idRef.current)); + idRef.current += DEFAULT_TESTS.length; + setStdin(DEFAULT_STDIN); + setStress((s) => ({ ...DEFAULT_STRESS, iterations: s.iterations })); + setRunResult(null); + setTestResult(null); + setDiagnostics([]); + setActiveSlug(null); + setSaveName(""); + setSavedSnapshot(null); + setSidebarOpen(false); + toast.success("New workspace"); + }, [dirty]); + + const handleExport = useCallback(async () => { + try { + const bundle = await exportProblems(); + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `cf-problems-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success(`Exported ${bundle.problems.length} problem(s)`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Export failed"); + } + }, []); + + const handleImport = useCallback( + async (file: File) => { + try { + const parsed = JSON.parse(await file.text()) as unknown; + const records = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === "object" + ? (parsed as { problems?: unknown }).problems + : undefined; + if (!Array.isArray(records)) + throw new Error("File is not a cf problems export"); + const { imported, skipped } = await importProblems(records, false); + await refreshProblems(); + toast.success( + `Imported ${imported} problem(s)${skipped ? `, skipped ${skipped} existing` : ""}`, + ); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Import failed"); + } + }, + [refreshProblems], + ); + + // ---- Sample import from a statement ---- + const handleImportStatement = useCallback( + async (statement: string): Promise => { + try { + const parsed = await parseSamples(statement); + if (parsed.tests.length === 0) { + toast.error("No Input / Output samples found in the statement"); + return parsed; + } + const existingEmpty = tests.every( + (t) => !t.input.trim() && !t.expected.trim(), + ); + const isDefault = + tests.length === DEFAULT_TESTS.length && + tests.every( + (t, i) => + t.input === DEFAULT_TESTS[i].input && + t.expected === DEFAULT_TESTS[i].expected, + ); + const fresh = parsed.tests.map((t) => ({ id: nextId(), ...t })); + setTests(existingEmpty || isDefault ? fresh : [...tests, ...fresh]); + setTestResult(null); + if (parsed.timeLimitMs) { + setSettings((s) => ({ + ...s, + timeLimitMs: clampTimeLimit(parsed.timeLimitMs as number), + })); + } + if (parsed.title && !saveName.trim()) setSaveName(parsed.title); + if (!stdin.trim() || stdin === DEFAULT_STDIN) + setStdin(parsed.tests[0].input); + toast.success( + `Imported ${parsed.tests.length} sample(s)${parsed.timeLimitMs ? `, time limit ${parsed.timeLimitMs} ms` : ""}`, + ); + return parsed; + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Could not parse statement", + ); + return null; + } + }, + [tests, nextId, saveName, stdin], + ); + + const addTestCase = useCallback( + (input: string, expected: string) => { + setTests((t) => [...t, { id: nextId(), input, expected }]); + setActiveTab("tests"); + }, + [nextId], + ); + + const useAsStdin = useCallback((input: string) => { + setStdin(input); + setActiveTab("run"); + }, []); + // Editing the source or a case makes any prior verdict stale — drop it so the // UI never shows a result that no longer matches the editor. Functional // updates keep the same reference (no extra render) when already cleared. const invalidateResults = useCallback(() => { setRunResult((r) => (r ? null : r)); setTestResult((r) => (r ? null : r)); + setDiagnostics((d) => (d.length ? [] : d)); }, []); const handleCodeChange = useCallback( @@ -331,7 +654,11 @@ export default function Workbench() { setTestResult((r) => (r ? null : r)); }, []); - // ---- Keyboard shortcuts (Cmd/Ctrl+Enter run, +Shift run all, Cmd/Ctrl+S save) ---- + const jumpToLine = useCallback((line: number) => { + setGotoLine({ line, nonce: Date.now() }); + }, []); + + // ---- Keyboard shortcuts ---- const runRef = useRef(handleRun); const runAllRef = useRef(handleRunAll); const saveRef = useRef(handleSave); @@ -350,9 +677,19 @@ export default function Workbench() { e.preventDefault(); if (e.shiftKey) runAllRef.current(); else runRef.current(); - } else if (e.key.toLowerCase() === "s") { + } else if (e.key.toLowerCase() === "s" && !e.shiftKey) { e.preventDefault(); if (nameRef.current.trim()) saveRef.current(); + else toast.error("Enter a problem name in the sidebar to save"); + } else if (e.key.toLowerCase() === "b" && !e.shiftKey) { + e.preventDefault(); + setSidebarHidden((h) => !h); + } else if (!e.shiftKey && !e.altKey) { + const tab = PANEL_TABS.find((t) => t.key === e.key); + if (tab) { + e.preventDefault(); + setActiveTab(tab.id); + } } }; window.addEventListener("keydown", onKey); @@ -377,6 +714,28 @@ export default function Workbench() { [problems, activeSlug], ); + const markers = useMemo(() => markersFrom(diagnostics), [diagnostics]); + const errorCount = diagnostics.filter((d) => d.severity === "error").length; + const warningCount = diagnostics.filter( + (d) => d.severity === "warning", + ).length; + const lineCount = useMemo(() => code.split("\n").length, [code]); + + const onResize = useCallback((f: number) => { + setLayout({ + panelFraction: Math.min( + MAX_PANEL_FRACTION, + Math.max(MIN_PANEL_FRACTION, f), + ), + }); + }, []); + + const compilerLabel = config?.compilerVersion + ? config.compilerVersion.replace(/\s*\(.*?\)\s*/g, " ").trim() + : config + ? "no compiler" + : null; + return (
{/* Top bar */} @@ -391,6 +750,18 @@ export default function Workbench() { > + cf @@ -399,16 +770,38 @@ export default function Workbench() { {activeName ? ( {activeName} + {dirty ? ( + + ) : null} ) : null}
- + {compilerLabel ? ( + + {compilerLabel} + + ) : null} + + ⌘/Ctrl + Enter