@exercode/problem-utils 4.3.3 → 4.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli/checkAllProblems.cjs +1 -1
  2. package/dist/cli/checkAllProblems.cjs.map +1 -1
  3. package/dist/cli/checkAllProblems.js +1 -1
  4. package/dist/cli/checkAllProblems.js.map +1 -1
  5. package/dist/cli/exercodeProblem.cjs +1 -1
  6. package/dist/cli/exercodeProblem.cjs.map +1 -1
  7. package/dist/cli/exercodeProblem.js +1 -1
  8. package/dist/cli/exercodeProblem.js.map +1 -1
  9. package/dist/helpers/javaJudge.cjs +1 -1
  10. package/dist/helpers/javaJudge.cjs.map +1 -1
  11. package/dist/helpers/javaJudge.js +1 -1
  12. package/dist/helpers/javaJudge.js.map +1 -1
  13. package/dist/helpers/parseCsv.cjs +2 -2
  14. package/dist/helpers/parseCsv.cjs.map +1 -1
  15. package/dist/helpers/parseCsv.js +2 -2
  16. package/dist/helpers/parseCsv.js.map +1 -1
  17. package/dist/helpers/removeCommentsInSourceCode.cjs +4 -4
  18. package/dist/helpers/removeCommentsInSourceCode.cjs.map +1 -1
  19. package/dist/helpers/removeCommentsInSourceCode.js +4 -4
  20. package/dist/helpers/removeCommentsInSourceCode.js.map +1 -1
  21. package/dist/helpers/runHarnessProcess.cjs +1 -1
  22. package/dist/helpers/runHarnessProcess.cjs.map +1 -1
  23. package/dist/helpers/runHarnessProcess.js +1 -1
  24. package/dist/helpers/runHarnessProcess.js.map +1 -1
  25. package/dist/helpers/spawnWithLimits.cjs +1 -1
  26. package/dist/helpers/spawnWithLimits.cjs.map +1 -1
  27. package/dist/helpers/spawnWithLimits.js +1 -1
  28. package/dist/helpers/spawnWithLimits.js.map +1 -1
  29. package/dist/helpers/startHttpServer.cjs +1 -1
  30. package/dist/helpers/startHttpServer.cjs.map +1 -1
  31. package/dist/helpers/startHttpServer.js +1 -1
  32. package/dist/helpers/startHttpServer.js.map +1 -1
  33. package/dist/learningMaterial/validateCourse.cjs +1 -1
  34. package/dist/learningMaterial/validateCourse.cjs.map +1 -1
  35. package/dist/learningMaterial/validateCourse.js +1 -1
  36. package/dist/learningMaterial/validateCourse.js.map +1 -1
  37. package/dist/learningMaterial/validateProblem.cjs +1 -1
  38. package/dist/learningMaterial/validateProblem.cjs.map +1 -1
  39. package/dist/learningMaterial/validateProblem.js +1 -1
  40. package/dist/learningMaterial/validateProblem.js.map +1 -1
  41. package/dist/presets/guiCommand.cjs +1 -1
  42. package/dist/presets/guiCommand.cjs.map +1 -1
  43. package/dist/presets/guiCommand.js +1 -1
  44. package/dist/presets/guiCommand.js.map +1 -1
  45. package/package.json +7 -8
