@vhite/vhite 0.0.0-stage → 0.6.0

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 (49) hide show
  1. package/README.md +151 -2
  2. package/bin/cli.js +59 -0
  3. package/bin/commands/baseline.js +27 -0
  4. package/bin/commands/hook.js +58 -0
  5. package/bin/commands/info.js +39 -0
  6. package/bin/commands/scan.js +206 -0
  7. package/bin/commands/shared.js +67 -0
  8. package/bin/commands/watch.js +44 -0
  9. package/bin/output/text.js +40 -0
  10. package/package.json +71 -4
  11. package/rules/a11y/a11y-click-div.js +39 -0
  12. package/rules/a11y/a11y-img-alt.js +36 -0
  13. package/rules/a11y/a11y-input-label.js +40 -0
  14. package/rules/core/console-log.js +44 -0
  15. package/rules/core/debugger-statement.js +36 -0
  16. package/rules/core/empty-block.js +41 -0
  17. package/rules/core/empty-catch.js +40 -0
  18. package/rules/core/hardcoded-secret.js +41 -0
  19. package/rules/core/leftover-todo.js +30 -0
  20. package/rules/core/unused-import.js +69 -0
  21. package/rules/index.js +27 -0
  22. package/rules/react/react-dangerous-html.js +30 -0
  23. package/rules/react/react-missing-key.js +56 -0
  24. package/rules/shared/jsx-helpers.js +30 -0
  25. package/src/core/args.js +134 -0
  26. package/src/core/config.js +92 -0
  27. package/src/core/engine.js +144 -0
  28. package/src/core/exit-codes.js +15 -0
  29. package/src/core/files.js +21 -0
  30. package/src/core/setup.js +114 -0
  31. package/src/core/version.js +6 -0
  32. package/src/fix/approve.js +71 -0
  33. package/src/fix/backup.js +73 -0
  34. package/src/fix/fixers/debugger-statement.js +12 -0
  35. package/src/fix/fixers/index.js +9 -0
  36. package/src/fix/fixers/leftover-todo.js +32 -0
  37. package/src/fix/fixers/unused-import.js +18 -0
  38. package/src/fix/gitignore.js +43 -0
  39. package/src/fix/safe-fix.js +178 -0
  40. package/src/output/explanations-frontend.js +22 -0
  41. package/src/output/explanations.js +37 -0
  42. package/src/output/json-output.js +47 -0
  43. package/src/output/reporter.js +52 -0
  44. package/src/output/run-report.js +194 -0
  45. package/src/output/sarif.js +60 -0
  46. package/src/scan/baseline.js +60 -0
  47. package/src/scan/git.js +28 -0
  48. package/src/scan/ignore.js +68 -0
  49. package/src/scan/suppress.js +53 -0
