@exercode/problem-utils 4.3.0 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/checkAllProblems.cjs.map +1 -1
- package/dist/cli/checkAllProblems.js.map +1 -1
- package/dist/cli/exercodeProblem.cjs.map +1 -1
- package/dist/cli/exercodeProblem.js.map +1 -1
- package/dist/cli/runSingleHarness.cjs.map +1 -1
- package/dist/cli/runSingleHarness.js.map +1 -1
- package/dist/cli/validateLearningMaterial.cjs.map +1 -1
- package/dist/cli/validateLearningMaterial.js.map +1 -1
- package/dist/helpers/checkProblemDirIsolation.cjs.map +1 -1
- package/dist/helpers/checkProblemDirIsolation.js.map +1 -1
- package/dist/helpers/cleanWorkingDirectory.cjs.map +1 -1
- package/dist/helpers/cleanWorkingDirectory.js.map +1 -1
- package/dist/helpers/compareExpectedOutputFiles.cjs.map +1 -1
- package/dist/helpers/compareExpectedOutputFiles.js.map +1 -1
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.cjs.map +1 -1
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.js.map +1 -1
- package/dist/helpers/copyTestCaseFileInput.cjs.map +1 -1
- package/dist/helpers/copyTestCaseFileInput.js.map +1 -1
- package/dist/helpers/defaultStdioHarness.cjs.map +1 -1
- package/dist/helpers/defaultStdioHarness.js.map +1 -1
- package/dist/helpers/findEntryPointFile.cjs.map +1 -1
- package/dist/helpers/findEntryPointFile.js.map +1 -1
- package/dist/helpers/findLanguageDefinitionByPath.cjs.map +1 -1
- package/dist/helpers/findLanguageDefinitionByPath.js.map +1 -1
- package/dist/helpers/findModelAnswerDirs.cjs.map +1 -1
- package/dist/helpers/findModelAnswerDirs.js.map +1 -1
- package/dist/helpers/guiRecording.cjs.map +1 -1
- package/dist/helpers/guiRecording.js.map +1 -1
- package/dist/helpers/javaJudge.cjs.map +1 -1
- package/dist/helpers/javaJudge.js.map +1 -1
- package/dist/helpers/judgeByStaticAnalysis.cjs.map +1 -1
- package/dist/helpers/judgeByStaticAnalysis.js.map +1 -1
- package/dist/helpers/parseArgs.cjs.map +1 -1
- package/dist/helpers/parseArgs.js.map +1 -1
- package/dist/helpers/parseCsv.cjs.map +1 -1
- package/dist/helpers/parseCsv.js.map +1 -1
- package/dist/helpers/printDebugBanner.cjs.map +1 -1
- package/dist/helpers/printDebugBanner.js.map +1 -1
- package/dist/helpers/printTestCaseResult.cjs.map +1 -1
- package/dist/helpers/printTestCaseResult.js.map +1 -1
- package/dist/helpers/readOutputFiles.cjs.map +1 -1
- package/dist/helpers/readOutputFiles.js.map +1 -1
- package/dist/helpers/readProblemMarkdownFrontMatter.cjs.map +1 -1
- package/dist/helpers/readProblemMarkdownFrontMatter.js.map +1 -1
- package/dist/helpers/readTestCases.cjs.map +1 -1
- package/dist/helpers/readTestCases.js.map +1 -1
- package/dist/helpers/removeCommentsInSourceCode.cjs.map +1 -1
- package/dist/helpers/removeCommentsInSourceCode.js.map +1 -1
- package/dist/helpers/resolveCwds.cjs.map +1 -1
- package/dist/helpers/resolveCwds.js.map +1 -1
- package/dist/helpers/runCommandInTemporaryPackageManagerProject.cjs.map +1 -1
- package/dist/helpers/runCommandInTemporaryPackageManagerProject.js.map +1 -1
- package/dist/helpers/runHarnessProcess.cjs.map +1 -1
- package/dist/helpers/runHarnessProcess.js.map +1 -1
- package/dist/helpers/runIsolationCheckInDebugMode.cjs.map +1 -1
- package/dist/helpers/runIsolationCheckInDebugMode.js.map +1 -1
- package/dist/helpers/runPythonInTemporaryUvProject.cjs.map +1 -1
- package/dist/helpers/runPythonInTemporaryUvProject.js.map +1 -1
- package/dist/helpers/sourceCodeGrammars.cjs.map +1 -1
- package/dist/helpers/sourceCodeGrammars.js.map +1 -1
- package/dist/helpers/spawnWithLimits.cjs.map +1 -1
- package/dist/helpers/spawnWithLimits.js.map +1 -1
- package/dist/helpers/spawnWithTimeout.cjs.map +1 -1
- package/dist/helpers/spawnWithTimeout.js.map +1 -1
- package/dist/helpers/startHttpServer.cjs.map +1 -1
- package/dist/helpers/startHttpServer.js.map +1 -1
- package/dist/helpers/stdioJudgeRules.cjs.map +1 -1
- package/dist/helpers/stdioJudgeRules.js.map +1 -1
- package/dist/helpers/temporaryProblemDirCopy.cjs.map +1 -1
- package/dist/helpers/temporaryProblemDirCopy.js.map +1 -1
- package/dist/learningMaterial/frontmatter.cjs.map +1 -1
- package/dist/learningMaterial/frontmatter.js.map +1 -1
- package/dist/learningMaterial/fsHelpers.cjs.map +1 -1
- package/dist/learningMaterial/fsHelpers.js.map +1 -1
- package/dist/learningMaterial/schemas.cjs.map +1 -1
- package/dist/learningMaterial/schemas.js.map +1 -1
- package/dist/learningMaterial/validateContest.cjs.map +1 -1
- package/dist/learningMaterial/validateContest.js.map +1 -1
- package/dist/learningMaterial/validateCourse.cjs.map +1 -1
- package/dist/learningMaterial/validateCourse.js.map +1 -1
- package/dist/learningMaterial/validateMaterial.cjs.map +1 -1
- package/dist/learningMaterial/validateMaterial.js.map +1 -1
- package/dist/learningMaterial/validateProblem.cjs.map +1 -1
- package/dist/learningMaterial/validateProblem.js.map +1 -1
- package/dist/learningMaterial/validationResult.cjs.map +1 -1
- package/dist/learningMaterial/validationResult.js.map +1 -1
- package/dist/presets/command.cjs.map +1 -1
- package/dist/presets/command.js.map +1 -1
- package/dist/presets/evaluation.cjs.map +1 -1
- package/dist/presets/evaluation.js.map +1 -1
- package/dist/presets/guiCommand.cjs.map +1 -1
- package/dist/presets/guiCommand.js.map +1 -1
- package/dist/presets/springBoot.cjs.map +1 -1
- package/dist/presets/springBoot.js.map +1 -1
- package/dist/presets/stdio.cjs.map +1 -1
- package/dist/presets/stdio.js.map +1 -1
- package/dist/presets/tomcat.cjs.map +1 -1
- package/dist/presets/tomcat.js.map +1 -1
- package/dist/types/decisionCode.cjs.map +1 -1
- package/dist/types/decisionCode.js.map +1 -1
- package/dist/types/language.cjs.map +1 -1
- package/dist/types/language.js.map +1 -1
- package/dist/types/problem.cjs.map +1 -1
- package/dist/types/problem.js.map +1 -1
- package/dist/types/testCaseResult.cjs.map +1 -1
- package/dist/types/testCaseResult.js.map +1 -1
- package/package.json +5 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compareExpectedOutputFiles.js","names":[],"sources":["../../src/helpers/compareExpectedOutputFiles.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { compareStdoutAsSpaceSeparatedTokens } from './compareStdoutAsSpaceSeparatedTokens.js';\nimport { encodeFileForTestCaseResult } from './printTestCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\n\n// Bound what the harness reads and reports.\nexport const MAX_COMPARED_FILE_BYTES = 8 * 1024 * 1024;\nexport const MAX_REPORTED_FILE_BYTES = 1024 * 1024;\n\nexport interface ExpectedOutputFilesComparison {\n /** Whether every file under the expected directory has a matching file in the working directory. */\n matches: boolean;\n /** Relative paths (from the working directory) of the files that are missing or different. */\n mismatchedPaths: string[];\n /**\n * `<name>_expected.<ext>` / `<name>_received.<ext>` pairs for each mismatched file, in the\n * format Exercode renders side by side. A received file that is missing, unreadable, not a\n * regular file or larger than `MAX_COMPARED_FILE_BYTES` yields only the expected entry, and a\n * file larger than `MAX_REPORTED_FILE_BYTES` is left out of the pair. Exercode decides per test case whether a learner may see these files.\n */\n outputFiles: OutputFile[];\n}\n\n/**\n * Compare the files under a test case's `.fout` directory with the files the submission wrote to\n * its working directory. Text files are compared like stdout (space-separated tokens with a\n * numeric tolerance); binary files must match byte for byte.\n */\nexport async function compareExpectedOutputFiles(\n cwd: string,\n fileOutputPath: string\n): Promise<ExpectedOutputFilesComparison> {\n const mismatchedPaths: string[] = [];\n const outputFiles: OutputFile[] = [];\n const dirents = await fs.promises.readdir(fileOutputPath, { withFileTypes: true, recursive: true });\n const expectedFiles = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => ({\n absolutePath: path.join(dirent.parentPath, dirent.name),\n // POSIX separators: `requiredOutputFilePaths` and the result format use them on every platform.\n relativePath: path.posix.join(...path.relative(fileOutputPath, dirent.parentPath).split(path.sep), dirent.name),\n }))\n .toSorted((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0));\n for (const { absolutePath, relativePath } of expectedFiles) {\n // A larger expectation could never be matched by a received file, so it is an authoring error.\n const expectedStats = await fs.promises.stat(absolutePath);\n if (expectedStats.size > MAX_COMPARED_FILE_BYTES) {\n throw new Error(`expected output file ${relativePath} exceeds ${MAX_COMPARED_FILE_BYTES} bytes`);\n }\n const expected = await fs.promises.readFile(absolutePath);\n const received = await readReceivedFile(path.join(cwd, relativePath));\n if (received && fileContentsMatch(expected, received)) continue;\n\n mismatchedPaths.push(relativePath);\n // A file too large to ship in a result line is left out of the pair rather than truncated.\n if (expected.length <= MAX_REPORTED_FILE_BYTES) {\n outputFiles.push(encodeFileForTestCaseResult(toComparisonPath(relativePath, 'expected'), expected));\n }\n if (received && received.length <= MAX_REPORTED_FILE_BYTES) {\n outputFiles.push(encodeFileForTestCaseResult(toComparisonPath(relativePath, 'received'), received));\n }\n }\n return { matches: mismatchedPaths.length === 0, mismatchedPaths, outputFiles };\n}\n\n/**\n * The default verdict of the presets: `.out` decides stdout and `.fout/` decides output files, both\n * checked so a result carries every mismatch; either expectation may be absent. Returns whether the\n * run matched and the output files to report (the plain copies of mismatched required output files\n * replaced by their comparison pairs).\n */\nexport async function judgeAgainstExpectations(context: {\n stdout: string;\n expectedStdout: string | undefined;\n fileOutputPath: string | undefined;\n cwd: string;\n outputFiles: readonly OutputFile[];\n}): Promise<{ matches: boolean; outputFiles: OutputFile[] }> {\n const { stdout, expectedStdout, fileOutputPath, cwd } = context;\n let outputFiles = [...context.outputFiles];\n let matches = expectedStdout === undefined || compareStdoutAsSpaceSeparatedTokens(stdout, expectedStdout);\n if (fileOutputPath !== undefined) {\n const comparison = await compareExpectedOutputFiles(cwd, fileOutputPath);\n if (!comparison.matches) {\n matches = false;\n outputFiles = mergeComparisonOutputFiles(outputFiles, comparison);\n }\n }\n return { matches, outputFiles };\n}\n\n/**\n * Replace the plain copies of mismatched required output files with the comparison pairs, so a\n * result shows `<name>_expected.<ext>` / `<name>_received.<ext>` instead of a lone `<name>.<ext>`.\n */\nexport function mergeComparisonOutputFiles(\n outputFiles: readonly OutputFile[],\n comparison: ExpectedOutputFilesComparison\n): OutputFile[] {\n const comparisonPaths = new Set(comparison.outputFiles.map((file) => file.path));\n // The plain copy gives way to its `_received` entry; a mismatch too large for a pair keeps the\n // plain copy, and a kept file literally named like a pair entry would make the path ambiguous.\n const replacedPaths = new Set(\n comparison.mismatchedPaths.filter((filePath) => comparisonPaths.has(toComparisonPath(filePath, 'received')))\n );\n return [\n ...outputFiles.filter((file) => !replacedPaths.has(file.path) && !comparisonPaths.has(file.path)),\n ...comparison.outputFiles,\n ];\n}\n\n/** `c.txt` → `c_expected.txt`; `out/c.txt` → `out/c_expected.txt`. */\nfunction toComparisonPath(filePath: string, role: 'expected' | 'received'): string {\n const { dir, name, ext } = path.posix.parse(filePath);\n return path.posix.join(dir, `${name}_${role}${ext}`);\n}\n\n// Anything that is not a plain, readable file within the size limit (a directory, a FIFO that would\n// block, an unreadable or huge file) counts as \"not produced\".\nasync function readReceivedFile(receivedPath: string): Promise<Buffer | undefined> {\n try {\n const stats = await fs.promises.stat(receivedPath);\n if (!stats.isFile() || stats.size > MAX_COMPARED_FILE_BYTES) return undefined;\n return await fs.promises.readFile(receivedPath);\n } catch {\n return undefined;\n }\n}\n\nfunction fileContentsMatch(expected: Buffer, received: Buffer): boolean {\n if (!isTextFile(expected)) return received.equals(expected);\n if (!isTextFile(received)) return false;\n return compareStdoutAsSpaceSeparatedTokens(received.toString('utf8'), expected.toString('utf8'));\n}\n\nconst utf8Decoder = new TextDecoder('utf-8', { fatal: true });\n\n// Valid UTF-8 without NUL bytes: a binary format that happens to decode (e.g. a PNG chunk) almost\n// always contains NUL, while text files containing U+FFFD are still treated as text.\nfunction isTextFile(data: Buffer): boolean {\n if (data.includes(0)) return false;\n try {\n utf8Decoder.decode(data);\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"compareExpectedOutputFiles.js","names":["fs","path","MAX_COMPARED_FILE_BYTES","MAX_REPORTED_FILE_BYTES","compareExpectedOutputFiles","mismatchedPaths","outputFiles","expectedFiles","fileOutputPath","dirent","a","b","absolutePath","relativePath","expected","received","readReceivedFile","cwd","fileContentsMatch","encodeFileForTestCaseResult","toComparisonPath","judgeAgainstExpectations","context","matches","expectedStdout","compareStdoutAsSpaceSeparatedTokens","stdout","comparison","mergeComparisonOutputFiles","comparisonPaths","file","replacedPaths","filePath","dir","name","role","ext","stats","receivedPath","isTextFile","utf8Decoder","data"],"sources":["../../src/helpers/compareExpectedOutputFiles.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { compareStdoutAsSpaceSeparatedTokens } from './compareStdoutAsSpaceSeparatedTokens.js';\nimport { encodeFileForTestCaseResult } from './printTestCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\n\n// Bound what the harness reads and reports.\nexport const MAX_COMPARED_FILE_BYTES = 8 * 1024 * 1024;\nexport const MAX_REPORTED_FILE_BYTES = 1024 * 1024;\n\nexport interface ExpectedOutputFilesComparison {\n /** Whether every file under the expected directory has a matching file in the working directory. */\n matches: boolean;\n /** Relative paths (from the working directory) of the files that are missing or different. */\n mismatchedPaths: string[];\n /**\n * `<name>_expected.<ext>` / `<name>_received.<ext>` pairs for each mismatched file, in the\n * format Exercode renders side by side. A received file that is missing, unreadable, not a\n * regular file or larger than `MAX_COMPARED_FILE_BYTES` yields only the expected entry, and a\n * file larger than `MAX_REPORTED_FILE_BYTES` is left out of the pair. Exercode decides per test case whether a learner may see these files.\n */\n outputFiles: OutputFile[];\n}\n\n/**\n * Compare the files under a test case's `.fout` directory with the files the submission wrote to\n * its working directory. Text files are compared like stdout (space-separated tokens with a\n * numeric tolerance); binary files must match byte for byte.\n */\nexport async function compareExpectedOutputFiles(\n cwd: string,\n fileOutputPath: string\n): Promise<ExpectedOutputFilesComparison> {\n const mismatchedPaths: string[] = [];\n const outputFiles: OutputFile[] = [];\n const dirents = await fs.promises.readdir(fileOutputPath, { withFileTypes: true, recursive: true });\n const expectedFiles = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => ({\n absolutePath: path.join(dirent.parentPath, dirent.name),\n // POSIX separators: `requiredOutputFilePaths` and the result format use them on every platform.\n relativePath: path.posix.join(...path.relative(fileOutputPath, dirent.parentPath).split(path.sep), dirent.name),\n }))\n .toSorted((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0));\n for (const { absolutePath, relativePath } of expectedFiles) {\n // A larger expectation could never be matched by a received file, so it is an authoring error.\n const expectedStats = await fs.promises.stat(absolutePath);\n if (expectedStats.size > MAX_COMPARED_FILE_BYTES) {\n throw new Error(`expected output file ${relativePath} exceeds ${MAX_COMPARED_FILE_BYTES} bytes`);\n }\n const expected = await fs.promises.readFile(absolutePath);\n const received = await readReceivedFile(path.join(cwd, relativePath));\n if (received && fileContentsMatch(expected, received)) continue;\n\n mismatchedPaths.push(relativePath);\n // A file too large to ship in a result line is left out of the pair rather than truncated.\n if (expected.length <= MAX_REPORTED_FILE_BYTES) {\n outputFiles.push(encodeFileForTestCaseResult(toComparisonPath(relativePath, 'expected'), expected));\n }\n if (received && received.length <= MAX_REPORTED_FILE_BYTES) {\n outputFiles.push(encodeFileForTestCaseResult(toComparisonPath(relativePath, 'received'), received));\n }\n }\n return { matches: mismatchedPaths.length === 0, mismatchedPaths, outputFiles };\n}\n\n/**\n * The default verdict of the presets: `.out` decides stdout and `.fout/` decides output files, both\n * checked so a result carries every mismatch; either expectation may be absent. Returns whether the\n * run matched and the output files to report (the plain copies of mismatched required output files\n * replaced by their comparison pairs).\n */\nexport async function judgeAgainstExpectations(context: {\n stdout: string;\n expectedStdout: string | undefined;\n fileOutputPath: string | undefined;\n cwd: string;\n outputFiles: readonly OutputFile[];\n}): Promise<{ matches: boolean; outputFiles: OutputFile[] }> {\n const { stdout, expectedStdout, fileOutputPath, cwd } = context;\n let outputFiles = [...context.outputFiles];\n let matches = expectedStdout === undefined || compareStdoutAsSpaceSeparatedTokens(stdout, expectedStdout);\n if (fileOutputPath !== undefined) {\n const comparison = await compareExpectedOutputFiles(cwd, fileOutputPath);\n if (!comparison.matches) {\n matches = false;\n outputFiles = mergeComparisonOutputFiles(outputFiles, comparison);\n }\n }\n return { matches, outputFiles };\n}\n\n/**\n * Replace the plain copies of mismatched required output files with the comparison pairs, so a\n * result shows `<name>_expected.<ext>` / `<name>_received.<ext>` instead of a lone `<name>.<ext>`.\n */\nexport function mergeComparisonOutputFiles(\n outputFiles: readonly OutputFile[],\n comparison: ExpectedOutputFilesComparison\n): OutputFile[] {\n const comparisonPaths = new Set(comparison.outputFiles.map((file) => file.path));\n // The plain copy gives way to its `_received` entry; a mismatch too large for a pair keeps the\n // plain copy, and a kept file literally named like a pair entry would make the path ambiguous.\n const replacedPaths = new Set(\n comparison.mismatchedPaths.filter((filePath) => comparisonPaths.has(toComparisonPath(filePath, 'received')))\n );\n return [\n ...outputFiles.filter((file) => !replacedPaths.has(file.path) && !comparisonPaths.has(file.path)),\n ...comparison.outputFiles,\n ];\n}\n\n/** `c.txt` → `c_expected.txt`; `out/c.txt` → `out/c_expected.txt`. */\nfunction toComparisonPath(filePath: string, role: 'expected' | 'received'): string {\n const { dir, name, ext } = path.posix.parse(filePath);\n return path.posix.join(dir, `${name}_${role}${ext}`);\n}\n\n// Anything that is not a plain, readable file within the size limit (a directory, a FIFO that would\n// block, an unreadable or huge file) counts as \"not produced\".\nasync function readReceivedFile(receivedPath: string): Promise<Buffer | undefined> {\n try {\n const stats = await fs.promises.stat(receivedPath);\n if (!stats.isFile() || stats.size > MAX_COMPARED_FILE_BYTES) return undefined;\n return await fs.promises.readFile(receivedPath);\n } catch {\n return undefined;\n }\n}\n\nfunction fileContentsMatch(expected: Buffer, received: Buffer): boolean {\n if (!isTextFile(expected)) return received.equals(expected);\n if (!isTextFile(received)) return false;\n return compareStdoutAsSpaceSeparatedTokens(received.toString('utf8'), expected.toString('utf8'));\n}\n\nconst utf8Decoder = new TextDecoder('utf-8', { fatal: true });\n\n// Valid UTF-8 without NUL bytes: a binary format that happens to decode (e.g. a PNG chunk) almost\n// always contains NUL, while text files containing U+FFFD are still treated as text.\nfunction isTextFile(data: Buffer): boolean {\n if (data.includes(0)) return false;\n try {\n utf8Decoder.decode(data);\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":"sNAWA,MAAaE,EAA0B,QAC1BC,EAA0B,QAqBvC,eAAsBC,EACpB,EACA,EACwC,CACxC,IAAMC,EAA4B,CAAC,EAC7BC,EAA4B,CAAC,EAE7BC,GAAgB,MADAP,EAAG,SAAS,QAAQQ,EAAgB,CAAE,cAAe,GAAM,UAAW,EAAK,CAAC,EAAA,CAE/F,OAAQ,GAAWC,EAAO,OAAO,CAAC,CAAC,CACnC,IAAK,IAAY,CAChB,aAAcR,EAAK,KAAKQ,EAAO,WAAYA,EAAO,IAAI,EAEtD,aAAcR,EAAK,MAAM,KAAK,GAAGA,EAAK,SAASO,EAAgBC,EAAO,UAAU,CAAC,CAAC,MAAMR,EAAK,GAAG,EAAGQ,EAAO,IAAI,CAChH,EAAE,CAAC,CACF,UAAU,EAAG,IAAOC,EAAE,aAAeC,EAAE,aAAe,GAAK,IAAE,aAAeA,EAAE,aAAqB,EACtG,IAAK,GAAM,CAAE,eAAc,kBAAkBJ,EAAe,CAG1D,IAAI,MADwBP,EAAG,SAAS,KAAKY,CAAY,EAAA,CACvC,KAAA,QAChB,MAAU,MAAM,wBAAwBC,EAAa,WAAWX,EAAwB,OAAO,EAEjG,IAAMY,EAAW,MAAMd,EAAG,SAAS,SAASY,CAAY,EAClDG,EAAW,MAAMC,EAAiBf,EAAK,KAAKgB,EAAKJ,CAAY,CAAC,EAChEE,GAAYG,EAAkBJ,EAAUC,CAAQ,IAEpDV,EAAgB,KAAKQ,CAAY,EAE7BC,EAAS,QAAA,SACXR,EAAY,KAAKa,EAA4BC,EAAiBP,EAAc,UAAU,EAAGC,CAAQ,CAAC,EAEhGC,GAAYA,EAAS,QAAA,SACvBT,EAAY,KAAKa,EAA4BC,EAAiBP,EAAc,UAAU,EAAGE,CAAQ,CAAC,EAEtG,CACA,MAAO,CAAE,QAASV,EAAgB,SAAW,EAAG,kBAAiB,aAAY,CAC/E,CAQA,eAAsBgB,EAAyB,EAMc,CAC3D,GAAM,CAAE,SAAQ,iBAAgB,iBAAgB,OAAQC,EACpDhB,EAAc,CAAC,GAAGgB,EAAQ,WAAW,EACrCC,EAAUC,IAAmB,IAAA,IAAaC,EAAoCC,EAAQF,CAAc,EACxG,GAAIhB,IAAmB,IAAA,GAAW,CAChC,IAAMmB,EAAa,MAAMvB,EAA2Ba,EAAKT,CAAc,EAClEmB,EAAW,UACd,EAAU,GACV,EAAcC,EAA2BtB,EAAaqB,CAAU,EAEpE,CACA,MAAO,CAAE,UAAS,aAAY,CAChC,CAMA,SAAgBC,EACd,EACA,EACc,CACd,IAAMC,EAAkB,IAAI,IAAIF,EAAW,YAAY,IAAK,GAASG,EAAK,IAAI,CAAC,EAGzEC,EAAgB,IAAI,IACxBJ,EAAW,gBAAgB,OAAQ,GAAaE,EAAgB,IAAIT,EAAiBY,EAAU,UAAU,CAAC,CAAC,CAC7G,EACA,MAAO,CACL,GAAG1B,EAAY,OAAQ,GAAS,CAACyB,EAAc,IAAID,EAAK,IAAI,GAAK,CAACD,EAAgB,IAAIC,EAAK,IAAI,CAAC,EAChG,GAAGH,EAAW,WAChB,CACF,CAGA,SAASP,EAAiB,EAAkB,EAAuC,CACjF,GAAM,CAAE,MAAK,OAAM,OAAQnB,EAAK,MAAM,MAAM+B,CAAQ,EACpD,OAAO/B,EAAK,MAAM,KAAKgC,EAAK,GAAGC,EAAK,GAAGC,IAAOC,GAAK,CACrD,CAIA,eAAepB,EAAiB,EAAmD,CACjF,GAAI,CACF,IAAMqB,EAAQ,MAAMrC,EAAG,SAAS,KAAKsC,CAAY,EAEjD,MADI,CAACD,EAAM,OAAO,GAAKA,EAAM,KAAA,QAAgC,OACtD,MAAMrC,EAAG,SAAS,SAASsC,CAAY,CAChD,MAAQ,CACN,MACF,CACF,CAEA,SAASpB,EAAkB,EAAkB,EAA2B,CAGtE,OAFKqB,EAAWzB,CAAQ,EACnByB,EAAWxB,CAAQ,EACjBU,EAAoCV,EAAS,SAAS,MAAM,EAAGD,EAAS,SAAS,MAAM,CAAC,EAD7D,GADAC,EAAS,OAAOD,CAAQ,CAG5D,CAEA,MAAM0B,EAAc,IAAI,YAAY,QAAS,CAAE,MAAO,EAAK,CAAC,EAI5D,SAASD,EAAW,EAAuB,CACzC,GAAIE,EAAK,SAAS,CAAC,EAAG,MAAO,GAC7B,GAAI,CAEF,OADAD,EAAY,OAAOC,CAAI,EAChB,EACT,MAAQ,CACN,MAAO,EACT,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compareStdoutAsSpaceSeparatedTokens.cjs","names":[],"sources":["../../src/helpers/compareStdoutAsSpaceSeparatedTokens.ts"],"sourcesContent":["const ACCEPTABLE_FLOAT_ERROR = 1e-6;\n\nexport function compareStdoutAsSpaceSeparatedTokens(received: string, expected: string): boolean {\n // Consecutive white spaces (including tabs, page breaks, and line breaks) are treated as one space character.\n // An empty (or blank) text has no tokens; `''.split(/\\s+/)` would yield one empty token that\n // `Number('')` turns into 0.\n const receivedTokens = received.trim() ? received.trim().split(/\\s+/) : [];\n const expectedTokens = expected.trim() ? expected.trim().split(/\\s+/) : [];\n\n if (receivedTokens.length !== expectedTokens.length) return false;\n\n for (const [i, expectedToken] of expectedTokens.entries()) {\n const receivedToken = receivedTokens[i];\n\n // Only a finite decimal gets a tolerance; an overflowing decimal such as `1.0e309` compares exactly.\n const isDecimal = Number.isFinite(Number(expectedToken)) && expectedToken.includes('.');\n if (isDecimal) {\n const receivedNumber = Number(receivedToken);\n const expectedNumber = Number(expectedToken);\n\n if (!Number.isFinite(receivedNumber)) return false;\n\n const absoluteError = Math.abs(receivedNumber - expectedNumber);\n const relativeError = expectedNumber === 0 ? Number.POSITIVE_INFINITY : Math.abs(absoluteError / expectedNumber);\n if (absoluteError > ACCEPTABLE_FLOAT_ERROR && relativeError > ACCEPTABLE_FLOAT_ERROR) return false;\n } else {\n if (receivedToken !== expectedToken) return false;\n }\n }\n\n return true;\n}\n"],"mappings":"aAAA,
|
|
1
|
+
{"version":3,"file":"compareStdoutAsSpaceSeparatedTokens.cjs","names":["ACCEPTABLE_FLOAT_ERROR","compareStdoutAsSpaceSeparatedTokens","receivedTokens","received","expectedTokens","expected","i","expectedToken","receivedToken","receivedNumber","expectedNumber","absoluteError","relativeError"],"sources":["../../src/helpers/compareStdoutAsSpaceSeparatedTokens.ts"],"sourcesContent":["const ACCEPTABLE_FLOAT_ERROR = 1e-6;\n\nexport function compareStdoutAsSpaceSeparatedTokens(received: string, expected: string): boolean {\n // Consecutive white spaces (including tabs, page breaks, and line breaks) are treated as one space character.\n // An empty (or blank) text has no tokens; `''.split(/\\s+/)` would yield one empty token that\n // `Number('')` turns into 0.\n const receivedTokens = received.trim() ? received.trim().split(/\\s+/) : [];\n const expectedTokens = expected.trim() ? expected.trim().split(/\\s+/) : [];\n\n if (receivedTokens.length !== expectedTokens.length) return false;\n\n for (const [i, expectedToken] of expectedTokens.entries()) {\n const receivedToken = receivedTokens[i];\n\n // Only a finite decimal gets a tolerance; an overflowing decimal such as `1.0e309` compares exactly.\n const isDecimal = Number.isFinite(Number(expectedToken)) && expectedToken.includes('.');\n if (isDecimal) {\n const receivedNumber = Number(receivedToken);\n const expectedNumber = Number(expectedToken);\n\n if (!Number.isFinite(receivedNumber)) return false;\n\n const absoluteError = Math.abs(receivedNumber - expectedNumber);\n const relativeError = expectedNumber === 0 ? Number.POSITIVE_INFINITY : Math.abs(absoluteError / expectedNumber);\n if (absoluteError > ACCEPTABLE_FLOAT_ERROR && relativeError > ACCEPTABLE_FLOAT_ERROR) return false;\n } else {\n if (receivedToken !== expectedToken) return false;\n }\n }\n\n return true;\n}\n"],"mappings":"aAAA,MAAMA,EAAyB,KAE/B,SAAgBC,EAAoC,EAAkB,EAA2B,CAI/F,IAAMC,EAAiBC,EAAS,KAAK,EAAIA,EAAS,KAAK,CAAC,CAAC,MAAM,KAAK,EAAI,CAAC,EACnEC,EAAiBC,EAAS,KAAK,EAAIA,EAAS,KAAK,CAAC,CAAC,MAAM,KAAK,EAAI,CAAC,EAEzE,GAAIH,EAAe,SAAWE,EAAe,OAAQ,MAAO,GAE5D,IAAK,GAAM,CAACE,EAAGC,KAAkBH,EAAe,QAAQ,EAAG,CACzD,IAAMI,EAAgBN,EAAeI,GAIrC,GADkB,OAAO,SAAS,OAAOC,CAAa,CAAC,GAAKA,EAAc,SAAS,GAAG,EACvE,CACb,IAAME,EAAiB,OAAOD,CAAa,EACrCE,EAAiB,OAAOH,CAAa,EAE3C,GAAI,CAAC,OAAO,SAASE,CAAc,EAAG,MAAO,GAE7C,IAAME,EAAgB,KAAK,IAAIF,EAAiBC,CAAc,EACxDE,EAAgBF,IAAmB,EAAI,IAA2B,KAAK,IAAIC,EAAgBD,CAAc,EAC/G,GAAIC,EAAgBX,GAA0BY,EAAgBZ,EAAwB,MAAO,EAC/F,MACE,GAAIQ,IAAkBD,EAAe,MAAO,EAEhD,CAEA,MAAO,EACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compareStdoutAsSpaceSeparatedTokens.js","names":[],"sources":["../../src/helpers/compareStdoutAsSpaceSeparatedTokens.ts"],"sourcesContent":["const ACCEPTABLE_FLOAT_ERROR = 1e-6;\n\nexport function compareStdoutAsSpaceSeparatedTokens(received: string, expected: string): boolean {\n // Consecutive white spaces (including tabs, page breaks, and line breaks) are treated as one space character.\n // An empty (or blank) text has no tokens; `''.split(/\\s+/)` would yield one empty token that\n // `Number('')` turns into 0.\n const receivedTokens = received.trim() ? received.trim().split(/\\s+/) : [];\n const expectedTokens = expected.trim() ? expected.trim().split(/\\s+/) : [];\n\n if (receivedTokens.length !== expectedTokens.length) return false;\n\n for (const [i, expectedToken] of expectedTokens.entries()) {\n const receivedToken = receivedTokens[i];\n\n // Only a finite decimal gets a tolerance; an overflowing decimal such as `1.0e309` compares exactly.\n const isDecimal = Number.isFinite(Number(expectedToken)) && expectedToken.includes('.');\n if (isDecimal) {\n const receivedNumber = Number(receivedToken);\n const expectedNumber = Number(expectedToken);\n\n if (!Number.isFinite(receivedNumber)) return false;\n\n const absoluteError = Math.abs(receivedNumber - expectedNumber);\n const relativeError = expectedNumber === 0 ? Number.POSITIVE_INFINITY : Math.abs(absoluteError / expectedNumber);\n if (absoluteError > ACCEPTABLE_FLOAT_ERROR && relativeError > ACCEPTABLE_FLOAT_ERROR) return false;\n } else {\n if (receivedToken !== expectedToken) return false;\n }\n }\n\n return true;\n}\n"],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"compareStdoutAsSpaceSeparatedTokens.js","names":["ACCEPTABLE_FLOAT_ERROR","compareStdoutAsSpaceSeparatedTokens","receivedTokens","received","expectedTokens","expected","i","expectedToken","receivedToken","receivedNumber","expectedNumber","absoluteError","relativeError"],"sources":["../../src/helpers/compareStdoutAsSpaceSeparatedTokens.ts"],"sourcesContent":["const ACCEPTABLE_FLOAT_ERROR = 1e-6;\n\nexport function compareStdoutAsSpaceSeparatedTokens(received: string, expected: string): boolean {\n // Consecutive white spaces (including tabs, page breaks, and line breaks) are treated as one space character.\n // An empty (or blank) text has no tokens; `''.split(/\\s+/)` would yield one empty token that\n // `Number('')` turns into 0.\n const receivedTokens = received.trim() ? received.trim().split(/\\s+/) : [];\n const expectedTokens = expected.trim() ? expected.trim().split(/\\s+/) : [];\n\n if (receivedTokens.length !== expectedTokens.length) return false;\n\n for (const [i, expectedToken] of expectedTokens.entries()) {\n const receivedToken = receivedTokens[i];\n\n // Only a finite decimal gets a tolerance; an overflowing decimal such as `1.0e309` compares exactly.\n const isDecimal = Number.isFinite(Number(expectedToken)) && expectedToken.includes('.');\n if (isDecimal) {\n const receivedNumber = Number(receivedToken);\n const expectedNumber = Number(expectedToken);\n\n if (!Number.isFinite(receivedNumber)) return false;\n\n const absoluteError = Math.abs(receivedNumber - expectedNumber);\n const relativeError = expectedNumber === 0 ? Number.POSITIVE_INFINITY : Math.abs(absoluteError / expectedNumber);\n if (absoluteError > ACCEPTABLE_FLOAT_ERROR && relativeError > ACCEPTABLE_FLOAT_ERROR) return false;\n } else {\n if (receivedToken !== expectedToken) return false;\n }\n }\n\n return true;\n}\n"],"mappings":"AAAA,MAAMA,EAAyB,KAE/B,SAAgBC,EAAoC,EAAkB,EAA2B,CAI/F,IAAMC,EAAiBC,EAAS,KAAK,EAAIA,EAAS,KAAK,CAAC,CAAC,MAAM,KAAK,EAAI,CAAC,EACnEC,EAAiBC,EAAS,KAAK,EAAIA,EAAS,KAAK,CAAC,CAAC,MAAM,KAAK,EAAI,CAAC,EAEzE,GAAIH,EAAe,SAAWE,EAAe,OAAQ,MAAO,GAE5D,IAAK,GAAM,CAACE,EAAGC,KAAkBH,EAAe,QAAQ,EAAG,CACzD,IAAMI,EAAgBN,EAAeI,GAIrC,GADkB,OAAO,SAAS,OAAOC,CAAa,CAAC,GAAKA,EAAc,SAAS,GAAG,EACvE,CACb,IAAME,EAAiB,OAAOD,CAAa,EACrCE,EAAiB,OAAOH,CAAa,EAE3C,GAAI,CAAC,OAAO,SAASE,CAAc,EAAG,MAAO,GAE7C,IAAME,EAAgB,KAAK,IAAIF,EAAiBC,CAAc,EACxDE,EAAgBF,IAAmB,EAAI,IAA2B,KAAK,IAAIC,EAAgBD,CAAc,EAC/G,GAAIC,EAAgBX,GAA0BY,EAAgBZ,EAAwB,MAAO,EAC/F,MACE,GAAIQ,IAAkBD,EAAe,MAAO,EAEhD,CAEA,MAAO,EACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"copyTestCaseFileInput.cjs","names":["fs"],"sources":["../../src/helpers/copyTestCaseFileInput.ts"],"sourcesContent":["import fs from 'node:fs/promises';\n\nexport async function copyTestCaseFileInput(fileInputPath: string, cwd: string): Promise<void> {\n await fs.cp(fileInputPath, cwd, { force: true, recursive: true });\n}\n"],"mappings":"qHAEA,
|
|
1
|
+
{"version":3,"file":"copyTestCaseFileInput.cjs","names":["fs","require_runtime","node_fs_promises","copyTestCaseFileInput","fileInputPath","cwd"],"sources":["../../src/helpers/copyTestCaseFileInput.ts"],"sourcesContent":["import fs from 'node:fs/promises';\n\nexport async function copyTestCaseFileInput(fileInputPath: string, cwd: string): Promise<void> {\n await fs.cp(fileInputPath, cwd, { force: true, recursive: true });\n}\n"],"mappings":"qHAEA,eAAsBG,EAAsB,EAAuB,EAA4B,CAC7F,MAAMH,EAAAA,QAAG,GAAGI,EAAeC,EAAK,CAAE,MAAO,GAAM,UAAW,EAAK,CAAC,CAClE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"copyTestCaseFileInput.js","names":[],"sources":["../../src/helpers/copyTestCaseFileInput.ts"],"sourcesContent":["import fs from 'node:fs/promises';\n\nexport async function copyTestCaseFileInput(fileInputPath: string, cwd: string): Promise<void> {\n await fs.cp(fileInputPath, cwd, { force: true, recursive: true });\n}\n"],"mappings":"gCAEA,
|
|
1
|
+
{"version":3,"file":"copyTestCaseFileInput.js","names":["fs","copyTestCaseFileInput","fileInputPath","cwd"],"sources":["../../src/helpers/copyTestCaseFileInput.ts"],"sourcesContent":["import fs from 'node:fs/promises';\n\nexport async function copyTestCaseFileInput(fileInputPath: string, cwd: string): Promise<void> {\n await fs.cp(fileInputPath, cwd, { force: true, recursive: true });\n}\n"],"mappings":"gCAEA,eAAsBC,EAAsB,EAAuB,EAA4B,CAC7F,MAAMD,EAAG,GAAGE,EAAeC,EAAK,CAAE,MAAO,GAAM,UAAW,EAAK,CAAC,CAClE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"defaultStdioHarness.cjs","names":["path","fs"],"sources":["../../src/helpers/defaultStdioHarness.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nexport const HARNESS_FILE_PRESETS = {\n 'judge.ts': 'stdioJudgePreset',\n 'debug.ts': 'stdioDebugPreset',\n} as const;\n\nexport type HarnessFileName = keyof typeof HARNESS_FILE_PRESETS;\n\n/**\n * Find harness files (`judge.ts` / `debug.ts`) in a problem directory whose content is identical to\n * the default stdio harness. Such files must not be committed: a problem without `judge.ts` is\n * judged with `stdioJudgePreset` (and debugged with `stdioDebugPreset`) automatically, and\n * committed copies would drift from the server's defaults.\n */\nexport async function findDefaultStdioHarnessFiles(problemDir: string): Promise<HarnessFileName[]> {\n const judgeContent = await readFileOrUndefined(path.join(problemDir, 'judge.ts'));\n const foundFileNames: HarnessFileName[] = [];\n if (judgeContent !== undefined && isDefaultStdioHarnessSource(judgeContent, HARNESS_FILE_PRESETS['judge.ts'])) {\n foundFileNames.push('judge.ts');\n }\n // A problem with a custom judge.ts gets no stdioDebugPreset fallback, so it must commit its own\n // debug.ts — even one whose content matches the default stdio debug harness.\n const hasCustomJudge = judgeContent !== undefined && foundFileNames.length === 0;\n if (!hasCustomJudge) {\n const debugContent = await readFileOrUndefined(path.join(problemDir, 'debug.ts'));\n if (debugContent !== undefined && isDefaultStdioHarnessSource(debugContent, HARNESS_FILE_PRESETS['debug.ts'])) {\n foundFileNames.push('debug.ts');\n }\n }\n return foundFileNames;\n}\n\nasync function readFileOrUndefined(filePath: string): Promise<string | undefined> {\n try {\n return await fs.readFile(filePath, 'utf8');\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether the source consists only of importing the given stdio preset from this package (or its\n * legacy `judge-utils` name) and awaiting it, i.e. it is semantically identical to the default\n * harness the server generates. Comments count as a difference on purpose: a harness kept for\n * demonstration can add an explanatory comment to be treated as custom.\n */\nexport function isDefaultStdioHarnessSource(source: string, presetName: string): boolean {\n const importRegex = new RegExp(\n String.raw`^\\s*import\\s*\\{\\s*${presetName}(?:\\s+as\\s+([A-Za-z_$][\\w$]*))?\\s*,?\\s*\\}\\s*from\\s*` +\n String.raw`(['\"])(?:@exercode/problem-utils|judge-utils)/presets/stdio(?:\\.js)?\\2\\s*;?\\s*`\n );\n const importMatch = importRegex.exec(source);\n if (!importMatch) return false;\n const localPresetName = (importMatch[1] ?? presetName).replaceAll('$', String.raw`\\$`);\n const callRegex = new RegExp(String.raw`^await\\s+${localPresetName}\\s*\\(\\s*import\\.meta\\.dirname\\s*\\)\\s*;?\\s*$`);\n return callRegex.test(source.slice(importMatch[0].length));\n}\n"],"mappings":"iKAGA,
|
|
1
|
+
{"version":3,"file":"defaultStdioHarness.cjs","names":["path","fs","require_runtime","node_path","node_fs_promises","HARNESS_FILE_PRESETS","findDefaultStdioHarnessFiles","judgeContent","readFileOrUndefined","problemDir","foundFileNames","isDefaultStdioHarnessSource","debugContent","filePath","importMatch","presetName","source","localPresetName"],"sources":["../../src/helpers/defaultStdioHarness.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nexport const HARNESS_FILE_PRESETS = {\n 'judge.ts': 'stdioJudgePreset',\n 'debug.ts': 'stdioDebugPreset',\n} as const;\n\nexport type HarnessFileName = keyof typeof HARNESS_FILE_PRESETS;\n\n/**\n * Find harness files (`judge.ts` / `debug.ts`) in a problem directory whose content is identical to\n * the default stdio harness. Such files must not be committed: a problem without `judge.ts` is\n * judged with `stdioJudgePreset` (and debugged with `stdioDebugPreset`) automatically, and\n * committed copies would drift from the server's defaults.\n */\nexport async function findDefaultStdioHarnessFiles(problemDir: string): Promise<HarnessFileName[]> {\n const judgeContent = await readFileOrUndefined(path.join(problemDir, 'judge.ts'));\n const foundFileNames: HarnessFileName[] = [];\n if (judgeContent !== undefined && isDefaultStdioHarnessSource(judgeContent, HARNESS_FILE_PRESETS['judge.ts'])) {\n foundFileNames.push('judge.ts');\n }\n // A problem with a custom judge.ts gets no stdioDebugPreset fallback, so it must commit its own\n // debug.ts — even one whose content matches the default stdio debug harness.\n const hasCustomJudge = judgeContent !== undefined && foundFileNames.length === 0;\n if (!hasCustomJudge) {\n const debugContent = await readFileOrUndefined(path.join(problemDir, 'debug.ts'));\n if (debugContent !== undefined && isDefaultStdioHarnessSource(debugContent, HARNESS_FILE_PRESETS['debug.ts'])) {\n foundFileNames.push('debug.ts');\n }\n }\n return foundFileNames;\n}\n\nasync function readFileOrUndefined(filePath: string): Promise<string | undefined> {\n try {\n return await fs.readFile(filePath, 'utf8');\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether the source consists only of importing the given stdio preset from this package (or its\n * legacy `judge-utils` name) and awaiting it, i.e. it is semantically identical to the default\n * harness the server generates. Comments count as a difference on purpose: a harness kept for\n * demonstration can add an explanatory comment to be treated as custom.\n */\nexport function isDefaultStdioHarnessSource(source: string, presetName: string): boolean {\n const importRegex = new RegExp(\n String.raw`^\\s*import\\s*\\{\\s*${presetName}(?:\\s+as\\s+([A-Za-z_$][\\w$]*))?\\s*,?\\s*\\}\\s*from\\s*` +\n String.raw`(['\"])(?:@exercode/problem-utils|judge-utils)/presets/stdio(?:\\.js)?\\2\\s*;?\\s*`\n );\n const importMatch = importRegex.exec(source);\n if (!importMatch) return false;\n const localPresetName = (importMatch[1] ?? presetName).replaceAll('$', String.raw`\\$`);\n const callRegex = new RegExp(String.raw`^await\\s+${localPresetName}\\s*\\(\\s*import\\.meta\\.dirname\\s*\\)\\s*;?\\s*$`);\n return callRegex.test(source.slice(importMatch[0].length));\n}\n"],"mappings":"iKAGA,MAAaK,EAAuB,CAClC,WAAY,mBACZ,WAAY,kBACd,EAUA,eAAsBC,EAA6B,EAAgD,CACjG,IAAMC,EAAe,MAAMC,EAAoBR,EAAAA,QAAK,KAAKS,EAAY,UAAU,CAAC,EAC1EC,EAAoC,CAAC,EAO3C,GANIH,IAAiB,IAAA,IAAaI,EAA4BJ,EAAcF,EAAqB,WAAW,GAC1GK,EAAe,KAAK,UAAU,EAITH,IAAiB,IAAA,IAAaG,EAAe,SAAW,EAC1D,CACnB,IAAME,EAAe,MAAMJ,EAAoBR,EAAAA,QAAK,KAAKS,EAAY,UAAU,CAAC,EAC5EG,IAAiB,IAAA,IAAaD,EAA4BC,EAAcP,EAAqB,WAAW,GAC1GK,EAAe,KAAK,UAAU,CAElC,CACA,OAAOA,CACT,CAEA,eAAeF,EAAoB,EAA+C,CAChF,GAAI,CACF,OAAO,MAAMP,EAAAA,QAAG,SAASY,EAAU,MAAM,CAC3C,MAAQ,CACN,MACF,CACF,CAQA,SAAgBF,EAA4B,EAAgB,EAA6B,CAKvF,IAAMG,EAAc,IAJI,OACtB,OAAO,GAAG,qBAAqBC,EAAW,qDACxC,OAAO,GAAG,gFAEgB,CAAC,CAAC,KAAKC,CAAM,EAC3C,GAAI,CAACF,EAAa,MAAO,GACzB,IAAMG,GAAmBH,EAAY,IAAMC,EAAA,CAAY,WAAW,IAAK,OAAO,GAAG,IAAI,EAErF,OAAO,IADe,OAAO,OAAO,GAAG,YAAYE,EAAgB,4CACpD,CAAC,CAAC,KAAKD,EAAO,MAAMF,EAAY,EAAE,CAAC,MAAM,CAAC,CAC3D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"defaultStdioHarness.js","names":[],"sources":["../../src/helpers/defaultStdioHarness.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nexport const HARNESS_FILE_PRESETS = {\n 'judge.ts': 'stdioJudgePreset',\n 'debug.ts': 'stdioDebugPreset',\n} as const;\n\nexport type HarnessFileName = keyof typeof HARNESS_FILE_PRESETS;\n\n/**\n * Find harness files (`judge.ts` / `debug.ts`) in a problem directory whose content is identical to\n * the default stdio harness. Such files must not be committed: a problem without `judge.ts` is\n * judged with `stdioJudgePreset` (and debugged with `stdioDebugPreset`) automatically, and\n * committed copies would drift from the server's defaults.\n */\nexport async function findDefaultStdioHarnessFiles(problemDir: string): Promise<HarnessFileName[]> {\n const judgeContent = await readFileOrUndefined(path.join(problemDir, 'judge.ts'));\n const foundFileNames: HarnessFileName[] = [];\n if (judgeContent !== undefined && isDefaultStdioHarnessSource(judgeContent, HARNESS_FILE_PRESETS['judge.ts'])) {\n foundFileNames.push('judge.ts');\n }\n // A problem with a custom judge.ts gets no stdioDebugPreset fallback, so it must commit its own\n // debug.ts — even one whose content matches the default stdio debug harness.\n const hasCustomJudge = judgeContent !== undefined && foundFileNames.length === 0;\n if (!hasCustomJudge) {\n const debugContent = await readFileOrUndefined(path.join(problemDir, 'debug.ts'));\n if (debugContent !== undefined && isDefaultStdioHarnessSource(debugContent, HARNESS_FILE_PRESETS['debug.ts'])) {\n foundFileNames.push('debug.ts');\n }\n }\n return foundFileNames;\n}\n\nasync function readFileOrUndefined(filePath: string): Promise<string | undefined> {\n try {\n return await fs.readFile(filePath, 'utf8');\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether the source consists only of importing the given stdio preset from this package (or its\n * legacy `judge-utils` name) and awaiting it, i.e. it is semantically identical to the default\n * harness the server generates. Comments count as a difference on purpose: a harness kept for\n * demonstration can add an explanatory comment to be treated as custom.\n */\nexport function isDefaultStdioHarnessSource(source: string, presetName: string): boolean {\n const importRegex = new RegExp(\n String.raw`^\\s*import\\s*\\{\\s*${presetName}(?:\\s+as\\s+([A-Za-z_$][\\w$]*))?\\s*,?\\s*\\}\\s*from\\s*` +\n String.raw`(['\"])(?:@exercode/problem-utils|judge-utils)/presets/stdio(?:\\.js)?\\2\\s*;?\\s*`\n );\n const importMatch = importRegex.exec(source);\n if (!importMatch) return false;\n const localPresetName = (importMatch[1] ?? presetName).replaceAll('$', String.raw`\\$`);\n const callRegex = new RegExp(String.raw`^await\\s+${localPresetName}\\s*\\(\\s*import\\.meta\\.dirname\\s*\\)\\s*;?\\s*$`);\n return callRegex.test(source.slice(importMatch[0].length));\n}\n"],"mappings":"yDAGA,
|
|
1
|
+
{"version":3,"file":"defaultStdioHarness.js","names":["path","fs","HARNESS_FILE_PRESETS","findDefaultStdioHarnessFiles","judgeContent","readFileOrUndefined","problemDir","foundFileNames","isDefaultStdioHarnessSource","debugContent","filePath","importMatch","presetName","source","localPresetName"],"sources":["../../src/helpers/defaultStdioHarness.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nexport const HARNESS_FILE_PRESETS = {\n 'judge.ts': 'stdioJudgePreset',\n 'debug.ts': 'stdioDebugPreset',\n} as const;\n\nexport type HarnessFileName = keyof typeof HARNESS_FILE_PRESETS;\n\n/**\n * Find harness files (`judge.ts` / `debug.ts`) in a problem directory whose content is identical to\n * the default stdio harness. Such files must not be committed: a problem without `judge.ts` is\n * judged with `stdioJudgePreset` (and debugged with `stdioDebugPreset`) automatically, and\n * committed copies would drift from the server's defaults.\n */\nexport async function findDefaultStdioHarnessFiles(problemDir: string): Promise<HarnessFileName[]> {\n const judgeContent = await readFileOrUndefined(path.join(problemDir, 'judge.ts'));\n const foundFileNames: HarnessFileName[] = [];\n if (judgeContent !== undefined && isDefaultStdioHarnessSource(judgeContent, HARNESS_FILE_PRESETS['judge.ts'])) {\n foundFileNames.push('judge.ts');\n }\n // A problem with a custom judge.ts gets no stdioDebugPreset fallback, so it must commit its own\n // debug.ts — even one whose content matches the default stdio debug harness.\n const hasCustomJudge = judgeContent !== undefined && foundFileNames.length === 0;\n if (!hasCustomJudge) {\n const debugContent = await readFileOrUndefined(path.join(problemDir, 'debug.ts'));\n if (debugContent !== undefined && isDefaultStdioHarnessSource(debugContent, HARNESS_FILE_PRESETS['debug.ts'])) {\n foundFileNames.push('debug.ts');\n }\n }\n return foundFileNames;\n}\n\nasync function readFileOrUndefined(filePath: string): Promise<string | undefined> {\n try {\n return await fs.readFile(filePath, 'utf8');\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether the source consists only of importing the given stdio preset from this package (or its\n * legacy `judge-utils` name) and awaiting it, i.e. it is semantically identical to the default\n * harness the server generates. Comments count as a difference on purpose: a harness kept for\n * demonstration can add an explanatory comment to be treated as custom.\n */\nexport function isDefaultStdioHarnessSource(source: string, presetName: string): boolean {\n const importRegex = new RegExp(\n String.raw`^\\s*import\\s*\\{\\s*${presetName}(?:\\s+as\\s+([A-Za-z_$][\\w$]*))?\\s*,?\\s*\\}\\s*from\\s*` +\n String.raw`(['\"])(?:@exercode/problem-utils|judge-utils)/presets/stdio(?:\\.js)?\\2\\s*;?\\s*`\n );\n const importMatch = importRegex.exec(source);\n if (!importMatch) return false;\n const localPresetName = (importMatch[1] ?? presetName).replaceAll('$', String.raw`\\$`);\n const callRegex = new RegExp(String.raw`^await\\s+${localPresetName}\\s*\\(\\s*import\\.meta\\.dirname\\s*\\)\\s*;?\\s*$`);\n return callRegex.test(source.slice(importMatch[0].length));\n}\n"],"mappings":"yDAGA,MAAaE,EAAuB,CAClC,WAAY,mBACZ,WAAY,kBACd,EAUA,eAAsBC,EAA6B,EAAgD,CACjG,IAAMC,EAAe,MAAMC,EAAoBL,EAAK,KAAKM,EAAY,UAAU,CAAC,EAC1EC,EAAoC,CAAC,EAO3C,GANIH,IAAiB,IAAA,IAAaI,EAA4BJ,EAAcF,EAAqB,WAAW,GAC1GK,EAAe,KAAK,UAAU,EAITH,IAAiB,IAAA,IAAaG,EAAe,SAAW,EAC1D,CACnB,IAAME,EAAe,MAAMJ,EAAoBL,EAAK,KAAKM,EAAY,UAAU,CAAC,EAC5EG,IAAiB,IAAA,IAAaD,EAA4BC,EAAcP,EAAqB,WAAW,GAC1GK,EAAe,KAAK,UAAU,CAElC,CACA,OAAOA,CACT,CAEA,eAAeF,EAAoB,EAA+C,CAChF,GAAI,CACF,OAAO,MAAMJ,EAAG,SAASS,EAAU,MAAM,CAC3C,MAAQ,CACN,MACF,CACF,CAQA,SAAgBF,EAA4B,EAAgB,EAA6B,CAKvF,IAAMG,EAAc,IAJI,OACtB,OAAO,GAAG,qBAAqBC,EAAW,qDACxC,OAAO,GAAG,gFAEgB,CAAC,CAAC,KAAKC,CAAM,EAC3C,GAAI,CAACF,EAAa,MAAO,GACzB,IAAMG,GAAmBH,EAAY,IAAMC,EAAA,CAAY,WAAW,IAAK,OAAO,GAAG,IAAI,EAErF,OAAO,IADe,OAAO,OAAO,GAAG,YAAYE,EAAgB,4CACpD,CAAC,CAAC,KAAKD,EAAO,MAAMF,EAAY,EAAE,CAAC,MAAM,CAAC,CAC3D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findEntryPointFile.cjs","names":["languageIdToDefinition","fs"],"sources":["../../src/helpers/findEntryPointFile.ts"],"sourcesContent":["import fs from 'node:fs';\n\nimport { languageIdToDefinition } from '../types/language.js';\n\n// The last is most prioritized.\nconst PRIOTIZED_FILE_NAMES = ['index', 'main'] as const;\n\nconst IGNORED_FILE_EXTENSIONS = ['.DS_Store', '.class'] as const;\n\nexport async function findEntryPointFile(\n cwd: string,\n language?: string | readonly string[]\n): Promise<string | undefined> {\n const fileExtensions =\n language && [language].flat().flatMap((language) => languageIdToDefinition[language]?.fileExtensions ?? []);\n\n let mainFileName: string | undefined;\n let mainFilePrioryty = -1;\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true })) {\n if (!dirent.isFile()) continue;\n if (IGNORED_FILE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) continue;\n if (fileExtensions && !fileExtensions.some((ext) => dirent.name.endsWith(ext))) continue;\n\n const direntPrioryty = PRIOTIZED_FILE_NAMES.findLastIndex((name) =>\n dirent.name.toLowerCase().startsWith(`${name}.`)\n );\n\n if (\n !mainFileName ||\n (direntPrioryty === mainFilePrioryty\n ? dirent.name.localeCompare(mainFileName) < 0\n : direntPrioryty > mainFilePrioryty)\n ) {\n mainFileName = dirent.name;\n mainFilePrioryty = direntPrioryty;\n }\n }\n\n return mainFileName;\n}\n"],"mappings":"+IAKA,
|
|
1
|
+
{"version":3,"file":"findEntryPointFile.cjs","names":["languageIdToDefinition","fs","require_runtime","require_language","node_fs","PRIOTIZED_FILE_NAMES","IGNORED_FILE_EXTENSIONS","findEntryPointFile","fileExtensions","language","mainFileName","mainFilePrioryty","dirent","cwd","ext","direntPrioryty","name"],"sources":["../../src/helpers/findEntryPointFile.ts"],"sourcesContent":["import fs from 'node:fs';\n\nimport { languageIdToDefinition } from '../types/language.js';\n\n// The last is most prioritized.\nconst PRIOTIZED_FILE_NAMES = ['index', 'main'] as const;\n\nconst IGNORED_FILE_EXTENSIONS = ['.DS_Store', '.class'] as const;\n\nexport async function findEntryPointFile(\n cwd: string,\n language?: string | readonly string[]\n): Promise<string | undefined> {\n const fileExtensions =\n language && [language].flat().flatMap((language) => languageIdToDefinition[language]?.fileExtensions ?? []);\n\n let mainFileName: string | undefined;\n let mainFilePrioryty = -1;\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true })) {\n if (!dirent.isFile()) continue;\n if (IGNORED_FILE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) continue;\n if (fileExtensions && !fileExtensions.some((ext) => dirent.name.endsWith(ext))) continue;\n\n const direntPrioryty = PRIOTIZED_FILE_NAMES.findLastIndex((name) =>\n dirent.name.toLowerCase().startsWith(`${name}.`)\n );\n\n if (\n !mainFileName ||\n (direntPrioryty === mainFilePrioryty\n ? dirent.name.localeCompare(mainFileName) < 0\n : direntPrioryty > mainFilePrioryty)\n ) {\n mainFileName = dirent.name;\n mainFilePrioryty = direntPrioryty;\n }\n }\n\n return mainFileName;\n}\n"],"mappings":"+IAKA,MAAMK,EAAuB,CAAC,QAAS,MAAM,EAEvCC,EAA0B,CAAC,YAAa,QAAQ,EAEtD,eAAsBC,EACpB,EACA,EAC6B,CAC7B,IAAMC,EACJC,GAAY,CAACA,CAAQ,CAAC,CAAC,KAAK,CAAC,CAAC,QAAS,GAAaT,EAAAA,uBAAuBS,EAAS,EAAE,gBAAkB,CAAC,CAAC,EAExGC,EACAC,EAAmB,GAEvB,IAAK,IAAMC,KAAU,MAAMX,EAAAA,QAAG,SAAS,QAAQY,EAAK,CAAE,cAAe,EAAK,CAAC,EAAG,CAG5E,GAFI,CAACD,EAAO,OAAO,GACfN,EAAwB,KAAM,GAAQM,EAAO,KAAK,SAASE,CAAG,CAAC,GAC/DN,GAAkB,CAACA,EAAe,KAAM,GAAQI,EAAO,KAAK,SAASE,CAAG,CAAC,EAAG,SAEhF,IAAMC,EAAiBV,EAAqB,cAAe,GACzDO,EAAO,KAAK,YAAY,CAAC,CAAC,WAAW,GAAGI,EAAK,EAAE,CACjD,GAGE,CAACN,IACAK,IAAmBJ,EAChBC,EAAO,KAAK,cAAcF,CAAY,EAAI,EAC1CK,EAAiBJ,MAErB,EAAeC,EAAO,KACtB,EAAmBG,EAEvB,CAEA,OAAOL,CACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findEntryPointFile.js","names":[],"sources":["../../src/helpers/findEntryPointFile.ts"],"sourcesContent":["import fs from 'node:fs';\n\nimport { languageIdToDefinition } from '../types/language.js';\n\n// The last is most prioritized.\nconst PRIOTIZED_FILE_NAMES = ['index', 'main'] as const;\n\nconst IGNORED_FILE_EXTENSIONS = ['.DS_Store', '.class'] as const;\n\nexport async function findEntryPointFile(\n cwd: string,\n language?: string | readonly string[]\n): Promise<string | undefined> {\n const fileExtensions =\n language && [language].flat().flatMap((language) => languageIdToDefinition[language]?.fileExtensions ?? []);\n\n let mainFileName: string | undefined;\n let mainFilePrioryty = -1;\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true })) {\n if (!dirent.isFile()) continue;\n if (IGNORED_FILE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) continue;\n if (fileExtensions && !fileExtensions.some((ext) => dirent.name.endsWith(ext))) continue;\n\n const direntPrioryty = PRIOTIZED_FILE_NAMES.findLastIndex((name) =>\n dirent.name.toLowerCase().startsWith(`${name}.`)\n );\n\n if (\n !mainFileName ||\n (direntPrioryty === mainFilePrioryty\n ? dirent.name.localeCompare(mainFileName) < 0\n : direntPrioryty > mainFilePrioryty)\n ) {\n mainFileName = dirent.name;\n mainFilePrioryty = direntPrioryty;\n }\n }\n\n return mainFileName;\n}\n"],"mappings":"qFAKA,
|
|
1
|
+
{"version":3,"file":"findEntryPointFile.js","names":["fs","PRIOTIZED_FILE_NAMES","IGNORED_FILE_EXTENSIONS","findEntryPointFile","fileExtensions","language","languageIdToDefinition","mainFileName","mainFilePrioryty","dirent","cwd","ext","direntPrioryty","name"],"sources":["../../src/helpers/findEntryPointFile.ts"],"sourcesContent":["import fs from 'node:fs';\n\nimport { languageIdToDefinition } from '../types/language.js';\n\n// The last is most prioritized.\nconst PRIOTIZED_FILE_NAMES = ['index', 'main'] as const;\n\nconst IGNORED_FILE_EXTENSIONS = ['.DS_Store', '.class'] as const;\n\nexport async function findEntryPointFile(\n cwd: string,\n language?: string | readonly string[]\n): Promise<string | undefined> {\n const fileExtensions =\n language && [language].flat().flatMap((language) => languageIdToDefinition[language]?.fileExtensions ?? []);\n\n let mainFileName: string | undefined;\n let mainFilePrioryty = -1;\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true })) {\n if (!dirent.isFile()) continue;\n if (IGNORED_FILE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) continue;\n if (fileExtensions && !fileExtensions.some((ext) => dirent.name.endsWith(ext))) continue;\n\n const direntPrioryty = PRIOTIZED_FILE_NAMES.findLastIndex((name) =>\n dirent.name.toLowerCase().startsWith(`${name}.`)\n );\n\n if (\n !mainFileName ||\n (direntPrioryty === mainFilePrioryty\n ? dirent.name.localeCompare(mainFileName) < 0\n : direntPrioryty > mainFilePrioryty)\n ) {\n mainFileName = dirent.name;\n mainFilePrioryty = direntPrioryty;\n }\n }\n\n return mainFileName;\n}\n"],"mappings":"qFAKA,MAAMC,EAAuB,CAAC,QAAS,MAAM,EAEvCC,EAA0B,CAAC,YAAa,QAAQ,EAEtD,eAAsBC,EACpB,EACA,EAC6B,CAC7B,IAAMC,EACJC,GAAY,CAACA,CAAQ,CAAC,CAAC,KAAK,CAAC,CAAC,QAAS,GAAaC,EAAuBD,EAAS,EAAE,gBAAkB,CAAC,CAAC,EAExGE,EACAC,EAAmB,GAEvB,IAAK,IAAMC,KAAU,MAAMT,EAAG,SAAS,QAAQU,EAAK,CAAE,cAAe,EAAK,CAAC,EAAG,CAG5E,GAFI,CAACD,EAAO,OAAO,GACfP,EAAwB,KAAM,GAAQO,EAAO,KAAK,SAASE,CAAG,CAAC,GAC/DP,GAAkB,CAACA,EAAe,KAAM,GAAQK,EAAO,KAAK,SAASE,CAAG,CAAC,EAAG,SAEhF,IAAMC,EAAiBX,EAAqB,cAAe,GACzDQ,EAAO,KAAK,YAAY,CAAC,CAAC,WAAW,GAAGI,EAAK,EAAE,CACjD,GAGE,CAACN,IACAK,IAAmBJ,EAChBC,EAAO,KAAK,cAAcF,CAAY,EAAI,EAC1CK,EAAiBJ,MAErB,EAAeC,EAAO,KACtB,EAAmBG,EAEvB,CAEA,OAAOL,CACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findLanguageDefinitionByPath.cjs","names":["languageIdToDefinition"],"sources":["../../src/helpers/findLanguageDefinitionByPath.ts"],"sourcesContent":["import { languageIdToDefinition } from '../types/language.js';\nimport type { LanguageDefinition } from '../types/language.js';\n\nexport function findLanguageDefinitionByPath(path: string): LanguageDefinition | undefined {\n return Object.values(languageIdToDefinition).find((definition) =>\n definition.fileExtensions.some((ext) => path.endsWith(ext))\n );\n}\n"],"mappings":"sDAGA,
|
|
1
|
+
{"version":3,"file":"findLanguageDefinitionByPath.cjs","names":["languageIdToDefinition","require_language","findLanguageDefinitionByPath","definition","path","ext"],"sources":["../../src/helpers/findLanguageDefinitionByPath.ts"],"sourcesContent":["import { languageIdToDefinition } from '../types/language.js';\nimport type { LanguageDefinition } from '../types/language.js';\n\nexport function findLanguageDefinitionByPath(path: string): LanguageDefinition | undefined {\n return Object.values(languageIdToDefinition).find((definition) =>\n definition.fileExtensions.some((ext) => path.endsWith(ext))\n );\n}\n"],"mappings":"sDAGA,SAAgBE,EAA6B,EAA8C,CACzF,OAAO,OAAO,OAAOF,EAAAA,sBAAsB,CAAC,CAAC,KAAM,GACjDG,EAAW,eAAe,KAAM,GAAQC,EAAK,SAASC,CAAG,CAAC,CAC5D,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findLanguageDefinitionByPath.js","names":[],"sources":["../../src/helpers/findLanguageDefinitionByPath.ts"],"sourcesContent":["import { languageIdToDefinition } from '../types/language.js';\nimport type { LanguageDefinition } from '../types/language.js';\n\nexport function findLanguageDefinitionByPath(path: string): LanguageDefinition | undefined {\n return Object.values(languageIdToDefinition).find((definition) =>\n definition.fileExtensions.some((ext) => path.endsWith(ext))\n );\n}\n"],"mappings":"8DAGA,
|
|
1
|
+
{"version":3,"file":"findLanguageDefinitionByPath.js","names":["findLanguageDefinitionByPath","languageIdToDefinition","definition","path","ext"],"sources":["../../src/helpers/findLanguageDefinitionByPath.ts"],"sourcesContent":["import { languageIdToDefinition } from '../types/language.js';\nimport type { LanguageDefinition } from '../types/language.js';\n\nexport function findLanguageDefinitionByPath(path: string): LanguageDefinition | undefined {\n return Object.values(languageIdToDefinition).find((definition) =>\n definition.fileExtensions.some((ext) => path.endsWith(ext))\n );\n}\n"],"mappings":"8DAGA,SAAgBA,EAA6B,EAA8C,CACzF,OAAO,OAAO,OAAOC,CAAsB,CAAC,CAAC,KAAM,GACjDC,EAAW,eAAe,KAAM,GAAQC,EAAK,SAASC,CAAG,CAAC,CAC5D,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findModelAnswerDirs.cjs","names":["path","fs","hasErrorCode"],"sources":["../../src/helpers/findModelAnswerDirs.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport const MODEL_ANSWERS_DIRNAME = 'model_answers';\nexport const FAILING_MODEL_ANSWERS_DIRNAME = 'model_answers.fails';\n\n/**\n * Find model answer directories under `<problemDir>/model_answers/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, MODEL_ANSWERS_DIRNAME);\n}\n\n/**\n * Find failing model answer directories under `<problemDir>/model_answers.fails/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findFailingModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, FAILING_MODEL_ANSWERS_DIRNAME);\n}\n\nasync function findAnswerDirs(problemDir: string, dirname: string): Promise<string[]> {\n const modelAnswersDir = path.join(problemDir, dirname);\n let entries;\n try {\n entries = await fs.readdir(modelAnswersDir, { withFileTypes: true });\n } catch (error) {\n if (hasErrorCode(error, 'ENOENT')) return [];\n throw error;\n }\n return entries\n .filter((entry) => entry.isDirectory())\n .map((entry) => path.join(modelAnswersDir, entry.name))\n .toSorted();\n}\n"],"mappings":"0MAIA,
|
|
1
|
+
{"version":3,"file":"findModelAnswerDirs.cjs","names":["path","fs","hasErrorCode","require_runtime","node_path","node_fs_promises","_willbooster_shared_lib","MODEL_ANSWERS_DIRNAME","FAILING_MODEL_ANSWERS_DIRNAME","findModelAnswerDirs","findAnswerDirs","problemDir","findFailingModelAnswerDirs","modelAnswersDir","dirname","entries","error","entry"],"sources":["../../src/helpers/findModelAnswerDirs.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport const MODEL_ANSWERS_DIRNAME = 'model_answers';\nexport const FAILING_MODEL_ANSWERS_DIRNAME = 'model_answers.fails';\n\n/**\n * Find model answer directories under `<problemDir>/model_answers/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, MODEL_ANSWERS_DIRNAME);\n}\n\n/**\n * Find failing model answer directories under `<problemDir>/model_answers.fails/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findFailingModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, FAILING_MODEL_ANSWERS_DIRNAME);\n}\n\nasync function findAnswerDirs(problemDir: string, dirname: string): Promise<string[]> {\n const modelAnswersDir = path.join(problemDir, dirname);\n let entries;\n try {\n entries = await fs.readdir(modelAnswersDir, { withFileTypes: true });\n } catch (error) {\n if (hasErrorCode(error, 'ENOENT')) return [];\n throw error;\n }\n return entries\n .filter((entry) => entry.isDirectory())\n .map((entry) => path.join(modelAnswersDir, entry.name))\n .toSorted();\n}\n"],"mappings":"0MAIA,MAAaO,EAAwB,gBACxBC,EAAgC,sBAM7C,eAAsBC,EAAoB,EAAuC,CAC/E,OAAOC,EAAeC,EAAYJ,CAAqB,CACzD,CAMA,eAAsBK,EAA2B,EAAuC,CACtF,OAAOF,EAAeC,EAAYH,CAA6B,CACjE,CAEA,eAAeE,EAAe,EAAoB,EAAoC,CACpF,IAAMG,EAAkBb,EAAAA,QAAK,KAAKW,EAAYG,CAAO,EACjDC,EACJ,GAAI,CACF,EAAU,MAAMd,EAAAA,QAAG,QAAQY,EAAiB,CAAE,cAAe,EAAK,CAAC,CACrE,OAASG,EAAO,CACd,IAAA,EAAId,EAAAA,aAAAA,CAAac,EAAO,QAAQ,EAAG,MAAO,CAAC,EAC3C,MAAMA,CACR,CACA,OAAOD,EACJ,OAAQ,GAAUE,EAAM,YAAY,CAAC,CAAC,CACtC,IAAK,GAAUjB,EAAAA,QAAK,KAAKa,EAAiBI,EAAM,IAAI,CAAC,CAAC,CACtD,SAAS,CACd"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"findModelAnswerDirs.js","names":[],"sources":["../../src/helpers/findModelAnswerDirs.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport const MODEL_ANSWERS_DIRNAME = 'model_answers';\nexport const FAILING_MODEL_ANSWERS_DIRNAME = 'model_answers.fails';\n\n/**\n * Find model answer directories under `<problemDir>/model_answers/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, MODEL_ANSWERS_DIRNAME);\n}\n\n/**\n * Find failing model answer directories under `<problemDir>/model_answers.fails/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findFailingModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, FAILING_MODEL_ANSWERS_DIRNAME);\n}\n\nasync function findAnswerDirs(problemDir: string, dirname: string): Promise<string[]> {\n const modelAnswersDir = path.join(problemDir, dirname);\n let entries;\n try {\n entries = await fs.readdir(modelAnswersDir, { withFileTypes: true });\n } catch (error) {\n if (hasErrorCode(error, 'ENOENT')) return [];\n throw error;\n }\n return entries\n .filter((entry) => entry.isDirectory())\n .map((entry) => path.join(modelAnswersDir, entry.name))\n .toSorted();\n}\n"],"mappings":"gHAIA,
|
|
1
|
+
{"version":3,"file":"findModelAnswerDirs.js","names":["path","fs","MODEL_ANSWERS_DIRNAME","FAILING_MODEL_ANSWERS_DIRNAME","findModelAnswerDirs","findAnswerDirs","problemDir","findFailingModelAnswerDirs","modelAnswersDir","dirname","entries","error","hasErrorCode","entry"],"sources":["../../src/helpers/findModelAnswerDirs.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { hasErrorCode } from '@willbooster/shared-lib';\n\nexport const MODEL_ANSWERS_DIRNAME = 'model_answers';\nexport const FAILING_MODEL_ANSWERS_DIRNAME = 'model_answers.fails';\n\n/**\n * Find model answer directories under `<problemDir>/model_answers/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, MODEL_ANSWERS_DIRNAME);\n}\n\n/**\n * Find failing model answer directories under `<problemDir>/model_answers.fails/`.\n * Returns absolute paths sorted lexicographically.\n */\nexport async function findFailingModelAnswerDirs(problemDir: string): Promise<string[]> {\n return findAnswerDirs(problemDir, FAILING_MODEL_ANSWERS_DIRNAME);\n}\n\nasync function findAnswerDirs(problemDir: string, dirname: string): Promise<string[]> {\n const modelAnswersDir = path.join(problemDir, dirname);\n let entries;\n try {\n entries = await fs.readdir(modelAnswersDir, { withFileTypes: true });\n } catch (error) {\n if (hasErrorCode(error, 'ENOENT')) return [];\n throw error;\n }\n return entries\n .filter((entry) => entry.isDirectory())\n .map((entry) => path.join(modelAnswersDir, entry.name))\n .toSorted();\n}\n"],"mappings":"gHAIA,MAAaE,EAAwB,gBACxBC,EAAgC,sBAM7C,eAAsBC,EAAoB,EAAuC,CAC/E,OAAOC,EAAeC,EAAYJ,CAAqB,CACzD,CAMA,eAAsBK,EAA2B,EAAuC,CACtF,OAAOF,EAAeC,EAAYH,CAA6B,CACjE,CAEA,eAAeE,EAAe,EAAoB,EAAoC,CACpF,IAAMG,EAAkBR,EAAK,KAAKM,EAAYG,CAAO,EACjDC,EACJ,GAAI,CACF,EAAU,MAAMT,EAAG,QAAQO,EAAiB,CAAE,cAAe,EAAK,CAAC,CACrE,OAASG,EAAO,CACd,GAAIC,EAAaD,EAAO,QAAQ,EAAG,MAAO,CAAC,EAC3C,MAAMA,CACR,CACA,OAAOD,EACJ,OAAQ,GAAUG,EAAM,YAAY,CAAC,CAAC,CACtC,IAAK,GAAUb,EAAK,KAAKQ,EAAiBK,EAAM,IAAI,CAAC,CAAC,CACtD,SAAS,CACd"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guiRecording.cjs","names":["crypto","zlib"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"guiRecording.cjs","names":["crypto","zlib","require_runtime","node_crypto","node_zlib","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","isAnimated","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"4JAIA,MAuCMK,EAAgB,OAAO,KAAK,mBAAoB,KAAK,EACrDC,EAAiD,CAAE,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,CAAE,EAChFC,EAAa,MASnB,IAAaC,EAAb,KAAyB,CACvB,mBAA+B,IAAI,IAEnC,IAAI,EAAkB,EAA4C,EAA4B,CAC5F,IAAMC,EAAW,KAAKC,GAAoB,IAAIC,CAAQ,GAAK,CACzD,KAAM,GACN,OAAQ,CAAC,EACT,WAAY,GACZ,YAAa,EACb,mBAAoB,EACpB,aAAc,EACd,WAAY,EACd,EACA,KAAKD,GAAoB,IAAIC,EAAUF,CAAQ,EAC/C,EAAS,KAAOG,EAAW,KAAK,QAAQ,SAAU,gBAAgB,EAClE,EAAS,aAAeC,EAExB,IAAMC,EAASd,EAAAA,QAAO,WAAW,QAAQ,CAAC,CAAC,OAAOY,EAAW,IAAI,CAAC,CAAC,OAAO,QAAQ,EAClF,GAAIH,EAAS,aAAeK,EAAQ,OAEpC,EAAS,WAAaA,EACtB,IAAMC,EAAc,EAAS,cACzBN,EAAS,YAAcM,EAAcN,EAAS,qBAAuB,IAEzEA,EAAS,OAAO,KAAK,CAAE,IAAK,OAAO,KAAKG,EAAW,KAAM,QAAQ,EAAG,cAAa,CAAC,EAClF,KAAKI,IAAc,CAAE,YAAaC,EAAO,QAAQ,EAAK,IAAUC,EAAMC,EAAM,IAAI,OAAQ,CAAC,CAAC,EAC5F,CAGA,MAAM,EAA6C,CACjD,IAAMC,kBAAsB,IAAI,IAC1BC,GAAU,CAAE,SAAQ,kBAA2C,CACnE,IAAMC,EACJF,EAAoB,IAAIH,CAAM,GAC9BM,EACEN,EAAO,KAAK,EAAO,KAAW,CAC5B,IAAKE,EAAM,IACX,SAAUF,EAAOO,EAAQ,EAAE,EAAE,cAAgBC,EAAeC,GAAmBP,EAAM,YACvF,EAAE,CACJ,EAEF,OADAC,EAAoB,IAAIH,EAAQK,CAAW,EACpCA,CACT,EACMK,EAAc,GAAsCC,EAAWnB,CAAQ,GAAKA,EAAS,OAAO,OAAS,EAI3G,OADA,KAAKO,GAAc,GAAcW,EAAWlB,CAAQ,EAAIY,EAAOZ,CAAQ,CAAC,CAAC,OAAS,CAAE,EAC7E,CAAC,GAAG,KAAKC,GAAoB,OAAO,CAAC,CAAC,CAAC,OAAOiB,CAAU,CAAC,CAAC,IAAK,IAAc,CAClF,KAAMlB,EAAS,KACf,KAAMY,EAAOZ,CAAQ,CAAC,CAAC,SAAS,QAAQ,EACxC,SAAU,QACZ,EAAE,CACJ,CAQA,GAAa,EAAqD,CAChE,IAAMoB,EAAc,CAAC,GAAG,KAAKnB,GAAoB,OAAO,CAAC,EACzD,OAAS,CACP,IAAMoB,EAAQ,IAAI,IAAID,EAAY,IAAK,GAAa,CAACpB,EAAUsB,EAAQtB,CAAQ,CAAC,CAAC,CAAC,EAClF,GAAI,CAAC,GAAGqB,EAAM,OAAO,CAAC,CAAC,CAAC,QAAQ,EAAK,IAASZ,EAAMc,EAAM,CAAC,GAAA,QAA8B,OAEzF,IAAMC,EAAmBJ,EACtB,OAAQ,GAAapB,EAAS,OAAO,OAAS,CAAC,CAAC,CAChD,UAAU,EAAG,KAAOqB,EAAM,IAAII,CAAC,GAAK,IAAMJ,EAAM,IAAIK,CAAC,GAAK,EAAE,EAEzD,CAACC,GAAeH,EACnB,OAAQ,GAAa,CAACL,EAAWnB,CAAQ,CAAC,CAAC,CAC3C,UAAU,EAAG,IAAM0B,EAAE,YAAcD,EAAE,WAAW,EAC7CG,EAAaJ,EAAiB,KAAM,GAAaxB,EAAS,OAAO,OAAS,CAAC,EACjF,GAAI2B,EACF,EAAY,OAAS,CAAC,OACjB,GAAIC,EACT,EAAW,OAASA,EAAW,OAAO,QAAQ,EAAG,IAAUb,EAAQ,GAAM,CAAC,EAC1E,EAAW,oBAAsB,MAC5B,CACL,GAAM,CAACc,GAAWL,EAClB,EAAQ,OAAS,CAAC,EAClB,EAAQ,WAAa,EACvB,CACF,CACF,CACF,EAEA,SAASL,EAAW,EAAmC,CACrD,OAAOnB,EAAS,aAAe,CACjC,CAOA,SAAgBc,EAAkB,EAA2C,CAC3E,IAAMgB,EAAetB,EAAO,IAAK,IAAW,CAAE,GAAGuB,EAASrB,EAAM,GAAG,EAAG,QAASA,EAAM,OAAQ,EAAE,EACzFsB,EAAcF,EAAa,EAAE,EAAE,OAAO,SAAS,CAAC,EACtD,GAAI,CAACE,EAAa,MAAU,MAAM,0CAA0C,EAC5E,IAAMC,EAAkBH,EACrB,OAAQ,GAAUpB,EAAM,OAAO,SAAS,CAAC,CAAC,CAAC,OAAOsB,CAAW,CAAC,CAAC,CAC/D,IAAK,IAAW,CAAE,GAAGtB,EAAO,MAAOA,EAAM,OAAO,aAAa,CAAC,EAAG,OAAQA,EAAM,OAAO,aAAa,CAAC,CAAE,EAAE,EACrG,CAACwB,GAAcD,EACfE,EAAQ,KAAK,IAAI,GAAGF,EAAgB,IAAK,GAAUvB,EAAM,KAAK,CAAC,EAC/D0B,EAAS,KAAK,IAAI,GAAGH,EAAgB,IAAK,GAAUvB,EAAM,MAAM,CAAC,EAEjE2B,EAAS,OAAO,KAAKH,EAAW,MAAM,EAC5CG,EAAO,cAAcF,EAAO,CAAC,EAC7BE,EAAO,cAAcD,EAAQ,CAAC,EAC9B,IAAME,EAAmB,OAAO,MAAM,CAAC,EACvCA,EAAiB,cAAcL,EAAgB,OAAQ,CAAC,EACxD,IAAMM,EAAS,CACb3C,EACA4C,EAAY,OAAQH,CAAM,EAC1B,GAAGH,EAAW,eACdM,EAAY,OAAQF,CAAgB,CACtC,EAIMG,EAAuBP,EAAW,QAAUC,GAASD,EAAW,SAAWE,EACjF,GAAI,CAACK,EAAsB,CACzB,IAAMC,EAAgBV,EAAY,IAAiBnC,EAAuBmC,EAAY,KAAiB,GACjGW,EAAiB,OAAO,MAAMP,GAAU,EAAI,KAAK,KAAMD,EAAQO,EAAgB,CAAC,EAAE,EACxFH,EAAO,KAAKC,EAAY,OAAQhD,EAAAA,QAAK,YAAYmD,CAAc,CAAC,CAAC,CACnE,CAEA,IAAIC,EAAiB,EACrB,IAAK,GAAM,CAAC7B,EAAOL,KAAUuB,EAAgB,QAAQ,EAAG,CACtD,IAAMY,EAAe,OAAO,MAAM,EAAE,EACpCA,EAAa,cAAc,IAAkB,CAAC,EAC9CA,EAAa,cAAcnC,EAAM,MAAO,CAAC,EACzCmC,EAAa,cAAcnC,EAAM,OAAQ,CAAC,EAG1C,IAAMoC,EAAsB,KAAK,IAAI,EAAG,KAAK,IAAI,IAAM,KAAK,MAAOhD,EAAa,IAAQY,EAAM,OAAO,CAAC,CAAC,EACjGqC,EAAQ,KAAK,MAAOrC,EAAM,QAAUoC,EAAuB,GAAI,EAOrE,GANAD,EAAa,cAAc,KAAK,IAAI,KAAK,IAAIE,EAAO,CAAC,EAAGjD,CAAU,EAAG,EAAE,EACvE+C,EAAa,cAAcC,EAAqB,EAAE,EAElDD,EAAa,WAAW,EAAG,EAAE,EAC7BN,EAAO,KAAKC,EAAY,OAAQK,CAAY,CAAC,EAEzC9B,IAAU,GAAK0B,EACjBF,EAAO,KAAKC,EAAY,OAAQ9B,EAAM,SAAS,CAAC,MAC3C,CACL,IAAMsC,EAAW,OAAO,MAAM,CAAC,EAC/BA,EAAS,cAAc,GAAgB,EACvCT,EAAO,KAAKC,EAAY,OAAQ,OAAO,OAAO,CAACQ,EAAUtC,EAAM,SAAS,CAAC,CAAC,CAAC,CAC7E,CACF,CAEA,OADA6B,EAAO,KAAKC,EAAY,OAAQ,OAAO,MAAM,CAAC,CAAC,CAAC,EACzC,OAAO,OAAOD,CAAM,CAC7B,CAEA,SAASR,EAAS,EAAwB,CACxC,GAAI,CAACkB,EAAI,SAAS,EAAGrD,EAAc,MAAM,CAAC,CAAC,OAAOA,CAAa,EAAG,MAAU,MAAM,iBAAiB,EAEnG,IAAIyC,EACEa,EAA2B,CAAC,EAC5BC,EAA4B,CAAC,EAC/BC,EAASxD,EAAc,OAC3B,KAAOwD,EAAS,IAAMH,EAAI,QAAQ,CAChC,IAAMI,EAAaJ,EAAI,aAAaG,CAAM,EACpCE,EAAOL,EAAI,SAAS,SAAUG,EAAS,EAAGA,EAAS,CAAC,EACpDG,EAAON,EAAI,SAASG,EAAS,EAAGA,EAAS,EAAIC,CAAU,EACvDG,EAAaJ,EAAS,GAAKC,EAC7BC,IAAS,OACX,EAASC,EACAD,IAAS,OAClBH,EAAgB,KAAKI,CAAI,EAChBJ,EAAgB,SAAW,GAAKG,IAAS,QAClDJ,EAAe,KAAKD,EAAI,SAASG,EAAQI,CAAU,CAAC,EAEtD,EAASA,CACX,CACA,GAAInB,GAAQ,SAAW,IAAMc,EAAgB,SAAW,EAAG,MAAU,MAAM,iBAAiB,EAC5F,MAAO,CAAE,SAAQ,iBAAgB,UAAW,OAAO,OAAOA,CAAe,CAAE,CAC7E,CAEA,SAASX,EAAY,EAAc,EAAsB,CACvD,IAAMiB,EAAc,OAAO,OAAO,CAAC,OAAO,KAAKH,EAAM,QAAQ,EAAGC,CAAI,CAAC,EAC/DG,EAAQ,OAAO,MAAMD,EAAY,OAAS,CAAC,EAIjD,OAHAC,EAAM,cAAcH,EAAK,OAAQ,CAAC,EAClCE,EAAY,KAAKC,EAAO,CAAC,EACzBA,EAAM,cAAclE,EAAAA,QAAK,MAAMiE,CAAW,EAAGC,EAAM,OAAS,CAAC,EACtDA,CACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guiRecording.js","names":[],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"guiRecording.js","names":["crypto","zlib","MAX_GUI_RECORDING_BYTES","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","isAnimated","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"javaJudge.cjs","names":["fs","path","z"],"sources":["../../src/helpers/javaJudge.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport { z } from 'zod';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\nexport const MAX_OUTPUT_LENGTH = 50_000;\n\nexport async function resolveJavaRelativePath(sourcePath: string, fallbackRelativePath: string): Promise<string> {\n const sourceCode = await fs.promises.readFile(sourcePath, 'utf8');\n const packageMatch = /^\\s*package\\s+([a-zA-Z0-9_.]+)\\s*;/m.exec(sourceCode);\n if (!packageMatch?.[1]) {\n return fallbackRelativePath;\n }\n\n return path.join(packageMatch[1].replaceAll('.', '/'), path.basename(fallbackRelativePath));\n}\n\nexport async function listSubmissionFiles(rootDir: string): Promise<string[]> {\n const filePaths: string[] = [];\n await collectSubmissionFiles(rootDir, '', filePaths);\n return filePaths.toSorted();\n}\n\nasync function collectSubmissionFiles(rootDir: string, relativeDir: string, filePaths: string[]): Promise<void> {\n const currentDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;\n const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });\n for (const entry of entries) {\n const entryRelativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;\n if (entry.isDirectory()) {\n await collectSubmissionFiles(rootDir, entryRelativePath, filePaths);\n continue;\n }\n if (entry.isFile()) {\n filePaths.push(entryRelativePath);\n }\n }\n}\n\nexport function readOutputFiles(filePath: string): OutputFile[] | undefined {\n if (!fs.existsSync(filePath)) {\n return undefined;\n }\n\n // Malformed evaluator JSON remains a harness error (JUDGE_NOT_AVAILABLE), not missing screenshots.\n const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n const values = z.array(z.unknown()).safeParse(parsed);\n if (!values.success) return undefined;\n const outputFileSchema = z.object({\n path: z.string(),\n data: z.string(),\n encoding: z.literal('base64').optional(),\n });\n return values.data.flatMap((value) => {\n const result = outputFileSchema.safeParse(value);\n return result.success ? [result.data] : [];\n });\n}\n\nexport function truncateOutput(value: string): string {\n return value.slice(0, MAX_OUTPUT_LENGTH);\n}\n"],"mappings":"6KAMA,
|
|
1
|
+
{"version":3,"file":"javaJudge.cjs","names":["fs","path","z","require_runtime","node_fs","node_path","zod","MAX_OUTPUT_LENGTH","resolveJavaRelativePath","sourceCode","sourcePath","packageMatch","fallbackRelativePath","listSubmissionFiles","filePaths","collectSubmissionFiles","rootDir","currentDir","relativeDir","entries","entry","entryRelativePath","readOutputFiles","filePath","parsed","values","outputFileSchema","result","value","truncateOutput"],"sources":["../../src/helpers/javaJudge.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport { z } from 'zod';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\nexport const MAX_OUTPUT_LENGTH = 50_000;\n\nexport async function resolveJavaRelativePath(sourcePath: string, fallbackRelativePath: string): Promise<string> {\n const sourceCode = await fs.promises.readFile(sourcePath, 'utf8');\n const packageMatch = /^\\s*package\\s+([a-zA-Z0-9_.]+)\\s*;/m.exec(sourceCode);\n if (!packageMatch?.[1]) {\n return fallbackRelativePath;\n }\n\n return path.join(packageMatch[1].replaceAll('.', '/'), path.basename(fallbackRelativePath));\n}\n\nexport async function listSubmissionFiles(rootDir: string): Promise<string[]> {\n const filePaths: string[] = [];\n await collectSubmissionFiles(rootDir, '', filePaths);\n return filePaths.toSorted();\n}\n\nasync function collectSubmissionFiles(rootDir: string, relativeDir: string, filePaths: string[]): Promise<void> {\n const currentDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;\n const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });\n for (const entry of entries) {\n const entryRelativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;\n if (entry.isDirectory()) {\n await collectSubmissionFiles(rootDir, entryRelativePath, filePaths);\n continue;\n }\n if (entry.isFile()) {\n filePaths.push(entryRelativePath);\n }\n }\n}\n\nexport function readOutputFiles(filePath: string): OutputFile[] | undefined {\n if (!fs.existsSync(filePath)) {\n return undefined;\n }\n\n // Malformed evaluator JSON remains a harness error (JUDGE_NOT_AVAILABLE), not missing screenshots.\n const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n const values = z.array(z.unknown()).safeParse(parsed);\n if (!values.success) return undefined;\n const outputFileSchema = z.object({\n path: z.string(),\n data: z.string(),\n encoding: z.literal('base64').optional(),\n });\n return values.data.flatMap((value) => {\n const result = outputFileSchema.safeParse(value);\n return result.success ? [result.data] : [];\n });\n}\n\nexport function truncateOutput(value: string): string {\n return value.slice(0, MAX_OUTPUT_LENGTH);\n}\n"],"mappings":"6KAMA,MAAaO,EAAoB,IAEjC,eAAsBC,EAAwB,EAAoB,EAA+C,CAC/G,IAAMC,EAAa,MAAMT,EAAAA,QAAG,SAAS,SAASU,EAAY,MAAM,EAC1DC,EAAe,sCAAsC,KAAKF,CAAU,EAK1E,OAJKE,IAAe,GAIbV,EAAAA,QAAK,KAAKU,EAAa,EAAE,CAAC,WAAW,IAAK,GAAG,EAAGV,EAAAA,QAAK,SAASW,CAAoB,CAAC,EAHjFA,CAIX,CAEA,eAAsBC,EAAoB,EAAoC,CAC5E,IAAMC,EAAsB,CAAC,EAE7B,OADA,MAAMC,EAAuBC,EAAS,GAAIF,CAAS,EAC5CA,EAAU,SAAS,CAC5B,CAEA,eAAeC,EAAuB,EAAiB,EAAqB,EAAoC,CAC9G,IAAME,EAAaC,EAAcjB,EAAAA,QAAK,KAAKe,EAASE,CAAW,EAAIF,EAC7DG,EAAU,MAAMnB,EAAAA,QAAG,SAAS,QAAQiB,EAAY,CAAE,cAAe,EAAK,CAAC,EAC7E,IAAK,IAAMG,KAASD,EAAS,CAC3B,IAAME,EAAoBH,EAAcjB,EAAAA,QAAK,KAAKiB,EAAaE,EAAM,IAAI,EAAIA,EAAM,KACnF,GAAIA,EAAM,YAAY,EAAG,CACvB,MAAML,EAAuBC,EAASK,EAAmBP,CAAS,EAClE,QACF,CACIM,EAAM,OAAO,GACfN,EAAU,KAAKO,CAAiB,CAEpC,CACF,CAEA,SAAgBC,EAAgB,EAA4C,CAC1E,GAAI,CAACtB,EAAAA,QAAG,WAAWuB,CAAQ,EACzB,OAIF,IAAMC,EAAkB,KAAK,MAAMxB,EAAAA,QAAG,aAAauB,EAAU,MAAM,CAAC,EAC9DE,EAASvB,EAAAA,EAAE,MAAMA,EAAAA,EAAE,QAAQ,CAAC,CAAC,CAAC,UAAUsB,CAAM,EACpD,GAAI,CAACC,EAAO,QAAS,OACrB,IAAMC,EAAmBxB,EAAAA,EAAE,OAAO,CAChC,KAAMA,EAAAA,EAAE,OAAO,EACf,KAAMA,EAAAA,EAAE,OAAO,EACf,SAAUA,EAAAA,EAAE,QAAQ,QAAQ,CAAC,CAAC,SAAS,CACzC,CAAC,EACD,OAAOuB,EAAO,KAAK,QAAS,GAAU,CACpC,IAAME,EAASD,EAAiB,UAAUE,CAAK,EAC/C,OAAOD,EAAO,QAAU,CAACA,EAAO,IAAI,EAAI,CAAC,CAC3C,CAAC,CACH,CAEA,SAAgBE,EAAe,EAAuB,CACpD,OAAOD,EAAM,MAAM,EAAGrB,CAAiB,CACzC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"javaJudge.js","names":[],"sources":["../../src/helpers/javaJudge.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport { z } from 'zod';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\nexport const MAX_OUTPUT_LENGTH = 50_000;\n\nexport async function resolveJavaRelativePath(sourcePath: string, fallbackRelativePath: string): Promise<string> {\n const sourceCode = await fs.promises.readFile(sourcePath, 'utf8');\n const packageMatch = /^\\s*package\\s+([a-zA-Z0-9_.]+)\\s*;/m.exec(sourceCode);\n if (!packageMatch?.[1]) {\n return fallbackRelativePath;\n }\n\n return path.join(packageMatch[1].replaceAll('.', '/'), path.basename(fallbackRelativePath));\n}\n\nexport async function listSubmissionFiles(rootDir: string): Promise<string[]> {\n const filePaths: string[] = [];\n await collectSubmissionFiles(rootDir, '', filePaths);\n return filePaths.toSorted();\n}\n\nasync function collectSubmissionFiles(rootDir: string, relativeDir: string, filePaths: string[]): Promise<void> {\n const currentDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;\n const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });\n for (const entry of entries) {\n const entryRelativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;\n if (entry.isDirectory()) {\n await collectSubmissionFiles(rootDir, entryRelativePath, filePaths);\n continue;\n }\n if (entry.isFile()) {\n filePaths.push(entryRelativePath);\n }\n }\n}\n\nexport function readOutputFiles(filePath: string): OutputFile[] | undefined {\n if (!fs.existsSync(filePath)) {\n return undefined;\n }\n\n // Malformed evaluator JSON remains a harness error (JUDGE_NOT_AVAILABLE), not missing screenshots.\n const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n const values = z.array(z.unknown()).safeParse(parsed);\n if (!values.success) return undefined;\n const outputFileSchema = z.object({\n path: z.string(),\n data: z.string(),\n encoding: z.literal('base64').optional(),\n });\n return values.data.flatMap((value) => {\n const result = outputFileSchema.safeParse(value);\n return result.success ? [result.data] : [];\n });\n}\n\nexport function truncateOutput(value: string): string {\n return value.slice(0, MAX_OUTPUT_LENGTH);\n}\n"],"mappings":"wEAMA,
|
|
1
|
+
{"version":3,"file":"javaJudge.js","names":["fs","path","MAX_OUTPUT_LENGTH","resolveJavaRelativePath","sourceCode","sourcePath","packageMatch","fallbackRelativePath","listSubmissionFiles","filePaths","collectSubmissionFiles","rootDir","currentDir","relativeDir","entries","entry","entryRelativePath","readOutputFiles","filePath","parsed","values","z","outputFileSchema","result","value","truncateOutput"],"sources":["../../src/helpers/javaJudge.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport { z } from 'zod';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\ntype OutputFile = NonNullable<TestCaseResult['outputFiles']>[number];\nexport const MAX_OUTPUT_LENGTH = 50_000;\n\nexport async function resolveJavaRelativePath(sourcePath: string, fallbackRelativePath: string): Promise<string> {\n const sourceCode = await fs.promises.readFile(sourcePath, 'utf8');\n const packageMatch = /^\\s*package\\s+([a-zA-Z0-9_.]+)\\s*;/m.exec(sourceCode);\n if (!packageMatch?.[1]) {\n return fallbackRelativePath;\n }\n\n return path.join(packageMatch[1].replaceAll('.', '/'), path.basename(fallbackRelativePath));\n}\n\nexport async function listSubmissionFiles(rootDir: string): Promise<string[]> {\n const filePaths: string[] = [];\n await collectSubmissionFiles(rootDir, '', filePaths);\n return filePaths.toSorted();\n}\n\nasync function collectSubmissionFiles(rootDir: string, relativeDir: string, filePaths: string[]): Promise<void> {\n const currentDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;\n const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });\n for (const entry of entries) {\n const entryRelativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;\n if (entry.isDirectory()) {\n await collectSubmissionFiles(rootDir, entryRelativePath, filePaths);\n continue;\n }\n if (entry.isFile()) {\n filePaths.push(entryRelativePath);\n }\n }\n}\n\nexport function readOutputFiles(filePath: string): OutputFile[] | undefined {\n if (!fs.existsSync(filePath)) {\n return undefined;\n }\n\n // Malformed evaluator JSON remains a harness error (JUDGE_NOT_AVAILABLE), not missing screenshots.\n const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n const values = z.array(z.unknown()).safeParse(parsed);\n if (!values.success) return undefined;\n const outputFileSchema = z.object({\n path: z.string(),\n data: z.string(),\n encoding: z.literal('base64').optional(),\n });\n return values.data.flatMap((value) => {\n const result = outputFileSchema.safeParse(value);\n return result.success ? [result.data] : [];\n });\n}\n\nexport function truncateOutput(value: string): string {\n return value.slice(0, MAX_OUTPUT_LENGTH);\n}\n"],"mappings":"wEAMA,MAAaE,EAAoB,IAEjC,eAAsBC,EAAwB,EAAoB,EAA+C,CAC/G,IAAMC,EAAa,MAAMJ,EAAG,SAAS,SAASK,EAAY,MAAM,EAC1DC,EAAe,sCAAsC,KAAKF,CAAU,EAK1E,OAJKE,IAAe,GAIbL,EAAK,KAAKK,EAAa,EAAE,CAAC,WAAW,IAAK,GAAG,EAAGL,EAAK,SAASM,CAAoB,CAAC,EAHjFA,CAIX,CAEA,eAAsBC,EAAoB,EAAoC,CAC5E,IAAMC,EAAsB,CAAC,EAE7B,OADA,MAAMC,EAAuBC,EAAS,GAAIF,CAAS,EAC5CA,EAAU,SAAS,CAC5B,CAEA,eAAeC,EAAuB,EAAiB,EAAqB,EAAoC,CAC9G,IAAME,EAAaC,EAAcZ,EAAK,KAAKU,EAASE,CAAW,EAAIF,EAC7DG,EAAU,MAAMd,EAAG,SAAS,QAAQY,EAAY,CAAE,cAAe,EAAK,CAAC,EAC7E,IAAK,IAAMG,KAASD,EAAS,CAC3B,IAAME,EAAoBH,EAAcZ,EAAK,KAAKY,EAAaE,EAAM,IAAI,EAAIA,EAAM,KACnF,GAAIA,EAAM,YAAY,EAAG,CACvB,MAAML,EAAuBC,EAASK,EAAmBP,CAAS,EAClE,QACF,CACIM,EAAM,OAAO,GACfN,EAAU,KAAKO,CAAiB,CAEpC,CACF,CAEA,SAAgBC,EAAgB,EAA4C,CAC1E,GAAI,CAACjB,EAAG,WAAWkB,CAAQ,EACzB,OAIF,IAAMC,EAAkB,KAAK,MAAMnB,EAAG,aAAakB,EAAU,MAAM,CAAC,EAC9DE,EAASC,EAAE,MAAMA,EAAE,QAAQ,CAAC,CAAC,CAAC,UAAUF,CAAM,EACpD,GAAI,CAACC,EAAO,QAAS,OACrB,IAAME,EAAmBD,EAAE,OAAO,CAChC,KAAMA,EAAE,OAAO,EACf,KAAMA,EAAE,OAAO,EACf,SAAUA,EAAE,QAAQ,QAAQ,CAAC,CAAC,SAAS,CACzC,CAAC,EACD,OAAOD,EAAO,KAAK,QAAS,GAAU,CACpC,IAAMG,EAASD,EAAiB,UAAUE,CAAK,EAC/C,OAAOD,EAAO,QAAU,CAACA,EAAO,IAAI,EAAI,CAAC,CAC3C,CAAC,CACH,CAEA,SAAgBE,EAAe,EAAuB,CACpD,OAAOD,EAAM,MAAM,EAAGtB,CAAiB,CACzC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"judgeByStaticAnalysis.cjs","names":["fs","path","findLanguageDefinitionByPath","removeCommentsInSourceCode","DecisionCode","normalizeCodeRule"],"sources":["../../src/helpers/judgeByStaticAnalysis.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport { normalizeCodeRule } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { findLanguageDefinitionByPath } from './findLanguageDefinitionByPath.js';\nimport { removeCommentsInSourceCode } from './removeCommentsInSourceCode.js';\n\ninterface ForbiddenRuleViolation {\n /** Learner-facing description of the rule; the raw pattern when the problem declares no message. */\n label: string;\n matches: { path: string; match: string }[];\n}\n\nexport async function judgeByStaticAnalysis(\n cwd: string,\n problemMarkdownFrontMatterLike: Pick<\n ProblemMarkdownFrontMatter,\n 'forbiddenRegExpsInCode' | 'forbiddenTextsInCode' | 'requiredRegExpsInCode' | 'requiredSubmissionFilePaths'\n >\n): Promise<Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown'> | undefined> {\n const needsSourceCode =\n !!problemMarkdownFrontMatterLike.forbiddenRegExpsInCode?.length ||\n !!problemMarkdownFrontMatterLike.forbiddenTextsInCode?.length ||\n !!problemMarkdownFrontMatterLike.requiredRegExpsInCode?.length;\n if (!needsSourceCode && !problemMarkdownFrontMatterLike.requiredSubmissionFilePaths?.length) return;\n\n const filePathSet = new Set<string>();\n const sourceCodeWithoutCommentFiles: { path: string; data: string }[] = [];\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true, recursive: true })) {\n if (!dirent.isFile()) continue;\n\n const relativePath = path.relative(cwd, path.join(dirent.parentPath, dirent.name));\n filePathSet.add(relativePath);\n\n if (!needsSourceCode) continue;\n const languageDefinition = findLanguageDefinitionByPath(dirent.name);\n if (!languageDefinition) continue;\n\n const text = await fs.promises.readFile(path.join(dirent.parentPath, dirent.name), 'utf8');\n const isBinary = text.includes('\\uFFFD');\n if (isBinary) continue;\n\n sourceCodeWithoutCommentFiles.push({\n path: relativePath,\n data: languageDefinition.grammer ? removeCommentsInSourceCode(languageDefinition.grammer, text) : text,\n });\n }\n\n if (problemMarkdownFrontMatterLike.requiredSubmissionFilePaths) {\n const missingFilePaths = problemMarkdownFrontMatterLike.requiredSubmissionFilePaths\n .filter((p) => !filePathSet.has(p))\n .toSorted();\n\n if (missingFilePaths.length > 0) {\n return {\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n feedbackMarkdown: `ファイルが不足しています。\n次のファイルを追加してから再度提出してください。\n\n${missingFilePaths.map((p) => `- \\`${p}\\``).join('\\n')}\n`,\n };\n }\n }\n\n const violations: ForbiddenRuleViolation[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.forbiddenRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern, 'g');\n const matches = sourceCodeWithoutCommentFiles.flatMap((file) =>\n [...file.data.matchAll(re)].map((match) => ({ path: file.path, match: match[0] }))\n );\n if (matches.length > 0) violations.push({ label: message ?? `禁止パターン \\`${re.toString()}\\``, matches });\n }\n for (const rule of problemMarkdownFrontMatterLike.forbiddenTextsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const matches = sourceCodeWithoutCommentFiles\n .filter((file) => file.data.includes(pattern))\n .map((file) => ({ path: file.path, match: pattern }));\n if (matches.length > 0) violations.push({ label: message ?? `禁止文字列 \\`${pattern}\\``, matches });\n }\n\n if (violations.length > 0) {\n return {\n decisionCode: DecisionCode.FORBIDDEN_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に禁止された文字列が含まれています。\nソースコードを修正してから再度提出してください。\n\n${violations.map(formatForbiddenRuleViolation).join('\\n')}\n`,\n };\n }\n\n const missingRequiredLabels: string[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.requiredRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern);\n const isFound = sourceCodeWithoutCommentFiles.some((f) => re.test(f.data));\n if (!isFound) missingRequiredLabels.push(message ?? `\\`${re.toString()}\\``);\n }\n\n if (missingRequiredLabels.length > 0) {\n return {\n decisionCode: DecisionCode.REQUIRED_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に必要な文字列が含まれていません。\nソースコードを修正してから再度提出してください。\n\n${missingRequiredLabels.map((label) => `- ${label}`).join('\\n')}\n`,\n };\n }\n\n return;\n}\n\nfunction formatForbiddenRuleViolation(violation: ForbiddenRuleViolation): string {\n const locations = [...new Set(violation.matches.map(({ path, match }) => ` - \\`${path}\\`: \\`${match}\\``))];\n return `- ${violation.label}\\n${locations.join('\\n')}`;\n}\n"],"mappings":"+TAiBA,
|
|
1
|
+
{"version":3,"file":"judgeByStaticAnalysis.cjs","names":["fs","path","findLanguageDefinitionByPath","removeCommentsInSourceCode","DecisionCode","normalizeCodeRule","require_runtime","require_removeCommentsInSourceCode","require_problem","require_findLanguageDefinitionByPath","require_decisionCode","node_fs","node_path","judgeByStaticAnalysis","needsSourceCode","problemMarkdownFrontMatterLike","filePathSet","sourceCodeWithoutCommentFiles","dirent","cwd","relativePath","languageDefinition","text","missingFilePaths","p","violations","rule","re","pattern","matches","file","match","message","formatForbiddenRuleViolation","missingRequiredLabels","f","label","locations","violation","path"],"sources":["../../src/helpers/judgeByStaticAnalysis.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport { normalizeCodeRule } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { findLanguageDefinitionByPath } from './findLanguageDefinitionByPath.js';\nimport { removeCommentsInSourceCode } from './removeCommentsInSourceCode.js';\n\ninterface ForbiddenRuleViolation {\n /** Learner-facing description of the rule; the raw pattern when the problem declares no message. */\n label: string;\n matches: { path: string; match: string }[];\n}\n\nexport async function judgeByStaticAnalysis(\n cwd: string,\n problemMarkdownFrontMatterLike: Pick<\n ProblemMarkdownFrontMatter,\n 'forbiddenRegExpsInCode' | 'forbiddenTextsInCode' | 'requiredRegExpsInCode' | 'requiredSubmissionFilePaths'\n >\n): Promise<Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown'> | undefined> {\n const needsSourceCode =\n !!problemMarkdownFrontMatterLike.forbiddenRegExpsInCode?.length ||\n !!problemMarkdownFrontMatterLike.forbiddenTextsInCode?.length ||\n !!problemMarkdownFrontMatterLike.requiredRegExpsInCode?.length;\n if (!needsSourceCode && !problemMarkdownFrontMatterLike.requiredSubmissionFilePaths?.length) return;\n\n const filePathSet = new Set<string>();\n const sourceCodeWithoutCommentFiles: { path: string; data: string }[] = [];\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true, recursive: true })) {\n if (!dirent.isFile()) continue;\n\n const relativePath = path.relative(cwd, path.join(dirent.parentPath, dirent.name));\n filePathSet.add(relativePath);\n\n if (!needsSourceCode) continue;\n const languageDefinition = findLanguageDefinitionByPath(dirent.name);\n if (!languageDefinition) continue;\n\n const text = await fs.promises.readFile(path.join(dirent.parentPath, dirent.name), 'utf8');\n const isBinary = text.includes('\\uFFFD');\n if (isBinary) continue;\n\n sourceCodeWithoutCommentFiles.push({\n path: relativePath,\n data: languageDefinition.grammer ? removeCommentsInSourceCode(languageDefinition.grammer, text) : text,\n });\n }\n\n if (problemMarkdownFrontMatterLike.requiredSubmissionFilePaths) {\n const missingFilePaths = problemMarkdownFrontMatterLike.requiredSubmissionFilePaths\n .filter((p) => !filePathSet.has(p))\n .toSorted();\n\n if (missingFilePaths.length > 0) {\n return {\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n feedbackMarkdown: `ファイルが不足しています。\n次のファイルを追加してから再度提出してください。\n\n${missingFilePaths.map((p) => `- \\`${p}\\``).join('\\n')}\n`,\n };\n }\n }\n\n const violations: ForbiddenRuleViolation[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.forbiddenRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern, 'g');\n const matches = sourceCodeWithoutCommentFiles.flatMap((file) =>\n [...file.data.matchAll(re)].map((match) => ({ path: file.path, match: match[0] }))\n );\n if (matches.length > 0) violations.push({ label: message ?? `禁止パターン \\`${re.toString()}\\``, matches });\n }\n for (const rule of problemMarkdownFrontMatterLike.forbiddenTextsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const matches = sourceCodeWithoutCommentFiles\n .filter((file) => file.data.includes(pattern))\n .map((file) => ({ path: file.path, match: pattern }));\n if (matches.length > 0) violations.push({ label: message ?? `禁止文字列 \\`${pattern}\\``, matches });\n }\n\n if (violations.length > 0) {\n return {\n decisionCode: DecisionCode.FORBIDDEN_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に禁止された文字列が含まれています。\nソースコードを修正してから再度提出してください。\n\n${violations.map(formatForbiddenRuleViolation).join('\\n')}\n`,\n };\n }\n\n const missingRequiredLabels: string[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.requiredRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern);\n const isFound = sourceCodeWithoutCommentFiles.some((f) => re.test(f.data));\n if (!isFound) missingRequiredLabels.push(message ?? `\\`${re.toString()}\\``);\n }\n\n if (missingRequiredLabels.length > 0) {\n return {\n decisionCode: DecisionCode.REQUIRED_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に必要な文字列が含まれていません。\nソースコードを修正してから再度提出してください。\n\n${missingRequiredLabels.map((label) => `- ${label}`).join('\\n')}\n`,\n };\n }\n\n return;\n}\n\nfunction formatForbiddenRuleViolation(violation: ForbiddenRuleViolation): string {\n const locations = [...new Set(violation.matches.map(({ path, match }) => ` - \\`${path}\\`: \\`${match}\\``))];\n return `- ${violation.label}\\n${locations.join('\\n')}`;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"judgeByStaticAnalysis.js","names":[],"sources":["../../src/helpers/judgeByStaticAnalysis.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport { normalizeCodeRule } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { findLanguageDefinitionByPath } from './findLanguageDefinitionByPath.js';\nimport { removeCommentsInSourceCode } from './removeCommentsInSourceCode.js';\n\ninterface ForbiddenRuleViolation {\n /** Learner-facing description of the rule; the raw pattern when the problem declares no message. */\n label: string;\n matches: { path: string; match: string }[];\n}\n\nexport async function judgeByStaticAnalysis(\n cwd: string,\n problemMarkdownFrontMatterLike: Pick<\n ProblemMarkdownFrontMatter,\n 'forbiddenRegExpsInCode' | 'forbiddenTextsInCode' | 'requiredRegExpsInCode' | 'requiredSubmissionFilePaths'\n >\n): Promise<Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown'> | undefined> {\n const needsSourceCode =\n !!problemMarkdownFrontMatterLike.forbiddenRegExpsInCode?.length ||\n !!problemMarkdownFrontMatterLike.forbiddenTextsInCode?.length ||\n !!problemMarkdownFrontMatterLike.requiredRegExpsInCode?.length;\n if (!needsSourceCode && !problemMarkdownFrontMatterLike.requiredSubmissionFilePaths?.length) return;\n\n const filePathSet = new Set<string>();\n const sourceCodeWithoutCommentFiles: { path: string; data: string }[] = [];\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true, recursive: true })) {\n if (!dirent.isFile()) continue;\n\n const relativePath = path.relative(cwd, path.join(dirent.parentPath, dirent.name));\n filePathSet.add(relativePath);\n\n if (!needsSourceCode) continue;\n const languageDefinition = findLanguageDefinitionByPath(dirent.name);\n if (!languageDefinition) continue;\n\n const text = await fs.promises.readFile(path.join(dirent.parentPath, dirent.name), 'utf8');\n const isBinary = text.includes('\\uFFFD');\n if (isBinary) continue;\n\n sourceCodeWithoutCommentFiles.push({\n path: relativePath,\n data: languageDefinition.grammer ? removeCommentsInSourceCode(languageDefinition.grammer, text) : text,\n });\n }\n\n if (problemMarkdownFrontMatterLike.requiredSubmissionFilePaths) {\n const missingFilePaths = problemMarkdownFrontMatterLike.requiredSubmissionFilePaths\n .filter((p) => !filePathSet.has(p))\n .toSorted();\n\n if (missingFilePaths.length > 0) {\n return {\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n feedbackMarkdown: `ファイルが不足しています。\n次のファイルを追加してから再度提出してください。\n\n${missingFilePaths.map((p) => `- \\`${p}\\``).join('\\n')}\n`,\n };\n }\n }\n\n const violations: ForbiddenRuleViolation[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.forbiddenRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern, 'g');\n const matches = sourceCodeWithoutCommentFiles.flatMap((file) =>\n [...file.data.matchAll(re)].map((match) => ({ path: file.path, match: match[0] }))\n );\n if (matches.length > 0) violations.push({ label: message ?? `禁止パターン \\`${re.toString()}\\``, matches });\n }\n for (const rule of problemMarkdownFrontMatterLike.forbiddenTextsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const matches = sourceCodeWithoutCommentFiles\n .filter((file) => file.data.includes(pattern))\n .map((file) => ({ path: file.path, match: pattern }));\n if (matches.length > 0) violations.push({ label: message ?? `禁止文字列 \\`${pattern}\\``, matches });\n }\n\n if (violations.length > 0) {\n return {\n decisionCode: DecisionCode.FORBIDDEN_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に禁止された文字列が含まれています。\nソースコードを修正してから再度提出してください。\n\n${violations.map(formatForbiddenRuleViolation).join('\\n')}\n`,\n };\n }\n\n const missingRequiredLabels: string[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.requiredRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern);\n const isFound = sourceCodeWithoutCommentFiles.some((f) => re.test(f.data));\n if (!isFound) missingRequiredLabels.push(message ?? `\\`${re.toString()}\\``);\n }\n\n if (missingRequiredLabels.length > 0) {\n return {\n decisionCode: DecisionCode.REQUIRED_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に必要な文字列が含まれていません。\nソースコードを修正してから再度提出してください。\n\n${missingRequiredLabels.map((label) => `- ${label}`).join('\\n')}\n`,\n };\n }\n\n return;\n}\n\nfunction formatForbiddenRuleViolation(violation: ForbiddenRuleViolation): string {\n const locations = [...new Set(violation.matches.map(({ path, match }) => ` - \\`${path}\\`: \\`${match}\\``))];\n return `- ${violation.label}\\n${locations.join('\\n')}`;\n}\n"],"mappings":"8TAiBA,
|
|
1
|
+
{"version":3,"file":"judgeByStaticAnalysis.js","names":["fs","path","judgeByStaticAnalysis","needsSourceCode","problemMarkdownFrontMatterLike","filePathSet","sourceCodeWithoutCommentFiles","dirent","cwd","relativePath","languageDefinition","findLanguageDefinitionByPath","text","removeCommentsInSourceCode","missingFilePaths","p","DecisionCode","violations","rule","normalizeCodeRule","re","pattern","matches","file","match","message","formatForbiddenRuleViolation","missingRequiredLabels","f","label","locations","violation"],"sources":["../../src/helpers/judgeByStaticAnalysis.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport { normalizeCodeRule } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nimport { findLanguageDefinitionByPath } from './findLanguageDefinitionByPath.js';\nimport { removeCommentsInSourceCode } from './removeCommentsInSourceCode.js';\n\ninterface ForbiddenRuleViolation {\n /** Learner-facing description of the rule; the raw pattern when the problem declares no message. */\n label: string;\n matches: { path: string; match: string }[];\n}\n\nexport async function judgeByStaticAnalysis(\n cwd: string,\n problemMarkdownFrontMatterLike: Pick<\n ProblemMarkdownFrontMatter,\n 'forbiddenRegExpsInCode' | 'forbiddenTextsInCode' | 'requiredRegExpsInCode' | 'requiredSubmissionFilePaths'\n >\n): Promise<Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown'> | undefined> {\n const needsSourceCode =\n !!problemMarkdownFrontMatterLike.forbiddenRegExpsInCode?.length ||\n !!problemMarkdownFrontMatterLike.forbiddenTextsInCode?.length ||\n !!problemMarkdownFrontMatterLike.requiredRegExpsInCode?.length;\n if (!needsSourceCode && !problemMarkdownFrontMatterLike.requiredSubmissionFilePaths?.length) return;\n\n const filePathSet = new Set<string>();\n const sourceCodeWithoutCommentFiles: { path: string; data: string }[] = [];\n\n for (const dirent of await fs.promises.readdir(cwd, { withFileTypes: true, recursive: true })) {\n if (!dirent.isFile()) continue;\n\n const relativePath = path.relative(cwd, path.join(dirent.parentPath, dirent.name));\n filePathSet.add(relativePath);\n\n if (!needsSourceCode) continue;\n const languageDefinition = findLanguageDefinitionByPath(dirent.name);\n if (!languageDefinition) continue;\n\n const text = await fs.promises.readFile(path.join(dirent.parentPath, dirent.name), 'utf8');\n const isBinary = text.includes('\\uFFFD');\n if (isBinary) continue;\n\n sourceCodeWithoutCommentFiles.push({\n path: relativePath,\n data: languageDefinition.grammer ? removeCommentsInSourceCode(languageDefinition.grammer, text) : text,\n });\n }\n\n if (problemMarkdownFrontMatterLike.requiredSubmissionFilePaths) {\n const missingFilePaths = problemMarkdownFrontMatterLike.requiredSubmissionFilePaths\n .filter((p) => !filePathSet.has(p))\n .toSorted();\n\n if (missingFilePaths.length > 0) {\n return {\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n feedbackMarkdown: `ファイルが不足しています。\n次のファイルを追加してから再度提出してください。\n\n${missingFilePaths.map((p) => `- \\`${p}\\``).join('\\n')}\n`,\n };\n }\n }\n\n const violations: ForbiddenRuleViolation[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.forbiddenRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern, 'g');\n const matches = sourceCodeWithoutCommentFiles.flatMap((file) =>\n [...file.data.matchAll(re)].map((match) => ({ path: file.path, match: match[0] }))\n );\n if (matches.length > 0) violations.push({ label: message ?? `禁止パターン \\`${re.toString()}\\``, matches });\n }\n for (const rule of problemMarkdownFrontMatterLike.forbiddenTextsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const matches = sourceCodeWithoutCommentFiles\n .filter((file) => file.data.includes(pattern))\n .map((file) => ({ path: file.path, match: pattern }));\n if (matches.length > 0) violations.push({ label: message ?? `禁止文字列 \\`${pattern}\\``, matches });\n }\n\n if (violations.length > 0) {\n return {\n decisionCode: DecisionCode.FORBIDDEN_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に禁止された文字列が含まれています。\nソースコードを修正してから再度提出してください。\n\n${violations.map(formatForbiddenRuleViolation).join('\\n')}\n`,\n };\n }\n\n const missingRequiredLabels: string[] = [];\n\n for (const rule of problemMarkdownFrontMatterLike.requiredRegExpsInCode ?? []) {\n const { pattern, message } = normalizeCodeRule(rule);\n const re = new RegExp(pattern);\n const isFound = sourceCodeWithoutCommentFiles.some((f) => re.test(f.data));\n if (!isFound) missingRequiredLabels.push(message ?? `\\`${re.toString()}\\``);\n }\n\n if (missingRequiredLabels.length > 0) {\n return {\n decisionCode: DecisionCode.REQUIRED_PATTERNS_IN_CODE_ERROR,\n feedbackMarkdown: `ソースコード中に必要な文字列が含まれていません。\nソースコードを修正してから再度提出してください。\n\n${missingRequiredLabels.map((label) => `- ${label}`).join('\\n')}\n`,\n };\n }\n\n return;\n}\n\nfunction formatForbiddenRuleViolation(violation: ForbiddenRuleViolation): string {\n const locations = [...new Set(violation.matches.map(({ path, match }) => ` - \\`${path}\\`: \\`${match}\\``))];\n return `- ${violation.label}\\n${locations.join('\\n')}`;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parseArgs.cjs","names":[],"sources":["../../src/helpers/parseArgs.ts"],"sourcesContent":["/**\n * Parse command line arguments in `judge.ts` or `debug.ts`.\n *\n * `cwd` is optional: when omitted, presets that support it may fall back to\n * judging against model answers under `<problemDir>/model_answers/*`.\n */\nexport function parseArgs(argv: readonly string[]): { cwd: string | undefined; params: unknown } {\n const cwd = argv[2] || undefined;\n const paramsJson = argv[3];\n\n try {\n return { cwd, params: paramsJson ? JSON.parse(paramsJson) : {} };\n } catch (error) {\n throw new Error('bad params argument', { cause: error });\n }\n}\n"],"mappings":"aAMA,
|
|
1
|
+
{"version":3,"file":"parseArgs.cjs","names":["parseArgs","cwd","argv","paramsJson","error"],"sources":["../../src/helpers/parseArgs.ts"],"sourcesContent":["/**\n * Parse command line arguments in `judge.ts` or `debug.ts`.\n *\n * `cwd` is optional: when omitted, presets that support it may fall back to\n * judging against model answers under `<problemDir>/model_answers/*`.\n */\nexport function parseArgs(argv: readonly string[]): { cwd: string | undefined; params: unknown } {\n const cwd = argv[2] || undefined;\n const paramsJson = argv[3];\n\n try {\n return { cwd, params: paramsJson ? JSON.parse(paramsJson) : {} };\n } catch (error) {\n throw new Error('bad params argument', { cause: error });\n }\n}\n"],"mappings":"aAMA,SAAgBA,EAAU,EAAuE,CAC/F,IAAMC,EAAMC,EAAK,IAAM,IAAA,GACjBC,EAAaD,EAAK,GAExB,GAAI,CACF,MAAO,CAAE,MAAK,OAAQC,EAAa,KAAK,MAAMA,CAAU,EAAI,CAAC,CAAE,CACjE,OAASC,EAAO,CACd,MAAU,MAAM,sBAAuB,CAAE,MAAOA,CAAM,CAAC,CACzD,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parseArgs.js","names":[],"sources":["../../src/helpers/parseArgs.ts"],"sourcesContent":["/**\n * Parse command line arguments in `judge.ts` or `debug.ts`.\n *\n * `cwd` is optional: when omitted, presets that support it may fall back to\n * judging against model answers under `<problemDir>/model_answers/*`.\n */\nexport function parseArgs(argv: readonly string[]): { cwd: string | undefined; params: unknown } {\n const cwd = argv[2] || undefined;\n const paramsJson = argv[3];\n\n try {\n return { cwd, params: paramsJson ? JSON.parse(paramsJson) : {} };\n } catch (error) {\n throw new Error('bad params argument', { cause: error });\n }\n}\n"],"mappings":"AAMA,
|
|
1
|
+
{"version":3,"file":"parseArgs.js","names":["parseArgs","cwd","argv","paramsJson","error"],"sources":["../../src/helpers/parseArgs.ts"],"sourcesContent":["/**\n * Parse command line arguments in `judge.ts` or `debug.ts`.\n *\n * `cwd` is optional: when omitted, presets that support it may fall back to\n * judging against model answers under `<problemDir>/model_answers/*`.\n */\nexport function parseArgs(argv: readonly string[]): { cwd: string | undefined; params: unknown } {\n const cwd = argv[2] || undefined;\n const paramsJson = argv[3];\n\n try {\n return { cwd, params: paramsJson ? JSON.parse(paramsJson) : {} };\n } catch (error) {\n throw new Error('bad params argument', { cause: error });\n }\n}\n"],"mappings":"AAMA,SAAgBA,EAAU,EAAuE,CAC/F,IAAMC,EAAMC,EAAK,IAAM,IAAA,GACjBC,EAAaD,EAAK,GAExB,GAAI,CACF,MAAO,CAAE,MAAK,OAAQC,EAAa,KAAK,MAAMA,CAAU,EAAI,CAAC,CAAE,CACjE,OAASC,EAAO,CACd,MAAU,MAAM,sBAAuB,CAAE,MAAOA,CAAM,CAAC,CACzD,CACF"}
|