@exercode/problem-utils 4.3.1 → 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.
Files changed (107) hide show
  1. package/dist/cli/checkAllProblems.cjs.map +1 -1
  2. package/dist/cli/checkAllProblems.js.map +1 -1
  3. package/dist/cli/exercodeProblem.cjs.map +1 -1
  4. package/dist/cli/exercodeProblem.js.map +1 -1
  5. package/dist/cli/runSingleHarness.cjs.map +1 -1
  6. package/dist/cli/runSingleHarness.js.map +1 -1
  7. package/dist/cli/validateLearningMaterial.cjs.map +1 -1
  8. package/dist/cli/validateLearningMaterial.js.map +1 -1
  9. package/dist/helpers/checkProblemDirIsolation.cjs.map +1 -1
  10. package/dist/helpers/checkProblemDirIsolation.js.map +1 -1
  11. package/dist/helpers/cleanWorkingDirectory.cjs.map +1 -1
  12. package/dist/helpers/cleanWorkingDirectory.js.map +1 -1
  13. package/dist/helpers/compareExpectedOutputFiles.cjs.map +1 -1
  14. package/dist/helpers/compareExpectedOutputFiles.js.map +1 -1
  15. package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.cjs.map +1 -1
  16. package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.js.map +1 -1
  17. package/dist/helpers/copyTestCaseFileInput.cjs.map +1 -1
  18. package/dist/helpers/copyTestCaseFileInput.js.map +1 -1
  19. package/dist/helpers/defaultStdioHarness.cjs.map +1 -1
  20. package/dist/helpers/defaultStdioHarness.js.map +1 -1
  21. package/dist/helpers/findEntryPointFile.cjs.map +1 -1
  22. package/dist/helpers/findEntryPointFile.js.map +1 -1
  23. package/dist/helpers/findLanguageDefinitionByPath.cjs.map +1 -1
  24. package/dist/helpers/findLanguageDefinitionByPath.js.map +1 -1
  25. package/dist/helpers/findModelAnswerDirs.cjs.map +1 -1
  26. package/dist/helpers/findModelAnswerDirs.js.map +1 -1
  27. package/dist/helpers/guiRecording.cjs.map +1 -1
  28. package/dist/helpers/guiRecording.js.map +1 -1
  29. package/dist/helpers/javaJudge.cjs.map +1 -1
  30. package/dist/helpers/javaJudge.js.map +1 -1
  31. package/dist/helpers/judgeByStaticAnalysis.cjs.map +1 -1
  32. package/dist/helpers/judgeByStaticAnalysis.js.map +1 -1
  33. package/dist/helpers/parseArgs.cjs.map +1 -1
  34. package/dist/helpers/parseArgs.js.map +1 -1
  35. package/dist/helpers/parseCsv.cjs.map +1 -1
  36. package/dist/helpers/parseCsv.js.map +1 -1
  37. package/dist/helpers/printDebugBanner.cjs.map +1 -1
  38. package/dist/helpers/printDebugBanner.js.map +1 -1
  39. package/dist/helpers/printTestCaseResult.cjs.map +1 -1
  40. package/dist/helpers/printTestCaseResult.js.map +1 -1
  41. package/dist/helpers/readOutputFiles.cjs.map +1 -1
  42. package/dist/helpers/readOutputFiles.js.map +1 -1
  43. package/dist/helpers/readProblemMarkdownFrontMatter.cjs.map +1 -1
  44. package/dist/helpers/readProblemMarkdownFrontMatter.js.map +1 -1
  45. package/dist/helpers/readTestCases.cjs.map +1 -1
  46. package/dist/helpers/readTestCases.js.map +1 -1
  47. package/dist/helpers/removeCommentsInSourceCode.cjs.map +1 -1
  48. package/dist/helpers/removeCommentsInSourceCode.js.map +1 -1
  49. package/dist/helpers/resolveCwds.cjs.map +1 -1
  50. package/dist/helpers/resolveCwds.js.map +1 -1
  51. package/dist/helpers/runCommandInTemporaryPackageManagerProject.cjs.map +1 -1
  52. package/dist/helpers/runCommandInTemporaryPackageManagerProject.js.map +1 -1
  53. package/dist/helpers/runHarnessProcess.cjs.map +1 -1
  54. package/dist/helpers/runHarnessProcess.js.map +1 -1
  55. package/dist/helpers/runIsolationCheckInDebugMode.cjs.map +1 -1
  56. package/dist/helpers/runIsolationCheckInDebugMode.js.map +1 -1
  57. package/dist/helpers/runPythonInTemporaryUvProject.cjs.map +1 -1
  58. package/dist/helpers/runPythonInTemporaryUvProject.js.map +1 -1
  59. package/dist/helpers/sourceCodeGrammars.cjs.map +1 -1
  60. package/dist/helpers/sourceCodeGrammars.js.map +1 -1
  61. package/dist/helpers/spawnWithLimits.cjs.map +1 -1
  62. package/dist/helpers/spawnWithLimits.js.map +1 -1
  63. package/dist/helpers/spawnWithTimeout.cjs.map +1 -1
  64. package/dist/helpers/spawnWithTimeout.js.map +1 -1
  65. package/dist/helpers/startHttpServer.cjs.map +1 -1
  66. package/dist/helpers/startHttpServer.js.map +1 -1
  67. package/dist/helpers/stdioJudgeRules.cjs.map +1 -1
  68. package/dist/helpers/stdioJudgeRules.js.map +1 -1
  69. package/dist/helpers/temporaryProblemDirCopy.cjs.map +1 -1
  70. package/dist/helpers/temporaryProblemDirCopy.js.map +1 -1
  71. package/dist/learningMaterial/frontmatter.cjs.map +1 -1
  72. package/dist/learningMaterial/frontmatter.js.map +1 -1
  73. package/dist/learningMaterial/fsHelpers.cjs.map +1 -1
  74. package/dist/learningMaterial/fsHelpers.js.map +1 -1
  75. package/dist/learningMaterial/schemas.cjs.map +1 -1
  76. package/dist/learningMaterial/schemas.js.map +1 -1
  77. package/dist/learningMaterial/validateContest.cjs.map +1 -1
  78. package/dist/learningMaterial/validateContest.js.map +1 -1
  79. package/dist/learningMaterial/validateCourse.cjs.map +1 -1
  80. package/dist/learningMaterial/validateCourse.js.map +1 -1
  81. package/dist/learningMaterial/validateMaterial.cjs.map +1 -1
  82. package/dist/learningMaterial/validateMaterial.js.map +1 -1
  83. package/dist/learningMaterial/validateProblem.cjs.map +1 -1
  84. package/dist/learningMaterial/validateProblem.js.map +1 -1
  85. package/dist/learningMaterial/validationResult.cjs.map +1 -1
  86. package/dist/learningMaterial/validationResult.js.map +1 -1
  87. package/dist/presets/command.cjs.map +1 -1
  88. package/dist/presets/command.js.map +1 -1
  89. package/dist/presets/evaluation.cjs.map +1 -1
  90. package/dist/presets/evaluation.js.map +1 -1
  91. package/dist/presets/guiCommand.cjs.map +1 -1
  92. package/dist/presets/guiCommand.js.map +1 -1
  93. package/dist/presets/springBoot.cjs.map +1 -1
  94. package/dist/presets/springBoot.js.map +1 -1
  95. package/dist/presets/stdio.cjs.map +1 -1
  96. package/dist/presets/stdio.js.map +1 -1
  97. package/dist/presets/tomcat.cjs.map +1 -1
  98. package/dist/presets/tomcat.js.map +1 -1
  99. package/dist/types/decisionCode.cjs.map +1 -1
  100. package/dist/types/decisionCode.js.map +1 -1
  101. package/dist/types/language.cjs.map +1 -1
  102. package/dist/types/language.js.map +1 -1
  103. package/dist/types/problem.cjs.map +1 -1
  104. package/dist/types/problem.js.map +1 -1
  105. package/dist/types/testCaseResult.cjs.map +1 -1
  106. package/dist/types/testCaseResult.js.map +1 -1
  107. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"validateCourse.cjs","names":["isRegularDirectory","resolve","isDirectory","realpath","basename","LEARNING_MATERIAL_ID_REGEX","join","dirname","readdir","isRegularFile","isFile","parseYaml","readFile","getErrorMessage","courseFileSchema","formatZodIssues","collectProblemDefinitions","isUsableProblemsDirectory","lstat","relative","sep","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","validateMaterialFile"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateCourse.cjs","names":["isRegularDirectory","resolve","isDirectory","realpath","basename","LEARNING_MATERIAL_ID_REGEX","join","dirname","readdir","isRegularFile","isFile","parseYaml","readFile","getErrorMessage","courseFileSchema","formatZodIssues","collectProblemDefinitions","isUsableProblemsDirectory","lstat","relative","sep","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","validateMaterialFile","require_schemas","require_fsHelpers","require_validationResult","require_validateMaterial","require_validateContest","node_path","node_fs_promises","_willbooster_shared_lib","yaml","validateCourseDirectory","errors","warnings","result","courseDirectoryPath","absoluteDirectoryPath","explicitProblemsDirectoryPath","options","canonicalize","courseId","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","courseFile","courseFileName","error","lectures","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","lectureIds","dirents","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","symlinkFileName","findFirst","courseFilePath","rawContent","parsed","item","items","predicate","definitionPathsById","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","relativePath","parentPath","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","materialOptions","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"validateCourse.js","names":["parseYaml"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateCourse.js","names":["parseYaml","validateCourseDirectory","errors","warnings","result","isRegularDirectory","resolve","courseDirectoryPath","isDirectory","absoluteDirectoryPath","realpath","explicitProblemsDirectoryPath","options","canonicalize","courseId","basename","LEARNING_MATERIAL_ID_REGEX","parsedCourseFile","parseCourseFile","problemsDirectoryPath","availableProblemIds","collectAvailableProblemIds","coursePeriodErrors","validateSubmissionPeriods","courseFile","courseFileName","error","lectures","reportDuplicateIds","lecture","validateLectureDirectory","reportOrphanLectureDirectories","absolutePath","path","join","dirname","lectureIds","dirents","readdir","dirent","entry","isMaterialFileName","courseFileNames","presentFileNames","fileName","isRegularFile","symlinkFileName","findFirst","isFile","courseFilePath","rawContent","parse","readFile","getErrorMessage","parsed","courseFileSchema","formatZodIssues","item","items","predicate","definitionPathsById","collectProblemDefinitions","reportProblemDefinitionIssues","courseProblemsDirectoryPath","namesConventionalDirectory","isSameEntry","isUsableProblemsDirectory","isOutsideDirectory","problemsDirectoryOption","path1","path2","stats1","stats2","lstat","relativePath","relative","parentPath","sep","lectureDirectoryPath","lectureId","materialIds","fileNames","f1","f2","materialFilePath","materialResult","CONTEST_MATERIAL_FILE_SUFFIX","validateContestFile","materialOptions","validateMaterialFile","warning","duplicateMaterialErrors"],"sources":["../../src/learningMaterial/validateCourse.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath } from 'node:fs/promises';\nimport { basename, dirname, join, relative, resolve, sep } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport {\n collectProblemDefinitions,\n isDirectory,\n isFile,\n isRegularDirectory,\n isRegularFile,\n isUsableProblemsDirectory,\n reportProblemDefinitionIssues,\n} from './fsHelpers.js';\nimport {\n CONTEST_MATERIAL_FILE_SUFFIX,\n courseFileSchema,\n LEARNING_MATERIAL_ID_REGEX,\n type CourseFile,\n} from './schemas.js';\nimport { validateContestFile } from './validateContest.js';\nimport { validateMaterialFile, validateSubmissionPeriods, type MaterialValidationOptions } from './validateMaterial.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\nexport interface CourseValidationOptions {\n /**\n * Optional extra check only: problem references always resolve against the problems discovered\n * inside the course directory, so a given directory must lie inside it (an outside or missing one\n * is an error).\n */\n problemsDirectoryPath?: string;\n}\n\n/** Validates an exercode course directory (course.yaml plus one directory per lecture with material files). */\nexport async function validateCourseDirectory(\n courseDirectoryPath: string,\n options: CourseValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n // Course discovery does not traverse a symbolic link, so a linked course directory is never imported.\n if (!(await isRegularDirectory(resolve(courseDirectoryPath)))) {\n errors.push(\n (await isDirectory(resolve(courseDirectoryPath)))\n ? `course directory is a symbolic link, which the judge does not traverse: ${courseDirectoryPath}`\n : `course directory not found: ${courseDirectoryPath}`\n );\n return result;\n }\n // Canonical paths make the \"inside the course\" comparisons below independent of how the caller\n // spelled either path (e.g. `/var` vs `/private/var` on macOS).\n const absoluteDirectoryPath = await realpath(courseDirectoryPath);\n const explicitProblemsDirectoryPath =\n options.problemsDirectoryPath === undefined ? undefined : await canonicalize(options.problemsDirectoryPath);\n const courseId = basename(absoluteDirectoryPath);\n if (!LEARNING_MATERIAL_ID_REGEX.test(courseId)) {\n errors.push(`course ID \"${courseId}\" (directory name) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n const parsedCourseFile = await parseCourseFile(absoluteDirectoryPath, errors);\n // Messages name the course directory because that is what discovery searches (see collectAvailableProblemIds).\n const problemsDirectoryPath = absoluteDirectoryPath;\n const availableProblemIds = await collectAvailableProblemIds(\n absoluteDirectoryPath,\n options.problemsDirectoryPath,\n explicitProblemsDirectoryPath,\n errors\n );\n\n if (!parsedCourseFile) return result;\n const { courseFile, courseFileName } = parsedCourseFile;\n const coursePeriodErrors: string[] = [];\n validateSubmissionPeriods(courseFile, coursePeriodErrors);\n errors.push(...coursePeriodErrors.map((error) => `${courseFileName}: ${error}`));\n // The importer treats a missing `lectures` as an empty list, so a course without lectures imports\n // nothing but is valid.\n const lectures = courseFile.lectures ?? [];\n if (lectures.length === 0) {\n warnings.push(`${courseFileName} declares no lectures, so no materials are imported`);\n }\n reportDuplicateIds(\n lectures.map((lecture) => lecture.id),\n 'lecture',\n errors\n );\n for (const lecture of lectures) {\n await validateLectureDirectory(\n absoluteDirectoryPath,\n { courseId, courseSubmissionPeriods: courseFile, problemsDirectoryPath, availableProblemIds },\n lecture.id,\n errors,\n warnings\n );\n }\n await reportOrphanLectureDirectories(absoluteDirectoryPath, courseFile, courseFileName, warnings);\n return result;\n}\n\n/**\n * Resolves symbolic links (and, on a case-insensitive file system, the casing) of a regular\n * directory; a linked or missing final entry keeps its own name so it can be reported as such,\n * with only its ancestors resolved.\n */\nasync function canonicalize(path: string): Promise<string> {\n const absolutePath = resolve(path);\n if (await isRegularDirectory(absolutePath)) return realpath(absolutePath);\n try {\n return join(await realpath(dirname(absolutePath)), basename(absolutePath));\n } catch {\n return absolutePath;\n }\n}\n\n/** Directories not listed as lectures are never imported, so their materials silently disappear. */\nasync function reportOrphanLectureDirectories(\n courseDirectoryPath: string,\n courseFile: CourseFile,\n courseFileName: string,\n warnings: string[]\n): Promise<void> {\n const lectureIds = new Set((courseFile.lectures ?? []).map((lecture) => lecture.id));\n const dirents = await readdir(courseDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (\n !dirent.isDirectory() ||\n dirent.name.startsWith('.') ||\n lectureIds.has(dirent.name) ||\n dirent.name === 'problems'\n )\n continue;\n // A lecture imports only its direct files, so only a directory that directly holds material files\n // would gain anything from being listed (problems are found at any depth regardless).\n const entries = await readdir(join(courseDirectoryPath, dirent.name), { withFileTypes: true });\n if (!entries.some((entry) => entry.isFile() && isMaterialFileName(entry.name))) continue;\n warnings.push(\n `directory \"${dirent.name}\" is not listed as a lecture in ${courseFileName}, so its materials are not imported`\n );\n }\n}\n\nasync function parseCourseFile(\n courseDirectoryPath: string,\n errors: string[]\n): Promise<{ courseFile: CourseFile; courseFileName: string } | undefined> {\n // The judge discovers courses by `course.yaml` or `course.yml` (rejecting a directory with both)\n // and skips symbolic links.\n const courseFileNames = ['course.yaml', 'course.yml'];\n const presentFileNames: string[] = [];\n for (const fileName of courseFileNames) {\n if (await isRegularFile(join(courseDirectoryPath, fileName))) presentFileNames.push(fileName);\n }\n if (presentFileNames.length > 1) {\n errors.push('both course.yaml and course.yml exist; the judge rejects the conflicting course files');\n return undefined;\n }\n const courseFileName = presentFileNames[0];\n if (courseFileName === undefined) {\n const symlinkFileName = await findFirst(courseFileNames, (fileName) => isFile(join(courseDirectoryPath, fileName)));\n errors.push(\n symlinkFileName === undefined\n ? 'course.yaml not found'\n : `${symlinkFileName} is a symbolic link, which the judge ignores; commit a regular file`\n );\n return undefined;\n }\n const courseFilePath = join(courseDirectoryPath, courseFileName);\n let rawContent: unknown;\n try {\n rawContent = parseYaml(await readFile(courseFilePath, 'utf8'));\n } catch (error) {\n errors.push(`${courseFileName}: invalid YAML: ${getErrorMessage(error)}`);\n return undefined;\n }\n const parsed = courseFileSchema.safeParse(rawContent);\n if (!parsed.success) {\n errors.push(...formatZodIssues(parsed.error, courseFileName));\n return undefined;\n }\n return { courseFile: parsed.data, courseFileName };\n}\n\nasync function findFirst<T>(items: readonly T[], predicate: (item: T) => Promise<boolean>): Promise<T | undefined> {\n for (const item of items) {\n if (await predicate(item)) return item;\n }\n return undefined;\n}\n\nfunction isMaterialFileName(fileName: string): boolean {\n return (\n fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX) ||\n (fileName.endsWith('.md') && fileName !== 'problem.md' && !fileName.endsWith('.problem.md'))\n );\n}\n\n/**\n * The judge scopes every problem inside the course directory (at any depth) to the course, so the\n * course is always searched; an explicit directory must lie inside it.\n */\nasync function collectAvailableProblemIds(\n courseDirectoryPath: string,\n problemsDirectoryOption: string | undefined,\n explicitProblemsDirectoryPath: string | undefined,\n errors: string[]\n): Promise<Set<string>> {\n const definitionPathsById = await collectProblemDefinitions(courseDirectoryPath);\n reportProblemDefinitionIssues(definitionPathsById, errors);\n const availableProblemIds = new Set(definitionPathsById.keys());\n // Discovery does not traverse a symbolic link, so a linked problems directory imports nothing.\n const courseProblemsDirectoryPath = join(courseDirectoryPath, 'problems');\n const namesConventionalDirectory =\n explicitProblemsDirectoryPath !== undefined &&\n (await isSameEntry(explicitProblemsDirectoryPath, courseProblemsDirectoryPath));\n if (namesConventionalDirectory || (await isDirectory(courseProblemsDirectoryPath))) {\n await isUsableProblemsDirectory(courseProblemsDirectoryPath, errors);\n }\n if (explicitProblemsDirectoryPath === undefined || namesConventionalDirectory) return availableProblemIds;\n if (\n (await isUsableProblemsDirectory(explicitProblemsDirectoryPath, errors)) &&\n isOutsideDirectory(courseDirectoryPath, explicitProblemsDirectoryPath)\n ) {\n // Exercode links a material only to problems inside its course, so outside problems never count.\n errors.push(\n `problems directory ${problemsDirectoryOption} is outside the course directory; Exercode links a material only to problems inside the course, so move them under the course`\n );\n }\n return availableProblemIds;\n}\n\n/** Whether two paths name the same directory entry (e.g. differently cased on a case-insensitive file system). */\nasync function isSameEntry(path1: string, path2: string): Promise<boolean> {\n if (path1 === path2) return true;\n try {\n const [stats1, stats2] = await Promise.all([lstat(path1), lstat(path2)]);\n return stats1.dev === stats2.dev && stats1.ino === stats2.ino;\n } catch {\n return false;\n }\n}\n\nfunction isOutsideDirectory(parentPath: string, path: string): boolean {\n const relativePath = relative(parentPath, path);\n return relativePath === '..' || relativePath.startsWith(`..${sep}`);\n}\n\nasync function validateLectureDirectory(\n courseDirectoryPath: string,\n materialOptions: MaterialValidationOptions,\n lectureId: string,\n errors: string[],\n warnings: string[]\n): Promise<void> {\n const lectureDirectoryPath = join(courseDirectoryPath, lectureId);\n // The judge lists lecture entries as regular files and directories, so symbolic links vanish.\n if (!(await isRegularDirectory(lectureDirectoryPath))) {\n errors.push(\n (await isDirectory(lectureDirectoryPath))\n ? `lecture \"${lectureId}\": directory is a symbolic link, which the judge does not traverse`\n : `lecture \"${lectureId}\": directory not found`\n );\n return;\n }\n\n const materialIds: string[] = [];\n const dirents = await readdir(lectureDirectoryPath, { withFileTypes: true });\n for (const dirent of dirents) {\n if (dirent.isSymbolicLink()) {\n errors.push(`${lectureId}/${dirent.name} is a symbolic link, which the judge ignores; commit a regular file`);\n }\n }\n const fileNames = dirents\n .filter((dirent) => dirent.isFile())\n .map((dirent) => dirent.name)\n .toSorted((f1, f2) => f1.localeCompare(f2));\n for (const fileName of fileNames) {\n const materialFilePath = join(lectureDirectoryPath, fileName);\n let materialResult: ValidationResult;\n if (fileName.endsWith(CONTEST_MATERIAL_FILE_SUFFIX)) {\n materialIds.push(fileName.slice(0, -CONTEST_MATERIAL_FILE_SUFFIX.length));\n materialResult = await validateContestFile(materialFilePath, { ...materialOptions, isNestedInCourse: true });\n } else if (fileName.endsWith('.md')) {\n materialIds.push(fileName.replace(/\\.[^/.]+$/, ''));\n materialResult = await validateMaterialFile(materialFilePath, materialOptions);\n } else {\n continue;\n }\n errors.push(...materialResult.errors.map((error) => `${lectureId}/${fileName}: ${error}`));\n warnings.push(...materialResult.warnings.map((warning) => `${lectureId}/${fileName}: ${warning}`));\n }\n\n if (materialIds.length === 0) {\n warnings.push(`lecture \"${lectureId}\": no material files (*.md or *${CONTEST_MATERIAL_FILE_SUFFIX}) found`);\n }\n // Material IDs only need to be unique per lecture: exercode's import key is\n // `${courseId}.${lectureId}.${materialId}`, so a course-wide check would reject courses\n // that import fine. Scope the report to the lecture so multi-lecture courses point at the\n // right files.\n const duplicateMaterialErrors: string[] = [];\n reportDuplicateIds(materialIds, 'material', duplicateMaterialErrors);\n errors.push(...duplicateMaterialErrors.map((error) => `lecture \"${lectureId}\": ${error}`));\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"validateMaterial.cjs","names":["basename","LEARNING_MATERIAL_ID_REGEX","readFile","parseFrontmatter","getErrorMessage","materialFrontmatterSchema","formatZodIssues","collectProblemIdsFromDirectory","parseYaml","questionInCodeBlockSchema"],"sources":["../../src/learningMaterial/validateMaterial.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport { collectProblemIdsFromDirectory, reportDanglingProblemReferences } from './fsHelpers.js';\nimport { parseFrontmatter } from './frontmatter.js';\nimport {\n LEARNING_MATERIAL_ID_REGEX,\n materialFrontmatterSchema,\n questionInCodeBlockSchema,\n type MaterialQuestion,\n} from './schemas.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\n// Same patterns the judge uses to extract embedded questions and problem links from material bodies.\n// The block regex intentionally requires LF line endings and a trailing newline after the closing\n// fence, exactly like the judge parser; blocks it misses are reported by detecting leftover openers.\nconst QUESTION_CODE_BLOCK_REGEX = /(?:^|\\n)(?<fence>`{3,}|~{3,})ya?ml +question\\n(?<yaml>[\\s\\S]+?)\\n\\k<fence>\\n/g;\n// Strictly laxer than the parser regex (tolerates CRLF, indentation, and stray whitespace) so\n// question-looking blocks the judge parser would silently skip surface as leftover openers.\nconst QUESTION_CODE_BLOCK_OPENER_REGEX = /^[ \\t]*(?:`{3,}|~{3,})[ \\t]*ya?ml[ \\t]+question[ \\t]*\\r?$/gm;\nconst PROBLEM_LINK_REGEX = /\\[.*?]\\(problems\\/([0-9_a-z-]+?)\\)/g;\nconst TURTLE_GRAPHICS_QUESTION_LINK_REGEX = /\\[.*?]\\(turtle-graphics-questions\\/([0-9_a-z-]+?)\\)/g;\n// Exercode's duplicate-assignment check scans the body with these patterns, whose link text may\n// span lines (unlike the judge's reference collection above).\nconst PROBLEM_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(problems\\/([0-9_a-z-]+)\\)/g;\nconst QUESTION_LINK_REGEX = /@\\[question]\\(([0-9_a-z-]+)\\)/g;\nconst TURTLE_GRAPHICS_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(turtle-graphics-questions\\/([0-9_a-z-]+)\\)/g;\nconst CHAT_MARKER = '<!-- chat -->';\n// exercode only counts the marker when it stands alone on a line, so fenced or inline mentions don't count.\nconst STANDALONE_CHAT_MARKER_REGEX = /(^|\\r?\\n)<!-- chat -->[ \\t]*(?=\\r?\\n|$)/g;\n\nexport interface SubmissionPeriods {\n submissionOpenedAt?: string;\n submissionSoftClosedAt?: string;\n submissionHardClosedAt?: string;\n}\n\nexport interface MaterialValidationOptions {\n /** Problem IDs already collected for the enclosing course; collected from `problemsDirectoryPath` otherwise. */\n availableProblemIds?: ReadonlySet<string>;\n /** When set, every referenced problem ID must be a problem discovered under this path. */\n problemsDirectoryPath?: string;\n /** Enclosing course ID; the judge rejects problem references whose `courseId` differs from it. */\n courseId?: string;\n /** Course-level submission periods; the judge merges them as `material[field] ?? course[field]`. */\n courseSubmissionPeriods?: SubmissionPeriods;\n}\n\n/** Validates an exercode markdown material file (frontmatter, embedded questions, problem references). */\nexport async function validateMaterialFile(\n materialFilePath: string,\n options: MaterialValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n const materialId = basename(materialFilePath).replace(/\\.[^/.]+$/, '');\n if (!LEARNING_MATERIAL_ID_REGEX.test(materialId)) {\n errors.push(`material ID \"${materialId}\" (file name without extension) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n let markdown: string;\n try {\n markdown = await readFile(materialFilePath, 'utf8');\n } catch {\n errors.push(`material file not found: ${materialFilePath}`);\n return result;\n }\n\n let attributes: unknown;\n let body: string;\n try {\n ({ attributes, body } = parseFrontmatter(markdown));\n } catch (error) {\n errors.push(`invalid YAML frontmatter: ${getErrorMessage(error)}`);\n return result;\n }\n\n const parsedFrontmatter = materialFrontmatterSchema.safeParse(attributes);\n if (!parsedFrontmatter.success) {\n errors.push(...formatZodIssues(parsedFrontmatter.error, 'frontmatter'));\n }\n const frontmatter = parsedFrontmatter.success ? parsedFrontmatter.data : undefined;\n\n const questionsInCodeBlocks = parseQuestionCodeBlocks(body, errors);\n const questionBlockIds = questionsInCodeBlocks.map((question) => question.id);\n const questions = [...(frontmatter?.questions ?? []), ...questionsInCodeBlocks];\n for (const question of questions) {\n validateQuestion(question, errors);\n }\n reportDuplicateIds(\n questions.map((question) => question.id),\n 'question',\n errors\n );\n // Turtle-graphics links in the body merge into the frontmatter list like problem links do,\n // and exercode rejects duplicates in the merged list; deferred until after the question blocks\n // are removed below so links inside question text don't count.\n\n // Exercode's import validation runs two checks: the body must not link the same problem,\n // question or turtle-graphics question twice (every occurrence of the lax link pattern counts),\n // and the generated reference list (frontmatter entries plus the judge's deduplicated body links)\n // must not repeat an ID. The judge replaces recognized question blocks with @[question](id) links\n // before collecting links, so links that appear only inside question text must not count, while\n // each block counts as one question link next to explicit @[question](id) links.\n const bodyWithoutQuestionBlocks = body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n');\n reportDuplicateIds(\n [...questionBlockIds, ...collectLinkedIds(bodyWithoutQuestionBlocks, QUESTION_LINK_REGEX)],\n 'question link',\n errors\n );\n reportDuplicateIds(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_ASSIGNMENT_REGEX), 'problem link', errors);\n reportDuplicateIds(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_ASSIGNMENT_REGEX),\n 'turtle-graphics question link',\n errors\n );\n const linkedTurtleGraphicsQuestionIds = new Set(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_QUESTION_LINK_REGEX)\n );\n reportDuplicateIds(\n [\n ...(frontmatter?.turtleGraphicsQuestions ?? []).map((question) => question.id),\n ...linkedTurtleGraphicsQuestionIds,\n ],\n 'turtleGraphicsQuestion',\n errors\n );\n // Only the links the judge collects become references, and it deduplicates them before merging.\n const problemIds = [\n ...(frontmatter?.problems ?? []).map((problem) => problem.id),\n ...new Set(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_LINK_REGEX)),\n ];\n reportDuplicateIds(problemIds, 'problem', errors);\n reportForeignProblemCourseIds(frontmatter?.problems ?? [], options.courseId, errors);\n const availableProblemIds =\n options.availableProblemIds ??\n (options.problemsDirectoryPath === undefined\n ? undefined\n : await collectProblemIdsFromDirectory(options.problemsDirectoryPath, errors));\n if (availableProblemIds !== undefined) {\n reportDanglingProblemReferences(problemIds, availableProblemIds, options.problemsDirectoryPath, errors);\n }\n\n reportExcessChatMarkers(body, errors);\n\n if (frontmatter) {\n validateSubmissionPeriods(\n mergeSubmissionPeriods(frontmatter, options.courseSubmissionPeriods),\n errors,\n frontmatter\n );\n }\n return result;\n}\n\nfunction collectLinkedIds(body: string, linkRegex: RegExp): string[] {\n return [...body.matchAll(linkRegex)].map((match) => match[1]).filter((id) => id !== undefined);\n}\n\n/** Applies the judge's course→material config merge (`material[field] ?? course[field]`). */\nexport function mergeSubmissionPeriods(\n own: SubmissionPeriods,\n courseDefaults: SubmissionPeriods | undefined\n): SubmissionPeriods {\n return {\n submissionOpenedAt: own.submissionOpenedAt ?? courseDefaults?.submissionOpenedAt,\n submissionSoftClosedAt: own.submissionSoftClosedAt ?? courseDefaults?.submissionSoftClosedAt,\n submissionHardClosedAt: own.submissionHardClosedAt ?? courseDefaults?.submissionHardClosedAt,\n };\n}\n\n/** exercode rejects more than one standalone chat delimiter line in material and problem bodies. */\nexport function reportExcessChatMarkers(body: string, errors: string[]): void {\n const chatMarkerCount = [...body.matchAll(STANDALONE_CHAT_MARKER_REGEX)].length;\n if (chatMarkerCount > 1) {\n errors.push(`at most one \\`${CHAT_MARKER}\\` marker is allowed, but found ${chatMarkerCount}`);\n }\n}\n\n/** The judge's resolveProblemCourseId throws when a problem reference names a different course. */\nexport function reportForeignProblemCourseIds(\n problems: readonly { id: string; courseId?: string }[],\n courseId: string | undefined,\n errors: string[]\n): void {\n if (courseId === undefined) return;\n for (const problem of problems) {\n if (problem.courseId !== undefined && problem.courseId !== courseId) {\n errors.push(`problem \"${problem.id}\": courseId must be \"${courseId}\" but is \"${problem.courseId}\"`);\n }\n }\n}\n\nfunction parseQuestionCodeBlocks(body: string, errors: string[]): MaterialQuestion[] {\n const questions: MaterialQuestion[] = [];\n const matches = [...body.matchAll(QUESTION_CODE_BLOCK_REGEX)];\n // The judge silently renders unparsed question blocks as plain code blocks, so a block the\n // strict regex misses (CRLF line endings, or no newline after the closing fence) would break\n // in production while looking fine here; flag it instead of validating a lie. Openers inside a\n // recognized block (e.g. an example fence in a question's text) are consumed with that block.\n const leftoverOpenerCount = [\n ...body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n').matchAll(QUESTION_CODE_BLOCK_OPENER_REGEX),\n ].length;\n if (leftoverOpenerCount > 0) {\n errors.push(\n `${leftoverOpenerCount} question code block(s) will not be recognized by the judge parser; separate consecutive question blocks with a blank line, use LF line endings, no indentation or trailing whitespace on the fence line, and end the closing fence with a newline`\n );\n }\n for (const [blockIndex, match] of matches.entries()) {\n const yamlText = match.groups?.['yaml'] ?? '';\n let rawQuestion: unknown;\n try {\n rawQuestion = parseYaml(yamlText);\n } catch (error) {\n errors.push(`question block ${blockIndex + 1}: invalid YAML: ${getErrorMessage(error)}`);\n continue;\n }\n const parsed = questionInCodeBlockSchema.safeParse(rawQuestion);\n if (parsed.success) {\n questions.push(parsed.data);\n } else {\n errors.push(...formatZodIssues(parsed.error, `question block ${blockIndex + 1}`));\n }\n }\n return questions;\n}\n\nfunction validateQuestion(question: MaterialQuestion, errors: string[]): void {\n const isSurvey = question.isSurvey === true;\n const [answerFieldName, isAnswerDefined] =\n question.type === 'select'\n ? (['answerIndex', question.answerIndex !== undefined] as const)\n : question.type === 'select_multiple'\n ? (['answerIndices', question.answerIndices !== undefined] as const)\n : (['answerPattern', question.answerPattern !== undefined] as const);\n if (isSurvey && isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must not be set when \\`isSurvey\\` is true`);\n }\n if (!isSurvey && !isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` is required when \\`isSurvey\\` is not true`);\n }\n\n if (question.type === 'select' || question.type === 'select_multiple') {\n const answerIndices =\n question.type === 'select'\n ? Array.isArray(question.answerIndex)\n ? question.answerIndex\n : question.answerIndex === undefined\n ? []\n : [question.answerIndex]\n : (question.answerIndices ?? []);\n // An empty answer set is broken at runtime (select: never correct; select_multiple: an empty\n // submission grades as correct), and exercode's material editor rejects it as well.\n if (!isSurvey && isAnswerDefined && answerIndices.length === 0) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must contain at least one correct answer`);\n }\n for (const answerIndex of answerIndices) {\n if (!Number.isInteger(answerIndex) || answerIndex < 0 || answerIndex >= question.options.length) {\n errors.push(\n `question ${question.id}: ${answerFieldName} out of bounds: ${answerIndex}; expected 0 <= index < options length ${question.options.length}`\n );\n }\n }\n return;\n }\n\n if (isSurvey || question.answerPattern === undefined) return;\n const answerPattern = question.answerPattern.trim();\n let answerRegExp: RegExp;\n let rawAnswerRegExp: RegExp;\n try {\n answerRegExp = new RegExp(`^(?:${answerPattern})$`);\n // Compiled inside the same guard: a pattern like `a)(b` is valid only once wrapped above,\n // so an unguarded raw compile would crash the CLI instead of reporting an error.\n rawAnswerRegExp = new RegExp(question.answerPattern);\n } catch {\n errors.push(\n `question ${question.id}: \\`answerPattern\\` is not a valid regular expression: ${question.answerPattern}`\n );\n return;\n }\n // Mirrors the judge heuristic: a pattern that doesn't match itself likely uses RegExp syntax,\n // so a modelAnswer is required to prove that some concrete answer is accepted.\n // Like the judge, an empty modelAnswer counts as missing.\n // The judge runs this heuristic on the untrimmed pattern and trims only for the anchored match.\n if (!question.modelAnswer && !rawAnswerRegExp.test(question.answerPattern)) {\n errors.push(`question ${question.id}: \\`answerPattern\\` seems a RegExp but \\`modelAnswer\\` is missing`);\n }\n if (question.modelAnswer && !answerRegExp.test(question.modelAnswer.trim())) {\n errors.push(`question ${question.id}: \\`modelAnswer\\` doesn't match \\`answerPattern\\``);\n }\n}\n\n/**\n * Reports inverted submission periods; shared by material, course.yaml, and contest validation.\n * When `ownPeriods` is given (the merged-values case), pairs whose both values are inherited from\n * the course are skipped: they are the course's mistake and are already reported against\n * course.yaml, so repeating them per material would send agents to files with nothing to fix.\n */\nexport function validateSubmissionPeriods(\n periods: SubmissionPeriods,\n errors: string[],\n ownPeriods: SubmissionPeriods = periods\n): void {\n const pairs = [\n ['submissionOpenedAt', 'submissionSoftClosedAt'],\n ['submissionSoftClosedAt', 'submissionHardClosedAt'],\n ['submissionOpenedAt', 'submissionHardClosedAt'],\n ] as const satisfies readonly (readonly [keyof SubmissionPeriods, keyof SubmissionPeriods])[];\n for (const [earlierName, laterName] of pairs) {\n const earlierValue = periods[earlierName];\n const laterValue = periods[laterName];\n if (earlierValue === undefined || laterValue === undefined) continue;\n if (ownPeriods[earlierName] === undefined && ownPeriods[laterName] === undefined) continue;\n if (new Date(laterValue).getTime() < new Date(earlierValue).getTime()) {\n errors.push(`${earlierName} (${earlierValue}) must be <= ${laterName} (${laterValue})`);\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateMaterial.cjs","names":["basename","LEARNING_MATERIAL_ID_REGEX","readFile","parseFrontmatter","getErrorMessage","materialFrontmatterSchema","formatZodIssues","collectProblemIdsFromDirectory","parseYaml","questionInCodeBlockSchema","require_schemas","require_fsHelpers","require_frontmatter","require_validationResult","node_path","node_fs_promises","_willbooster_shared_lib","yaml","QUESTION_CODE_BLOCK_REGEX","QUESTION_CODE_BLOCK_OPENER_REGEX","PROBLEM_LINK_REGEX","TURTLE_GRAPHICS_QUESTION_LINK_REGEX","PROBLEM_ASSIGNMENT_REGEX","QUESTION_LINK_REGEX","TURTLE_GRAPHICS_ASSIGNMENT_REGEX","STANDALONE_CHAT_MARKER_REGEX","validateMaterialFile","errors","result","materialId","materialFilePath","markdown","attributes","body","error","parsedFrontmatter","frontmatter","questionsInCodeBlocks","parseQuestionCodeBlocks","questionBlockIds","question","questions","validateQuestion","bodyWithoutQuestionBlocks","collectLinkedIds","linkedTurtleGraphicsQuestionIds","problemIds","problem","reportForeignProblemCourseIds","options","availableProblemIds","reportExcessChatMarkers","validateSubmissionPeriods","mergeSubmissionPeriods","linkRegex","match","id","own","courseDefaults","chatMarkerCount","courseId","problems","matches","leftoverOpenerCount","blockIndex","yamlText","\"yaml\"","rawQuestion","parsed","isSurvey","answerFieldName","isAnswerDefined","answerIndices","answerIndex","answerPattern","answerRegExp","rawAnswerRegExp","periods","earlierName","laterName","earlierValue","laterValue","ownPeriods"],"sources":["../../src/learningMaterial/validateMaterial.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport { collectProblemIdsFromDirectory, reportDanglingProblemReferences } from './fsHelpers.js';\nimport { parseFrontmatter } from './frontmatter.js';\nimport {\n LEARNING_MATERIAL_ID_REGEX,\n materialFrontmatterSchema,\n questionInCodeBlockSchema,\n type MaterialQuestion,\n} from './schemas.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\n// Same patterns the judge uses to extract embedded questions and problem links from material bodies.\n// The block regex intentionally requires LF line endings and a trailing newline after the closing\n// fence, exactly like the judge parser; blocks it misses are reported by detecting leftover openers.\nconst QUESTION_CODE_BLOCK_REGEX = /(?:^|\\n)(?<fence>`{3,}|~{3,})ya?ml +question\\n(?<yaml>[\\s\\S]+?)\\n\\k<fence>\\n/g;\n// Strictly laxer than the parser regex (tolerates CRLF, indentation, and stray whitespace) so\n// question-looking blocks the judge parser would silently skip surface as leftover openers.\nconst QUESTION_CODE_BLOCK_OPENER_REGEX = /^[ \\t]*(?:`{3,}|~{3,})[ \\t]*ya?ml[ \\t]+question[ \\t]*\\r?$/gm;\nconst PROBLEM_LINK_REGEX = /\\[.*?]\\(problems\\/([0-9_a-z-]+?)\\)/g;\nconst TURTLE_GRAPHICS_QUESTION_LINK_REGEX = /\\[.*?]\\(turtle-graphics-questions\\/([0-9_a-z-]+?)\\)/g;\n// Exercode's duplicate-assignment check scans the body with these patterns, whose link text may\n// span lines (unlike the judge's reference collection above).\nconst PROBLEM_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(problems\\/([0-9_a-z-]+)\\)/g;\nconst QUESTION_LINK_REGEX = /@\\[question]\\(([0-9_a-z-]+)\\)/g;\nconst TURTLE_GRAPHICS_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(turtle-graphics-questions\\/([0-9_a-z-]+)\\)/g;\nconst CHAT_MARKER = '<!-- chat -->';\n// exercode only counts the marker when it stands alone on a line, so fenced or inline mentions don't count.\nconst STANDALONE_CHAT_MARKER_REGEX = /(^|\\r?\\n)<!-- chat -->[ \\t]*(?=\\r?\\n|$)/g;\n\nexport interface SubmissionPeriods {\n submissionOpenedAt?: string;\n submissionSoftClosedAt?: string;\n submissionHardClosedAt?: string;\n}\n\nexport interface MaterialValidationOptions {\n /** Problem IDs already collected for the enclosing course; collected from `problemsDirectoryPath` otherwise. */\n availableProblemIds?: ReadonlySet<string>;\n /** When set, every referenced problem ID must be a problem discovered under this path. */\n problemsDirectoryPath?: string;\n /** Enclosing course ID; the judge rejects problem references whose `courseId` differs from it. */\n courseId?: string;\n /** Course-level submission periods; the judge merges them as `material[field] ?? course[field]`. */\n courseSubmissionPeriods?: SubmissionPeriods;\n}\n\n/** Validates an exercode markdown material file (frontmatter, embedded questions, problem references). */\nexport async function validateMaterialFile(\n materialFilePath: string,\n options: MaterialValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n const materialId = basename(materialFilePath).replace(/\\.[^/.]+$/, '');\n if (!LEARNING_MATERIAL_ID_REGEX.test(materialId)) {\n errors.push(`material ID \"${materialId}\" (file name without extension) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n let markdown: string;\n try {\n markdown = await readFile(materialFilePath, 'utf8');\n } catch {\n errors.push(`material file not found: ${materialFilePath}`);\n return result;\n }\n\n let attributes: unknown;\n let body: string;\n try {\n ({ attributes, body } = parseFrontmatter(markdown));\n } catch (error) {\n errors.push(`invalid YAML frontmatter: ${getErrorMessage(error)}`);\n return result;\n }\n\n const parsedFrontmatter = materialFrontmatterSchema.safeParse(attributes);\n if (!parsedFrontmatter.success) {\n errors.push(...formatZodIssues(parsedFrontmatter.error, 'frontmatter'));\n }\n const frontmatter = parsedFrontmatter.success ? parsedFrontmatter.data : undefined;\n\n const questionsInCodeBlocks = parseQuestionCodeBlocks(body, errors);\n const questionBlockIds = questionsInCodeBlocks.map((question) => question.id);\n const questions = [...(frontmatter?.questions ?? []), ...questionsInCodeBlocks];\n for (const question of questions) {\n validateQuestion(question, errors);\n }\n reportDuplicateIds(\n questions.map((question) => question.id),\n 'question',\n errors\n );\n // Turtle-graphics links in the body merge into the frontmatter list like problem links do,\n // and exercode rejects duplicates in the merged list; deferred until after the question blocks\n // are removed below so links inside question text don't count.\n\n // Exercode's import validation runs two checks: the body must not link the same problem,\n // question or turtle-graphics question twice (every occurrence of the lax link pattern counts),\n // and the generated reference list (frontmatter entries plus the judge's deduplicated body links)\n // must not repeat an ID. The judge replaces recognized question blocks with @[question](id) links\n // before collecting links, so links that appear only inside question text must not count, while\n // each block counts as one question link next to explicit @[question](id) links.\n const bodyWithoutQuestionBlocks = body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n');\n reportDuplicateIds(\n [...questionBlockIds, ...collectLinkedIds(bodyWithoutQuestionBlocks, QUESTION_LINK_REGEX)],\n 'question link',\n errors\n );\n reportDuplicateIds(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_ASSIGNMENT_REGEX), 'problem link', errors);\n reportDuplicateIds(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_ASSIGNMENT_REGEX),\n 'turtle-graphics question link',\n errors\n );\n const linkedTurtleGraphicsQuestionIds = new Set(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_QUESTION_LINK_REGEX)\n );\n reportDuplicateIds(\n [\n ...(frontmatter?.turtleGraphicsQuestions ?? []).map((question) => question.id),\n ...linkedTurtleGraphicsQuestionIds,\n ],\n 'turtleGraphicsQuestion',\n errors\n );\n // Only the links the judge collects become references, and it deduplicates them before merging.\n const problemIds = [\n ...(frontmatter?.problems ?? []).map((problem) => problem.id),\n ...new Set(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_LINK_REGEX)),\n ];\n reportDuplicateIds(problemIds, 'problem', errors);\n reportForeignProblemCourseIds(frontmatter?.problems ?? [], options.courseId, errors);\n const availableProblemIds =\n options.availableProblemIds ??\n (options.problemsDirectoryPath === undefined\n ? undefined\n : await collectProblemIdsFromDirectory(options.problemsDirectoryPath, errors));\n if (availableProblemIds !== undefined) {\n reportDanglingProblemReferences(problemIds, availableProblemIds, options.problemsDirectoryPath, errors);\n }\n\n reportExcessChatMarkers(body, errors);\n\n if (frontmatter) {\n validateSubmissionPeriods(\n mergeSubmissionPeriods(frontmatter, options.courseSubmissionPeriods),\n errors,\n frontmatter\n );\n }\n return result;\n}\n\nfunction collectLinkedIds(body: string, linkRegex: RegExp): string[] {\n return [...body.matchAll(linkRegex)].map((match) => match[1]).filter((id) => id !== undefined);\n}\n\n/** Applies the judge's course→material config merge (`material[field] ?? course[field]`). */\nexport function mergeSubmissionPeriods(\n own: SubmissionPeriods,\n courseDefaults: SubmissionPeriods | undefined\n): SubmissionPeriods {\n return {\n submissionOpenedAt: own.submissionOpenedAt ?? courseDefaults?.submissionOpenedAt,\n submissionSoftClosedAt: own.submissionSoftClosedAt ?? courseDefaults?.submissionSoftClosedAt,\n submissionHardClosedAt: own.submissionHardClosedAt ?? courseDefaults?.submissionHardClosedAt,\n };\n}\n\n/** exercode rejects more than one standalone chat delimiter line in material and problem bodies. */\nexport function reportExcessChatMarkers(body: string, errors: string[]): void {\n const chatMarkerCount = [...body.matchAll(STANDALONE_CHAT_MARKER_REGEX)].length;\n if (chatMarkerCount > 1) {\n errors.push(`at most one \\`${CHAT_MARKER}\\` marker is allowed, but found ${chatMarkerCount}`);\n }\n}\n\n/** The judge's resolveProblemCourseId throws when a problem reference names a different course. */\nexport function reportForeignProblemCourseIds(\n problems: readonly { id: string; courseId?: string }[],\n courseId: string | undefined,\n errors: string[]\n): void {\n if (courseId === undefined) return;\n for (const problem of problems) {\n if (problem.courseId !== undefined && problem.courseId !== courseId) {\n errors.push(`problem \"${problem.id}\": courseId must be \"${courseId}\" but is \"${problem.courseId}\"`);\n }\n }\n}\n\nfunction parseQuestionCodeBlocks(body: string, errors: string[]): MaterialQuestion[] {\n const questions: MaterialQuestion[] = [];\n const matches = [...body.matchAll(QUESTION_CODE_BLOCK_REGEX)];\n // The judge silently renders unparsed question blocks as plain code blocks, so a block the\n // strict regex misses (CRLF line endings, or no newline after the closing fence) would break\n // in production while looking fine here; flag it instead of validating a lie. Openers inside a\n // recognized block (e.g. an example fence in a question's text) are consumed with that block.\n const leftoverOpenerCount = [\n ...body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n').matchAll(QUESTION_CODE_BLOCK_OPENER_REGEX),\n ].length;\n if (leftoverOpenerCount > 0) {\n errors.push(\n `${leftoverOpenerCount} question code block(s) will not be recognized by the judge parser; separate consecutive question blocks with a blank line, use LF line endings, no indentation or trailing whitespace on the fence line, and end the closing fence with a newline`\n );\n }\n for (const [blockIndex, match] of matches.entries()) {\n const yamlText = match.groups?.['yaml'] ?? '';\n let rawQuestion: unknown;\n try {\n rawQuestion = parseYaml(yamlText);\n } catch (error) {\n errors.push(`question block ${blockIndex + 1}: invalid YAML: ${getErrorMessage(error)}`);\n continue;\n }\n const parsed = questionInCodeBlockSchema.safeParse(rawQuestion);\n if (parsed.success) {\n questions.push(parsed.data);\n } else {\n errors.push(...formatZodIssues(parsed.error, `question block ${blockIndex + 1}`));\n }\n }\n return questions;\n}\n\nfunction validateQuestion(question: MaterialQuestion, errors: string[]): void {\n const isSurvey = question.isSurvey === true;\n const [answerFieldName, isAnswerDefined] =\n question.type === 'select'\n ? (['answerIndex', question.answerIndex !== undefined] as const)\n : question.type === 'select_multiple'\n ? (['answerIndices', question.answerIndices !== undefined] as const)\n : (['answerPattern', question.answerPattern !== undefined] as const);\n if (isSurvey && isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must not be set when \\`isSurvey\\` is true`);\n }\n if (!isSurvey && !isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` is required when \\`isSurvey\\` is not true`);\n }\n\n if (question.type === 'select' || question.type === 'select_multiple') {\n const answerIndices =\n question.type === 'select'\n ? Array.isArray(question.answerIndex)\n ? question.answerIndex\n : question.answerIndex === undefined\n ? []\n : [question.answerIndex]\n : (question.answerIndices ?? []);\n // An empty answer set is broken at runtime (select: never correct; select_multiple: an empty\n // submission grades as correct), and exercode's material editor rejects it as well.\n if (!isSurvey && isAnswerDefined && answerIndices.length === 0) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must contain at least one correct answer`);\n }\n for (const answerIndex of answerIndices) {\n if (!Number.isInteger(answerIndex) || answerIndex < 0 || answerIndex >= question.options.length) {\n errors.push(\n `question ${question.id}: ${answerFieldName} out of bounds: ${answerIndex}; expected 0 <= index < options length ${question.options.length}`\n );\n }\n }\n return;\n }\n\n if (isSurvey || question.answerPattern === undefined) return;\n const answerPattern = question.answerPattern.trim();\n let answerRegExp: RegExp;\n let rawAnswerRegExp: RegExp;\n try {\n answerRegExp = new RegExp(`^(?:${answerPattern})$`);\n // Compiled inside the same guard: a pattern like `a)(b` is valid only once wrapped above,\n // so an unguarded raw compile would crash the CLI instead of reporting an error.\n rawAnswerRegExp = new RegExp(question.answerPattern);\n } catch {\n errors.push(\n `question ${question.id}: \\`answerPattern\\` is not a valid regular expression: ${question.answerPattern}`\n );\n return;\n }\n // Mirrors the judge heuristic: a pattern that doesn't match itself likely uses RegExp syntax,\n // so a modelAnswer is required to prove that some concrete answer is accepted.\n // Like the judge, an empty modelAnswer counts as missing.\n // The judge runs this heuristic on the untrimmed pattern and trims only for the anchored match.\n if (!question.modelAnswer && !rawAnswerRegExp.test(question.answerPattern)) {\n errors.push(`question ${question.id}: \\`answerPattern\\` seems a RegExp but \\`modelAnswer\\` is missing`);\n }\n if (question.modelAnswer && !answerRegExp.test(question.modelAnswer.trim())) {\n errors.push(`question ${question.id}: \\`modelAnswer\\` doesn't match \\`answerPattern\\``);\n }\n}\n\n/**\n * Reports inverted submission periods; shared by material, course.yaml, and contest validation.\n * When `ownPeriods` is given (the merged-values case), pairs whose both values are inherited from\n * the course are skipped: they are the course's mistake and are already reported against\n * course.yaml, so repeating them per material would send agents to files with nothing to fix.\n */\nexport function validateSubmissionPeriods(\n periods: SubmissionPeriods,\n errors: string[],\n ownPeriods: SubmissionPeriods = periods\n): void {\n const pairs = [\n ['submissionOpenedAt', 'submissionSoftClosedAt'],\n ['submissionSoftClosedAt', 'submissionHardClosedAt'],\n ['submissionOpenedAt', 'submissionHardClosedAt'],\n ] as const satisfies readonly (readonly [keyof SubmissionPeriods, keyof SubmissionPeriods])[];\n for (const [earlierName, laterName] of pairs) {\n const earlierValue = periods[earlierName];\n const laterValue = periods[laterName];\n if (earlierValue === undefined || laterValue === undefined) continue;\n if (ownPeriods[earlierName] === undefined && ownPeriods[laterName] === undefined) continue;\n if (new Date(laterValue).getTime() < new Date(earlierValue).getTime()) {\n errors.push(`${earlierName} (${earlierValue}) must be <= ${laterName} (${laterValue})`);\n }\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"validateMaterial.js","names":["parseYaml"],"sources":["../../src/learningMaterial/validateMaterial.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport { collectProblemIdsFromDirectory, reportDanglingProblemReferences } from './fsHelpers.js';\nimport { parseFrontmatter } from './frontmatter.js';\nimport {\n LEARNING_MATERIAL_ID_REGEX,\n materialFrontmatterSchema,\n questionInCodeBlockSchema,\n type MaterialQuestion,\n} from './schemas.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\n// Same patterns the judge uses to extract embedded questions and problem links from material bodies.\n// The block regex intentionally requires LF line endings and a trailing newline after the closing\n// fence, exactly like the judge parser; blocks it misses are reported by detecting leftover openers.\nconst QUESTION_CODE_BLOCK_REGEX = /(?:^|\\n)(?<fence>`{3,}|~{3,})ya?ml +question\\n(?<yaml>[\\s\\S]+?)\\n\\k<fence>\\n/g;\n// Strictly laxer than the parser regex (tolerates CRLF, indentation, and stray whitespace) so\n// question-looking blocks the judge parser would silently skip surface as leftover openers.\nconst QUESTION_CODE_BLOCK_OPENER_REGEX = /^[ \\t]*(?:`{3,}|~{3,})[ \\t]*ya?ml[ \\t]+question[ \\t]*\\r?$/gm;\nconst PROBLEM_LINK_REGEX = /\\[.*?]\\(problems\\/([0-9_a-z-]+?)\\)/g;\nconst TURTLE_GRAPHICS_QUESTION_LINK_REGEX = /\\[.*?]\\(turtle-graphics-questions\\/([0-9_a-z-]+?)\\)/g;\n// Exercode's duplicate-assignment check scans the body with these patterns, whose link text may\n// span lines (unlike the judge's reference collection above).\nconst PROBLEM_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(problems\\/([0-9_a-z-]+)\\)/g;\nconst QUESTION_LINK_REGEX = /@\\[question]\\(([0-9_a-z-]+)\\)/g;\nconst TURTLE_GRAPHICS_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(turtle-graphics-questions\\/([0-9_a-z-]+)\\)/g;\nconst CHAT_MARKER = '<!-- chat -->';\n// exercode only counts the marker when it stands alone on a line, so fenced or inline mentions don't count.\nconst STANDALONE_CHAT_MARKER_REGEX = /(^|\\r?\\n)<!-- chat -->[ \\t]*(?=\\r?\\n|$)/g;\n\nexport interface SubmissionPeriods {\n submissionOpenedAt?: string;\n submissionSoftClosedAt?: string;\n submissionHardClosedAt?: string;\n}\n\nexport interface MaterialValidationOptions {\n /** Problem IDs already collected for the enclosing course; collected from `problemsDirectoryPath` otherwise. */\n availableProblemIds?: ReadonlySet<string>;\n /** When set, every referenced problem ID must be a problem discovered under this path. */\n problemsDirectoryPath?: string;\n /** Enclosing course ID; the judge rejects problem references whose `courseId` differs from it. */\n courseId?: string;\n /** Course-level submission periods; the judge merges them as `material[field] ?? course[field]`. */\n courseSubmissionPeriods?: SubmissionPeriods;\n}\n\n/** Validates an exercode markdown material file (frontmatter, embedded questions, problem references). */\nexport async function validateMaterialFile(\n materialFilePath: string,\n options: MaterialValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n const materialId = basename(materialFilePath).replace(/\\.[^/.]+$/, '');\n if (!LEARNING_MATERIAL_ID_REGEX.test(materialId)) {\n errors.push(`material ID \"${materialId}\" (file name without extension) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n let markdown: string;\n try {\n markdown = await readFile(materialFilePath, 'utf8');\n } catch {\n errors.push(`material file not found: ${materialFilePath}`);\n return result;\n }\n\n let attributes: unknown;\n let body: string;\n try {\n ({ attributes, body } = parseFrontmatter(markdown));\n } catch (error) {\n errors.push(`invalid YAML frontmatter: ${getErrorMessage(error)}`);\n return result;\n }\n\n const parsedFrontmatter = materialFrontmatterSchema.safeParse(attributes);\n if (!parsedFrontmatter.success) {\n errors.push(...formatZodIssues(parsedFrontmatter.error, 'frontmatter'));\n }\n const frontmatter = parsedFrontmatter.success ? parsedFrontmatter.data : undefined;\n\n const questionsInCodeBlocks = parseQuestionCodeBlocks(body, errors);\n const questionBlockIds = questionsInCodeBlocks.map((question) => question.id);\n const questions = [...(frontmatter?.questions ?? []), ...questionsInCodeBlocks];\n for (const question of questions) {\n validateQuestion(question, errors);\n }\n reportDuplicateIds(\n questions.map((question) => question.id),\n 'question',\n errors\n );\n // Turtle-graphics links in the body merge into the frontmatter list like problem links do,\n // and exercode rejects duplicates in the merged list; deferred until after the question blocks\n // are removed below so links inside question text don't count.\n\n // Exercode's import validation runs two checks: the body must not link the same problem,\n // question or turtle-graphics question twice (every occurrence of the lax link pattern counts),\n // and the generated reference list (frontmatter entries plus the judge's deduplicated body links)\n // must not repeat an ID. The judge replaces recognized question blocks with @[question](id) links\n // before collecting links, so links that appear only inside question text must not count, while\n // each block counts as one question link next to explicit @[question](id) links.\n const bodyWithoutQuestionBlocks = body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n');\n reportDuplicateIds(\n [...questionBlockIds, ...collectLinkedIds(bodyWithoutQuestionBlocks, QUESTION_LINK_REGEX)],\n 'question link',\n errors\n );\n reportDuplicateIds(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_ASSIGNMENT_REGEX), 'problem link', errors);\n reportDuplicateIds(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_ASSIGNMENT_REGEX),\n 'turtle-graphics question link',\n errors\n );\n const linkedTurtleGraphicsQuestionIds = new Set(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_QUESTION_LINK_REGEX)\n );\n reportDuplicateIds(\n [\n ...(frontmatter?.turtleGraphicsQuestions ?? []).map((question) => question.id),\n ...linkedTurtleGraphicsQuestionIds,\n ],\n 'turtleGraphicsQuestion',\n errors\n );\n // Only the links the judge collects become references, and it deduplicates them before merging.\n const problemIds = [\n ...(frontmatter?.problems ?? []).map((problem) => problem.id),\n ...new Set(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_LINK_REGEX)),\n ];\n reportDuplicateIds(problemIds, 'problem', errors);\n reportForeignProblemCourseIds(frontmatter?.problems ?? [], options.courseId, errors);\n const availableProblemIds =\n options.availableProblemIds ??\n (options.problemsDirectoryPath === undefined\n ? undefined\n : await collectProblemIdsFromDirectory(options.problemsDirectoryPath, errors));\n if (availableProblemIds !== undefined) {\n reportDanglingProblemReferences(problemIds, availableProblemIds, options.problemsDirectoryPath, errors);\n }\n\n reportExcessChatMarkers(body, errors);\n\n if (frontmatter) {\n validateSubmissionPeriods(\n mergeSubmissionPeriods(frontmatter, options.courseSubmissionPeriods),\n errors,\n frontmatter\n );\n }\n return result;\n}\n\nfunction collectLinkedIds(body: string, linkRegex: RegExp): string[] {\n return [...body.matchAll(linkRegex)].map((match) => match[1]).filter((id) => id !== undefined);\n}\n\n/** Applies the judge's course→material config merge (`material[field] ?? course[field]`). */\nexport function mergeSubmissionPeriods(\n own: SubmissionPeriods,\n courseDefaults: SubmissionPeriods | undefined\n): SubmissionPeriods {\n return {\n submissionOpenedAt: own.submissionOpenedAt ?? courseDefaults?.submissionOpenedAt,\n submissionSoftClosedAt: own.submissionSoftClosedAt ?? courseDefaults?.submissionSoftClosedAt,\n submissionHardClosedAt: own.submissionHardClosedAt ?? courseDefaults?.submissionHardClosedAt,\n };\n}\n\n/** exercode rejects more than one standalone chat delimiter line in material and problem bodies. */\nexport function reportExcessChatMarkers(body: string, errors: string[]): void {\n const chatMarkerCount = [...body.matchAll(STANDALONE_CHAT_MARKER_REGEX)].length;\n if (chatMarkerCount > 1) {\n errors.push(`at most one \\`${CHAT_MARKER}\\` marker is allowed, but found ${chatMarkerCount}`);\n }\n}\n\n/** The judge's resolveProblemCourseId throws when a problem reference names a different course. */\nexport function reportForeignProblemCourseIds(\n problems: readonly { id: string; courseId?: string }[],\n courseId: string | undefined,\n errors: string[]\n): void {\n if (courseId === undefined) return;\n for (const problem of problems) {\n if (problem.courseId !== undefined && problem.courseId !== courseId) {\n errors.push(`problem \"${problem.id}\": courseId must be \"${courseId}\" but is \"${problem.courseId}\"`);\n }\n }\n}\n\nfunction parseQuestionCodeBlocks(body: string, errors: string[]): MaterialQuestion[] {\n const questions: MaterialQuestion[] = [];\n const matches = [...body.matchAll(QUESTION_CODE_BLOCK_REGEX)];\n // The judge silently renders unparsed question blocks as plain code blocks, so a block the\n // strict regex misses (CRLF line endings, or no newline after the closing fence) would break\n // in production while looking fine here; flag it instead of validating a lie. Openers inside a\n // recognized block (e.g. an example fence in a question's text) are consumed with that block.\n const leftoverOpenerCount = [\n ...body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n').matchAll(QUESTION_CODE_BLOCK_OPENER_REGEX),\n ].length;\n if (leftoverOpenerCount > 0) {\n errors.push(\n `${leftoverOpenerCount} question code block(s) will not be recognized by the judge parser; separate consecutive question blocks with a blank line, use LF line endings, no indentation or trailing whitespace on the fence line, and end the closing fence with a newline`\n );\n }\n for (const [blockIndex, match] of matches.entries()) {\n const yamlText = match.groups?.['yaml'] ?? '';\n let rawQuestion: unknown;\n try {\n rawQuestion = parseYaml(yamlText);\n } catch (error) {\n errors.push(`question block ${blockIndex + 1}: invalid YAML: ${getErrorMessage(error)}`);\n continue;\n }\n const parsed = questionInCodeBlockSchema.safeParse(rawQuestion);\n if (parsed.success) {\n questions.push(parsed.data);\n } else {\n errors.push(...formatZodIssues(parsed.error, `question block ${blockIndex + 1}`));\n }\n }\n return questions;\n}\n\nfunction validateQuestion(question: MaterialQuestion, errors: string[]): void {\n const isSurvey = question.isSurvey === true;\n const [answerFieldName, isAnswerDefined] =\n question.type === 'select'\n ? (['answerIndex', question.answerIndex !== undefined] as const)\n : question.type === 'select_multiple'\n ? (['answerIndices', question.answerIndices !== undefined] as const)\n : (['answerPattern', question.answerPattern !== undefined] as const);\n if (isSurvey && isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must not be set when \\`isSurvey\\` is true`);\n }\n if (!isSurvey && !isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` is required when \\`isSurvey\\` is not true`);\n }\n\n if (question.type === 'select' || question.type === 'select_multiple') {\n const answerIndices =\n question.type === 'select'\n ? Array.isArray(question.answerIndex)\n ? question.answerIndex\n : question.answerIndex === undefined\n ? []\n : [question.answerIndex]\n : (question.answerIndices ?? []);\n // An empty answer set is broken at runtime (select: never correct; select_multiple: an empty\n // submission grades as correct), and exercode's material editor rejects it as well.\n if (!isSurvey && isAnswerDefined && answerIndices.length === 0) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must contain at least one correct answer`);\n }\n for (const answerIndex of answerIndices) {\n if (!Number.isInteger(answerIndex) || answerIndex < 0 || answerIndex >= question.options.length) {\n errors.push(\n `question ${question.id}: ${answerFieldName} out of bounds: ${answerIndex}; expected 0 <= index < options length ${question.options.length}`\n );\n }\n }\n return;\n }\n\n if (isSurvey || question.answerPattern === undefined) return;\n const answerPattern = question.answerPattern.trim();\n let answerRegExp: RegExp;\n let rawAnswerRegExp: RegExp;\n try {\n answerRegExp = new RegExp(`^(?:${answerPattern})$`);\n // Compiled inside the same guard: a pattern like `a)(b` is valid only once wrapped above,\n // so an unguarded raw compile would crash the CLI instead of reporting an error.\n rawAnswerRegExp = new RegExp(question.answerPattern);\n } catch {\n errors.push(\n `question ${question.id}: \\`answerPattern\\` is not a valid regular expression: ${question.answerPattern}`\n );\n return;\n }\n // Mirrors the judge heuristic: a pattern that doesn't match itself likely uses RegExp syntax,\n // so a modelAnswer is required to prove that some concrete answer is accepted.\n // Like the judge, an empty modelAnswer counts as missing.\n // The judge runs this heuristic on the untrimmed pattern and trims only for the anchored match.\n if (!question.modelAnswer && !rawAnswerRegExp.test(question.answerPattern)) {\n errors.push(`question ${question.id}: \\`answerPattern\\` seems a RegExp but \\`modelAnswer\\` is missing`);\n }\n if (question.modelAnswer && !answerRegExp.test(question.modelAnswer.trim())) {\n errors.push(`question ${question.id}: \\`modelAnswer\\` doesn't match \\`answerPattern\\``);\n }\n}\n\n/**\n * Reports inverted submission periods; shared by material, course.yaml, and contest validation.\n * When `ownPeriods` is given (the merged-values case), pairs whose both values are inherited from\n * the course are skipped: they are the course's mistake and are already reported against\n * course.yaml, so repeating them per material would send agents to files with nothing to fix.\n */\nexport function validateSubmissionPeriods(\n periods: SubmissionPeriods,\n errors: string[],\n ownPeriods: SubmissionPeriods = periods\n): void {\n const pairs = [\n ['submissionOpenedAt', 'submissionSoftClosedAt'],\n ['submissionSoftClosedAt', 'submissionHardClosedAt'],\n ['submissionOpenedAt', 'submissionHardClosedAt'],\n ] as const satisfies readonly (readonly [keyof SubmissionPeriods, keyof SubmissionPeriods])[];\n for (const [earlierName, laterName] of pairs) {\n const earlierValue = periods[earlierName];\n const laterValue = periods[laterName];\n if (earlierValue === undefined || laterValue === undefined) continue;\n if (ownPeriods[earlierName] === undefined && ownPeriods[laterName] === undefined) continue;\n if (new Date(laterValue).getTime() < new Date(earlierValue).getTime()) {\n errors.push(`${earlierName} (${earlierValue}) must be <= ${laterName} (${laterValue})`);\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateMaterial.js","names":["parseYaml","QUESTION_CODE_BLOCK_REGEX","QUESTION_CODE_BLOCK_OPENER_REGEX","PROBLEM_LINK_REGEX","TURTLE_GRAPHICS_QUESTION_LINK_REGEX","PROBLEM_ASSIGNMENT_REGEX","QUESTION_LINK_REGEX","TURTLE_GRAPHICS_ASSIGNMENT_REGEX","STANDALONE_CHAT_MARKER_REGEX","validateMaterialFile","errors","result","materialId","basename","materialFilePath","LEARNING_MATERIAL_ID_REGEX","markdown","readFile","attributes","body","parseFrontmatter","error","getErrorMessage","parsedFrontmatter","materialFrontmatterSchema","formatZodIssues","frontmatter","questionsInCodeBlocks","parseQuestionCodeBlocks","questionBlockIds","question","questions","validateQuestion","reportDuplicateIds","bodyWithoutQuestionBlocks","collectLinkedIds","linkedTurtleGraphicsQuestionIds","problemIds","problem","reportForeignProblemCourseIds","options","availableProblemIds","collectProblemIdsFromDirectory","reportDanglingProblemReferences","reportExcessChatMarkers","validateSubmissionPeriods","mergeSubmissionPeriods","linkRegex","match","id","own","courseDefaults","chatMarkerCount","courseId","problems","matches","leftoverOpenerCount","blockIndex","yamlText","\"yaml\"","rawQuestion","parse","parsed","questionInCodeBlockSchema","isSurvey","answerFieldName","isAnswerDefined","answerIndices","answerIndex","answerPattern","answerRegExp","rawAnswerRegExp","periods","earlierName","laterName","earlierValue","laterValue","ownPeriods"],"sources":["../../src/learningMaterial/validateMaterial.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { parse as parseYaml } from 'yaml';\nimport { collectProblemIdsFromDirectory, reportDanglingProblemReferences } from './fsHelpers.js';\nimport { parseFrontmatter } from './frontmatter.js';\nimport {\n LEARNING_MATERIAL_ID_REGEX,\n materialFrontmatterSchema,\n questionInCodeBlockSchema,\n type MaterialQuestion,\n} from './schemas.js';\nimport { formatZodIssues, reportDuplicateIds, type ValidationResult } from './validationResult.js';\n\n// Same patterns the judge uses to extract embedded questions and problem links from material bodies.\n// The block regex intentionally requires LF line endings and a trailing newline after the closing\n// fence, exactly like the judge parser; blocks it misses are reported by detecting leftover openers.\nconst QUESTION_CODE_BLOCK_REGEX = /(?:^|\\n)(?<fence>`{3,}|~{3,})ya?ml +question\\n(?<yaml>[\\s\\S]+?)\\n\\k<fence>\\n/g;\n// Strictly laxer than the parser regex (tolerates CRLF, indentation, and stray whitespace) so\n// question-looking blocks the judge parser would silently skip surface as leftover openers.\nconst QUESTION_CODE_BLOCK_OPENER_REGEX = /^[ \\t]*(?:`{3,}|~{3,})[ \\t]*ya?ml[ \\t]+question[ \\t]*\\r?$/gm;\nconst PROBLEM_LINK_REGEX = /\\[.*?]\\(problems\\/([0-9_a-z-]+?)\\)/g;\nconst TURTLE_GRAPHICS_QUESTION_LINK_REGEX = /\\[.*?]\\(turtle-graphics-questions\\/([0-9_a-z-]+?)\\)/g;\n// Exercode's duplicate-assignment check scans the body with these patterns, whose link text may\n// span lines (unlike the judge's reference collection above).\nconst PROBLEM_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(problems\\/([0-9_a-z-]+)\\)/g;\nconst QUESTION_LINK_REGEX = /@\\[question]\\(([0-9_a-z-]+)\\)/g;\nconst TURTLE_GRAPHICS_ASSIGNMENT_REGEX = /\\[[^\\]]*]\\(turtle-graphics-questions\\/([0-9_a-z-]+)\\)/g;\nconst CHAT_MARKER = '<!-- chat -->';\n// exercode only counts the marker when it stands alone on a line, so fenced or inline mentions don't count.\nconst STANDALONE_CHAT_MARKER_REGEX = /(^|\\r?\\n)<!-- chat -->[ \\t]*(?=\\r?\\n|$)/g;\n\nexport interface SubmissionPeriods {\n submissionOpenedAt?: string;\n submissionSoftClosedAt?: string;\n submissionHardClosedAt?: string;\n}\n\nexport interface MaterialValidationOptions {\n /** Problem IDs already collected for the enclosing course; collected from `problemsDirectoryPath` otherwise. */\n availableProblemIds?: ReadonlySet<string>;\n /** When set, every referenced problem ID must be a problem discovered under this path. */\n problemsDirectoryPath?: string;\n /** Enclosing course ID; the judge rejects problem references whose `courseId` differs from it. */\n courseId?: string;\n /** Course-level submission periods; the judge merges them as `material[field] ?? course[field]`. */\n courseSubmissionPeriods?: SubmissionPeriods;\n}\n\n/** Validates an exercode markdown material file (frontmatter, embedded questions, problem references). */\nexport async function validateMaterialFile(\n materialFilePath: string,\n options: MaterialValidationOptions = {}\n): Promise<ValidationResult> {\n const errors: string[] = [];\n const warnings: string[] = [];\n const result = { errors, warnings };\n\n const materialId = basename(materialFilePath).replace(/\\.[^/.]+$/, '');\n if (!LEARNING_MATERIAL_ID_REGEX.test(materialId)) {\n errors.push(`material ID \"${materialId}\" (file name without extension) must match ${LEARNING_MATERIAL_ID_REGEX}`);\n }\n\n let markdown: string;\n try {\n markdown = await readFile(materialFilePath, 'utf8');\n } catch {\n errors.push(`material file not found: ${materialFilePath}`);\n return result;\n }\n\n let attributes: unknown;\n let body: string;\n try {\n ({ attributes, body } = parseFrontmatter(markdown));\n } catch (error) {\n errors.push(`invalid YAML frontmatter: ${getErrorMessage(error)}`);\n return result;\n }\n\n const parsedFrontmatter = materialFrontmatterSchema.safeParse(attributes);\n if (!parsedFrontmatter.success) {\n errors.push(...formatZodIssues(parsedFrontmatter.error, 'frontmatter'));\n }\n const frontmatter = parsedFrontmatter.success ? parsedFrontmatter.data : undefined;\n\n const questionsInCodeBlocks = parseQuestionCodeBlocks(body, errors);\n const questionBlockIds = questionsInCodeBlocks.map((question) => question.id);\n const questions = [...(frontmatter?.questions ?? []), ...questionsInCodeBlocks];\n for (const question of questions) {\n validateQuestion(question, errors);\n }\n reportDuplicateIds(\n questions.map((question) => question.id),\n 'question',\n errors\n );\n // Turtle-graphics links in the body merge into the frontmatter list like problem links do,\n // and exercode rejects duplicates in the merged list; deferred until after the question blocks\n // are removed below so links inside question text don't count.\n\n // Exercode's import validation runs two checks: the body must not link the same problem,\n // question or turtle-graphics question twice (every occurrence of the lax link pattern counts),\n // and the generated reference list (frontmatter entries plus the judge's deduplicated body links)\n // must not repeat an ID. The judge replaces recognized question blocks with @[question](id) links\n // before collecting links, so links that appear only inside question text must not count, while\n // each block counts as one question link next to explicit @[question](id) links.\n const bodyWithoutQuestionBlocks = body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n');\n reportDuplicateIds(\n [...questionBlockIds, ...collectLinkedIds(bodyWithoutQuestionBlocks, QUESTION_LINK_REGEX)],\n 'question link',\n errors\n );\n reportDuplicateIds(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_ASSIGNMENT_REGEX), 'problem link', errors);\n reportDuplicateIds(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_ASSIGNMENT_REGEX),\n 'turtle-graphics question link',\n errors\n );\n const linkedTurtleGraphicsQuestionIds = new Set(\n collectLinkedIds(bodyWithoutQuestionBlocks, TURTLE_GRAPHICS_QUESTION_LINK_REGEX)\n );\n reportDuplicateIds(\n [\n ...(frontmatter?.turtleGraphicsQuestions ?? []).map((question) => question.id),\n ...linkedTurtleGraphicsQuestionIds,\n ],\n 'turtleGraphicsQuestion',\n errors\n );\n // Only the links the judge collects become references, and it deduplicates them before merging.\n const problemIds = [\n ...(frontmatter?.problems ?? []).map((problem) => problem.id),\n ...new Set(collectLinkedIds(bodyWithoutQuestionBlocks, PROBLEM_LINK_REGEX)),\n ];\n reportDuplicateIds(problemIds, 'problem', errors);\n reportForeignProblemCourseIds(frontmatter?.problems ?? [], options.courseId, errors);\n const availableProblemIds =\n options.availableProblemIds ??\n (options.problemsDirectoryPath === undefined\n ? undefined\n : await collectProblemIdsFromDirectory(options.problemsDirectoryPath, errors));\n if (availableProblemIds !== undefined) {\n reportDanglingProblemReferences(problemIds, availableProblemIds, options.problemsDirectoryPath, errors);\n }\n\n reportExcessChatMarkers(body, errors);\n\n if (frontmatter) {\n validateSubmissionPeriods(\n mergeSubmissionPeriods(frontmatter, options.courseSubmissionPeriods),\n errors,\n frontmatter\n );\n }\n return result;\n}\n\nfunction collectLinkedIds(body: string, linkRegex: RegExp): string[] {\n return [...body.matchAll(linkRegex)].map((match) => match[1]).filter((id) => id !== undefined);\n}\n\n/** Applies the judge's course→material config merge (`material[field] ?? course[field]`). */\nexport function mergeSubmissionPeriods(\n own: SubmissionPeriods,\n courseDefaults: SubmissionPeriods | undefined\n): SubmissionPeriods {\n return {\n submissionOpenedAt: own.submissionOpenedAt ?? courseDefaults?.submissionOpenedAt,\n submissionSoftClosedAt: own.submissionSoftClosedAt ?? courseDefaults?.submissionSoftClosedAt,\n submissionHardClosedAt: own.submissionHardClosedAt ?? courseDefaults?.submissionHardClosedAt,\n };\n}\n\n/** exercode rejects more than one standalone chat delimiter line in material and problem bodies. */\nexport function reportExcessChatMarkers(body: string, errors: string[]): void {\n const chatMarkerCount = [...body.matchAll(STANDALONE_CHAT_MARKER_REGEX)].length;\n if (chatMarkerCount > 1) {\n errors.push(`at most one \\`${CHAT_MARKER}\\` marker is allowed, but found ${chatMarkerCount}`);\n }\n}\n\n/** The judge's resolveProblemCourseId throws when a problem reference names a different course. */\nexport function reportForeignProblemCourseIds(\n problems: readonly { id: string; courseId?: string }[],\n courseId: string | undefined,\n errors: string[]\n): void {\n if (courseId === undefined) return;\n for (const problem of problems) {\n if (problem.courseId !== undefined && problem.courseId !== courseId) {\n errors.push(`problem \"${problem.id}\": courseId must be \"${courseId}\" but is \"${problem.courseId}\"`);\n }\n }\n}\n\nfunction parseQuestionCodeBlocks(body: string, errors: string[]): MaterialQuestion[] {\n const questions: MaterialQuestion[] = [];\n const matches = [...body.matchAll(QUESTION_CODE_BLOCK_REGEX)];\n // The judge silently renders unparsed question blocks as plain code blocks, so a block the\n // strict regex misses (CRLF line endings, or no newline after the closing fence) would break\n // in production while looking fine here; flag it instead of validating a lie. Openers inside a\n // recognized block (e.g. an example fence in a question's text) are consumed with that block.\n const leftoverOpenerCount = [\n ...body.replaceAll(QUESTION_CODE_BLOCK_REGEX, '\\n').matchAll(QUESTION_CODE_BLOCK_OPENER_REGEX),\n ].length;\n if (leftoverOpenerCount > 0) {\n errors.push(\n `${leftoverOpenerCount} question code block(s) will not be recognized by the judge parser; separate consecutive question blocks with a blank line, use LF line endings, no indentation or trailing whitespace on the fence line, and end the closing fence with a newline`\n );\n }\n for (const [blockIndex, match] of matches.entries()) {\n const yamlText = match.groups?.['yaml'] ?? '';\n let rawQuestion: unknown;\n try {\n rawQuestion = parseYaml(yamlText);\n } catch (error) {\n errors.push(`question block ${blockIndex + 1}: invalid YAML: ${getErrorMessage(error)}`);\n continue;\n }\n const parsed = questionInCodeBlockSchema.safeParse(rawQuestion);\n if (parsed.success) {\n questions.push(parsed.data);\n } else {\n errors.push(...formatZodIssues(parsed.error, `question block ${blockIndex + 1}`));\n }\n }\n return questions;\n}\n\nfunction validateQuestion(question: MaterialQuestion, errors: string[]): void {\n const isSurvey = question.isSurvey === true;\n const [answerFieldName, isAnswerDefined] =\n question.type === 'select'\n ? (['answerIndex', question.answerIndex !== undefined] as const)\n : question.type === 'select_multiple'\n ? (['answerIndices', question.answerIndices !== undefined] as const)\n : (['answerPattern', question.answerPattern !== undefined] as const);\n if (isSurvey && isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must not be set when \\`isSurvey\\` is true`);\n }\n if (!isSurvey && !isAnswerDefined) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` is required when \\`isSurvey\\` is not true`);\n }\n\n if (question.type === 'select' || question.type === 'select_multiple') {\n const answerIndices =\n question.type === 'select'\n ? Array.isArray(question.answerIndex)\n ? question.answerIndex\n : question.answerIndex === undefined\n ? []\n : [question.answerIndex]\n : (question.answerIndices ?? []);\n // An empty answer set is broken at runtime (select: never correct; select_multiple: an empty\n // submission grades as correct), and exercode's material editor rejects it as well.\n if (!isSurvey && isAnswerDefined && answerIndices.length === 0) {\n errors.push(`question ${question.id}: \\`${answerFieldName}\\` must contain at least one correct answer`);\n }\n for (const answerIndex of answerIndices) {\n if (!Number.isInteger(answerIndex) || answerIndex < 0 || answerIndex >= question.options.length) {\n errors.push(\n `question ${question.id}: ${answerFieldName} out of bounds: ${answerIndex}; expected 0 <= index < options length ${question.options.length}`\n );\n }\n }\n return;\n }\n\n if (isSurvey || question.answerPattern === undefined) return;\n const answerPattern = question.answerPattern.trim();\n let answerRegExp: RegExp;\n let rawAnswerRegExp: RegExp;\n try {\n answerRegExp = new RegExp(`^(?:${answerPattern})$`);\n // Compiled inside the same guard: a pattern like `a)(b` is valid only once wrapped above,\n // so an unguarded raw compile would crash the CLI instead of reporting an error.\n rawAnswerRegExp = new RegExp(question.answerPattern);\n } catch {\n errors.push(\n `question ${question.id}: \\`answerPattern\\` is not a valid regular expression: ${question.answerPattern}`\n );\n return;\n }\n // Mirrors the judge heuristic: a pattern that doesn't match itself likely uses RegExp syntax,\n // so a modelAnswer is required to prove that some concrete answer is accepted.\n // Like the judge, an empty modelAnswer counts as missing.\n // The judge runs this heuristic on the untrimmed pattern and trims only for the anchored match.\n if (!question.modelAnswer && !rawAnswerRegExp.test(question.answerPattern)) {\n errors.push(`question ${question.id}: \\`answerPattern\\` seems a RegExp but \\`modelAnswer\\` is missing`);\n }\n if (question.modelAnswer && !answerRegExp.test(question.modelAnswer.trim())) {\n errors.push(`question ${question.id}: \\`modelAnswer\\` doesn't match \\`answerPattern\\``);\n }\n}\n\n/**\n * Reports inverted submission periods; shared by material, course.yaml, and contest validation.\n * When `ownPeriods` is given (the merged-values case), pairs whose both values are inherited from\n * the course are skipped: they are the course's mistake and are already reported against\n * course.yaml, so repeating them per material would send agents to files with nothing to fix.\n */\nexport function validateSubmissionPeriods(\n periods: SubmissionPeriods,\n errors: string[],\n ownPeriods: SubmissionPeriods = periods\n): void {\n const pairs = [\n ['submissionOpenedAt', 'submissionSoftClosedAt'],\n ['submissionSoftClosedAt', 'submissionHardClosedAt'],\n ['submissionOpenedAt', 'submissionHardClosedAt'],\n ] as const satisfies readonly (readonly [keyof SubmissionPeriods, keyof SubmissionPeriods])[];\n for (const [earlierName, laterName] of pairs) {\n const earlierValue = periods[earlierName];\n const laterValue = periods[laterName];\n if (earlierValue === undefined || laterValue === undefined) continue;\n if (ownPeriods[earlierName] === undefined && ownPeriods[laterName] === undefined) continue;\n if (new Date(laterValue).getTime() < new Date(earlierValue).getTime()) {\n errors.push(`${earlierName} (${earlierValue}) must be <= ${laterName} (${laterValue})`);\n }\n }\n}\n"],"mappings":"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"}