@@ -1 +1 @@
1
- {"version":3,"file":"spawnWithLimits.js","names":["path","fs","os","childProcess","killGracePeriodMilliseconds","timeCommand","resolveTimeCommand","isTimeCommandAvailable","liveSubprocesses","subprocess","killSubprocessGroup","spawnWithLimits","timeOutput","openUnlinkedFile","detached","boundedCommand","context","command","spawnedCommand","timeLimitMilliseconds","startTimeMilliseconds","stdoutChunks","stderrChunks","outputBytes","timedOut","outputLimitExceeded","wallTimeSeconds","spawnErrorMessage","appendOutputChunk","chunk","remainingBytes","appendedChunk","chunks","timeout","killTimeout","settled","pendingError","closeTimeout","settle","reject","resolve","code","exitSignal","failAfterClose","error","timeResult","parseTimeOutput","readTail","endedByTimeout","status","timeLimitSeconds","signal","hasErrorCode","directory","prefix","file","length","size","maxBytes","buffer","match","content","parseSeconds","text","result"],"sources":["../../src/helpers/spawnWithLimits.ts"],"sourcesContent":["import childProcess from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport interface SpawnWithLimitsResult {\n stdout: string;\n stderr: string;\n status: number | undefined;\n signal: NodeJS.Signals | undefined;\n /** Wall time measured by GNU time, or measured here until the command exited when it produced none. */\n timeSeconds: number;\n /**\n * User plus system CPU time of the command and the descendants it waited for, measured by GNU\n * time, or 0 when it produced no measurement. A run `timeout` ended is measured too, so a caller\n * can tell a program that used up its time limit from one that waited for the CPU.\n */\n cpuTimeSeconds: number;\n /**\n * Peak resident set size measured by GNU time, or 0 when it produced no measurement. GNU time\n * measures its child `timeout` and the descendants it waited for, so the value is at least\n * `timeout`'s own footprint (about 1 MiB), which only shows for the smallest native programs.\n */\n memoryBytes: number;\n /** The note GNU time adds when the command exits abnormally, e.g. `Command terminated by signal 11`. */\n timeCommandMessage: string | undefined;\n timedOut: boolean;\n outputLimitExceeded: boolean;\n}\n\nconst killGracePeriodMilliseconds = 1000;\nconst MIN_TIMEOUT_SECONDS = 0.001;\n// GNU `timeout` exits with 124 after ending a run at its limit, or with 128 + SIGKILL when the run\n// ignored its SIGTERM and it had to be killed after the grace period.\nconst TIMEOUT_EXIT_STATUS = 124;\nconst KILLED_AFTER_TIMEOUT_EXIT_STATUS = 137;\n// GNU time writes its record to a file this process opened and unlinked at once, handed down as fd 3\n// of the command and reopened by GNU time through `/dev/fd`: the command runs as the same OS user,\n// so a record reachable by path could be swapped for a fake or for a FIFO that blocks the read.\nconst TIME_OUTPUT_FD = 3;\n// The command inherits that descriptor too, so only the tail that can hold GNU time's record is read.\nconst TIME_OUTPUT_TAIL_BYTES = 4096;\nconst timeCommand = resolveTimeCommand();\n\n/** Whether GNU time is available, i.e. whether `timeSeconds`, `cpuTimeSeconds`, and `memoryBytes` are measured at all. */\nexport const isTimeCommandAvailable = timeCommand !== undefined;\n\n// The commands run in their own sessions, so they outlive this process unless it ends them itself\n// on the way out (e.g. a preset's SIGINT handler calling `process.exit`).\nconst liveSubprocesses = new Set<childProcess.ChildProcess>();\nprocess.once('exit', () => {\n for (const subprocess of liveSubprocesses) killSubprocessGroup(subprocess, 'SIGKILL');\n});\n\n/**\n * Runs a command in its own process group with `stdin` piped in, killing the whole group once it\n * exceeds the time or output limit, and reports the wall time, CPU time, and peak memory measured\n * by GNU time.\n */\nexport async function spawnWithLimits(\n command: readonly [string, ...string[]],\n context: {\n cwd: string;\n env: NodeJS.ProcessEnv;\n outputLimitBytes: number;\n stdin: string;\n timeLimitSeconds: number;\n }\n): Promise<SpawnWithLimitsResult> {\n const timeOutput = timeCommand === undefined ? undefined : await openUnlinkedFile('exercode-time-');\n try {\n const detached = process.platform !== 'win32';\n // The command runs in its own session so the group kill below cannot hit this process, but that\n // also puts it out of reach of whoever kills this process. GNU `timeout` keeps the run bounded\n // in that case, and it is what normally ends a run at its limit: GNU time wraps it, so a run\n // that hit the limit still gets its CPU time recorded (a group kill from here would take GNU\n // time down with the command), and `--foreground` keeps the command in this group so the kill\n // below still reaches every descendant. The timers below only back `timeout` up. Should this\n // process die first, `timeout` ends the command alone (`--foreground` signals no descendant),\n // an accepted gap: the judge server sweeps every process of the request's sandbox user anyway.\n const boundedCommand: readonly [string, ...string[]] = detached\n ? [\n 'timeout',\n '--foreground',\n '-k',\n String(killGracePeriodMilliseconds / 1000),\n // A zero duration would disable `timeout`; the front matter allows a zero time limit.\n String(Math.max(context.timeLimitSeconds, MIN_TIMEOUT_SECONDS)),\n ...command,\n ]\n : command;\n const spawnedCommand: readonly [string, ...string[]] =\n timeCommand === undefined\n ? boundedCommand\n : [...timeCommand, `--output=/dev/fd/${TIME_OUTPUT_FD}`, ...boundedCommand];\n // `timeout` sends SIGKILL a grace period after its SIGTERM; the timers here fire once even that\n // should have ended the run.\n const timeLimitMilliseconds = detached\n ? context.timeLimitSeconds * 1000 + 2 * killGracePeriodMilliseconds\n : context.timeLimitSeconds * 1000;\n // Monotonic: a wall-clock step must not turn a run `timeout` ended into one that finished in time.\n const startTimeMilliseconds = performance.now();\n // The standard streams are always pipes; the type cannot express that with the extra entry.\n const subprocess = childProcess.spawn(spawnedCommand[0], spawnedCommand.slice(1), {\n cwd: context.cwd,\n detached,\n env: context.env,\n stdio: timeOutput === undefined ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe', timeOutput.fd],\n }) as childProcess.ChildProcessWithoutNullStreams;\n liveSubprocesses.add(subprocess);\n\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n let outputBytes = 0;\n let timedOut = false;\n let outputLimitExceeded = false;\n let wallTimeSeconds = 0;\n let spawnErrorMessage: string | undefined;\n\n const appendOutputChunk = (chunks: Buffer[], chunk: Buffer): void => {\n if (outputBytes >= context.outputLimitBytes) {\n if (chunk.byteLength > 0) {\n outputLimitExceeded = true;\n killSubprocessGroup(subprocess, 'SIGKILL');\n }\n return;\n }\n\n const remainingBytes = context.outputLimitBytes - outputBytes;\n const appendedChunk = chunk.byteLength > remainingBytes ? chunk.subarray(0, remainingBytes) : chunk;\n chunks.push(appendedChunk);\n outputBytes += appendedChunk.byteLength;\n\n if (chunk.byteLength > remainingBytes) {\n outputLimitExceeded = true;\n killSubprocessGroup(subprocess, 'SIGKILL');\n }\n };\n\n subprocess.stdout.on('data', (chunk: Buffer) => appendOutputChunk(stdoutChunks, chunk));\n subprocess.stderr.on('data', (chunk: Buffer) => appendOutputChunk(stderrChunks, chunk));\n\n const timeout = setTimeout(() => {\n timedOut = true;\n killSubprocessGroup(subprocess, 'SIGTERM');\n }, timeLimitMilliseconds);\n const killTimeout = setTimeout(() => {\n if (timedOut) killSubprocessGroup(subprocess, 'SIGKILL');\n }, timeLimitMilliseconds + killGracePeriodMilliseconds);\n killTimeout.unref();\n\n const { status, signal } = await new Promise<{ status: number | undefined; signal: NodeJS.Signals | undefined }>(\n (resolve, reject) => {\n let settled = false;\n let pendingError: Error | undefined;\n let closeTimeout: NodeJS.Timeout | undefined;\n const settle = (code: number | null | undefined, exitSignal: NodeJS.Signals | null | undefined): void => {\n if (settled) return;\n settled = true;\n clearTimeout(closeTimeout);\n if (pendingError) {\n reject(pendingError);\n return;\n }\n resolve({ status: code ?? undefined, signal: exitSignal ?? undefined });\n };\n const failAfterClose = (error: Error): void => {\n if (settled) return;\n if (subprocess.pid === undefined) {\n // The spawn itself failed, i.e. the head of the chain (GNU time, or `timeout` without\n // it, or the command itself on Windows) or the cwd is missing. A missing judged command\n // is reported as status 127 by the wrapper instead. Report this like a run that produced\n // only this message.\n spawnErrorMessage = error.message;\n settle(undefined, undefined);\n return;\n }\n pendingError = error;\n killSubprocessGroup(subprocess, 'SIGKILL');\n };\n subprocess.on('error', failAfterClose);\n subprocess.stdin.on('error', (error: NodeJS.ErrnoException) => {\n if (error.code !== 'EPIPE') failAfterClose(error);\n });\n subprocess.on('exit', (code, exitSignal) => {\n // The command is gone, so the limit timers must not fire during the grace period below. A\n // descendant that inherited the pipes (e.g. `sleep 1000 &`) would keep them open and hold\n // back 'close' until it ends: kill what is left of the group. A descendant that moved to\n // its own session survives that, so stop waiting for the pipes after the grace period and\n // keep the output read so far.\n clearTimeout(timeout);\n clearTimeout(killTimeout);\n wallTimeSeconds = (performance.now() - startTimeMilliseconds) / 1000;\n killSubprocessGroup(subprocess, 'SIGKILL');\n closeTimeout = setTimeout(() => {\n subprocess.stdout.destroy();\n subprocess.stderr.destroy();\n settle(code, exitSignal);\n }, killGracePeriodMilliseconds);\n });\n subprocess.on('close', settle);\n subprocess.stdin.end(context.stdin);\n }\n ).finally(() => {\n clearTimeout(timeout);\n clearTimeout(killTimeout);\n liveSubprocesses.delete(subprocess);\n });\n\n const timeResult =\n timeOutput === undefined ? undefined : parseTimeOutput(await readTail(timeOutput, TIME_OUTPUT_TAIL_BYTES));\n\n return {\n stdout: Buffer.concat(stdoutChunks).toString(),\n stderr: spawnErrorMessage ?? Buffer.concat(stderrChunks).toString(),\n status,\n signal,\n // GNU time rounds a fast run to 0.00; the wall time until 'exit' stands in for it (never the\n // grace period spent on the pipes afterwards).\n timeSeconds: timeResult?.timeSeconds || wallTimeSeconds,\n cpuTimeSeconds: timeResult?.cpuTimeSeconds ?? 0,\n memoryBytes: timeResult?.memoryBytes ?? 0,\n timeCommandMessage: timeResult?.message,\n timedOut: timedOut || endedByTimeout(status, wallTimeSeconds, context.timeLimitSeconds),\n outputLimitExceeded,\n };\n } finally {\n await timeOutput?.close();\n }\n}\n\n/**\n * Whether `timeout` ended the run: 124 says it did so at the limit, and 137 that the run ignored its\n * SIGTERM and had to be killed after the grace period. A program can exit with either status on its\n * own, so each is trusted only once the wall time reached the moment `timeout` would have produced it.\n */\nfunction endedByTimeout(status: number | undefined, wallTimeSeconds: number, timeLimitSeconds: number): boolean {\n if (status === TIMEOUT_EXIT_STATUS) return wallTimeSeconds >= timeLimitSeconds;\n if (status === KILLED_AFTER_TIMEOUT_EXIT_STATUS) {\n return wallTimeSeconds >= timeLimitSeconds + killGracePeriodMilliseconds / 1000;\n }\n return false;\n}\n\nfunction killSubprocessGroup(subprocess: childProcess.ChildProcess, signal: NodeJS.Signals): void {\n if (subprocess.pid === undefined) return;\n\n try {\n if (process.platform === 'win32') {\n subprocess.kill(signal);\n return;\n }\n process.kill(-subprocess.pid, signal);\n } catch (error) {\n if (!hasErrorCode(error, 'ESRCH') && !hasErrorCode(error, 'EPERM')) throw error;\n }\n}\n\n// The file is unlinked before it is returned, so nothing can reach it by path anymore.\nasync function openUnlinkedFile(prefix: string): Promise<fs.FileHandle> {\n const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix));\n try {\n return await fs.open(path.join(directory, 'record'), 'wx+', 0o600);\n } finally {\n await fs.rm(directory, { recursive: true, force: true });\n }\n}\n\nasync function readTail(file: fs.FileHandle, maxBytes: number): Promise<string> {\n const { size } = await file.stat();\n const length = Math.min(size, maxBytes);\n const { buffer } = await file.read(Buffer.alloc(length), 0, length, size - length);\n return buffer.toString();\n}\n\n// Empty when the command was killed before GNU time wrote its record.\nfunction parseTimeOutput(\n content: string\n): { timeSeconds: number; cpuTimeSeconds: number; memoryBytes: number; message: string | undefined } | undefined {\n const match = /(?:^|\\n)(\\d+(?:[.,]\\d+)?) (\\d+(?:[.,]\\d+)?) (\\d+(?:[.,]\\d+)?) (\\d+)\\s*$/.exec(content);\n if (!match) return undefined;\n\n return {\n timeSeconds: parseSeconds(match[1]!),\n cpuTimeSeconds: parseSeconds(match[2]!) + parseSeconds(match[3]!),\n memoryBytes: Number(match[4]) * 1024,\n message: content.slice(0, match.index).trim() || undefined,\n };\n}\n\n// GNU time formats its seconds with the locale's decimal separator.\nfunction parseSeconds(text: string): number {\n return Number(text.replace(',', '.'));\n}\n\nfunction resolveTimeCommand(): readonly [string, ...string[]] | undefined {\n const command = os.platform() === 'darwin' ? 'gtime' : '/usr/bin/time';\n const result = childProcess.spawnSync(command, ['--version'], { stdio: 'ignore' });\n if (result.error || result.status !== 0) return undefined;\n\n // Wall seconds, user CPU seconds, system CPU seconds, peak resident set size in KiB.\n return [command, '--format', '%e %U %S %M'];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"spawnWithLimits.js","names":["path","fs","os","childProcess","killGracePeriodMilliseconds","timeCommand","resolveTimeCommand","isTimeCommandAvailable","liveSubprocesses","subprocess","killSubprocessGroup","spawnWithLimits","timeOutput","openUnlinkedFile","detached","boundedCommand","context","command","spawnedCommand","timeLimitMilliseconds","startTimeMilliseconds","stdoutChunks","stderrChunks","outputBytes","timedOut","outputLimitExceeded","wallTimeSeconds","spawnErrorMessage","appendOutputChunk","chunk","remainingBytes","appendedChunk","chunks","timeout","killTimeout","settled","pendingError","closeTimeout","settle","reject","resolve","code","exitSignal","failAfterClose","error","timeResult","parseTimeOutput","readTail","endedByTimeout","status","timeLimitSeconds","signal","hasErrorCode","directory","prefix","file","length","size","maxBytes","buffer","match","content","parseSeconds","text","result"],"sources":["../../src/helpers/spawnWithLimits.ts"],"sourcesContent":["import childProcess from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport interface SpawnWithLimitsResult {\n stdout: string;\n stderr: string;\n status: number | undefined;\n signal: NodeJS.Signals | undefined;\n /** Wall time measured by GNU time, or measured here until the command exited when it produced none. */\n timeSeconds: number;\n /**\n * User plus system CPU time of the command and the descendants it waited for, measured by GNU\n * time, or 0 when it produced no measurement. A run `timeout` ended is measured too, so a caller\n * can tell a program that used up its time limit from one that waited for the CPU.\n */\n cpuTimeSeconds: number;\n /**\n * Peak resident set size measured by GNU time, or 0 when it produced no measurement. GNU time\n * measures its child `timeout` and the descendants it waited for, so the value is at least\n * `timeout`'s own footprint (about 1 MiB), which only shows for the smallest native programs.\n */\n memoryBytes: number;\n /** The note GNU time adds when the command exits abnormally, e.g. `Command terminated by signal 11`. */\n timeCommandMessage: string | undefined;\n timedOut: boolean;\n outputLimitExceeded: boolean;\n}\n\nconst killGracePeriodMilliseconds = 1000;\nconst MIN_TIMEOUT_SECONDS = 0.001;\n// GNU `timeout` exits with 124 after ending a run at its limit, or with 128 + SIGKILL when the run\n// ignored its SIGTERM and it had to be killed after the grace period.\nconst TIMEOUT_EXIT_STATUS = 124;\nconst KILLED_AFTER_TIMEOUT_EXIT_STATUS = 137;\n// GNU time writes its record to a file this process opened and unlinked at once, handed down as fd 3\n// of the command and reopened by GNU time through `/dev/fd`: the command runs as the same OS user,\n// so a record reachable by path could be swapped for a fake or for a FIFO that blocks the read.\nconst TIME_OUTPUT_FD = 3;\n// The command inherits that descriptor too, so only the tail that can hold GNU time's record is read.\nconst TIME_OUTPUT_TAIL_BYTES = 4096;\nconst timeCommand = resolveTimeCommand();\n\n/** Whether GNU time is available, i.e. whether `timeSeconds`, `cpuTimeSeconds`, and `memoryBytes` are measured at all. */\nexport const isTimeCommandAvailable = timeCommand !== undefined;\n\n// The commands run in their own sessions, so they outlive this process unless it ends them itself\n// on the way out (e.g. a preset's SIGINT handler calling `process.exit`).\nconst liveSubprocesses = new Set<childProcess.ChildProcess>();\nprocess.once('exit', () => {\n for (const subprocess of liveSubprocesses) killSubprocessGroup(subprocess, 'SIGKILL');\n});\n\n/**\n * Runs a command in its own process group with `stdin` piped in, killing the whole group once it\n * exceeds the time or output limit, and reports the wall time, CPU time, and peak memory measured\n * by GNU time.\n */\nexport async function spawnWithLimits(\n command: readonly [string, ...string[]],\n context: {\n cwd: string;\n env: NodeJS.ProcessEnv;\n outputLimitBytes: number;\n stdin: string;\n timeLimitSeconds: number;\n }\n): Promise<SpawnWithLimitsResult> {\n const timeOutput = timeCommand === undefined ? undefined : await openUnlinkedFile('exercode-time-');\n try {\n const detached = process.platform !== 'win32';\n // The command runs in its own session so the group kill below cannot hit this process, but that\n // also puts it out of reach of whoever kills this process. GNU `timeout` keeps the run bounded\n // in that case, and it is what normally ends a run at its limit: GNU time wraps it, so a run\n // that hit the limit still gets its CPU time recorded (a group kill from here would take GNU\n // time down with the command), and `--foreground` keeps the command in this group so the kill\n // below still reaches every descendant. The timers below only back `timeout` up. Should this\n // process die first, `timeout` ends the command alone (`--foreground` signals no descendant),\n // an accepted gap: the judge server sweeps every process of the request's sandbox user anyway.\n const boundedCommand: readonly [string, ...string[]] = detached\n ? [\n 'timeout',\n '--foreground',\n '-k',\n String(killGracePeriodMilliseconds / 1000),\n // A zero duration would disable `timeout`; the front matter allows a zero time limit.\n String(Math.max(context.timeLimitSeconds, MIN_TIMEOUT_SECONDS)),\n ...command,\n ]\n : command;\n const spawnedCommand: readonly [string, ...string[]] =\n timeCommand === undefined\n ? boundedCommand\n : [...timeCommand, `--output=/dev/fd/${TIME_OUTPUT_FD}`, ...boundedCommand];\n // `timeout` sends SIGKILL a grace period after its SIGTERM; the timers here fire once even that\n // should have ended the run.\n const timeLimitMilliseconds = detached\n ? context.timeLimitSeconds * 1000 + 2 * killGracePeriodMilliseconds\n : context.timeLimitSeconds * 1000;\n // Monotonic: a wall-clock step must not turn a run `timeout` ended into one that finished in time.\n const startTimeMilliseconds = performance.now();\n // The standard streams are always pipes; the type cannot express that with the extra entry.\n const subprocess = childProcess.spawn(spawnedCommand[0], spawnedCommand.slice(1), {\n cwd: context.cwd,\n detached,\n env: context.env,\n stdio: timeOutput === undefined ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe', timeOutput.fd],\n }) as childProcess.ChildProcessWithoutNullStreams;\n liveSubprocesses.add(subprocess);\n\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n let outputBytes = 0;\n let timedOut = false;\n let outputLimitExceeded = false;\n let wallTimeSeconds = 0;\n let spawnErrorMessage: string | undefined;\n\n const appendOutputChunk = (chunks: Buffer[], chunk: Buffer): void => {\n if (outputBytes >= context.outputLimitBytes) {\n if (chunk.byteLength > 0) {\n outputLimitExceeded = true;\n killSubprocessGroup(subprocess, 'SIGKILL');\n }\n return;\n }\n\n const remainingBytes = context.outputLimitBytes - outputBytes;\n const appendedChunk = chunk.byteLength > remainingBytes ? chunk.subarray(0, remainingBytes) : chunk;\n chunks.push(appendedChunk);\n outputBytes += appendedChunk.byteLength;\n\n if (chunk.byteLength > remainingBytes) {\n outputLimitExceeded = true;\n killSubprocessGroup(subprocess, 'SIGKILL');\n }\n };\n\n subprocess.stdout.on('data', (chunk: Buffer) => appendOutputChunk(stdoutChunks, chunk));\n subprocess.stderr.on('data', (chunk: Buffer) => appendOutputChunk(stderrChunks, chunk));\n\n const timeout = setTimeout(() => {\n timedOut = true;\n killSubprocessGroup(subprocess, 'SIGTERM');\n }, timeLimitMilliseconds);\n const killTimeout = setTimeout(() => {\n if (timedOut) killSubprocessGroup(subprocess, 'SIGKILL');\n }, timeLimitMilliseconds + killGracePeriodMilliseconds);\n killTimeout.unref();\n\n const { status, signal } = await new Promise<{ status: number | undefined; signal: NodeJS.Signals | undefined }>(\n (resolve, reject) => {\n let settled = false;\n let pendingError: Error | undefined;\n let closeTimeout: NodeJS.Timeout | undefined;\n const settle = (code: number | null | undefined, exitSignal: NodeJS.Signals | null | undefined): void => {\n if (settled) return;\n settled = true;\n clearTimeout(closeTimeout);\n if (pendingError) {\n reject(pendingError);\n return;\n }\n resolve({ status: code ?? undefined, signal: exitSignal ?? undefined });\n };\n const failAfterClose = (error: Error): void => {\n if (settled) return;\n if (subprocess.pid === undefined) {\n // The spawn itself failed, i.e. the head of the chain (GNU time, or `timeout` without\n // it, or the command itself on Windows) or the cwd is missing. A missing judged command\n // is reported as status 127 by the wrapper instead. Report this like a run that produced\n // only this message.\n spawnErrorMessage = error.message;\n settle(undefined, undefined);\n return;\n }\n pendingError = error;\n killSubprocessGroup(subprocess, 'SIGKILL');\n };\n subprocess.on('error', failAfterClose);\n subprocess.stdin.on('error', (error: NodeJS.ErrnoException) => {\n if (error.code !== 'EPIPE') failAfterClose(error);\n });\n subprocess.on('exit', (code, exitSignal) => {\n // The command is gone, so the limit timers must not fire during the grace period below. A\n // descendant that inherited the pipes (e.g. `sleep 1000 &`) would keep them open and hold\n // back 'close' until it ends: kill what is left of the group. A descendant that moved to\n // its own session survives that, so stop waiting for the pipes after the grace period and\n // keep the output read so far.\n clearTimeout(timeout);\n clearTimeout(killTimeout);\n wallTimeSeconds = (performance.now() - startTimeMilliseconds) / 1000;\n killSubprocessGroup(subprocess, 'SIGKILL');\n closeTimeout = setTimeout(() => {\n subprocess.stdout.destroy();\n subprocess.stderr.destroy();\n settle(code, exitSignal);\n }, killGracePeriodMilliseconds);\n });\n subprocess.on('close', settle);\n subprocess.stdin.end(context.stdin);\n }\n ).finally(() => {\n clearTimeout(timeout);\n clearTimeout(killTimeout);\n liveSubprocesses.delete(subprocess);\n });\n\n const timeResult =\n timeOutput === undefined ? undefined : parseTimeOutput(await readTail(timeOutput, TIME_OUTPUT_TAIL_BYTES));\n\n return {\n stdout: Buffer.concat(stdoutChunks).toString(),\n stderr: spawnErrorMessage ?? Buffer.concat(stderrChunks).toString(),\n status,\n signal,\n // GNU time rounds a fast run to 0.00; the wall time until 'exit' stands in for it (never the\n // grace period spent on the pipes afterwards).\n timeSeconds: timeResult?.timeSeconds || wallTimeSeconds,\n cpuTimeSeconds: timeResult?.cpuTimeSeconds ?? 0,\n memoryBytes: timeResult?.memoryBytes ?? 0,\n timeCommandMessage: timeResult?.message,\n timedOut: timedOut || endedByTimeout(status, wallTimeSeconds, context.timeLimitSeconds),\n outputLimitExceeded,\n };\n } finally {\n await timeOutput?.close();\n }\n}\n\n/**\n * Whether `timeout` ended the run: 124 says it did so at the limit, and 137 that the run ignored its\n * SIGTERM and had to be killed after the grace period. A program can exit with either status on its\n * own, so each is trusted only once the wall time reached the moment `timeout` would have produced it.\n */\nfunction endedByTimeout(status: number | undefined, wallTimeSeconds: number, timeLimitSeconds: number): boolean {\n if (status === TIMEOUT_EXIT_STATUS) return wallTimeSeconds >= timeLimitSeconds;\n if (status === KILLED_AFTER_TIMEOUT_EXIT_STATUS) {\n return wallTimeSeconds >= timeLimitSeconds + killGracePeriodMilliseconds / 1000;\n }\n return false;\n}\n\nfunction killSubprocessGroup(subprocess: childProcess.ChildProcess, signal: NodeJS.Signals): void {\n if (subprocess.pid === undefined) return;\n\n try {\n if (process.platform === 'win32') {\n subprocess.kill(signal);\n return;\n }\n process.kill(-subprocess.pid, signal);\n } catch (error) {\n if (!hasErrorCode(error, 'ESRCH') && !hasErrorCode(error, 'EPERM')) throw error;\n }\n}\n\n// The file is unlinked before it is returned, so nothing can reach it by path anymore.\nasync function openUnlinkedFile(prefix: string): Promise<fs.FileHandle> {\n const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix));\n try {\n return await fs.open(path.join(directory, 'record'), 'wx+', 0o600);\n } finally {\n await fs.rm(directory, { recursive: true, force: true });\n }\n}\n\nasync function readTail(file: fs.FileHandle, maxBytes: number): Promise<string> {\n const { size } = await file.stat();\n const length = Math.min(size, maxBytes);\n const { buffer } = await file.read(Buffer.alloc(length), 0, length, size - length);\n return buffer.toString();\n}\n\n// Empty when the command was killed before GNU time wrote its record.\nfunction parseTimeOutput(\n content: string\n): { timeSeconds: number; cpuTimeSeconds: number; memoryBytes: number; message: string | undefined } | undefined {\n const match = /(?:^|\\n)(\\d+(?:[.,]\\d+)?) (\\d+(?:[.,]\\d+)?) (\\d+(?:[.,]\\d+)?) (\\d+)\\s*$/.exec(content);\n if (!match) return undefined;\n\n return {\n timeSeconds: parseSeconds(match[1]!),\n cpuTimeSeconds: parseSeconds(match[2]!) + parseSeconds(match[3]!),\n memoryBytes: Number(match[4]) * 1024,\n message: content.slice(0, match.index).trim() || undefined,\n };\n}\n\n// GNU time formats its seconds with the locale's decimal separator.\nfunction parseSeconds(text: string): number {\n return Number(text.replace(',', '.'));\n}\n\nfunction resolveTimeCommand(): readonly [string, ...string[]] | undefined {\n const command = os.platform() === 'darwin' ? 'gtime' : '/usr/bin/time';\n const result = childProcess.spawnSync(command, ['--version'], { stdio: 'ignore' });\n if (result.error || result.status !== 0) return undefined;\n\n // Wall seconds, user CPU seconds, system CPU seconds, peak resident set size in KiB.\n return [command, '--format', '%e %U %S %M'];\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);let r=require("node:http");r=e.__toESM(r,1);function i(e){let t=o(e);return t.listen(),s(t)}async function a(e){let t=o(e);return await new Promise((e,n)=>{t.once(`error`,n),t.listen(0,`127.0.0.1`,()=>{t.off(`error`,n),e()})}),s(t)}function o(e){let i=n.default.resolve(e);return r.default.createServer((e,r)=>{let a;try{let t=new URL(e.url??`/`,`http://127.0.0.1`).pathname;a=decodeURIComponent(t.replaceAll(/%(?![\da-f]{2})/giu,`%25`))}catch{r.writeHead(400),r.end();return}a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?n.default.join(a,`index.html`):a,s=n.default.resolve(i,`.${o}`),c=n.default.relative(i,s);if(c===`..`||c.startsWith(`..${n.default.sep}`)){r.writeHead(403),r.end();return}if(t.default.existsSync(s))try{let e=t.default.readFileSync(s);r.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":l(o)}),r.end(e)}catch{r.statusCode=500,r.end()}else r.statusCode=404,r.end()})}function s(e){let t=e.address();if(!t)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{e.closeAllConnections(),await new Promise(t=>{e.close(()=>{t()})})},url:typeof t==`string`?t:`http://127.0.0.1:${t.port}`,port:typeof t==`object`?t.port:void 0}}const c={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function l(e){for(let[t,n]of Object.entries(c))if(e.endsWith(t))return n;return`text/plain`}exports.startHttpServer=i,exports.startLocalHttpServer=a;
1
+ "use strict";const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);let r=require("node:http");r=e.__toESM(r,1);function i(e){let t=o(e);return t.listen(),s(t)}async function a(e){let t=o(e);return await new Promise((e,n)=>{t.once(`error`,n),t.listen(0,`127.0.0.1`,()=>{t.off(`error`,n),e()})}),s(t)}function o(e){let i=n.default.resolve(e);return r.default.createServer((e,r)=>{let a;try{let t=new URL(e.url??`/`,`http://127.0.0.1`).pathname;a=decodeURIComponent(t.replaceAll(/%(?![\da-f]{2})/giu,`%25`))}catch{r.writeHead(400),r.end();return}a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?n.default.join(a,`index.html`):a,s=n.default.resolve(i,`.${o}`),c=n.default.relative(i,s);if(c===`..`||c.startsWith(`..${n.default.sep}`))r.writeHead(403),r.end();else if(t.default.existsSync(s))try{let e=t.default.readFileSync(s);r.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":l(o)}),r.end(e)}catch{r.statusCode=500,r.end()}else r.statusCode=404,r.end()})}function s(e){let t=e.address();if(!t)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{e.closeAllConnections(),await new Promise(t=>{e.close(()=>{t()})})},url:typeof t==`string`?t:`http://127.0.0.1:${t.port}`,port:typeof t==`object`?t.port:void 0}}const c={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function l(e){for(let[t,n]of Object.entries(c))if(e.endsWith(t))return n;return`text/plain`}exports.startHttpServer=i,exports.startLocalHttpServer=a;
2
2
  //# sourceMappingURL=startHttpServer.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"startHttpServer.cjs","names":["path","http","fs","require_runtime","node_fs","node_path","node_http","startHttpServer","server","createAssetServer","dir","createServerHandle","startLocalHttpServer","reject","resolve","rootPath","pathname","encodedPathname","request","response","pathnameWithIndexHtml","filePath","relativePath","buffer","getContentType","address","CONTENT_TYPE_BY_SUFFIX","suffix","contentType"],"sources":["../../src/helpers/startHttpServer.ts"],"sourcesContent":["import fs from 'node:fs';\nimport http from 'node:http';\nimport path from 'node:path';\n\nexport interface HttpServer {\n [Symbol.asyncDispose](): Promise<void>;\n url: string;\n port: number | undefined;\n}\n\n/** Starts a web-page test server and returns its address synchronously. */\nexport function startHttpServer(dir: string): HttpServer {\n const server = createAssetServer(dir);\n server.listen();\n return createServerHandle(server);\n}\n\n/** Starts an asset server reachable only from the local machine. */\nexport async function startLocalHttpServer(dir: string): Promise<HttpServer> {\n const server = createAssetServer(dir);\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n server.off('error', reject);\n resolve();\n });\n });\n return createServerHandle(server);\n}\n\nfunction createAssetServer(dir: string): http.Server {\n const rootPath = path.resolve(dir);\n return http.createServer((request, response) => {\n let pathname: string;\n try {\n const encodedPathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;\n pathname = decodeURIComponent(encodedPathname.replaceAll(/%(?![\\da-f]{2})/giu, '%25'));\n } catch {\n response.writeHead(400);\n response.end();\n return;\n }\n\n if (pathname === '/') {\n pathname = '/index.html';\n }\n\n const pathnameWithIndexHtml = pathname.endsWith('/') ? path.join(pathname, 'index.html') : pathname;\n\n const filePath = path.resolve(rootPath, `.${pathnameWithIndexHtml}`);\n const relativePath = path.relative(rootPath, filePath);\n if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {\n response.writeHead(403);\n response.end();\n return;\n }\n\n if (fs.existsSync(filePath)) {\n try {\n const buffer = fs.readFileSync(filePath);\n response.writeHead(200, {\n 'Access-Control-Allow-Origin': '*',\n Pragma: 'no-cache',\n 'Cache-Control': 'no-cache',\n 'Content-Type': getContentType(pathnameWithIndexHtml),\n });\n response.end(buffer);\n } catch {\n response.statusCode = 500;\n response.end();\n }\n } else {\n response.statusCode = 404;\n response.end();\n }\n });\n}\n\nfunction createServerHandle(server: http.Server): HttpServer {\n const address = server.address();\n if (!address) throw new Error('server has been unexpectedly closed');\n\n return {\n [Symbol.asyncDispose]: async () => {\n server.closeAllConnections();\n await new Promise<void>((resolve) => {\n server.close(() => {\n resolve();\n });\n });\n },\n url: typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`,\n port: typeof address === 'object' ? address.port : undefined,\n };\n}\n\nconst CONTENT_TYPE_BY_SUFFIX: Record<string, string> = {\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'text/javascript',\n '.gif': 'image/gif',\n '.jpg': 'image/jpeg',\n '.png': 'image/png',\n '.svg': 'image/svg+xml',\n};\n\nfunction getContentType(pathname: string): string {\n for (const [suffix, contentType] of Object.entries(CONTENT_TYPE_BY_SUFFIX)) {\n if (pathname.endsWith(suffix)) return contentType;\n }\n return 'text/plain';\n}\n"],"mappings":"oMAWA,SAAgBO,EAAgB,EAAyB,CACvD,IAAMC,EAASC,EAAkBC,CAAG,EAEpC,OADAF,EAAO,OAAO,EACPG,EAAmBH,CAAM,CAClC,CAGA,eAAsBI,EAAqB,EAAkC,CAC3E,IAAMJ,EAASC,EAAkBC,CAAG,EAQpC,OAPA,MAAM,IAAI,SAAe,EAAS,IAAW,CAC3CF,EAAO,KAAK,QAASK,CAAM,EAC3BL,EAAO,OAAO,EAAG,gBAAmB,CAClCA,EAAO,IAAI,QAASK,CAAM,EAC1BC,EAAQ,CACV,CAAC,CACH,CAAC,EACMH,EAAmBH,CAAM,CAClC,CAEA,SAASC,EAAkB,EAA0B,CACnD,IAAMM,EAAWf,EAAAA,QAAK,QAAQU,CAAG,EACjC,OAAOT,EAAAA,QAAK,cAAc,EAAS,IAAa,CAC9C,IAAIe,EACJ,GAAI,CACF,IAAMC,EAAkB,IAAI,IAAIC,EAAQ,KAAO,IAAK,kBAAkB,CAAC,CAAC,SACxE,EAAW,mBAAmBD,EAAgB,WAAW,qBAAsB,KAAK,CAAC,CACvF,MAAQ,CACNE,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEIH,IAAa,MACf,EAAW,eAGb,IAAMI,EAAwBJ,EAAS,SAAS,GAAG,EAAIhB,EAAAA,QAAK,KAAKgB,EAAU,YAAY,EAAIA,EAErFK,EAAWrB,EAAAA,QAAK,QAAQe,EAAU,IAAIK,GAAuB,EAC7DE,EAAetB,EAAAA,QAAK,SAASe,EAAUM,CAAQ,EACrD,GAAIC,IAAiB,MAAQA,EAAa,WAAW,KAAKtB,EAAAA,QAAK,KAAK,EAAG,CACrEmB,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEA,GAAIjB,EAAAA,QAAG,WAAWmB,CAAQ,EACxB,GAAI,CACF,IAAME,EAASrB,EAAAA,QAAG,aAAamB,CAAQ,EACvCF,EAAS,UAAU,IAAK,CACtB,8BAA+B,IAC/B,OAAQ,WACR,gBAAiB,WACjB,eAAgBK,EAAeJ,CAAqB,CACtD,CAAC,EACDD,EAAS,IAAII,CAAM,CACrB,MAAQ,CACN,EAAS,WAAa,IACtBJ,EAAS,IAAI,CACf,KAEA,GAAS,WAAa,IACtBA,EAAS,IAAI,CAEjB,CAAC,CACH,CAEA,SAASR,EAAmB,EAAiC,CAC3D,IAAMc,EAAUjB,EAAO,QAAQ,EAC/B,GAAI,CAACiB,EAAS,MAAU,MAAM,qCAAqC,EAEnE,MAAO,EACJ,OAAO,cAAe,SAAY,CACjCjB,EAAO,oBAAoB,EAC3B,MAAM,IAAI,QAAe,GAAY,CACnCA,EAAO,UAAY,CACjBM,EAAQ,CACV,CAAC,CACH,CAAC,CACH,EACA,IAAK,OAAOW,GAAY,SAAWA,EAAU,oBAAoBA,EAAQ,OACzE,KAAM,OAAOA,GAAY,SAAWA,EAAQ,KAAO,IAAA,EACrD,CACF,CAEA,MAAMC,EAAiD,CACrD,OAAQ,WACR,QAAS,YACT,MAAO,kBACP,OAAQ,YACR,OAAQ,aACR,OAAQ,YACR,OAAQ,eACV,EAEA,SAASF,EAAe,EAA0B,CAChD,IAAK,GAAM,CAACG,EAAQC,KAAgB,OAAO,QAAQF,CAAsB,EACvE,GAAIV,EAAS,SAASW,CAAM,EAAG,OAAOC,EAExC,MAAO,YACT"}
1
+ {"version":3,"file":"startHttpServer.cjs","names":["path","http","fs","require_runtime","node_fs","node_path","node_http","startHttpServer","server","createAssetServer","dir","createServerHandle","startLocalHttpServer","reject","resolve","rootPath","pathname","encodedPathname","request","response","pathnameWithIndexHtml","filePath","relativePath","buffer","getContentType","address","CONTENT_TYPE_BY_SUFFIX","suffix","contentType"],"sources":["../../src/helpers/startHttpServer.ts"],"sourcesContent":["import fs from 'node:fs';\nimport http from 'node:http';\nimport path from 'node:path';\n\nexport interface HttpServer {\n [Symbol.asyncDispose](): Promise<void>;\n url: string;\n port: number | undefined;\n}\n\n/** Starts a web-page test server and returns its address synchronously. */\nexport function startHttpServer(dir: string): HttpServer {\n const server = createAssetServer(dir);\n server.listen();\n return createServerHandle(server);\n}\n\n/** Starts an asset server reachable only from the local machine. */\nexport async function startLocalHttpServer(dir: string): Promise<HttpServer> {\n const server = createAssetServer(dir);\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n server.off('error', reject);\n resolve();\n });\n });\n return createServerHandle(server);\n}\n\nfunction createAssetServer(dir: string): http.Server {\n const rootPath = path.resolve(dir);\n return http.createServer((request, response) => {\n let pathname: string;\n try {\n const encodedPathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;\n pathname = decodeURIComponent(encodedPathname.replaceAll(/%(?![\\da-f]{2})/giu, '%25'));\n } catch {\n response.writeHead(400);\n response.end();\n return;\n }\n\n if (pathname === '/') {\n pathname = '/index.html';\n }\n\n const pathnameWithIndexHtml = pathname.endsWith('/') ? path.join(pathname, 'index.html') : pathname;\n\n const filePath = path.resolve(rootPath, `.${pathnameWithIndexHtml}`);\n const relativePath = path.relative(rootPath, filePath);\n if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {\n response.writeHead(403);\n response.end();\n return;\n }\n\n if (fs.existsSync(filePath)) {\n try {\n const buffer = fs.readFileSync(filePath);\n response.writeHead(200, {\n 'Access-Control-Allow-Origin': '*',\n Pragma: 'no-cache',\n 'Cache-Control': 'no-cache',\n 'Content-Type': getContentType(pathnameWithIndexHtml),\n });\n response.end(buffer);\n } catch {\n response.statusCode = 500;\n response.end();\n }\n } else {\n response.statusCode = 404;\n response.end();\n }\n });\n}\n\nfunction createServerHandle(server: http.Server): HttpServer {\n const address = server.address();\n if (!address) throw new Error('server has been unexpectedly closed');\n\n return {\n [Symbol.asyncDispose]: async () => {\n server.closeAllConnections();\n await new Promise<void>((resolve) => {\n server.close(() => {\n resolve();\n });\n });\n },\n url: typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`,\n port: typeof address === 'object' ? address.port : undefined,\n };\n}\n\nconst CONTENT_TYPE_BY_SUFFIX: Record<string, string> = {\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'text/javascript',\n '.gif': 'image/gif',\n '.jpg': 'image/jpeg',\n '.png': 'image/png',\n '.svg': 'image/svg+xml',\n};\n\nfunction getContentType(pathname: string): string {\n for (const [suffix, contentType] of Object.entries(CONTENT_TYPE_BY_SUFFIX)) {\n if (pathname.endsWith(suffix)) return contentType;\n }\n return 'text/plain';\n}\n"],"mappings":"oMAWA,SAAgBO,EAAgB,EAAyB,CACvD,IAAMC,EAASC,EAAkBC,CAAG,EAEpC,OADAF,EAAO,OAAO,EACPG,EAAmBH,CAAM,CAClC,CAGA,eAAsBI,EAAqB,EAAkC,CAC3E,IAAMJ,EAASC,EAAkBC,CAAG,EAQpC,OAPA,MAAM,IAAI,SAAe,EAAS,IAAW,CAC3CF,EAAO,KAAK,QAASK,CAAM,EAC3BL,EAAO,OAAO,EAAG,gBAAmB,CAClCA,EAAO,IAAI,QAASK,CAAM,EAC1BC,EAAQ,CACV,CAAC,CACH,CAAC,EACMH,EAAmBH,CAAM,CAClC,CAEA,SAASC,EAAkB,EAA0B,CACnD,IAAMM,EAAWf,EAAAA,QAAK,QAAQU,CAAG,EACjC,OAAOT,EAAAA,QAAK,cAAc,EAAS,IAAa,CAC9C,IAAIe,EACJ,GAAI,CACF,IAAMC,EAAkB,IAAI,IAAIC,EAAQ,KAAO,IAAK,kBAAkB,CAAC,CAAC,SACxE,EAAW,mBAAmBD,EAAgB,WAAW,qBAAsB,KAAK,CAAC,CACvF,MAAQ,CACNE,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEIH,IAAa,MACf,EAAW,eAGb,IAAMI,EAAwBJ,EAAS,SAAS,GAAG,EAAIhB,EAAAA,QAAK,KAAKgB,EAAU,YAAY,EAAIA,EAErFK,EAAWrB,EAAAA,QAAK,QAAQe,EAAU,IAAIK,GAAuB,EAC7DE,EAAetB,EAAAA,QAAK,SAASe,EAAUM,CAAQ,EACrD,GAAIC,IAAiB,MAAQA,EAAa,WAAW,KAAKtB,EAAAA,QAAK,KAAK,EAClEmB,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,OAIf,GAAIjB,EAAAA,QAAG,WAAWmB,CAAQ,EACxB,GAAI,CACF,IAAME,EAASrB,EAAAA,QAAG,aAAamB,CAAQ,EACvCF,EAAS,UAAU,IAAK,CACtB,8BAA+B,IAC/B,OAAQ,WACR,gBAAiB,WACjB,eAAgBK,EAAeJ,CAAqB,CACtD,CAAC,EACDD,EAAS,IAAII,CAAM,CACrB,MAAQ,CACN,EAAS,WAAa,IACtBJ,EAAS,IAAI,CACf,KAEA,GAAS,WAAa,IACtBA,EAAS,IAAI,CAEjB,CAAC,CACH,CAEA,SAASR,EAAmB,EAAiC,CAC3D,IAAMc,EAAUjB,EAAO,QAAQ,EAC/B,GAAI,CAACiB,EAAS,MAAU,MAAM,qCAAqC,EAEnE,MAAO,EACJ,OAAO,cAAe,SAAY,CACjCjB,EAAO,oBAAoB,EAC3B,MAAM,IAAI,QAAe,GAAY,CACnCA,EAAO,UAAY,CACjBM,EAAQ,CACV,CAAC,CACH,CAAC,CACH,EACA,IAAK,OAAOW,GAAY,SAAWA,EAAU,oBAAoBA,EAAQ,OACzE,KAAM,OAAOA,GAAY,SAAWA,EAAQ,KAAO,IAAA,EACrD,CACF,CAEA,MAAMC,EAAiD,CACrD,OAAQ,WACR,QAAS,YACT,MAAO,kBACP,OAAQ,YACR,OAAQ,aACR,OAAQ,YACR,OAAQ,eACV,EAEA,SAASF,EAAe,EAA0B,CAChD,IAAK,GAAM,CAACG,EAAQC,KAAgB,OAAO,QAAQF,CAAsB,EACvE,GAAIV,EAAS,SAASW,CAAM,EAAG,OAAOC,EAExC,MAAO,YACT"}
@@ -1,2 +1,2 @@
1
- import e from"node:fs";import t from"node:path";import n from"node:http";function r(e){let t=a(e);return t.listen(),o(t)}async function i(e){let t=a(e);return await new Promise((e,n)=>{t.once(`error`,n),t.listen(0,`127.0.0.1`,()=>{t.off(`error`,n),e()})}),o(t)}function a(r){let i=t.resolve(r);return n.createServer((n,r)=>{let a;try{let e=new URL(n.url??`/`,`http://127.0.0.1`).pathname;a=decodeURIComponent(e.replaceAll(/%(?![\da-f]{2})/giu,`%25`))}catch{r.writeHead(400),r.end();return}a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?t.join(a,`index.html`):a,s=t.resolve(i,`.${o}`),l=t.relative(i,s);if(l===`..`||l.startsWith(`..${t.sep}`)){r.writeHead(403),r.end();return}if(e.existsSync(s))try{let t=e.readFileSync(s);r.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":c(o)}),r.end(t)}catch{r.statusCode=500,r.end()}else r.statusCode=404,r.end()})}function o(e){let t=e.address();if(!t)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{e.closeAllConnections(),await new Promise(t=>{e.close(()=>{t()})})},url:typeof t==`string`?t:`http://127.0.0.1:${t.port}`,port:typeof t==`object`?t.port:void 0}}const s={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function c(e){for(let[t,n]of Object.entries(s))if(e.endsWith(t))return n;return`text/plain`}export{r as startHttpServer,i as startLocalHttpServer};
1
+ import e from"node:fs";import t from"node:path";import n from"node:http";function r(e){let t=a(e);return t.listen(),o(t)}async function i(e){let t=a(e);return await new Promise((e,n)=>{t.once(`error`,n),t.listen(0,`127.0.0.1`,()=>{t.off(`error`,n),e()})}),o(t)}function a(r){let i=t.resolve(r);return n.createServer((n,r)=>{let a;try{let e=new URL(n.url??`/`,`http://127.0.0.1`).pathname;a=decodeURIComponent(e.replaceAll(/%(?![\da-f]{2})/giu,`%25`))}catch{r.writeHead(400),r.end();return}a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?t.join(a,`index.html`):a,s=t.resolve(i,`.${o}`),l=t.relative(i,s);if(l===`..`||l.startsWith(`..${t.sep}`))r.writeHead(403),r.end();else if(e.existsSync(s))try{let t=e.readFileSync(s);r.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":c(o)}),r.end(t)}catch{r.statusCode=500,r.end()}else r.statusCode=404,r.end()})}function o(e){let t=e.address();if(!t)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{e.closeAllConnections(),await new Promise(t=>{e.close(()=>{t()})})},url:typeof t==`string`?t:`http://127.0.0.1:${t.port}`,port:typeof t==`object`?t.port:void 0}}const s={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function c(e){for(let[t,n]of Object.entries(s))if(e.endsWith(t))return n;return`text/plain`}export{r as startHttpServer,i as startLocalHttpServer};
2
2
  //# sourceMappingURL=startHttpServer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"startHttpServer.js","names":["fs","path","http","startHttpServer","server","createAssetServer","dir","createServerHandle","startLocalHttpServer","reject","resolve","rootPath","pathname","encodedPathname","request","response","pathnameWithIndexHtml","filePath","relativePath","buffer","getContentType","address","CONTENT_TYPE_BY_SUFFIX","suffix","contentType"],"sources":["../../src/helpers/startHttpServer.ts"],"sourcesContent":["import fs from 'node:fs';\nimport http from 'node:http';\nimport path from 'node:path';\n\nexport interface HttpServer {\n [Symbol.asyncDispose](): Promise<void>;\n url: string;\n port: number | undefined;\n}\n\n/** Starts a web-page test server and returns its address synchronously. */\nexport function startHttpServer(dir: string): HttpServer {\n const server = createAssetServer(dir);\n server.listen();\n return createServerHandle(server);\n}\n\n/** Starts an asset server reachable only from the local machine. */\nexport async function startLocalHttpServer(dir: string): Promise<HttpServer> {\n const server = createAssetServer(dir);\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n server.off('error', reject);\n resolve();\n });\n });\n return createServerHandle(server);\n}\n\nfunction createAssetServer(dir: string): http.Server {\n const rootPath = path.resolve(dir);\n return http.createServer((request, response) => {\n let pathname: string;\n try {\n const encodedPathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;\n pathname = decodeURIComponent(encodedPathname.replaceAll(/%(?![\\da-f]{2})/giu, '%25'));\n } catch {\n response.writeHead(400);\n response.end();\n return;\n }\n\n if (pathname === '/') {\n pathname = '/index.html';\n }\n\n const pathnameWithIndexHtml = pathname.endsWith('/') ? path.join(pathname, 'index.html') : pathname;\n\n const filePath = path.resolve(rootPath, `.${pathnameWithIndexHtml}`);\n const relativePath = path.relative(rootPath, filePath);\n if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {\n response.writeHead(403);\n response.end();\n return;\n }\n\n if (fs.existsSync(filePath)) {\n try {\n const buffer = fs.readFileSync(filePath);\n response.writeHead(200, {\n 'Access-Control-Allow-Origin': '*',\n Pragma: 'no-cache',\n 'Cache-Control': 'no-cache',\n 'Content-Type': getContentType(pathnameWithIndexHtml),\n });\n response.end(buffer);\n } catch {\n response.statusCode = 500;\n response.end();\n }\n } else {\n response.statusCode = 404;\n response.end();\n }\n });\n}\n\nfunction createServerHandle(server: http.Server): HttpServer {\n const address = server.address();\n if (!address) throw new Error('server has been unexpectedly closed');\n\n return {\n [Symbol.asyncDispose]: async () => {\n server.closeAllConnections();\n await new Promise<void>((resolve) => {\n server.close(() => {\n resolve();\n });\n });\n },\n url: typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`,\n port: typeof address === 'object' ? address.port : undefined,\n };\n}\n\nconst CONTENT_TYPE_BY_SUFFIX: Record<string, string> = {\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'text/javascript',\n '.gif': 'image/gif',\n '.jpg': 'image/jpeg',\n '.png': 'image/png',\n '.svg': 'image/svg+xml',\n};\n\nfunction getContentType(pathname: string): string {\n for (const [suffix, contentType] of Object.entries(CONTENT_TYPE_BY_SUFFIX)) {\n if (pathname.endsWith(suffix)) return contentType;\n }\n return 'text/plain';\n}\n"],"mappings":"yEAWA,SAAgBG,EAAgB,EAAyB,CACvD,IAAMC,EAASC,EAAkBC,CAAG,EAEpC,OADAF,EAAO,OAAO,EACPG,EAAmBH,CAAM,CAClC,CAGA,eAAsBI,EAAqB,EAAkC,CAC3E,IAAMJ,EAASC,EAAkBC,CAAG,EAQpC,OAPA,MAAM,IAAI,SAAe,EAAS,IAAW,CAC3CF,EAAO,KAAK,QAASK,CAAM,EAC3BL,EAAO,OAAO,EAAG,gBAAmB,CAClCA,EAAO,IAAI,QAASK,CAAM,EAC1BC,EAAQ,CACV,CAAC,CACH,CAAC,EACMH,EAAmBH,CAAM,CAClC,CAEA,SAASC,EAAkB,EAA0B,CACnD,IAAMM,EAAWV,EAAK,QAAQK,CAAG,EACjC,OAAOJ,EAAK,cAAc,EAAS,IAAa,CAC9C,IAAIU,EACJ,GAAI,CACF,IAAMC,EAAkB,IAAI,IAAIC,EAAQ,KAAO,IAAK,kBAAkB,CAAC,CAAC,SACxE,EAAW,mBAAmBD,EAAgB,WAAW,qBAAsB,KAAK,CAAC,CACvF,MAAQ,CACNE,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEIH,IAAa,MACf,EAAW,eAGb,IAAMI,EAAwBJ,EAAS,SAAS,GAAG,EAAIX,EAAK,KAAKW,EAAU,YAAY,EAAIA,EAErFK,EAAWhB,EAAK,QAAQU,EAAU,IAAIK,GAAuB,EAC7DE,EAAejB,EAAK,SAASU,EAAUM,CAAQ,EACrD,GAAIC,IAAiB,MAAQA,EAAa,WAAW,KAAKjB,EAAK,KAAK,EAAG,CACrEc,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEA,GAAIf,EAAG,WAAWiB,CAAQ,EACxB,GAAI,CACF,IAAME,EAASnB,EAAG,aAAaiB,CAAQ,EACvCF,EAAS,UAAU,IAAK,CACtB,8BAA+B,IAC/B,OAAQ,WACR,gBAAiB,WACjB,eAAgBK,EAAeJ,CAAqB,CACtD,CAAC,EACDD,EAAS,IAAII,CAAM,CACrB,MAAQ,CACN,EAAS,WAAa,IACtBJ,EAAS,IAAI,CACf,KAEA,GAAS,WAAa,IACtBA,EAAS,IAAI,CAEjB,CAAC,CACH,CAEA,SAASR,EAAmB,EAAiC,CAC3D,IAAMc,EAAUjB,EAAO,QAAQ,EAC/B,GAAI,CAACiB,EAAS,MAAU,MAAM,qCAAqC,EAEnE,MAAO,EACJ,OAAO,cAAe,SAAY,CACjCjB,EAAO,oBAAoB,EAC3B,MAAM,IAAI,QAAe,GAAY,CACnCA,EAAO,UAAY,CACjBM,EAAQ,CACV,CAAC,CACH,CAAC,CACH,EACA,IAAK,OAAOW,GAAY,SAAWA,EAAU,oBAAoBA,EAAQ,OACzE,KAAM,OAAOA,GAAY,SAAWA,EAAQ,KAAO,IAAA,EACrD,CACF,CAEA,MAAMC,EAAiD,CACrD,OAAQ,WACR,QAAS,YACT,MAAO,kBACP,OAAQ,YACR,OAAQ,aACR,OAAQ,YACR,OAAQ,eACV,EAEA,SAASF,EAAe,EAA0B,CAChD,IAAK,GAAM,CAACG,EAAQC,KAAgB,OAAO,QAAQF,CAAsB,EACvE,GAAIV,EAAS,SAASW,CAAM,EAAG,OAAOC,EAExC,MAAO,YACT"}
1
+ {"version":3,"file":"startHttpServer.js","names":["fs","path","http","startHttpServer","server","createAssetServer","dir","createServerHandle","startLocalHttpServer","reject","resolve","rootPath","pathname","encodedPathname","request","response","pathnameWithIndexHtml","filePath","relativePath","buffer","getContentType","address","CONTENT_TYPE_BY_SUFFIX","suffix","contentType"],"sources":["../../src/helpers/startHttpServer.ts"],"sourcesContent":["import fs from 'node:fs';\nimport http from 'node:http';\nimport path from 'node:path';\n\nexport interface HttpServer {\n [Symbol.asyncDispose](): Promise<void>;\n url: string;\n port: number | undefined;\n}\n\n/** Starts a web-page test server and returns its address synchronously. */\nexport function startHttpServer(dir: string): HttpServer {\n const server = createAssetServer(dir);\n server.listen();\n return createServerHandle(server);\n}\n\n/** Starts an asset server reachable only from the local machine. */\nexport async function startLocalHttpServer(dir: string): Promise<HttpServer> {\n const server = createAssetServer(dir);\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n server.off('error', reject);\n resolve();\n });\n });\n return createServerHandle(server);\n}\n\nfunction createAssetServer(dir: string): http.Server {\n const rootPath = path.resolve(dir);\n return http.createServer((request, response) => {\n let pathname: string;\n try {\n const encodedPathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;\n pathname = decodeURIComponent(encodedPathname.replaceAll(/%(?![\\da-f]{2})/giu, '%25'));\n } catch {\n response.writeHead(400);\n response.end();\n return;\n }\n\n if (pathname === '/') {\n pathname = '/index.html';\n }\n\n const pathnameWithIndexHtml = pathname.endsWith('/') ? path.join(pathname, 'index.html') : pathname;\n\n const filePath = path.resolve(rootPath, `.${pathnameWithIndexHtml}`);\n const relativePath = path.relative(rootPath, filePath);\n if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {\n response.writeHead(403);\n response.end();\n return;\n }\n\n if (fs.existsSync(filePath)) {\n try {\n const buffer = fs.readFileSync(filePath);\n response.writeHead(200, {\n 'Access-Control-Allow-Origin': '*',\n Pragma: 'no-cache',\n 'Cache-Control': 'no-cache',\n 'Content-Type': getContentType(pathnameWithIndexHtml),\n });\n response.end(buffer);\n } catch {\n response.statusCode = 500;\n response.end();\n }\n } else {\n response.statusCode = 404;\n response.end();\n }\n });\n}\n\nfunction createServerHandle(server: http.Server): HttpServer {\n const address = server.address();\n if (!address) throw new Error('server has been unexpectedly closed');\n\n return {\n [Symbol.asyncDispose]: async () => {\n server.closeAllConnections();\n await new Promise<void>((resolve) => {\n server.close(() => {\n resolve();\n });\n });\n },\n url: typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`,\n port: typeof address === 'object' ? address.port : undefined,\n };\n}\n\nconst CONTENT_TYPE_BY_SUFFIX: Record<string, string> = {\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'text/javascript',\n '.gif': 'image/gif',\n '.jpg': 'image/jpeg',\n '.png': 'image/png',\n '.svg': 'image/svg+xml',\n};\n\nfunction getContentType(pathname: string): string {\n for (const [suffix, contentType] of Object.entries(CONTENT_TYPE_BY_SUFFIX)) {\n if (pathname.endsWith(suffix)) return contentType;\n }\n return 'text/plain';\n}\n"],"mappings":"yEAWA,SAAgBG,EAAgB,EAAyB,CACvD,IAAMC,EAASC,EAAkBC,CAAG,EAEpC,OADAF,EAAO,OAAO,EACPG,EAAmBH,CAAM,CAClC,CAGA,eAAsBI,EAAqB,EAAkC,CAC3E,IAAMJ,EAASC,EAAkBC,CAAG,EAQpC,OAPA,MAAM,IAAI,SAAe,EAAS,IAAW,CAC3CF,EAAO,KAAK,QAASK,CAAM,EAC3BL,EAAO,OAAO,EAAG,gBAAmB,CAClCA,EAAO,IAAI,QAASK,CAAM,EAC1BC,EAAQ,CACV,CAAC,CACH,CAAC,EACMH,EAAmBH,CAAM,CAClC,CAEA,SAASC,EAAkB,EAA0B,CACnD,IAAMM,EAAWV,EAAK,QAAQK,CAAG,EACjC,OAAOJ,EAAK,cAAc,EAAS,IAAa,CAC9C,IAAIU,EACJ,GAAI,CACF,IAAMC,EAAkB,IAAI,IAAIC,EAAQ,KAAO,IAAK,kBAAkB,CAAC,CAAC,SACxE,EAAW,mBAAmBD,EAAgB,WAAW,qBAAsB,KAAK,CAAC,CACvF,MAAQ,CACNE,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,EACb,MACF,CAEIH,IAAa,MACf,EAAW,eAGb,IAAMI,EAAwBJ,EAAS,SAAS,GAAG,EAAIX,EAAK,KAAKW,EAAU,YAAY,EAAIA,EAErFK,EAAWhB,EAAK,QAAQU,EAAU,IAAIK,GAAuB,EAC7DE,EAAejB,EAAK,SAASU,EAAUM,CAAQ,EACrD,GAAIC,IAAiB,MAAQA,EAAa,WAAW,KAAKjB,EAAK,KAAK,EAClEc,EAAS,UAAU,GAAG,EACtBA,EAAS,IAAI,OAIf,GAAIf,EAAG,WAAWiB,CAAQ,EACxB,GAAI,CACF,IAAME,EAASnB,EAAG,aAAaiB,CAAQ,EACvCF,EAAS,UAAU,IAAK,CACtB,8BAA+B,IAC/B,OAAQ,WACR,gBAAiB,WACjB,eAAgBK,EAAeJ,CAAqB,CACtD,CAAC,EACDD,EAAS,IAAII,CAAM,CACrB,MAAQ,CACN,EAAS,WAAa,IACtBJ,EAAS,IAAI,CACf,KAEA,GAAS,WAAa,IACtBA,EAAS,IAAI,CAEjB,CAAC,CACH,CAEA,SAASR,EAAmB,EAAiC,CAC3D,IAAMc,EAAUjB,EAAO,QAAQ,EAC/B,GAAI,CAACiB,EAAS,MAAU,MAAM,qCAAqC,EAEnE,MAAO,EACJ,OAAO,cAAe,SAAY,CACjCjB,EAAO,oBAAoB,EAC3B,MAAM,IAAI,QAAe,GAAY,CACnCA,EAAO,UAAY,CACjBM,EAAQ,CACV,CAAC,CACH,CAAC,CACH,EACA,IAAK,OAAOW,GAAY,SAAWA,EAAU,oBAAoBA,EAAQ,OACzE,KAAM,OAAOA,GAAY,SAAWA,EAAQ,KAAO,IAAA,EACrD,CACF,CAEA,MAAMC,EAAiD,CACrD,OAAQ,WACR,QAAS,YACT,MAAO,kBACP,OAAQ,YACR,OAAQ,aACR,OAAQ,YACR,OAAQ,eACV,EAEA,SAASF,EAAe,EAA0B,CAChD,IAAK,GAAM,CAACG,EAAQC,KAAgB,OAAO,QAAQF,CAAsB,EACvE,GAAIV,EAAS,SAASW,CAAM,EAAG,OAAOC,EAExC,MAAO,YACT"}
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("./schemas.cjs"),t=require("./fsHelpers.cjs"),n=require("./validationResult.cjs"),r=require("./validateMaterial.cjs"),i=require("./validateContest.cjs");let a=require("node:path"),o=require("node:fs/promises"),s=require("@willbooster/shared-lib"),c=require("yaml");async function l(i,s={}){let c=[],l=[],p={errors:c,warnings:l};if(!await t.isRegularDirectory((0,a.resolve)(i)))return c.push(await t.isDirectory((0,a.resolve)(i))?`course directory is a symbolic link, which the judge does not traverse: ${i}`:`course directory not found: ${i}`),p;let m=await(0,o.realpath)(i),g=s.problemsDirectoryPath===void 0?void 0:await u(s.problemsDirectoryPath),_=(0,a.basename)(m);e.LEARNING_MATERIAL_ID_REGEX.test(_)||c.push(`course ID "${_}" (directory name) must match ${e.LEARNING_MATERIAL_ID_REGEX}`);let y=await f(m,c),b=m,x=await h(m,s.problemsDirectoryPath,g,c);if(!y)return p;let{courseFile:S,courseFileName:C}=y,w=[];r.validateSubmissionPeriods(S,w),c.push(...w.map(e=>`${C}: ${e}`));let T=S.lectures??[];T.length===0&&l.push(`${C} declares no lectures, so no materials are imported`),n.reportDuplicateIds(T.map(e=>e.id),`lecture`,c);for(let e of T)await v(m,{courseId:_,courseSubmissionPeriods:S,problemsDirectoryPath:b,availableProblemIds:x},e.id,c,l);return await d(m,S,C,l),p}async function u(e){let n=(0,a.resolve)(e);if(await t.isRegularDirectory(n))return(0,o.realpath)(n);try{return(0,a.join)(await(0,o.realpath)((0,a.dirname)(n)),(0,a.basename)(n))}catch{return n}}async function d(e,t,n,r){let i=new Set((t.lectures??[]).map(e=>e.id)),s=await(0,o.readdir)(e,{withFileTypes:!0});for(let t of s)!t.isDirectory()||t.name.startsWith(`.`)||i.has(t.name)||t.name===`problems`||(await(0,o.readdir)((0,a.join)(e,t.name),{withFileTypes:!0})).some(e=>e.isFile()&&m(e.name))&&r.push(`directory "${t.name}" is not listed as a lecture in ${n}, so its materials are not imported`)}async function f(r,i){let l=[`course.yaml`,`course.yml`],u=[];for(let e of l)await t.isRegularFile((0,a.join)(r,e))&&u.push(e);if(u.length>1){i.push(`both course.yaml and course.yml exist; the judge rejects the conflicting course files`);return}let d=u[0];if(d===void 0){let e=await p(l,e=>t.isFile((0,a.join)(r,e)));i.push(e===void 0?`course.yaml not found`:`${e} is a symbolic link, which the judge ignores; commit a regular file`);return}let f=(0,a.join)(r,d),m;try{m=(0,c.parse)(await(0,o.readFile)(f,`utf8`))}catch(e){i.push(`${d}: invalid YAML: ${(0,s.getErrorMessage)(e)}`);return}let h=e.courseFileSchema.safeParse(m);if(!h.success){i.push(...n.formatZodIssues(h.error,d));return}return{courseFile:h.data,courseFileName:d}}async function p(e,t){for(let n of e)if(await t(n))return n}function m(e){return e.endsWith(`.contest.yaml`)||e.endsWith(`.md`)&&e!==`problem.md`&&!e.endsWith(`.problem.md`)}async function h(e,n,r,i){let o=await t.collectProblemDefinitions(e);t.reportProblemDefinitionIssues(o,i);let s=new Set(o.keys()),c=(0,a.join)(e,`problems`),l=r!==void 0&&await g(r,c);return(l||await t.isDirectory(c))&&await t.isUsableProblemsDirectory(c,i),r===void 0||l||await t.isUsableProblemsDirectory(r,i)&&_(e,r)&&i.push(`problems directory ${n} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`),s}async function g(e,t){if(e===t)return!0;try{let[n,r]=await Promise.all([(0,o.lstat)(e),(0,o.lstat)(t)]);return n.dev===r.dev&&n.ino===r.ino}catch{return!1}}function _(e,t){let n=(0,a.relative)(e,t);return n===`..`||n.startsWith(`..${a.sep}`)}async function v(s,c,l,u,d){let f=(0,a.join)(s,l);if(!await t.isRegularDirectory(f)){u.push(await t.isDirectory(f)?`lecture "${l}": directory is a symbolic link, which the judge does not traverse`:`lecture "${l}": directory not found`);return}let p=[],m=await(0,o.readdir)(f,{withFileTypes:!0});for(let e of m)e.isSymbolicLink()&&u.push(`${l}/${e.name} is a symbolic link, which the judge ignores; commit a regular file`);let h=m.filter(e=>e.isFile()).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let t of h){let n=(0,a.join)(f,t),o;if(t.endsWith(`.contest.yaml`))p.push(t.slice(0,-e.CONTEST_MATERIAL_FILE_SUFFIX.length)),o=await i.validateContestFile(n,{...c,isNestedInCourse:!0});else if(t.endsWith(`.md`))p.push(t.replace(/\.[^/.]+$/,``)),o=await r.validateMaterialFile(n,c);else continue;u.push(...o.errors.map(e=>`${l}/${t}: ${e}`)),d.push(...o.warnings.map(e=>`${l}/${t}: ${e}`))}p.length===0&&d.push(`lecture "${l}": no material files (*.md or *${e.CONTEST_MATERIAL_FILE_SUFFIX}) found`);let g=[];n.reportDuplicateIds(p,`material`,g),u.push(...g.map(e=>`lecture "${l}": ${e}`))}exports.validateCourseDirectory=l;
1
+ "use strict";const e=require("./schemas.cjs"),t=require("./fsHelpers.cjs"),n=require("./validationResult.cjs"),r=require("./validateMaterial.cjs"),i=require("./validateContest.cjs");let a=require("node:path"),o=require("node:fs/promises"),s=require("@willbooster/shared-lib"),c=require("yaml");async function l(i,s={}){let c=[],l=[],p={errors:c,warnings:l};if(!await t.isRegularDirectory((0,a.resolve)(i)))return c.push(await t.isDirectory((0,a.resolve)(i))?`course directory is a symbolic link, which the judge does not traverse: ${i}`:`course directory not found: ${i}`),p;let m=await(0,o.realpath)(i),g=s.problemsDirectoryPath===void 0?void 0:await u(s.problemsDirectoryPath),_=(0,a.basename)(m);e.LEARNING_MATERIAL_ID_REGEX.test(_)||c.push(`course ID "${_}" (directory name) must match ${e.LEARNING_MATERIAL_ID_REGEX}`);let y=await f(m,c),b=m,x=await h(m,s.problemsDirectoryPath,g,c);if(!y)return p;let{courseFile:S,courseFileName:C}=y,w=[];r.validateSubmissionPeriods(S,w),c.push(...w.map(e=>`${C}: ${e}`));let T=S.lectures??[];T.length===0&&l.push(`${C} declares no lectures, so no materials are imported`),n.reportDuplicateIds(T.map(e=>e.id),`lecture`,c);for(let e of T)await v(m,{courseId:_,courseSubmissionPeriods:S,problemsDirectoryPath:b,availableProblemIds:x},e.id,c,l);return await d(m,S,C,l),p}async function u(e){let n=(0,a.resolve)(e);if(await t.isRegularDirectory(n))return(0,o.realpath)(n);try{return(0,a.join)(await(0,o.realpath)((0,a.dirname)(n)),(0,a.basename)(n))}catch{return n}}async function d(e,t,n,r){let i=new Set((t.lectures??[]).map(e=>e.id)),s=await(0,o.readdir)(e,{withFileTypes:!0});for(let t of s)!t.isDirectory()||t.name.startsWith(`.`)||i.has(t.name)||t.name===`problems`||(await(0,o.readdir)((0,a.join)(e,t.name),{withFileTypes:!0})).some(e=>e.isFile()&&m(e.name))&&r.push(`directory "${t.name}" is not listed as a lecture in ${n}, so its materials are not imported`)}async function f(r,i){let l=[`course.yaml`,`course.yml`],u=[];for(let e of l)await t.isRegularFile((0,a.join)(r,e))&&u.push(e);if(u.length>1){i.push(`both course.yaml and course.yml exist; the judge rejects the conflicting course files`);return}let d=u[0];if(d===void 0){let e=await p(l,e=>t.isFile((0,a.join)(r,e)));i.push(e===void 0?`course.yaml not found`:`${e} is a symbolic link, which the judge ignores; commit a regular file`);return}let f=(0,a.join)(r,d),m;try{m=(0,c.parse)(await(0,o.readFile)(f,`utf8`))}catch(e){i.push(`${d}: invalid YAML: ${(0,s.getErrorMessage)(e)}`);return}let h=e.courseFileSchema.safeParse(m);if(!h.success)i.push(...n.formatZodIssues(h.error,d));else return{courseFile:h.data,courseFileName:d}}async function p(e,t){for(let n of e)if(await t(n))return n}function m(e){return e.endsWith(`.contest.yaml`)||e.endsWith(`.md`)&&e!==`problem.md`&&!e.endsWith(`.problem.md`)}async function h(e,n,r,i){let o=await t.collectProblemDefinitions(e);t.reportProblemDefinitionIssues(o,i);let s=new Set(o.keys()),c=(0,a.join)(e,`problems`),l=r!==void 0&&await g(r,c);return(l||await t.isDirectory(c))&&await t.isUsableProblemsDirectory(c,i),r===void 0||l||await t.isUsableProblemsDirectory(r,i)&&_(e,r)&&i.push(`problems directory ${n} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`),s}async function g(e,t){if(e===t)return!0;try{let[n,r]=await Promise.all([(0,o.lstat)(e),(0,o.lstat)(t)]);return n.dev===r.dev&&n.ino===r.ino}catch{return!1}}function _(e,t){let n=(0,a.relative)(e,t);return n===`..`||n.startsWith(`..${a.sep}`)}async function v(s,c,l,u,d){let f=(0,a.join)(s,l);if(!await t.isRegularDirectory(f)){u.push(await t.isDirectory(f)?`lecture "${l}": directory is a symbolic link, which the judge does not traverse`:`lecture "${l}": directory not found`);return}let p=[],m=await(0,o.readdir)(f,{withFileTypes:!0});for(let e of m)e.isSymbolicLink()&&u.push(`${l}/${e.name} is a symbolic link, which the judge ignores; commit a regular file`);let h=m.filter(e=>e.isFile()).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let t of h){let n=(0,a.join)(f,t),o;if(t.endsWith(`.contest.yaml`))p.push(t.slice(0,-e.CONTEST_MATERIAL_FILE_SUFFIX.length)),o=await i.validateContestFile(n,{...c,isNestedInCourse:!0});else if(t.endsWith(`.md`))p.push(t.replace(/\.[^/.]+$/,``)),o=await r.validateMaterialFile(n,c);else continue;u.push(...o.errors.map(e=>`${l}/${t}: ${e}`)),d.push(...o.warnings.map(e=>`${l}/${t}: ${e}`))}p.length===0&&d.push(`lecture "${l}": no material files (*.md or *${e.CONTEST_MATERIAL_FILE_SUFFIX}) found`);let g=[];n.reportDuplicateIds(p,`material`,g),u.push(...g.map(e=>`lecture "${l}": ${e}`))}exports.validateCourseDirectory=l;
2
2
  //# sourceMappingURL=validateCourse.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"validateCourse.cjs","names":["isRegularDirectory","resolve","isDirectory","realpath","basename","LEARNING_MATERIAL_ID_REGEX","join","dirname","readdir","isRegularFile","isFile","parseYaml","readFile","getErrorMessage","courseFileSchema","formatZodIssues","collectProblemDefinitions","isUsableProblemsDirectory","lstat","relative","sep","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","validateMaterialFile","require_schemas","require_fsHelpers","require_validationResult","require_validateMaterial","require_validateContest","node_path","node_fs_promises","_willbooster_shared_lib","yaml","validateCourseDirectory","errors","warnings","result","courseDirectoryPath","absoluteDirectoryPath","explicitProblemsDirectoryPath","options","canonicalize","courseId","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","courseFile","courseFileName","error","lectures","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","lectureIds","dirents","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","symlinkFileName","findFirst","courseFilePath","rawContent","parsed","item","items","predicate","definitionPathsById","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","relativePath","parentPath","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","materialOptions","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateCourse.cjs","names":["isRegularDirectory","resolve","isDirectory","realpath","basename","LEARNING_MATERIAL_ID_REGEX","join","dirname","readdir","isRegularFile","isFile","parseYaml","readFile","getErrorMessage","courseFileSchema","formatZodIssues","collectProblemDefinitions","isUsableProblemsDirectory","lstat","relative","sep","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","validateMaterialFile","require_schemas","require_fsHelpers","require_validationResult","require_validateMaterial","require_validateContest","node_path","node_fs_promises","_willbooster_shared_lib","yaml","validateCourseDirectory","errors","warnings","result","courseDirectoryPath","absoluteDirectoryPath","explicitProblemsDirectoryPath","options","canonicalize","courseId","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","courseFile","courseFileName","error","lectures","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","lectureIds","dirents","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","symlinkFileName","findFirst","courseFilePath","rawContent","parsed","item","items","predicate","definitionPathsById","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","relativePath","parentPath","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","materialOptions","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{CONTEST_MATERIAL_FILE_SUFFIX as e,LEARNING_MATERIAL_ID_REGEX as t,courseFileSchema as n}from"./schemas.js";import{collectProblemDefinitions as r,isDirectory as i,isFile as a,isRegularDirectory as o,isRegularFile as s,isUsableProblemsDirectory as c,reportProblemDefinitionIssues as l}from"./fsHelpers.js";import{formatZodIssues as u,reportDuplicateIds as d}from"./validationResult.js";import{validateMaterialFile as f,validateSubmissionPeriods as p}from"./validateMaterial.js";import{validateContestFile as m}from"./validateContest.js";import{basename as h,dirname as g,join as _,relative as v,resolve as y,sep as b}from"node:path";import{lstat as x,readFile as S,readdir as C,realpath as w}from"node:fs/promises";import{getErrorMessage as T}from"@willbooster/shared-lib";import{parse as E}from"yaml";async function D(e,n={}){let r=[],a=[],s={errors:r,warnings:a};if(!await o(y(e)))return r.push(await i(y(e))?`course directory is a symbolic link, which the judge does not traverse: ${e}`:`course directory not found: ${e}`),s;let c=await w(e),l=n.problemsDirectoryPath===void 0?void 0:await O(n.problemsDirectoryPath),u=h(c);t.test(u)||r.push(`course ID "${u}" (directory name) must match ${t}`);let f=await A(c,r),m=c,g=await N(c,n.problemsDirectoryPath,l,r);if(!f)return s;let{courseFile:_,courseFileName:v}=f,b=[];p(_,b),r.push(...b.map(e=>`${v}: ${e}`));let x=_.lectures??[];x.length===0&&a.push(`${v} declares no lectures, so no materials are imported`),d(x.map(e=>e.id),`lecture`,r);for(let e of x)await I(c,{courseId:u,courseSubmissionPeriods:_,problemsDirectoryPath:m,availableProblemIds:g},e.id,r,a);return await k(c,_,v,a),s}async function O(e){let t=y(e);if(await o(t))return w(t);try{return _(await w(g(t)),h(t))}catch{return t}}async function k(e,t,n,r){let i=new Set((t.lectures??[]).map(e=>e.id)),a=await C(e,{withFileTypes:!0});for(let t of a)!t.isDirectory()||t.name.startsWith(`.`)||i.has(t.name)||t.name===`problems`||(await C(_(e,t.name),{withFileTypes:!0})).some(e=>e.isFile()&&M(e.name))&&r.push(`directory "${t.name}" is not listed as a lecture in ${n}, so its materials are not imported`)}async function A(e,t){let r=[`course.yaml`,`course.yml`],i=[];for(let t of r)await s(_(e,t))&&i.push(t);if(i.length>1){t.push(`both course.yaml and course.yml exist; the judge rejects the conflicting course files`);return}let o=i[0];if(o===void 0){let n=await j(r,t=>a(_(e,t)));t.push(n===void 0?`course.yaml not found`:`${n} is a symbolic link, which the judge ignores; commit a regular file`);return}let c=_(e,o),l;try{l=E(await S(c,`utf8`))}catch(e){t.push(`${o}: invalid YAML: ${T(e)}`);return}let d=n.safeParse(l);if(!d.success){t.push(...u(d.error,o));return}return{courseFile:d.data,courseFileName:o}}async function j(e,t){for(let n of e)if(await t(n))return n}function M(e){return e.endsWith(`.contest.yaml`)||e.endsWith(`.md`)&&e!==`problem.md`&&!e.endsWith(`.problem.md`)}async function N(e,t,n,a){let o=await r(e);l(o,a);let s=new Set(o.keys()),u=_(e,`problems`),d=n!==void 0&&await P(n,u);return(d||await i(u))&&await c(u,a),n===void 0||d||await c(n,a)&&F(e,n)&&a.push(`problems directory ${t} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`),s}async function P(e,t){if(e===t)return!0;try{let[n,r]=await Promise.all([x(e),x(t)]);return n.dev===r.dev&&n.ino===r.ino}catch{return!1}}function F(e,t){let n=v(e,t);return n===`..`||n.startsWith(`..${b}`)}async function I(t,n,r,a,s){let c=_(t,r);if(!await o(c)){a.push(await i(c)?`lecture "${r}": directory is a symbolic link, which the judge does not traverse`:`lecture "${r}": directory not found`);return}let l=[],u=await C(c,{withFileTypes:!0});for(let e of u)e.isSymbolicLink()&&a.push(`${r}/${e.name} is a symbolic link, which the judge ignores; commit a regular file`);let p=u.filter(e=>e.isFile()).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let t of p){let i=_(c,t),o;if(t.endsWith(`.contest.yaml`))l.push(t.slice(0,-e.length)),o=await m(i,{...n,isNestedInCourse:!0});else if(t.endsWith(`.md`))l.push(t.replace(/\.[^/.]+$/,``)),o=await f(i,n);else continue;a.push(...o.errors.map(e=>`${r}/${t}: ${e}`)),s.push(...o.warnings.map(e=>`${r}/${t}: ${e}`))}l.length===0&&s.push(`lecture "${r}": no material files (*.md or *${e}) found`);let h=[];d(l,`material`,h),a.push(...h.map(e=>`lecture "${r}": ${e}`))}export{D as validateCourseDirectory};
1
+ import{CONTEST_MATERIAL_FILE_SUFFIX as e,LEARNING_MATERIAL_ID_REGEX as t,courseFileSchema as n}from"./schemas.js";import{collectProblemDefinitions as r,isDirectory as i,isFile as a,isRegularDirectory as o,isRegularFile as s,isUsableProblemsDirectory as c,reportProblemDefinitionIssues as l}from"./fsHelpers.js";import{formatZodIssues as u,reportDuplicateIds as d}from"./validationResult.js";import{validateMaterialFile as f,validateSubmissionPeriods as p}from"./validateMaterial.js";import{validateContestFile as m}from"./validateContest.js";import{basename as h,dirname as g,join as _,relative as v,resolve as y,sep as b}from"node:path";import{lstat as x,readFile as S,readdir as C,realpath as w}from"node:fs/promises";import{getErrorMessage as T}from"@willbooster/shared-lib";import{parse as E}from"yaml";async function D(e,n={}){let r=[],a=[],s={errors:r,warnings:a};if(!await o(y(e)))return r.push(await i(y(e))?`course directory is a symbolic link, which the judge does not traverse: ${e}`:`course directory not found: ${e}`),s;let c=await w(e),l=n.problemsDirectoryPath===void 0?void 0:await O(n.problemsDirectoryPath),u=h(c);t.test(u)||r.push(`course ID "${u}" (directory name) must match ${t}`);let f=await A(c,r),m=c,g=await N(c,n.problemsDirectoryPath,l,r);if(!f)return s;let{courseFile:_,courseFileName:v}=f,b=[];p(_,b),r.push(...b.map(e=>`${v}: ${e}`));let x=_.lectures??[];x.length===0&&a.push(`${v} declares no lectures, so no materials are imported`),d(x.map(e=>e.id),`lecture`,r);for(let e of x)await I(c,{courseId:u,courseSubmissionPeriods:_,problemsDirectoryPath:m,availableProblemIds:g},e.id,r,a);return await k(c,_,v,a),s}async function O(e){let t=y(e);if(await o(t))return w(t);try{return _(await w(g(t)),h(t))}catch{return t}}async function k(e,t,n,r){let i=new Set((t.lectures??[]).map(e=>e.id)),a=await C(e,{withFileTypes:!0});for(let t of a)!t.isDirectory()||t.name.startsWith(`.`)||i.has(t.name)||t.name===`problems`||(await C(_(e,t.name),{withFileTypes:!0})).some(e=>e.isFile()&&M(e.name))&&r.push(`directory "${t.name}" is not listed as a lecture in ${n}, so its materials are not imported`)}async function A(e,t){let r=[`course.yaml`,`course.yml`],i=[];for(let t of r)await s(_(e,t))&&i.push(t);if(i.length>1){t.push(`both course.yaml and course.yml exist; the judge rejects the conflicting course files`);return}let o=i[0];if(o===void 0){let n=await j(r,t=>a(_(e,t)));t.push(n===void 0?`course.yaml not found`:`${n} is a symbolic link, which the judge ignores; commit a regular file`);return}let c=_(e,o),l;try{l=E(await S(c,`utf8`))}catch(e){t.push(`${o}: invalid YAML: ${T(e)}`);return}let d=n.safeParse(l);if(!d.success)t.push(...u(d.error,o));else return{courseFile:d.data,courseFileName:o}}async function j(e,t){for(let n of e)if(await t(n))return n}function M(e){return e.endsWith(`.contest.yaml`)||e.endsWith(`.md`)&&e!==`problem.md`&&!e.endsWith(`.problem.md`)}async function N(e,t,n,a){let o=await r(e);l(o,a);let s=new Set(o.keys()),u=_(e,`problems`),d=n!==void 0&&await P(n,u);return(d||await i(u))&&await c(u,a),n===void 0||d||await c(n,a)&&F(e,n)&&a.push(`problems directory ${t} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`),s}async function P(e,t){if(e===t)return!0;try{let[n,r]=await Promise.all([x(e),x(t)]);return n.dev===r.dev&&n.ino===r.ino}catch{return!1}}function F(e,t){let n=v(e,t);return n===`..`||n.startsWith(`..${b}`)}async function I(t,n,r,a,s){let c=_(t,r);if(!await o(c)){a.push(await i(c)?`lecture "${r}": directory is a symbolic link, which the judge does not traverse`:`lecture "${r}": directory not found`);return}let l=[],u=await C(c,{withFileTypes:!0});for(let e of u)e.isSymbolicLink()&&a.push(`${r}/${e.name} is a symbolic link, which the judge ignores; commit a regular file`);let p=u.filter(e=>e.isFile()).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let t of p){let i=_(c,t),o;if(t.endsWith(`.contest.yaml`))l.push(t.slice(0,-e.length)),o=await m(i,{...n,isNestedInCourse:!0});else if(t.endsWith(`.md`))l.push(t.replace(/\.[^/.]+$/,``)),o=await f(i,n);else continue;a.push(...o.errors.map(e=>`${r}/${t}: ${e}`)),s.push(...o.warnings.map(e=>`${r}/${t}: ${e}`))}l.length===0&&s.push(`lecture "${r}": no material files (*.md or *${e}) found`);let h=[];d(l,`material`,h),a.push(...h.map(e=>`lecture "${r}": ${e}`))}export{D as validateCourseDirectory};
2
2
  //# sourceMappingURL=validateCourse.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"validateCourse.js","names":["parseYaml","validateCourseDirectory","errors","warnings","result","isRegularDirectory","resolve","courseDirectoryPath","isDirectory","absoluteDirectoryPath","realpath","explicitProblemsDirectoryPath","options","canonicalize","courseId","basename","LEARNING_MATERIAL_ID_REGEX","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","validateSubmissionPeriods","courseFile","courseFileName","error","lectures","reportDuplicateIds","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","join","dirname","lectureIds","dirents","readdir","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","isRegularFile","symlinkFileName","findFirst","isFile","courseFilePath","rawContent","parse","readFile","getErrorMessage","parsed","courseFileSchema","formatZodIssues","item","items","predicate","definitionPathsById","collectProblemDefinitions","reportProblemDefinitionIssues","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isUsableProblemsDirectory","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","lstat","relativePath","relative","parentPath","sep","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","materialOptions","validateMaterialFile","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateCourse.js","names":["parseYaml","validateCourseDirectory","errors","warnings","result","isRegularDirectory","resolve","courseDirectoryPath","isDirectory","absoluteDirectoryPath","realpath","explicitProblemsDirectoryPath","options","canonicalize","courseId","basename","LEARNING_MATERIAL_ID_REGEX","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","validateSubmissionPeriods","courseFile","courseFileName","error","lectures","reportDuplicateIds","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","join","dirname","lectureIds","dirents","readdir","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","isRegularFile","symlinkFileName","findFirst","isFile","courseFilePath","rawContent","parse","readFile","getErrorMessage","parsed","courseFileSchema","formatZodIssues","item","items","predicate","definitionPathsById","collectProblemDefinitions","reportProblemDefinitionIssues","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isUsableProblemsDirectory","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","lstat","relativePath","relative","parentPath","sep","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","materialOptions","validateMaterialFile","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("../helpers/compareExpectedOutputFiles.cjs"),t=require("../helpers/defaultStdioHarness.cjs"),n=require("../helpers/removeCommentsInSourceCode.cjs"),r=require("../types/problem.cjs"),i=require("./schemas.cjs"),a=require("./fsHelpers.cjs"),o=require("./frontmatter.cjs"),s=require("./validationResult.cjs"),c=require("./validateMaterial.cjs"),l=require("../helpers/findLanguageDefinitionByPath.cjs"),u=require("../helpers/readProblemMarkdownFrontMatter.cjs"),d=require("../helpers/stdioJudgeRules.cjs");let f=require("node:path"),p=require("node:fs/promises"),m=require("@willbooster/shared-lib");const h=[`Scanner`,`map`,`int`,`split`],g=[`a, b = map(int, input().split())`,`Scanner scanner = new Scanner(System.in);`,`const [a, b] = line.split(' ').map(Number);`],_=`_shared`,v=/* @__PURE__ */ new Set([`.DS_Store`,`__pycache__`]);async function y(e){let t=[],n=[],r={errors:t,warnings:n},o=(0,f.resolve)(e);if(!await a.isRegularDirectory(o))return t.push(await a.isDirectory(o)?`problem directory is a symbolic link, which the judge does not traverse: ${e}`:`problem directory not found: ${e}`),r;let s=(0,f.basename)(o);i.LEARNING_MATERIAL_ID_REGEX.test(s)||t.push(`problem ID "${s}" (directory name) must match ${i.LEARNING_MATERIAL_ID_REGEX}`);for(let e of await w(o))t.push(`${(0,f.relative)(o,e)} is a symbolic link, which the judge ignores; commit a real file or directory`);let c=await(0,p.readdir)(o,{withFileTypes:!0});for(let e of c.toSorted((e,t)=>e.name.localeCompare(t.name)))e.isFile()&&e.name.endsWith(`.problem.md`)&&t.push(`found v1 problem file ${e.name}; use the v2 layout with a file named exactly problem.md`);let d=(0,f.join)(o,`problem.md`);if(!await a.isRegularFile(d))return await a.isFile(d)||t.push(`problem.md not found; a v2 problem directory must contain problem.md`),r;let m=await S(d,t),h=await b(o),g=m===void 0||!u.judgesTestCasesWithoutExpectations(m);D(m,await C(o,{hasCustomJudgeTs:h.hasCustomJudgeTs,requiresExpectedOutput:g},t,n),h.hasCustomJudgeTs,t,n);let _=m!==void 0&&m.isManualScoringRequired!==!0&&m.type!==`prompt_study`;x(h,m!==void 0,t,n);let v=await k(o,t,n),y=v.filter(e=>{if(h.hasCustomJudgeTs)return!0;let n=e.files.some(e=>l.findLanguageDefinitionByPath(e.path)!==void 0&&e.data.trim().length>0);return n||t.push(`model answer directory "${e.id}" has no non-empty source file of a supported language, which the stdio judge needs as its entry point`),n});return _&&v.length===0&&t.push(`no model answers found; add at least one model_answers/<languageId>/ directory with non-empty files`),m&&(A(m,y,t,n),j(m,y,t)),await P(o,y,h.hasCustomJudgeTs,t,n),r}async function b(e){let n=await a.isFile((0,f.join)(e,`judge.ts`)),r=await t.findDefaultStdioHarnessFiles(e);return{hasDebugTs:await a.isFile((0,f.join)(e,`debug.ts`)),rejectedDefaultHarnessFileNames:r,hasCustomJudgeTs:n&&!r.includes(`judge.ts`)}}function x(e,t,n,r){e.rejectedDefaultHarnessFileNames.includes(`judge.ts`)&&n.push(`judge.ts only calls stdioJudgePreset; delete it — the judge auto-generates it, and only a problem without judge.ts is treated as a standard stdio problem (which enables the default debug runner)`),e.rejectedDefaultHarnessFileNames.includes(`debug.ts`)&&n.push(`debug.ts only calls stdioDebugPreset; delete it — the judge auto-generates it for problems without judge.ts`),t&&e.hasCustomJudgeTs&&!e.hasDebugTs&&r.push(`custom judge.ts without debug.ts; the debug feature is unavailable for this problem — add debug.ts (e.g. calling stdioDebugPreset from '@exercode/problem-utils/presets/stdio') or a custom debug harness`)}async function S(e,t){let n;try{let r;({attributes:n,body:r}=o.parseFrontmatter(await(0,p.readFile)(e,`utf8`))),c.reportExcessChatMarkers(r,t)}catch(e){t.push(`problem.md: invalid YAML frontmatter: ${(0,m.getErrorMessage)(e)}`);return}let r=i.problemFrontmatterSchema.safeParse(n);if(!r.success){t.push(...s.formatZodIssues(r.error,`problem.md frontmatter`));return}return r.data}async function C(t,{hasCustomJudgeTs:n,requiresExpectedOutput:r},i,o){let s=(0,f.join)(t,`test_cases`);if(!await a.isDirectory(s))return[];let c=await(0,p.readdir)(s,{withFileTypes:!0}),l=/* @__PURE__ */ new Map,u=e=>{let t=l.get(e);return t||(t={id:e,hasFileInput:!1,hasFileOutput:!1,hasJudgeConfig:!1},l.set(e,t)),t};for(let t of c.toSorted((e,t)=>e.name.localeCompare(t.name))){if(t.isSymbolicLink())continue;if(t.name===_||t.name.startsWith(`${_}.`)){t.name===`${_}.fin`?t.isDirectory()?(await T((0,f.join)(s,t.name))).length===0&&i.push(`test_cases/${t.name}/ is empty; put at least one shared input file in it`):i.push(`test_cases/${t.name} must be a directory`):i.push(`test_cases/${t.name} is not supported; only _shared.fin/ is shared across test cases`);continue}let r=t.name.replace(/\.(fin|fout)$/,``);if(r===``){i.push(`test_cases/${t.name} must have a test case ID before its extension`);continue}if(r===t.name){t.isDirectory()&&/\.(in|out|json)$/.test(t.name)&&i.push(`test_cases/${t.name} must be a file, not a directory`);continue}if(!t.isDirectory()){i.push(`test case ${r}: test_cases/${t.name} must be a directory`);continue}let a=await T((0,f.join)(s,t.name));if(a.length===0){i.push(`test case ${r}: ${t.name}/ is empty; put at least one file in it or delete it`);continue}let c=u(r);if(t.name.endsWith(`.fin`)){c.hasFileInput=!0;continue}c.hasFileOutput=!0;for(let e of a)(0,f.basename)(e)===`.gitkeep`&&o.push(`test case ${r}: ${t.name}/${(0,f.relative)((0,f.join)(s,t.name),e)} is compared as an expected output file; delete the placeholder`);if(!n)for(let n of a)(await(0,p.stat)(n)).size>8388608&&i.push(`test case ${r}: ${t.name}/${(0,f.relative)((0,f.join)(s,t.name),n)} is larger than ${e.MAX_COMPARED_FILE_BYTES} bytes, which the stdio judge cannot compare`)}let m=c.filter(e=>e.isFile()&&/\.(in|out|json)$/.test(e.name)&&!e.name.startsWith(`${_}.`)).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let e of m){let t=e.replace(/\.(in|out|json)$/,``);if(t===``){i.push(`test_cases/${e} must have a test case ID before its extension`);continue}let r=u(t),a=await(0,p.readFile)((0,f.join)(s,e),`utf8`);if(e.endsWith(`.in`))r.stdin=a.trimEnd();else if(e.endsWith(`.out`))r.stdout=a.trimEnd();else{r.hasJudgeConfig=!0,n||i.push(`test case ${t}: ${e} is configuration for a custom judge.ts, which this problem does not have`);try{JSON.parse(a)}catch{i.push(`test case ${t}: ${e} is not valid JSON`)}}}let h=[...l.values()].toSorted((e,t)=>e.id.localeCompare(t.id)),g=h.some(E);for(let e of h){if(!E(e)&&!e.hasJudgeConfig){if(n)g&&o.push(`test case ${e.id}: ${e.id}.out (or ${e.id}.fout/) is missing while other cases have one`);else{if(!r)continue;i.push(`test case ${e.id}: ${e.id}.out (or ${e.id}.fout/) is missing; a problem without judge.ts needs an expected output for every test case`)}}if(e.stdout!==void 0){let t=n?1e5:d.MAX_STDOUT_LENGTH;e.stdout.length>t&&i.push(n?`test case ${e.id}: .out is too large (length: ${e.stdout.length} > ${t})`:`test case ${e.id}: .out is too large (length: ${e.stdout.length}); the stdio judge rejects a run that prints more than ${t} characters`)}e.stdout!==void 0&&e.stdout.length===0&&o.push(`test case ${e.id}: .out is empty, so only a program that prints nothing (or an empty line) is accepted; delete .out when stdout is not compared`)}let v=/* @__PURE__ */ new Map;for(let e of h){if(!e.stdin||e.hasFileInput)continue;let t=v.get(e.stdin);t===void 0?v.set(e.stdin,e.id):o.push(`test cases ${t} and ${e.id} have identical input`)}return h}async function w(e){let t=[];for(let n of await(0,p.readdir)(e,{withFileTypes:!0})){let r=(0,f.join)(e,n.name);n.isSymbolicLink()?t.push(r):n.isDirectory()&&n.name!==`node_modules`&&t.push(...await w(r))}return t.toSorted((e,t)=>e.localeCompare(t))}async function T(e){let t=[];for(let n of await(0,p.readdir)(e,{withFileTypes:!0})){if(v.has(n.name))continue;let r=(0,f.join)(e,n.name);n.isFile()?t.push(r):n.isDirectory()&&t.push(...await T(r))}return t}function E(e){return e.stdout!==void 0||e.hasFileOutput}function D(e,t,n,r,i){t.length===0&&e?.isManualScoringRequired!==!0&&(n?i.push(`no test cases found; fine for a special judge, but stdio-preset judges require an expected output (test_cases/<id>.out or <id>.fout/) for every case`):r.push(`no test cases found; add test cases with an expected output (test_cases/<id>.out or <id>.fout/, plus <id>.in or <id>.fin/ when the program reads input; at least one example_* and one hidden test_*), or set isManualScoringRequired: true`)),t.length>0&&O(t,r,i)}function O(e,t,n){e.some(e=>d.EXAMPLE_TEST_CASE_ID_PATTERN.test(e.id))||t.push(`no example test case found; add at least one test case whose ID contains "example" (e.g. example_1)`),e.every(e=>d.EXAMPLE_TEST_CASE_ID_PATTERN.test(e.id))&&t.push(`no hidden test case found; add at least one test case whose ID does not contain "example" (e.g. test_1)`),e.length<4&&n.push(`only ${e.length} test cases found; 4 or more are recommended`)}async function k(e,t,n){let r=(0,f.join)(e,`model_answers`);if(!await a.isDirectory(r))return[];let o=[],s=await(0,p.readdir)(r,{withFileTypes:!0});for(let e of s.toSorted((e,t)=>e.name.localeCompare(t.name))){if(!e.isDirectory()||e.name===`__pycache__`)continue;if(!i.LEARNING_MATERIAL_ID_REGEX.test(e.name)){t.push(`model answer directory name "${e.name}" must match ${i.LEARNING_MATERIAL_ID_REGEX}`);continue}i.availableLanguageIds.includes(e.name)||n.push(`model answer directory name "${e.name}" is not a known language ID (${i.availableLanguageIds.join(`, `)})`);let s=await a.readSourceFilesRecursively((0,f.join)(r,e.name));if(s.length===0){t.push(`model answer directory "${e.name}" has no source files; add files or delete it`);continue}o.push({id:e.name,files:s})}return o}function A(e,t,n,i){let a=N(e.requiredRegExpsInCode,`requiredRegExpsInCode`,n),o=N(e.forbiddenRegExpsInCode,`forbiddenRegExpsInCode`,n),s=e.forbiddenTextsInCode.map(e=>r.normalizeCodeRule(e).pattern);for(let[e,t]of o)g.some(e=>t.test(e))&&i.push(`forbiddenRegExpsInCode pattern "${e}" matches typical stdin-parsing code; patterns must not restrict parsing helpers (${h.join(`, `)})`);for(let e of s)g.some(t=>t.includes(e))&&i.push(`forbiddenTextsInCode text "${e}" appears in typical stdin-parsing code; texts must not restrict parsing helpers (${h.join(`, `)})`);for(let e of t){let t=M(e.files);for(let[r,i]of a)t.some(e=>i.test(e.data))||n.push(`required pattern "${r}" does not match any file of model answer "${e.id}" (comments are ignored)`);for(let[r,i]of o)for(let a of t){let t=a.data.match(i);t&&n.push(`forbidden pattern "${r}" matches model answer file ${e.id}/${a.path} (matched "${t[0]}")`)}for(let r of s)for(let i of t)i.data.includes(r)&&n.push(`forbidden text "${r}" appears in model answer file ${e.id}/${i.path}`)}}function j(e,t,n){for(let r of t){let t=new Set(r.files.map(e=>e.path));for(let i of e.requiredSubmissionFilePaths)t.has(i)||n.push(`required submission file "${i}" is missing from model answer "${r.id}" (requiredSubmissionFilePaths)`)}}function M(e){return e.flatMap(e=>{let t=l.findLanguageDefinitionByPath(e.path);if(!t||e.data.includes(`�`))return[];let r=t.grammer;return[{path:e.path,data:r?n.removeCommentsInSourceCode(r,e.data):e.data}]})}function N(e,t,n){let i=[];for(let a of e){let{pattern:e}=r.normalizeCodeRule(a);try{i.push([e,new RegExp(e)])}catch{n.push(`invalid regular expression in ${t}: ${e}`)}}return i}async function P(e,t,n,r,o){let s=(0,f.join)(e,`templates`);if(!await a.isDirectory(s))return;let c=(await(0,p.readdir)(s,{withFileTypes:!0})).filter(e=>e.name!==`__pycache__`);c.some(e=>e.name===`.DS_Store`)&&o.push(`templates/.DS_Store found; delete it (the judge treats it as a template file)`);let u=c.some(e=>e.isFile()),d=c.some(e=>e.isDirectory());if(u&&d){r.push(`templates directory must not contain both files and directories; use templates/<languageId>/ or templates/_default/ (or plain files as the default)`);return}for(let e of c)e.isDirectory()&&e.name!==`_default`&&!i.availableLanguageIds.includes(e.name)&&r.push(`invalid template directory name "${e.name}"; expected a language ID (${i.availableLanguageIds.join(`, `)}) or _default`);let m=c.filter(e=>e.isDirectory()).map(e=>e.name),h=d?m:[``];for(let e of h){let r=await a.readSourceFilesRecursively((0,f.join)(s,e)),i=new Map(r.map(e=>[e.path,e.data]));for(let r of t){let t=r.files.filter(e=>n||e.data.trim().length>0&&l.findLanguageDefinitionByPath(e.path)!==void 0);t.length>0&&t.every(e=>i.get(e.path)===e.data)&&o.push(`templates/${e} contains every judged file of model answer "${r.id}" unchanged (identical to a model answer file); templates must not pass the judge`)}}}exports.validateProblemDirectory=y;
1
+ "use strict";const e=require("../helpers/compareExpectedOutputFiles.cjs"),t=require("../helpers/defaultStdioHarness.cjs"),n=require("../helpers/removeCommentsInSourceCode.cjs"),r=require("../types/problem.cjs"),i=require("./schemas.cjs"),a=require("./fsHelpers.cjs"),o=require("./frontmatter.cjs"),s=require("./validationResult.cjs"),c=require("./validateMaterial.cjs"),l=require("../helpers/findLanguageDefinitionByPath.cjs"),u=require("../helpers/readProblemMarkdownFrontMatter.cjs"),d=require("../helpers/stdioJudgeRules.cjs");let f=require("node:path"),p=require("node:fs/promises"),m=require("@willbooster/shared-lib");const h=[`Scanner`,`map`,`int`,`split`],g=[`a, b = map(int, input().split())`,`Scanner scanner = new Scanner(System.in);`,`const [a, b] = line.split(' ').map(Number);`],_=`_shared`,v=/* @__PURE__ */ new Set([`.DS_Store`,`__pycache__`]);async function y(e){let t=[],n=[],r={errors:t,warnings:n},o=(0,f.resolve)(e);if(!await a.isRegularDirectory(o))return t.push(await a.isDirectory(o)?`problem directory is a symbolic link, which the judge does not traverse: ${e}`:`problem directory not found: ${e}`),r;let s=(0,f.basename)(o);i.LEARNING_MATERIAL_ID_REGEX.test(s)||t.push(`problem ID "${s}" (directory name) must match ${i.LEARNING_MATERIAL_ID_REGEX}`);for(let e of await w(o))t.push(`${(0,f.relative)(o,e)} is a symbolic link, which the judge ignores; commit a real file or directory`);let c=await(0,p.readdir)(o,{withFileTypes:!0});for(let e of c.toSorted((e,t)=>e.name.localeCompare(t.name)))e.isFile()&&e.name.endsWith(`.problem.md`)&&t.push(`found v1 problem file ${e.name}; use the v2 layout with a file named exactly problem.md`);let d=(0,f.join)(o,`problem.md`);if(!await a.isRegularFile(d))return await a.isFile(d)||t.push(`problem.md not found; a v2 problem directory must contain problem.md`),r;let m=await S(d,t),h=await b(o),g=m===void 0||!u.judgesTestCasesWithoutExpectations(m);D(m,await C(o,{hasCustomJudgeTs:h.hasCustomJudgeTs,requiresExpectedOutput:g},t,n),h.hasCustomJudgeTs,t,n);let _=m!==void 0&&m.isManualScoringRequired!==!0&&m.type!==`prompt_study`;x(h,m!==void 0,t,n);let v=await k(o,t,n),y=v.filter(e=>{if(h.hasCustomJudgeTs)return!0;let n=e.files.some(e=>l.findLanguageDefinitionByPath(e.path)!==void 0&&e.data.trim().length>0);return n||t.push(`model answer directory "${e.id}" has no non-empty source file of a supported language, which the stdio judge needs as its entry point`),n});return _&&v.length===0&&t.push(`no model answers found; add at least one model_answers/<languageId>/ directory with non-empty files`),m&&(A(m,y,t,n),j(m,y,t)),await P(o,y,h.hasCustomJudgeTs,t,n),r}async function b(e){let n=await a.isFile((0,f.join)(e,`judge.ts`)),r=await t.findDefaultStdioHarnessFiles(e);return{hasDebugTs:await a.isFile((0,f.join)(e,`debug.ts`)),rejectedDefaultHarnessFileNames:r,hasCustomJudgeTs:n&&!r.includes(`judge.ts`)}}function x(e,t,n,r){e.rejectedDefaultHarnessFileNames.includes(`judge.ts`)&&n.push(`judge.ts only calls stdioJudgePreset; delete it — the judge auto-generates it, and only a problem without judge.ts is treated as a standard stdio problem (which enables the default debug runner)`),e.rejectedDefaultHarnessFileNames.includes(`debug.ts`)&&n.push(`debug.ts only calls stdioDebugPreset; delete it — the judge auto-generates it for problems without judge.ts`),t&&e.hasCustomJudgeTs&&!e.hasDebugTs&&r.push(`custom judge.ts without debug.ts; the debug feature is unavailable for this problem — add debug.ts (e.g. calling stdioDebugPreset from '@exercode/problem-utils/presets/stdio') or a custom debug harness`)}async function S(e,t){let n;try{let r;({attributes:n,body:r}=o.parseFrontmatter(await(0,p.readFile)(e,`utf8`))),c.reportExcessChatMarkers(r,t)}catch(e){t.push(`problem.md: invalid YAML frontmatter: ${(0,m.getErrorMessage)(e)}`);return}let r=i.problemFrontmatterSchema.safeParse(n);if(!r.success)t.push(...s.formatZodIssues(r.error,`problem.md frontmatter`));else return r.data}async function C(t,{hasCustomJudgeTs:n,requiresExpectedOutput:r},i,o){let s=(0,f.join)(t,`test_cases`);if(!await a.isDirectory(s))return[];let c=await(0,p.readdir)(s,{withFileTypes:!0}),l=/* @__PURE__ */ new Map,u=e=>{let t=l.get(e);return t||(t={id:e,hasFileInput:!1,hasFileOutput:!1,hasJudgeConfig:!1},l.set(e,t)),t};for(let t of c.toSorted((e,t)=>e.name.localeCompare(t.name))){if(t.isSymbolicLink())continue;if(t.name===_||t.name.startsWith(`${_}.`)){t.name===`${_}.fin`?t.isDirectory()?(await T((0,f.join)(s,t.name))).length===0&&i.push(`test_cases/${t.name}/ is empty; put at least one shared input file in it`):i.push(`test_cases/${t.name} must be a directory`):i.push(`test_cases/${t.name} is not supported; only _shared.fin/ is shared across test cases`);continue}let r=t.name.replace(/\.(fin|fout)$/,``);if(r===``){i.push(`test_cases/${t.name} must have a test case ID before its extension`);continue}if(r===t.name){t.isDirectory()&&/\.(in|out|json)$/.test(t.name)&&i.push(`test_cases/${t.name} must be a file, not a directory`);continue}if(!t.isDirectory()){i.push(`test case ${r}: test_cases/${t.name} must be a directory`);continue}let a=await T((0,f.join)(s,t.name));if(a.length===0){i.push(`test case ${r}: ${t.name}/ is empty; put at least one file in it or delete it`);continue}let c=u(r);if(t.name.endsWith(`.fin`))c.hasFileInput=!0;else{c.hasFileOutput=!0;for(let e of a)(0,f.basename)(e)===`.gitkeep`&&o.push(`test case ${r}: ${t.name}/${(0,f.relative)((0,f.join)(s,t.name),e)} is compared as an expected output file; delete the placeholder`);if(!n)for(let n of a)(await(0,p.stat)(n)).size>8388608&&i.push(`test case ${r}: ${t.name}/${(0,f.relative)((0,f.join)(s,t.name),n)} is larger than ${e.MAX_COMPARED_FILE_BYTES} bytes, which the stdio judge cannot compare`)}}let m=c.filter(e=>e.isFile()&&/\.(in|out|json)$/.test(e.name)&&!e.name.startsWith(`${_}.`)).map(e=>e.name).toSorted((e,t)=>e.localeCompare(t));for(let e of m){let t=e.replace(/\.(in|out|json)$/,``);if(t===``){i.push(`test_cases/${e} must have a test case ID before its extension`);continue}let r=u(t),a=await(0,p.readFile)((0,f.join)(s,e),`utf8`);if(e.endsWith(`.in`))r.stdin=a.trimEnd();else if(e.endsWith(`.out`))r.stdout=a.trimEnd();else{r.hasJudgeConfig=!0,n||i.push(`test case ${t}: ${e} is configuration for a custom judge.ts, which this problem does not have`);try{JSON.parse(a)}catch{i.push(`test case ${t}: ${e} is not valid JSON`)}}}let h=[...l.values()].toSorted((e,t)=>e.id.localeCompare(t.id)),g=h.some(E);for(let e of h){if(!E(e)&&!e.hasJudgeConfig){if(n)g&&o.push(`test case ${e.id}: ${e.id}.out (or ${e.id}.fout/) is missing while other cases have one`);else{if(!r)continue;i.push(`test case ${e.id}: ${e.id}.out (or ${e.id}.fout/) is missing; a problem without judge.ts needs an expected output for every test case`)}}if(e.stdout!==void 0){let t=n?1e5:d.MAX_STDOUT_LENGTH;e.stdout.length>t&&i.push(n?`test case ${e.id}: .out is too large (length: ${e.stdout.length} > ${t})`:`test case ${e.id}: .out is too large (length: ${e.stdout.length}); the stdio judge rejects a run that prints more than ${t} characters`)}e.stdout!==void 0&&e.stdout.length===0&&o.push(`test case ${e.id}: .out is empty, so only a program that prints nothing (or an empty line) is accepted; delete .out when stdout is not compared`)}let v=/* @__PURE__ */ new Map;for(let e of h){if(!e.stdin||e.hasFileInput)continue;let t=v.get(e.stdin);t===void 0?v.set(e.stdin,e.id):o.push(`test cases ${t} and ${e.id} have identical input`)}return h}async function w(e){let t=[];for(let n of await(0,p.readdir)(e,{withFileTypes:!0})){let r=(0,f.join)(e,n.name);n.isSymbolicLink()?t.push(r):n.isDirectory()&&n.name!==`node_modules`&&t.push(...await w(r))}return t.toSorted((e,t)=>e.localeCompare(t))}async function T(e){let t=[];for(let n of await(0,p.readdir)(e,{withFileTypes:!0})){if(v.has(n.name))continue;let r=(0,f.join)(e,n.name);n.isFile()?t.push(r):n.isDirectory()&&t.push(...await T(r))}return t}function E(e){return e.stdout!==void 0||e.hasFileOutput}function D(e,t,n,r,i){t.length===0&&e?.isManualScoringRequired!==!0&&(n?i.push(`no test cases found; fine for a special judge, but stdio-preset judges require an expected output (test_cases/<id>.out or <id>.fout/) for every case`):r.push(`no test cases found; add test cases with an expected output (test_cases/<id>.out or <id>.fout/, plus <id>.in or <id>.fin/ when the program reads input; at least one example_* and one hidden test_*), or set isManualScoringRequired: true`)),t.length>0&&O(t,r,i)}function O(e,t,n){e.some(e=>d.EXAMPLE_TEST_CASE_ID_PATTERN.test(e.id))||t.push(`no example test case found; add at least one test case whose ID contains "example" (e.g. example_1)`),e.every(e=>d.EXAMPLE_TEST_CASE_ID_PATTERN.test(e.id))&&t.push(`no hidden test case found; add at least one test case whose ID does not contain "example" (e.g. test_1)`),e.length<4&&n.push(`only ${e.length} test cases found; 4 or more are recommended`)}async function k(e,t,n){let r=(0,f.join)(e,`model_answers`);if(!await a.isDirectory(r))return[];let o=[],s=await(0,p.readdir)(r,{withFileTypes:!0});for(let e of s.toSorted((e,t)=>e.name.localeCompare(t.name))){if(!e.isDirectory()||e.name===`__pycache__`)continue;if(!i.LEARNING_MATERIAL_ID_REGEX.test(e.name)){t.push(`model answer directory name "${e.name}" must match ${i.LEARNING_MATERIAL_ID_REGEX}`);continue}i.availableLanguageIds.includes(e.name)||n.push(`model answer directory name "${e.name}" is not a known language ID (${i.availableLanguageIds.join(`, `)})`);let s=await a.readSourceFilesRecursively((0,f.join)(r,e.name));s.length===0?t.push(`model answer directory "${e.name}" has no source files; add files or delete it`):o.push({id:e.name,files:s})}return o}function A(e,t,n,i){let a=N(e.requiredRegExpsInCode,`requiredRegExpsInCode`,n),o=N(e.forbiddenRegExpsInCode,`forbiddenRegExpsInCode`,n),s=e.forbiddenTextsInCode.map(e=>r.normalizeCodeRule(e).pattern);for(let[e,t]of o)g.some(e=>t.test(e))&&i.push(`forbiddenRegExpsInCode pattern "${e}" matches typical stdin-parsing code; patterns must not restrict parsing helpers (${h.join(`, `)})`);for(let e of s)g.some(t=>t.includes(e))&&i.push(`forbiddenTextsInCode text "${e}" appears in typical stdin-parsing code; texts must not restrict parsing helpers (${h.join(`, `)})`);for(let e of t){let t=M(e.files);for(let[r,i]of a)t.some(e=>i.test(e.data))||n.push(`required pattern "${r}" does not match any file of model answer "${e.id}" (comments are ignored)`);for(let[r,i]of o)for(let a of t){let t=a.data.match(i);t&&n.push(`forbidden pattern "${r}" matches model answer file ${e.id}/${a.path} (matched "${t[0]}")`)}for(let r of s)for(let i of t)i.data.includes(r)&&n.push(`forbidden text "${r}" appears in model answer file ${e.id}/${i.path}`)}}function j(e,t,n){for(let r of t){let t=new Set(r.files.map(e=>e.path));for(let i of e.requiredSubmissionFilePaths)t.has(i)||n.push(`required submission file "${i}" is missing from model answer "${r.id}" (requiredSubmissionFilePaths)`)}}function M(e){return e.flatMap(e=>{let t=l.findLanguageDefinitionByPath(e.path);if(!t||e.data.includes(`�`))return[];let r=t.grammer;return[{path:e.path,data:r?n.removeCommentsInSourceCode(r,e.data):e.data}]})}function N(e,t,n){let i=[];for(let a of e){let{pattern:e}=r.normalizeCodeRule(a);try{i.push([e,new RegExp(e)])}catch{n.push(`invalid regular expression in ${t}: ${e}`)}}return i}async function P(e,t,n,r,o){let s=(0,f.join)(e,`templates`);if(!await a.isDirectory(s))return;let c=(await(0,p.readdir)(s,{withFileTypes:!0})).filter(e=>e.name!==`__pycache__`);c.some(e=>e.name===`.DS_Store`)&&o.push(`templates/.DS_Store found; delete it (the judge treats it as a template file)`);let u=c.some(e=>e.isFile()),d=c.some(e=>e.isDirectory());if(u&&d){r.push(`templates directory must not contain both files and directories; use templates/<languageId>/ or templates/_default/ (or plain files as the default)`);return}for(let e of c)e.isDirectory()&&e.name!==`_default`&&!i.availableLanguageIds.includes(e.name)&&r.push(`invalid template directory name "${e.name}"; expected a language ID (${i.availableLanguageIds.join(`, `)}) or _default`);let m=c.filter(e=>e.isDirectory()).map(e=>e.name),h=d?m:[``];for(let e of h){let r=await a.readSourceFilesRecursively((0,f.join)(s,e)),i=new Map(r.map(e=>[e.path,e.data]));for(let r of t){let t=r.files.filter(e=>n||e.data.trim().length>0&&l.findLanguageDefinitionByPath(e.path)!==void 0);t.length>0&&t.every(e=>i.get(e.path)===e.data)&&o.push(`templates/${e} contains every judged file of model answer "${r.id}" unchanged (identical to a model answer file); templates must not pass the judge`)}}}exports.validateProblemDirectory=y;
2
2
  //# sourceMappingURL=validateProblem.cjs.map