@@ -0,0 +1,30 @@
1
+ export const SKIP_KEYS = new Set(["loc", "range", "tokens", "comments", "parent"]);
2
+
3
+ export function childNodes(node) {
4
+ const children = [];
5
+ for (const [key, value] of Object.entries(node)) {
6
+ if (SKIP_KEYS.has(key)) continue;
7
+ if (Array.isArray(value)) {
8
+ for (const child of value) {
9
+ if (child && typeof child.type === "string") children.push(child);
10
+ }
11
+ } else if (value && typeof value.type === "string") {
12
+ children.push(value);
13
+ }
14
+ }
15
+ return children;
16
+ }
17
+
18
+ export function attributeNamed(element, name) {
19
+ return element.attributes.find(
20
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === name
21
+ );
22
+ }
23
+
24
+ export function hasSpread(element) {
25
+ return element.attributes.some((attr) => attr.type === "JSXSpreadAttribute");
26
+ }
27
+
28
+ export function literalValue(attribute) {
29
+ return attribute?.value?.type === "Literal" ? attribute.value.value : null;
30
+ }
@@ -0,0 +1,134 @@
1
+ export const COMMANDS = ["scan", "fix", "baseline", "hook", "rules", "explain", "version", "help"];
2
+
3
+ const FORMATS = ["text", "json", "sarif"];
4
+ const VALUE_FLAGS = new Set(["--format", "--rule", "--disable", "--include", "--exclude"]);
5
+
6
+ export class UsageError extends Error {}
7
+
8
+ export function parseArgs(argv) {
9
+ const args = [...argv];
10
+ const options = {
11
+ command: "scan",
12
+ positionals: [],
13
+ format: "text",
14
+ quiet: false,
15
+ fix: false,
16
+ changed: false,
17
+ staged: false,
18
+ useBaseline: true,
19
+ watch: false,
20
+ noPrompt: false,
21
+ force: false,
22
+ rules: [],
23
+ disable: [],
24
+ include: [],
25
+ exclude: [],
26
+ };
27
+
28
+ if (args.length > 0 && COMMANDS.includes(args[0])) {
29
+ options.command = args.shift();
30
+ }
31
+
32
+ for (let index = 0; index < args.length; index += 1) {
33
+ const arg = args[index];
34
+
35
+ if (arg === "--help" || arg === "-h") {
36
+ options.command = "help";
37
+ } else if (arg === "--version" || arg === "-v") {
38
+ options.command = "version";
39
+ } else if (arg === "--quiet" || arg === "-q") {
40
+ options.quiet = true;
41
+ } else if (arg === "--fix") {
42
+ options.fix = true;
43
+ } else if (arg === "--changed") {
44
+ options.changed = true;
45
+ } else if (arg === "--staged") {
46
+ options.staged = true;
47
+ } else if (arg === "--no-baseline") {
48
+ options.useBaseline = false;
49
+ } else if (arg === "--no-prompt") {
50
+ options.noPrompt = true;
51
+ } else if (arg === "--watch") {
52
+ options.watch = true;
53
+ } else if (arg === "--force") {
54
+ options.force = true;
55
+ } else if (VALUE_FLAGS.has(arg)) {
56
+ const value = args[index + 1];
57
+ if (value === undefined || value.startsWith("--")) {
58
+ throw new UsageError(`${arg} needs a value`);
59
+ }
60
+ index += 1;
61
+ applyValue(options, arg, value);
62
+ } else if (arg.startsWith("-")) {
63
+ throw new UsageError(`Unknown option: ${arg}`);
64
+ } else {
65
+ options.positionals.push(arg);
66
+ }
67
+ }
68
+
69
+ if (options.changed && options.staged) {
70
+ throw new UsageError("Use either --changed or --staged, not both");
71
+ }
72
+
73
+ if (options.watch && (options.command !== "scan" || options.format !== "text")) {
74
+ throw new UsageError("--watch works only with a scan that prints text");
75
+ }
76
+
77
+ if (options.command === "explain") {
78
+ if (options.positionals.length !== 1) {
79
+ throw new UsageError("explain needs exactly one rule name, for example: vhite explain console-log");
80
+ }
81
+ options.ruleName = options.positionals[0];
82
+ } else if (options.command === "hook") {
83
+ const action = options.positionals[0];
84
+ if (!["install", "remove"].includes(action)) {
85
+ throw new UsageError("hook needs an action: vhite hook install or vhite hook remove");
86
+ }
87
+ if (options.positionals.length > 2) {
88
+ throw new UsageError("Only one folder can be used with hook");
89
+ }
90
+ options.hookAction = action;
91
+ options.path = options.positionals[1] ?? ".";
92
+ } else if (["scan", "fix", "baseline"].includes(options.command)) {
93
+ if (options.positionals.length > 1) {
94
+ throw new UsageError("Only one folder can be scanned at a time");
95
+ }
96
+ options.path = options.positionals[0] ?? ".";
97
+ }
98
+
99
+ return options;
100
+ }
101
+
102
+ function applyValue(options, flag, value) {
103
+ if (flag === "--format") {
104
+ if (!FORMATS.includes(value)) {
105
+ throw new UsageError(`--format must be text, json, or sarif, not "${value}"`);
106
+ }
107
+ options.format = value;
108
+ } else if (flag === "--rule") {
109
+ options.rules.push(value);
110
+ } else if (flag === "--disable") {
111
+ options.disable.push(value);
112
+ } else if (flag === "--include") {
113
+ options.include.push(value);
114
+ } else if (flag === "--exclude") {
115
+ options.exclude.push(value);
116
+ }
117
+ }
118
+
119
+ export function normalizePath(path) {
120
+ const cleaned = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
121
+ return cleaned === "." ? "" : cleaned;
122
+ }
123
+
124
+ export function isIncluded(relativePath, include, exclude) {
125
+ const rel = normalizePath(relativePath);
126
+ const matches = (prefix) => {
127
+ const clean = normalizePath(prefix);
128
+ return clean === "" || rel === clean || rel.startsWith(`${clean}/`);
129
+ };
130
+
131
+ if (include.length > 0 && !include.some(matches)) return false;
132
+ if (exclude.some(matches)) return false;
133
+ return true;
134
+ }
@@ -0,0 +1,92 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { rules } from "../../rules/index.js";
4
+
5
+ const CONFIG_NAME = "vhite.config.json";
6
+ const knownRules = new Set(rules.map((rule) => rule.name));
7
+ const VALID_SEVERITIES = new Set(["error", "warning", "info", "off"]);
8
+
9
+ const DEFAULT_SEVERITY = {
10
+ parser: "error",
11
+ "hardcoded-secret": "error",
12
+ "debugger-statement": "error",
13
+ "console-log": "warning",
14
+ "empty-catch": "warning",
15
+ "unused-import": "warning",
16
+ "empty-block": "warning",
17
+ "leftover-todo": "info",
18
+ };
19
+
20
+ export function severityOf(name, config) {
21
+ return config.severities.get(name) ?? DEFAULT_SEVERITY[name] ?? "warning";
22
+ }
23
+
24
+ export function applySeverity(issues, config) {
25
+ for (const issue of issues) {
26
+ issue.severity = severityOf(issue.rule, config);
27
+ }
28
+ return issues;
29
+ }
30
+
31
+ export async function loadConfig(root) {
32
+ const empty = { disabledRules: new Set(), severities: new Map(), warnings: [] };
33
+
34
+ let text;
35
+ try {
36
+ text = await readFile(join(root, CONFIG_NAME), "utf8");
37
+ } catch (error) {
38
+ if (error.code === "ENOENT") return empty;
39
+ throw error;
40
+ }
41
+
42
+ let data;
43
+ try {
44
+ data = JSON.parse(text);
45
+ } catch (error) {
46
+ throw new Error(`${CONFIG_NAME} is not valid JSON: ${error.message}`);
47
+ }
48
+
49
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
50
+ throw new Error(`${CONFIG_NAME} must contain a JSON object`);
51
+ }
52
+
53
+ const config = { ...empty, disabledRules: new Set(), severities: new Map(), warnings: [] };
54
+
55
+ const requested = Array.isArray(data.disabledRules) ? data.disabledRules : [];
56
+ for (const name of requested) {
57
+ if (knownRules.has(name)) {
58
+ config.disabledRules.add(name);
59
+ } else {
60
+ config.warnings.push(`"${name}" is not a rule name, so it was ignored`);
61
+ }
62
+ }
63
+
64
+ if (data.rules !== undefined) {
65
+ if (typeof data.rules !== "object" || data.rules === null || Array.isArray(data.rules)) {
66
+ throw new Error(`${CONFIG_NAME} "rules" must be an object`);
67
+ }
68
+
69
+ for (const [name, value] of Object.entries(data.rules)) {
70
+ if (!knownRules.has(name)) {
71
+ config.warnings.push(`"${name}" is not a rule name, so it was ignored`);
72
+ continue;
73
+ }
74
+
75
+ const severity = typeof value === "string" ? value : value?.severity;
76
+ if (!VALID_SEVERITIES.has(severity)) {
77
+ config.warnings.push(
78
+ `"${name}" has an unknown severity "${severity}", so the default was kept`
79
+ );
80
+ continue;
81
+ }
82
+
83
+ if (severity === "off") {
84
+ config.disabledRules.add(name);
85
+ } else {
86
+ config.severities.set(name, severity);
87
+ }
88
+ }
89
+ }
90
+
91
+ return config;
92
+ }
@@ -0,0 +1,144 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+ import { parse } from "espree";
4
+ import { rules } from "../../rules/index.js";
5
+
6
+ function parseSource(source, file) {
7
+ const extension = extname(file).toLowerCase();
8
+
9
+ return parse(source, {
10
+ ecmaVersion: "latest",
11
+ sourceType: extension === ".cjs" ? "commonjs" : "module",
12
+ ecmaFeatures: {
13
+ jsx: true,
14
+ },
15
+ loc: true,
16
+ range: true,
17
+ comment: true,
18
+ tokens: true,
19
+ allowHashBang: true,
20
+ });
21
+ }
22
+
23
+ function lineStartsOf(source) {
24
+ const starts = [0];
25
+ for (let index = 0; index < source.length; index += 1) {
26
+ if (source[index] === "\n") starts.push(index + 1);
27
+ }
28
+ return starts;
29
+ }
30
+
31
+ function positionAt(starts, offset) {
32
+ let low = 0;
33
+ let high = starts.length - 1;
34
+
35
+ while (low < high) {
36
+ const middle = Math.ceil((low + high) / 2);
37
+ if (starts[middle] <= offset) {
38
+ low = middle;
39
+ } else {
40
+ high = middle - 1;
41
+ }
42
+ }
43
+
44
+ return { line: low + 1, column: offset - starts[low] + 1 };
45
+ }
46
+
47
+ function withLocation(issue, source, starts) {
48
+ if (typeof issue.start !== "number") {
49
+ return {
50
+ ...issue,
51
+ line: issue.line ?? 1,
52
+ column: issue.column ?? 1,
53
+ };
54
+ }
55
+
56
+ const start = positionAt(starts, issue.start);
57
+ const end = positionAt(starts, Math.max(issue.end ?? issue.start, issue.start));
58
+ const lineStart = starts[start.line - 1];
59
+ const lineEnd = source.indexOf("\n", lineStart);
60
+ const snippet = source
61
+ .slice(lineStart, lineEnd === -1 ? source.length : lineEnd)
62
+ .replace(/\r$/, "");
63
+
64
+ return {
65
+ ...issue,
66
+ line: start.line,
67
+ column: start.column,
68
+ endLine: end.line,
69
+ endColumn: end.column,
70
+ snippet,
71
+ };
72
+ }
73
+
74
+ export async function runRules(files, { disabledRules = new Set() } = {}) {
75
+ const issues = [];
76
+ const activeRules = rules.filter((rule) => !disabledRules.has(rule.name));
77
+
78
+ for (const file of files) {
79
+ const source = await readFile(file, "utf8");
80
+ const extension = extname(file).toLowerCase();
81
+ const starts = lineStartsOf(source);
82
+
83
+ const applicableRules = activeRules.filter((rule) =>
84
+ rule.extensions?.includes(extension)
85
+ );
86
+
87
+ if (applicableRules.length === 0) {
88
+ continue;
89
+ }
90
+
91
+ const needsAst = applicableRules.some(
92
+ (rule) => rule.requiresAst === true
93
+ );
94
+
95
+ let ast;
96
+
97
+ if (needsAst) {
98
+ try {
99
+ ast = parseSource(source, file);
100
+ } catch (error) {
101
+ issues.push(
102
+ withLocation(
103
+ {
104
+ rule: "parser",
105
+ file,
106
+ line: error.lineNumber ?? 1,
107
+ message: `Unable to parse file: ${error.message}`,
108
+ },
109
+ source,
110
+ starts
111
+ )
112
+ );
113
+ }
114
+ }
115
+
116
+ for (const rule of applicableRules) {
117
+ if (rule.requiresAst === true && !ast) {
118
+ continue;
119
+ }
120
+
121
+ const found = rule.check({
122
+ file,
123
+ source,
124
+ ...(rule.requiresAst === true ? { ast } : {}),
125
+ });
126
+
127
+ for (const issue of found) {
128
+ issues.push(
129
+ withLocation(
130
+ {
131
+ rule: rule.name,
132
+ file,
133
+ ...issue,
134
+ },
135
+ source,
136
+ starts
137
+ )
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ return issues;
144
+ }
@@ -0,0 +1,15 @@
1
+ export const EXIT = {
2
+ CLEAN: 0,
3
+ ISSUES: 1,
4
+ ERROR: 2,
5
+ FIXED: 3,
6
+ ROLLED_BACK: 4,
7
+ };
8
+
9
+ export function decideExitCode({ failed = false, rolledBack = 0, issues = 0, applied = 0 }) {
10
+ if (failed) return EXIT.ERROR;
11
+ if (rolledBack > 0) return EXIT.ROLLED_BACK;
12
+ if (issues > 0) return EXIT.ISSUES;
13
+ if (applied > 0) return EXIT.FIXED;
14
+ return EXIT.CLEAN;
15
+ }
@@ -0,0 +1,21 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, extname } from "node:path";
3
+
4
+ const IGNORED = new Set(["node_modules", ".git", "dist", "build"]);
5
+ const EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs", ".html", ".css"]);
6
+
7
+ export async function findFiles(dir) {
8
+ const entries = await readdir(dir, { withFileTypes: true });
9
+ const files = [];
10
+
11
+ for (const entry of entries) {
12
+ if (entry.isDirectory()) {
13
+ if (IGNORED.has(entry.name)) continue;
14
+ files.push(...(await findFiles(join(dir, entry.name))));
15
+ } else if (EXTENSIONS.has(extname(entry.name))) {
16
+ files.push(join(dir, entry.name));
17
+ }
18
+ }
19
+
20
+ return files;
21
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ writeFile,
3
+ readFile,
4
+ appendFile,
5
+ } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { rules } from "../../rules/index.js";
9
+
10
+ export const CONFIG_NAME = "vhite.config.json";
11
+
12
+ export function canAskQuestions() {
13
+ return Boolean(
14
+ process.stdin.isTTY &&
15
+ process.stdout.isTTY &&
16
+ !process.env.CI
17
+ );
18
+ }
19
+
20
+ async function ensureConfigIgnored(root) {
21
+ const gitignorePath = join(root, ".gitignore");
22
+ const entry = CONFIG_NAME;
23
+
24
+ let contents = "";
25
+
26
+ try {
27
+ contents = await readFile(gitignorePath, "utf8");
28
+ } catch (error) {
29
+ if (error.code !== "ENOENT") throw error;
30
+ }
31
+
32
+ const alreadyIgnored = contents
33
+ .split(/\r?\n/)
34
+ .some((line) => line.trim() === entry);
35
+
36
+ if (alreadyIgnored) return;
37
+
38
+ const prefix =
39
+ contents.length > 0 && !contents.endsWith("\n")
40
+ ? "\n"
41
+ : "";
42
+
43
+ await appendFile(
44
+ gitignorePath,
45
+ `${prefix}${entry}\n`,
46
+ "utf8"
47
+ );
48
+ }
49
+
50
+ export async function runFirstTimeSetup(root) {
51
+ const rl = createInterface({
52
+ input: process.stdin,
53
+ output: process.stdout,
54
+ });
55
+
56
+ try {
57
+ console.log(
58
+ `No ${CONFIG_NAME} found. Choose which rules to turn off.`
59
+ );
60
+
61
+ rules.forEach((rule, index) => {
62
+ console.log(
63
+ ` ${index + 1}. ${rule.name}: ${rule.description}`
64
+ );
65
+ });
66
+
67
+ console.log("");
68
+
69
+ const answer = (
70
+ await rl.question(
71
+ "Enter rule numbers separated by commas (for example 1,3), or press Enter to use all rules: "
72
+ )
73
+ ).trim();
74
+
75
+ const disabled = new Set();
76
+
77
+ if (answer !== "") {
78
+ for (const part of answer.split(",")) {
79
+ const entry = part.trim();
80
+ if (entry === "") continue;
81
+
82
+ const number = Number(entry);
83
+
84
+ if (
85
+ Number.isInteger(number) &&
86
+ number >= 1 &&
87
+ number <= rules.length
88
+ ) {
89
+ disabled.add(rules[number - 1].name);
90
+ } else {
91
+ console.log(
92
+ `Ignored "${entry}": not a rule number`
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ const config = {
99
+ disabledRules: [...disabled],
100
+ };
101
+
102
+ await writeFile(
103
+ join(root, CONFIG_NAME),
104
+ JSON.stringify(config, null, 2) + "\n",
105
+ "utf8"
106
+ );
107
+
108
+ await ensureConfigIgnored(root);
109
+
110
+ console.log(`Saved ${CONFIG_NAME}`);
111
+ } finally {
112
+ rl.close();
113
+ }
114
+ }
@@ -0,0 +1,6 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export async function readVersion() {
4
+ const text = await readFile(new URL("../../package.json", import.meta.url), "utf8");
5
+ return JSON.parse(text).version;
6
+ }
@@ -0,0 +1,71 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { relative } from "node:path";
3
+
4
+ export async function chooseProposals(root, proposals) {
5
+ if (proposals.length === 0) {
6
+ return [];
7
+ }
8
+
9
+ const rl = createInterface({
10
+ input: process.stdin,
11
+ output: process.stdout,
12
+ });
13
+
14
+ try {
15
+ console.log(`\n${proposals.length} safe fix(es) available:\n`);
16
+
17
+ proposals.forEach((proposal, index) => {
18
+ console.log(
19
+ ` ${index + 1}. ${relative(root, proposal.file)} line ${proposal.line}: ${proposal.message}`
20
+ );
21
+
22
+ console.log(
23
+ ` Proposed change: delete this line: ${proposal.lineText}`
24
+ );
25
+ });
26
+
27
+ console.log("");
28
+
29
+ const answer = (
30
+ await rl.question(
31
+ "Enter fix numbers to apply (for example 1,3), type all, or press Enter to skip: "
32
+ )
33
+ ).trim().toLowerCase();
34
+
35
+ if (answer === "") {
36
+ return [];
37
+ }
38
+
39
+ if (answer === "all") {
40
+ return proposals;
41
+ }
42
+
43
+ const picked = new Set();
44
+
45
+ for (const part of answer.split(",")) {
46
+ const entry = part.trim();
47
+
48
+ if (entry === "") {
49
+ continue;
50
+ }
51
+
52
+ const number = Number(entry);
53
+
54
+ if (
55
+ Number.isInteger(number) &&
56
+ number >= 1 &&
57
+ number <= proposals.length
58
+ ) {
59
+ picked.add(number - 1);
60
+ } else {
61
+ console.log(`Ignored "${entry}": not a fix number`);
62
+ }
63
+ }
64
+
65
+ return [...picked]
66
+ .sort((a, b) => a - b)
67
+ .map((index) => proposals[index]);
68
+ } finally {
69
+ rl.close();
70
+ }
71
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdir, copyFile, rm } from "node:fs/promises";
2
+ import {
3
+ join,
4
+ relative,
5
+ dirname,
6
+ isAbsolute,
7
+ } from "node:path";
8
+ import {
9
+ FIX_DIR,
10
+ ensureGitignoreEntry,
11
+ } from "./gitignore.js";
12
+
13
+ export async function prepareBackupDir(root) {
14
+ const changed = await ensureGitignoreEntry(root);
15
+ const dir = join(root, FIX_DIR, "backup");
16
+
17
+ await mkdir(dir, { recursive: true });
18
+
19
+ return { dir, changed };
20
+ }
21
+
22
+ function backupPathFor(root, file, attempt) {
23
+ const rel = relative(root, file);
24
+
25
+ if (rel.startsWith("..") || isAbsolute(rel)) {
26
+ throw new Error(
27
+ `Refusing to back up a file outside the project: ${file}`
28
+ );
29
+ }
30
+
31
+ return join(
32
+ root,
33
+ FIX_DIR,
34
+ "backup",
35
+ `${rel}.${attempt}.bak`
36
+ );
37
+ }
38
+
39
+ export async function saveBackup(root, file, attempt) {
40
+ const dest = backupPathFor(root, file, attempt);
41
+
42
+ await mkdir(dirname(dest), { recursive: true });
43
+ await copyFile(file, dest);
44
+
45
+ return dest;
46
+ }
47
+
48
+ export async function restoreBackup(file, backupPath) {
49
+ await copyFile(backupPath, file);
50
+ }
51
+
52
+ /**
53
+ * Deletes only backups explicitly recorded in the results
54
+ * for this run. It does not delete older backup files.
55
+ */
56
+ export async function deleteRunBackups(results) {
57
+ const paths = [
58
+ ...new Set(
59
+ results
60
+ .map((result) => result.backupPath)
61
+ .filter((backupPath) => typeof backupPath === "string")
62
+ ),
63
+ ];
64
+
65
+ let deleted = 0;
66
+
67
+ for (const backupPath of paths) {
68
+ await rm(backupPath, { force: true });
69
+ deleted += 1;
70
+ }
71
+
72
+ return { deleted, paths };
73
+ }