grading
Version:
Grading of student submissions, in particular programming tests.
447 lines (391 loc) • 29.6 kB
text/typescript
import { Command, Option } from 'commander';
import * as fs from 'fs/promises';
import path from 'path';
import { createDir, EXT_EXCEL, fileExists, findDirsInPathToRoot, folderExists } from "../fsUtil";
import { error, log, verb, verbosity, warn } from "./cliUtil";
import { parse } from "comment-json"
declare global {
var program: Command;
var SEP: String;
}
type SettingType = 'folder' | 'file' | 'int' | 'boolean' | 'string' | 'number' | 'name';
/**
* A setting definition is used to create a {@link Setting}.
*/
type SettingDefinition<T> = {
/**
* Name of the setting, used as long option in command line (e.g. --name)
*/
name: string,
/**
* Short option in command line (e.g. -n)
*/
short?: string,
/**
* Type of the setting, used to generate help text; if not given, the type of the default value is used to infer the type.
*/
type?: SettingType,
/**
* Help text for the setting
*/
help: string,
/**
* The default value of the setting
*/
def: T,
/**
* If set to false, the setting is not loaded (or written) from a settings file.
* This is useful for settings that are only used as command line arguments but using the same mechanism as settings.
*/
settings?: boolean
}
/**
* Settings are used to configure the behavior of the CLI.
* The can be loaded from a settings file and used as {@link Option}s in Commander's {@link Command}s.
* That enables user settings to be either stored in a file or passed as command line arguments.
*
* @author <Jens.vonPilgrim@bht-berlin.de>
*/
class Setting<T = any> {
public readonly name: string;
private short: string | undefined;
public readonly type: SettingType | undefined;
public readonly help: string;
public readonly def: T;
public readonly variadic: boolean;
public actual: T
public settings = true;
constructor(spec: SettingDefinition<T>) {
this.name = spec.name;
this.short = spec.short;
this.type = spec.type;
if (spec.settings !== undefined) {
this.settings = spec.settings;
}
if (!spec.type) {
let val = spec.def instanceof Array ? spec.def[0] : spec.def;
if (val === undefined) {
val = "";
}
if (typeof val === 'number') {
this.type = 'number';
} else if (typeof val === 'boolean') {
this.type = 'boolean';
} else {
this.type = 'string';
}
}
this.variadic = spec.def instanceof Array;
this.help = spec.help;
this.def = spec.def;
this.actual = this.def;
}
setActual(value: any) {
this.actual = value;
}
toOption() {
let flag = "";
if (this.short) {
flag = `-${this.short}, `;
}
flag += `--${this.name}`;
if (this.type !== 'boolean') {
flag += ` <${this.type}`
if (this.variadic) {
flag += "..."
}
flag += ">";
}
let description = this.help;
let defValueDesc = (this.actual !== this.def) ? `originally ${JSON.stringify(this.def)}, set to ${JSON.stringify(this.actual)}` : undefined;
return new Option(flag, description).default(this.actual, defValueDesc);
}
toNoOption() {
if (this.type !== 'boolean') {
throw new Error(`Setting ${this.name} is not a boolean setting.`);
}
return new Option(`--no-${this.name}`, `Sets ${this.name} to false`);
}
}
const SETTINGS =
{
// init, check
checkDir: new Setting({ name: 'checkDir', type: 'folder', help: 'Check tests folder, this is mounted in docker as /check', def: './check' }),
// init
noFolders: new Setting({ name: 'noFolders', help: 'Do not create standard input folder', def: false }),
noGitignore: new Setting({ name: 'noGitignore', help: 'Do not add input and output folders to.gitignore', def: false }),
stdInFolder: new Setting({ name: 'stdInFolder', type: 'folder', help: 'Name of folder to be created for (moodle) input files(and added to.gitignore).', def: 'gradingIn' }),
stdOutFolder: new Setting({ name: 'stdOutFolder', type: 'folder', help: 'Name of folder to be created for output files(and added to.gitignore).', def: 'gradingOut' }),
generateSettings: new Setting({ name: 'generateSettings', help: 'Generate .grading/settings.json', def: false }),
generateCorrectionsFile: new Setting({ name: 'generateCorrectionsFile', help: 'If set to true, a correction file is created as well. It is created in the standard in folder. If a grading scheme already exists, nothing else is generated and the correction files uses information found in the grading schema. If results already exists, the file is populated with the students.', def: false }),
taskInGeneratedCorrectionsFile: new Setting({ name: 'taskInGeneratedCorrectionsFile', help: 'If set to true, templates for tasks are created in correction file as well. Usually this is not required.', def: false }),
manualCorrectionsFile: new Setting({ name: 'manualCorrectionsFile', type: 'file', help: 'Name of the manual corrections JSON file; with variables in ${..} (stdInFolder).', def: './${stdInFolder}/manual.corrections.json' }),
generateManualConclusionFile: new Setting({ name: 'generateManualConclusionFile', help: 'If set to true, a manual conclusion file is created as well.', def: false }),
manualConclusionFile: new Setting({ name: 'manualConclusionFile', type: 'file', help: 'Name of the manual conclusion JSON file with total grades and aliases; with variables in ${..} (stdInFolder, resultsDir).', def: '${resultsDir}/manual.conclusion.json' }),
// check
selected: new Setting({ name: 'selected', help: 'Process only on given submission ids or students with given name (prefix). Existing files of other submitters are not overwritten', def: [] }),
selectPatched: new Setting({ name: 'selectPatched', help: 'Process only submission which have been patched', def: false }),
max: new Setting({ name: 'max', type: 'int', help: 'Maximal number of submissions to be processed, all others are skipped; use 0 for no limit.', def: "0" }),
fromSubmission: new Setting({ name: 'fromSubmission', type: 'int', help: 'Index (1-based) of submission (included) which is the first one to be checked', def: "0" }),
toSubmission: new Setting({ name: 'toSubmission', type: 'int', help: 'Index (1-based) of submission (included) which is the last one to be checked.', def: "0" }),
dry: new Setting({ name: 'dry', help: 'Dry run, do not write any files and do not start scripts', def: false }),
dockerImage: new Setting({ name: 'dockerImage', type: 'name', help: 'Docker image name', def: 'node:18.10-alpine' }),
dockerUserDir: new Setting({ name: 'dockerUserDir', type: 'folder', help: 'Docker user dir (used for npmcache folder)', def: 'root' }),
dockerWorkDir: new Setting({ name: 'dockerWorkDir', type: 'folder', help: 'Docker working dir', def: '/' }),
dockerShellCmd: new Setting({ name: 'dockerShellCmd', type: 'name', help: 'Name of shell used to run in docker', def: 'sh' }),
dockerArgs: new Setting({ name: 'dockerArgs', help: 'Additional arguments used to run docker', def: [] }),
preCheckScript: new Setting({ name: 'preCheckScript', type: 'file', help: 'Script to run before docker is started. Environment variables: CLIP (if clipDir is set, this folder with submissionID), PROJECT (student project folder), CHECK (check folder)', def: 'check/preCheck.sh' }),
postCheckScript: new Setting({ name: 'postCheckScript', type: 'file', help: 'Script to run after docker has run. Environment variables: CLIP (if clipDir is set, this folder with submissionID), PROJECT (student project folder), CHECK (check folder)', def: 'check/postCheck.sh' }),
dockerScript: new Setting({ name: 'dockerScript', type: 'file', help: 'Script to run in docker, this must be path in the vm', def: 'check/checkInDocker.sh' }),
timeoutPerDockerRun: new Setting({ name: 'timeoutPerDockerRun', help: 'Timeout per docker run(for each submission)', def: '300s' }),
submissionsDir: new Setting({ name: 'submissionsDir', type: 'folder', help: 'submissions directory (where zip is extracted), this folder is ignored when looking for project folder in prepare', def: './gradingOut/submissions' }),
reportsDir: new Setting({ name: 'reportsDir', type: 'folder', help: 'folder for generated JUnit and coverage reports, mounted in docker as /reports', def: './gradingOut/reports' }),
clipDir: new Setting({ name: 'clipDir', type: 'folder', help: 'folder for temporarily stored files and folders, a subfolder with the submission id is mounted in docker as /clip. After check, this folder should be empty.', def: './gradingOut/clip' }),
npmCacheDir: new Setting({ name: 'npmCacheDir', type: 'folder', help: 'folder with NPM cache (mounted as .npm if not "")', def: './gradingOut/npmcache' }),
cacheDir: new Setting({ name: 'cacheDir', type: 'folder', help: 'folder with cache (mounted as .cache if not "")', def: './gradingOut/cache' }),
prepareSubmissionCmd: new Setting({ name: 'prepareSubmissionCmd', help: 'Bash commands to prepare submission folder, one string that is split into command and args', def: 'git reset --hard' }),
skipPrepareSubmission: new Setting({ name: 'skipPrepareSubmission', help: 'Do not run prepare submission (use this in order to keep manual changes in submission', def: false }),
onlyPrepareSubmission: new Setting({ name: 'onlyPrepareSubmission', help: 'Do only run prepare submission, skip docker', def: false }),
// diff and patch
patch: new Setting({ name: 'patch', help: 'Applies patch files in the patchFolder to student projects. If no diff file is found, nothing happens. If skipPrepareSubmission is true, no patches are applied', def: true }),
patchFolder: new Setting({ name: 'patchFolder', type: 'folder', help: 'Name of the folder in which patch files are searched or created.', def: './gradingIn/patches' }),
patchSubmissionBranches: new Setting({ name: 'patchSubmissionBranches', help: 'The names of the branches used in the submission, the first one found is used', def: ["main", "master"] }),
patchGradingBranch: new Setting({ name: 'patchGradingBranch', help: 'The name of the branch used by the grader', def: "grading" }),
skipStudentTests: new Setting({ name: 'skipStudentTests', help: 'Do not run student tests (SKIP_STUDENT_TESTS in env set to "true"), existing student test reports are not deleted', def: false }),
volumes: new Setting({ name: 'volumes', help: 'Additional volumes to be mounted in docker during check (besides submission, reports, clip, cache and npmCache), e.g. node_modules:/testee/node_modules. Host path is made absolut if specified relative to the project folder', def: [] }),
// grade
clean: new Setting({ name: 'clean', help: 'Automatically clean folder with PDFs', def: false }),
createGradingFile: new Setting({ name: 'createGradingFile', short: 'cg', type: 'file', help: 'If provided, instead of grading, create grading scheme based on checks run on solution', def: '' }),
encoding: new Setting({ name: 'encoding', type: 'name', help: 'Encoding used for reading and writing result, statistic files and conclusion file (see iconv - lite for encodings), e.g.UTF - 8 or macroman', def: 'macroman' }),
gradedMoodleFile: new Setting({ name: 'gradedMoodleFile', type: 'file', help: 'Name of the Moodle CSV file with the results; with variables in ${..} (moodleFile -- base name without extension, examName, date, time, selected-- add with preceding dot), just "${moodleFile} will override the exported Moodle file.', def: './gradingOut/${moodleFile}_graded_${date}-${time}${selected}.csv' }),
gradingValue: new Setting({ name: 'gradingValue', type: 'string', help: 'Use either grade or points for grading in CSV file (grade or points)', def: 'points' }),
gradingSchemaFile: new Setting({ name: 'gradingSchemaFile', type: 'file', help: 'JSON file with grading schema(*.grading.json)', def: './check/exam.grading.json' }),
ignoredDirs: new Setting({ name: 'ignoredDirs', type: 'folder', help: 'Folders ignored when looking for project folder.Hidden folders(starting with ".") are ignored anyway.', def: ['node_modules'] }),
latex: new Setting({ name: 'latex', type: 'name', help: 'Name of LaTeX tool', def: 'xelatex' }),
latexConsole: new Setting({ name: 'latexConsole', help: 'Show LaTex console output', def: false }),
latexFragmentFile: new Setting({ name: 'latexFragmentFile', type: 'file', help: 'Name of LaTeX fragment (previously created via grade)', def: './gradingOut/reports/results.tex' }),
latexFragmentFileSolution: new Setting({ name: 'latexFragmentFileSolution', type: 'file', help: 'Name of LaTeX fragment for solution(previously created via prepare)', def: './gradingOut/solution/reports/results.tex' }),
latexMainFile: new Setting({ name: 'latexMainFile', type: 'file', help: 'Name of LaTeX main file (importing fragment via input)', def: 'check/latex/grading.tex' }),
latexMainFolderCopy: new Setting({ name: 'latexMainFolderCopy', type: 'boolean', help: 'Copy content of folder in which latex main file is found (e.g. to add additional files such as stypes)', def: true}),
noLatex: new Setting({ name: 'noLatex', help: 'Do not create LaTeX fragment(required for PDF reports)', def: false }),
noMoodleCSV: new Setting({ name: 'noMoodleCSV', help: 'Do not create Moodle grading file.', def: false }),
noPDFZip: new Setting({ name: 'noPDFZip', help: 'Do not zip PDF folder(required for upload)', def: false }),
noResultCSV: new Setting({ name: 'noResultCSV', help: 'Do not create CSV with results(for your own statistics)', def: false }),
noStatisticsCSV: new Setting({ name: 'noStatisticsCSV', help: 'Do not create statistics file.', def: false }),
pdfDir: new Setting({ name: 'pdfDir', type: 'folder', help: 'The folder into which the PDF report is generated (using Moodle\'s folder structure).', def: './gradingOut/pdfReports' }),
pdfDirSolution: new Setting({ name: 'pdfDirSolution', type: 'folder', help: 'The folder into which the PDF report for the solution is generated (using Moodle\'s folder structure).', def: './gradingOut/solution/pdfReport' }),
pdfZipFile: new Setting({ name: 'pdfZipFile', type: 'file', help: 'File name of created zip file to be uploaded to Moodle(Alle Abgaben Anzeigen / Bewertungsvorgang: Mehrere Feedbackdateien in einer Zip - Datei hochladen); with variables in ${..} (date, time).', def: "./gradingOut/pdfReports_${date}-${time}.zip" }),
preserveContainer: new Setting({ name: 'preserveContainer', help: 'Preserves the container after run, u.e. - rm is not passed to Docker', def: false }),
projectFile: new Setting({ name: 'projectFile', type: 'file', help: 'Project file to identify project folder, the project folder is mounted in docker as / testee', def: '.git' }),
resultCSVDelimiter: new Setting({ name: 'resultCSVDelimiter', help: 'Delimiter used in CSV with the results (and conclusion)', def: ';' }),
resultFile: new Setting({ name: 'resultFile', type: 'file', help: 'Name of the CSV (.csv) file with the results, with variables in ${..} (examName, date, time), used as pattern in some cases', def: './gradingOut/results_${examName}_${date}-${time}.csv' }),
showUnchanged: new Setting({ name: 'showUnchanged', help: 'Show also individual grading results which have not change', def: false }),
statisticsFile: new Setting({ name: 'statisticsFile', type: 'file', help: 'Name of the statistics CSV file; with variables in ${..} (examName, date, time). If result file is an Excel file, instead of a statistics file a worksheet in results is created', def: './gradingOut/statistics_${examName}_${date}-${time}.csv' }),
summaryOnly: new Setting({ name: 'summaryOnly', help: 'Show only summary, no individual changes', def: false }),
testOutputEndMarker: new Setting({ name: 'testOutputEndMarker', help: 'End marker containing text to be added to test line in output', def: '§§§' }),
testOutputStartMarker: new Setting({ name: 'testOutputStartMarker', help: 'Start marker containing text to be added to test line in output', def: '§§§' }),
validateOnly: new Setting({ name: 'validateOnly', help: 'Do not run tests, only validate grading schema', def: false }),
workDir: new Setting({ name: 'workDir', type: 'folder', help: 'Name of working folder, this folder is cleaned before and after)', def: "./gradingOut/_working" }),
logOutput: new Setting({ name: 'logOutput', type: 'file', help: 'Name of log file with complete output, no log is created if file is empty. Variables: with variables in ${..} (date, time)', def: "./gradingOut/check_${date}-${time}.log" }),
compareResults: new Setting({ name: 'compareResults', help: 'When a new result file has been created, show comparison with previous one if found.', def: true }),
// prepare
skipDocker: new Setting({ name: 'skipDocker', help: 'Do not run docker and tests, just adjust grading and report', def: false }),
skipPDF: new Setting({ name: 'skipPDF', help: 'Do not create PDF, basically for debugging purposes', def: false }),
reportsDirSolution: new Setting({ name: 'reportsDirSolution', type: 'folder', help: 'folder for generated JUnit and coverage reports of solution, mounted in docker as / reports', def: './gradingOut/solution/reports' }),
// conclude
resultsDir: new Setting({ name: 'resultsDir', type: 'folder', help: 'Path to folder with results to be concluded', def: './gradingOut' }),
deprecatedResultsDir: new Setting({ name: 'deprecatedResultsDir', type: 'folder', help: 'Path to folder with deprectated (old) result, may use variable ${resultsDir}', def: '${resultsDir}/deprecated' }),
conclusionFile: new Setting({ name: 'conclusionFile', type: 'file', help: 'Name of the concluded results CSV (.csv) or Excel (' + EXT_EXCEL + '), may use variables in ${..} (date, time, firstExam, lastExam)', def: './gradingOut/conclusion_${date}-${time}_${firstExam}-${lastExam}' + EXT_EXCEL }),
selectBest: new Setting({ name: 'selectBest', help: 'Select n best result for each student', def: 9 }),
totalExams: new Setting({ name: 'totalExams', help: 'Total number of exams per student at end of term', def: 11 }),
maxFailed: new Setting({ name: 'maxFailed', help: 'Number of failed submissions (not submitted or 5) before total failure, if -1 this is ignored', def: 3 }),
noFinalGradeBefore: new Setting({ name: 'noFinalGradeBefore', help: 'Number of exam which means exam is started. That is, if only exams before that number are submitted, the final grade will be "-" (not participated). The exams before still count, though! If empty, this is ignored.', def: "Blatt 03" }),
autoCopyResult: new Setting({ name: 'autoCopyResult', help: 'Automatically copy result file to results dir', def: true }),
autoCopyConclusion: new Setting({ name: 'autoCopyConclusion', help: 'Automatically copy conclusion file to results dir', def: true }),
keepOldResult: new Setting({ name: 'keepOldResult', help: 'Automatically copy old result file to deprecated results dir', def: true }),
// selected and check
cleanBefore: new Setting({ name: 'cleanBefore', help: 'Enforce clean before running tests when selected is active. Warning: This removes all reports for existing submissions', def: false }),
// prepareAsPrevious
prevProjectsDir: new Setting({ name: 'prevProjectsDir', type: 'folder', help: 'Folder in which submissions are extracted (you may use capturing groups or replacement and variables cwd, cwdName or zip)',
def: '${zip/(we[0-9]).*?Blatt ([0-9]+).*/$1.blatt$2}' }),
// summary
thisPartDescription: new Setting({ name: 'thisPartDescription', help: 'Descriptions of this items in summary', def: 'Übung' }),
summaryLatexMainFile: new Setting({ name: 'summaryLatexMainFile', type: 'file', help: 'Name of LaTeX summary main file(importing fragment via input)', def: 'check/latex/summary.tex' }),
summariesLatexFragmentFile: new Setting({ name: 'summariesLatexFragmentFile', type: 'file', help: 'Name of LaTeX fragment for summary', def: './gradingOut/reports/summaries.tex' }),
pdfSummariesDir: new Setting({ name: 'pdfSummariesDir', type: 'folder', help: 'The folder into which the PDF summary reports are generated (using Moodle\'s folder structure)', def: './gradingOut/pdfSummaries' }),
noSummaryPDFZip: new Setting({ name: 'noSummaryPDFZip', help: 'Do not create zip file with summaries', def: false }),
summaryPDFZipFile: new Setting({ name: 'summaryPDFZipFile', type: 'file', help: 'File name of created zip file with summaries to be uploaded to Moodle (Alle Abgaben Anzeigen/Bewertungsvorgang: Mehrere Feedbackdateien in einer Zip-Datei hochladen); with variables in ${..} (examName, date, time).', def: "./gradingOut/pdfSummaries_${date}-${time}.zip" }),
// general
quiet: new Setting({ name: 'quiet', short: 'q', help: 'Quiet mode, emit only errors', def: false }),
verbose: new Setting({ name: 'verbose', short: 'v', help: 'Emit verbose messages', def: false }),
debug: new Setting({ name: 'debug', help: 'Emit debug messages (which may be different from verbose)', def: false }),
/**
*
* Color Foreground/Background Code
* ```
* Black - 30/40 | Red - 31/41 | Green: 31/42 | Yellow: 33/43
* Blue: 34/44 | Magenta: 35/45 | Cyan: 36/46 | L.Gray: 37/47
* Gray: 90/100 | L.Red: 91/101 | L.Green: 92/102 | L.Yellow: 93/103
* L.Blue: 94/104 | L.Magenta: 95/105 | L.Cyan: 96/106 | White: 97/107
* ```
* Stype Code Description
* ```
* Reset/Normal: 0 | Bold text: 1 | Faint text: 2 | Italics: 3 | Underlined: 4
* ```
* Color is set in escape sequence: `\e[<style>;<foreground>;<background>m`,
* in the color settings below, only the numbers are given, e.g. `1;31`for bold red.
*
* Additionally, colors can be set in output by `<<color:XXXX>>`, e.g. `<<color:1;31>>` for bold red.
* This setting is resetted whenever a new section is started or via `<<color:reset>>`.
* The explicit color will be prefixed to the computed color, which enables light colors by setting `<<color:2>>` for example.
*/
colored: new Setting({ name: 'colored', help: 'Use colored output', def: true }),
colorSection: new Setting({ name: 'colorSection', help: 'Color for heading (start of each submission)', def: '1;97;44' }),
colorSubsection: new Setting({ name: 'colorSubsection', help: 'Color for sub section, detected by ^[0-9]+.', def: '1;34' }),
colorPre: new Setting({ name: 'colorPre', help: 'Color for pre script', def: '34' }),
colorPost: new Setting({ name: 'colorPost', help: 'Color for post script', def: '35' }),
colorError: new Setting({ name: 'colorError', help: 'Color for error output', def: '3' }),
}
/**
* Name of configuration folder
*/
const CONFIG_FOLDER = ".grading";
const SETTINGS_FILE = "settings.json";
export type Settings = typeof SETTINGS;
/**
* Reads the settings from the settings file.
* The settings file "settings.json" is searched in a subfolder ".grading" of the current folder and all parent folders.
* If a settings file is found, it is read and its content is merged with other setting files found in parent folders.
* The settings file in the home folder has the highest priority.
*
* Although the file format is JSON, comments are allowed (package comment-json is used for reading and writing).
*/
export async function readSettings(): Promise<Settings> {
// Commands were not parsed yet, so we need to do it here
verbosity({
verbose: process.argv.includes("--verbose") || process.argv.includes("-v"),
quiet: process.argv.includes("--quiet") || process.argv.includes("-q")
});
const gradingSettingsFolders = await findDirsInPathToRoot(process.cwd(), CONFIG_FOLDER);
if (process.env.HOME) {
const userSettings = path.join(process.env.HOME, CONFIG_FOLDER);
if (!gradingSettingsFolders.includes(userSettings)
&& await folderExists(userSettings)) {
gradingSettingsFolders.push(userSettings);
}
}
for (const folder of gradingSettingsFolders) {
const settingsFile = path.join(folder, SETTINGS_FILE);
if (await fileExists(settingsFile)) {
verb(`Read settings from ${settingsFile}`);
const settings = await fs.readFile(settingsFile, 'utf8');
const settingsObj: any = parse(settings);
for (const key of Object.keys(settingsObj)) {
//@ts-ignore
const setting: Setting = SETTINGS[key];
if (setting === undefined) {
throw new Error(`Unknown setting '${key}' in settings file ${settingsFile}.`);
} else {
const newValue = settingsObj[key];
if (typeof setting.def !== typeof newValue) {
warn(`Setting '${key}' in settings file ${settingsFile} is a ${typeof newValue}, should be a ${typeof setting.def} (${setting.type}).`);
} else {
setting.setActual(newValue);
}
}
}
}
}
return SETTINGS
}
/**
* Writes the settings to the settings file "settings.json" in subfolder ".grading" of the current folder.
* The settings are grouped by the command they are used in.
*
* Although the file format is JSON, comments are used (package comment-json is used for reading and writing).
*/
export async function writeSettings() {
const settingsFile = path.join(".", CONFIG_FOLDER, SETTINGS_FILE);
if (await fileExists(settingsFile)) {
warn(`Settings file ${settingsFile} already exists, not overwriting.`);
return;
}
const settingUsages = new Map<string, string[]>();
for (const key of Object.keys(SETTINGS)) {
settingUsages.set(key, []);
}
const notInSettings = new Map<string, string[]>();
program.commands.forEach(command => {
const opts = command.opts()
Object.entries(opts).forEach(([key, value]) => {
const usedBy = settingUsages.get(key);
if (!usedBy) {
let notInSettingsVal = notInSettings.get(key);
if (!notInSettingsVal) {
notInSettingsVal = [];
notInSettings.set(key, notInSettingsVal);
}
notInSettingsVal.push(command.name());
} else {
usedBy.push(command.name());
}
});
});
const sortedSettings = Object.keys(SETTINGS).sort((s1, s2) => {
const usedBy1 = settingUsages.get(s1);
const usedBy2 = settingUsages.get(s2);
if (usedBy1 && usedBy2) {
let diff = usedBy2.length - usedBy1.length;
if (diff === 0) diff = (usedBy1.join() + s1).localeCompare(usedBy2.join() + s2);
return diff;
} else if (usedBy1) {
return -1;
} else if (usedBy2) {
return 1;
} else {
const notInSettings1 = notInSettings.get(s1);
const notInSettings2 = notInSettings.get(s2);
if (notInSettings1 && notInSettings2) {
return notInSettings2.length - notInSettings1.length;
} else if (usedBy1) {
return -1;
} else if (usedBy2) {
return 1;
} else {
return s1.localeCompare(s2);
}
}
});
let out = "{ /* Settings file for grading, values maybe overwritten in subfolder settings or command line arguments, overwrites values in parent folders. */\n";
let prevUsedByStr = "";
sortedSettings.forEach((name) => {
const commandsUsingIt = notInSettings.get(name);
if (commandsUsingIt) {
if (prevUsedByStr !== "--") {
out += `\n /*Cannot be set in settings file:*/\n`;
prevUsedByStr = "--"
}
out += ` /* ${name}, used by ${commandsUsingIt.join(", ")} */\n`;
} else {
const usedBy = settingUsages.get(name);
const usedByStr = usedBy ? usedBy.join(", ") : "";
if (usedByStr != prevUsedByStr) {
if (usedByStr == "") {
out += `\n /* The following settings cannot be overwritten by command line arguments: */\n`;
} else {
out += `\n /* ${usedByStr} */\n`;
}
prevUsedByStr = usedByStr;
}
const setting = SETTINGS[name as keyof Settings];
const nameValue = `"${name}": ${JSON.stringify(setting.actual)}`;
const space = nameValue.length < 45 ? " ".repeat(45 - nameValue.length) : "";
out += ` // ${nameValue}, ${space}/* ${setting.help} (${setting.variadic ? '...' : ''}${setting.type})*/\n`;
}
});
out += "}\n";
const pointGrading = path.dirname(settingsFile);
await createDir(pointGrading)
log(`Write settings file ${settingsFile}`);
await fs.writeFile(settingsFile, out, 'utf8');
}