@vhite/vhite 0.0.0-stage → 0.1.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.
- package/README.md +56 -2
- package/bin/cli.js +216 -0
- package/package.json +43 -3
- package/rules/console-log.js +43 -0
- package/rules/debugger-statement.js +35 -0
- package/rules/empty-block.js +40 -0
- package/rules/empty-catch.js +39 -0
- package/rules/hardcoded-secret.js +36 -0
- package/rules/index.js +17 -0
- package/rules/leftover-todo.js +26 -0
- package/rules/unused-import.js +67 -0
- package/src/approve.js +71 -0
- package/src/backup.js +73 -0
- package/src/config.js +42 -0
- package/src/engine.js +80 -0
- package/src/explanations.js +35 -0
- package/src/files.js +21 -0
- package/src/fixers/debugger-statement.js +12 -0
- package/src/fixers/index.js +9 -0
- package/src/fixers/leftover-todo.js +32 -0
- package/src/fixers/unused-import.js +18 -0
- package/src/gitignore.js +43 -0
- package/src/reporter.js +33 -0
- package/src/run-report.js +194 -0
- package/src/safe-fix.js +178 -0
- package/src/setup.js +114 -0
package/src/approve.js
ADDED
|
@@ -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
|
+
}
|
package/src/backup.js
ADDED
|
@@ -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
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
|
|
8
|
+
export async function loadConfig(root) {
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = await readFile(join(root, CONFIG_NAME), "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code === "ENOENT") return { disabledRules: new Set(), warnings: [] };
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = JSON.parse(text);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(`${CONFIG_NAME} is not valid JSON: ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
25
|
+
throw new Error(`${CONFIG_NAME} must contain a JSON object`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const requested = Array.isArray(data.disabledRules) ? data.disabledRules : [];
|
|
29
|
+
const disabledRules = new Set();
|
|
30
|
+
const warnings = [];
|
|
31
|
+
|
|
32
|
+
for (const name of requested) {
|
|
33
|
+
if (knownRules.has(name)) {
|
|
34
|
+
disabledRules.add(name);
|
|
35
|
+
} else {
|
|
36
|
+
warnings.push(`"${name}" is not a rule name, so it was ignored`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { disabledRules, warnings };
|
|
41
|
+
}
|
|
42
|
+
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
export async function runRules(files, { disabledRules = new Set() } = {}) {
|
|
24
|
+
const issues = [];
|
|
25
|
+
const activeRules = rules.filter((rule) => !disabledRules.has(rule.name));
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const source = await readFile(file, "utf8");
|
|
29
|
+
const extension = extname(file).toLowerCase();
|
|
30
|
+
|
|
31
|
+
const applicableRules = activeRules.filter((rule) =>
|
|
32
|
+
rule.extensions?.includes(extension)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (applicableRules.length === 0) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const needsAst = applicableRules.some(
|
|
40
|
+
(rule) => rule.requiresAst === true
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
let ast;
|
|
44
|
+
|
|
45
|
+
if (needsAst) {
|
|
46
|
+
try {
|
|
47
|
+
ast = parseSource(source, file);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
issues.push({
|
|
50
|
+
rule: "parser",
|
|
51
|
+
file,
|
|
52
|
+
line: error.lineNumber ?? 1,
|
|
53
|
+
message: `Unable to parse file: ${error.message}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const rule of applicableRules) {
|
|
59
|
+
if (rule.requiresAst === true && !ast) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const found = rule.check({
|
|
64
|
+
file,
|
|
65
|
+
source,
|
|
66
|
+
...(rule.requiresAst === true ? { ast } : {}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
for (const issue of found) {
|
|
70
|
+
issues.push({
|
|
71
|
+
rule: rule.name,
|
|
72
|
+
file,
|
|
73
|
+
...issue,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const explanations = {
|
|
2
|
+
"leftover-todo": {
|
|
3
|
+
whatHappened: "A comment is still in the code. Comments that mark unfinished work (TODO or FIXME) are easy to forget before release, and other comments can go out of date and mislead the next developer.",
|
|
4
|
+
proposedChange: "Finish the task and delete the note, or move it to your issue tracker. Delete other comments that no longer explain anything.",
|
|
5
|
+
},
|
|
6
|
+
"debugger-statement": {
|
|
7
|
+
whatHappened: "A debugger statement pauses the program whenever developer tools are open. Left in, it can freeze the app for anyone who has them open.",
|
|
8
|
+
proposedChange: "Delete the debugger line.",
|
|
9
|
+
},
|
|
10
|
+
"console-log": {
|
|
11
|
+
whatHappened: "A console.log call prints a message to the console. It's often a leftover from debugging, and it can reveal internal details to anyone who opens developer tools.",
|
|
12
|
+
proposedChange: "Delete the line if it was only for debugging. If the output is needed, keep it but make the message clear, or remove it before release.",
|
|
13
|
+
},
|
|
14
|
+
"empty-catch": {
|
|
15
|
+
whatHappened: "This catch block catches an error and does nothing with it. If something fails, the app keeps going silently and nobody sees the problem.",
|
|
16
|
+
proposedChange: "Log the error, for example with console.error(error), or show the user a helpful message. If ignoring the error is truly intended, add a comment explaining why.",
|
|
17
|
+
},
|
|
18
|
+
"unused-import": {
|
|
19
|
+
whatHappened: "This file imports something it never uses. It adds clutter and can make the file load slower.",
|
|
20
|
+
proposedChange: "Delete the unused import line.",
|
|
21
|
+
},
|
|
22
|
+
"empty-block": {
|
|
23
|
+
whatHappened: "An if, else, or loop has a body with no code in it. This often means logic was started and never finished.",
|
|
24
|
+
proposedChange: "Add the missing logic, or delete the block if it isn't needed. If the empty block is intentional, add a comment saying so.",
|
|
25
|
+
},
|
|
26
|
+
"hardcoded-secret": {
|
|
27
|
+
whatHappened: "A value that looks like a password, API key, or token is written directly in the code. Anyone who can read the code can use it.",
|
|
28
|
+
proposedChange: "Move the value into an environment variable or a settings file that isn't shared. If it was a real value, replace it at the service that issued it, since it may already be exposed.",
|
|
29
|
+
},
|
|
30
|
+
parser: {
|
|
31
|
+
whatHappened: "vhite couldn't read this file as JavaScript, so it skipped the checks that need to understand the code.",
|
|
32
|
+
proposedChange: "Open the file at the line shown and fix the syntax error, then scan again.",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
package/src/files.js
ADDED
|
@@ -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,12 @@
|
|
|
1
|
+
const debuggerFixer = {
|
|
2
|
+
rule: "debugger-statement",
|
|
3
|
+
apply(source, issue) {
|
|
4
|
+
const lines = source.split("\n");
|
|
5
|
+
const index = issue.line - 1;
|
|
6
|
+
if (!/^\s*debugger\s*;?\s*$/.test(lines[index] ?? "")) return null;
|
|
7
|
+
lines.splice(index, 1);
|
|
8
|
+
return lines.join("\n");
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default debuggerFixer;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import debuggerFixer from "./debugger-statement.js";
|
|
2
|
+
import unusedImportFixer from "./unused-import.js";
|
|
3
|
+
import commentFixer from "./leftover-todo.js";
|
|
4
|
+
|
|
5
|
+
export const fixers = {
|
|
6
|
+
[debuggerFixer.rule]: debuggerFixer,
|
|
7
|
+
[unusedImportFixer.rule]: unusedImportFixer,
|
|
8
|
+
[commentFixer.rule]: commentFixer,
|
|
9
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const commentFixer = {
|
|
2
|
+
rule: "leftover-todo",
|
|
3
|
+
apply(source, issue) {
|
|
4
|
+
const { type, value, range } = issue.comment ?? {};
|
|
5
|
+
if (!range) return null;
|
|
6
|
+
|
|
7
|
+
const [start, end] = range;
|
|
8
|
+
const expected = type === "Line" ? `//${value}` : `/*${value}*/`;
|
|
9
|
+
if (source.slice(start, end) !== expected) return null;
|
|
10
|
+
|
|
11
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
12
|
+
const newlineAfter = source.indexOf("\n", end);
|
|
13
|
+
const lineEnd = newlineAfter === -1 ? source.length : newlineAfter;
|
|
14
|
+
const before = source.slice(lineStart, start);
|
|
15
|
+
const after = source.slice(end, lineEnd);
|
|
16
|
+
|
|
17
|
+
if (before.trim() === "" && after.trim() === "") {
|
|
18
|
+
const removeTo = newlineAfter === -1 ? source.length : newlineAfter + 1;
|
|
19
|
+
return source.slice(0, lineStart) + source.slice(removeTo);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const head = source.slice(0, start).replace(/[ \t]+$/, "");
|
|
23
|
+
if (after.trim() === "") {
|
|
24
|
+
return head + source.slice(lineEnd);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rest = source.slice(end).replace(/^[ \t]+/, "");
|
|
28
|
+
return before.trim() === "" ? head + rest : `${head} ${rest}`;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default commentFixer;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const unusedImportFixer = {
|
|
2
|
+
rule: "unused-import",
|
|
3
|
+
apply(source, issue) {
|
|
4
|
+
const name = issue.message.match(/"([^"]+)"/)?.[1];
|
|
5
|
+
if (!name) return null;
|
|
6
|
+
const escaped = name.replace(/\$/g, "\\$");
|
|
7
|
+
const lines = source.split("\n");
|
|
8
|
+
const index = issue.line - 1;
|
|
9
|
+
const single = new RegExp(
|
|
10
|
+
`^\\s*import\\s+(?:${escaped}|\\{\\s*${escaped}\\s*\\})\\s+from\\s+["'][^"']+["'];?\\s*$`
|
|
11
|
+
);
|
|
12
|
+
if (!single.test(lines[index] ?? "")) return null;
|
|
13
|
+
lines.splice(index, 1);
|
|
14
|
+
return lines.join("\n");
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default unusedImportFixer;
|
package/src/gitignore.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFile,
|
|
3
|
+
writeFile,
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const FIX_DIR = ".vhite-fix";
|
|
8
|
+
|
|
9
|
+
export async function ensureGitignoreEntry(root) {
|
|
10
|
+
const path = join(root, ".gitignore");
|
|
11
|
+
const entry = `${FIX_DIR}/`;
|
|
12
|
+
|
|
13
|
+
let text = "";
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
text = await readFile(path, "utf8");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code !== "ENOENT") {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const lines = text
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.map((line) => line.trim());
|
|
26
|
+
|
|
27
|
+
if (lines.includes(entry) || lines.includes(FIX_DIR)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const separator =
|
|
32
|
+
text === "" || text.endsWith("\n")
|
|
33
|
+
? ""
|
|
34
|
+
: "\n";
|
|
35
|
+
|
|
36
|
+
await writeFile(
|
|
37
|
+
path,
|
|
38
|
+
`${text}${separator}${entry}\n`,
|
|
39
|
+
"utf8"
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { explanations } from "./explanations.js";
|
|
3
|
+
|
|
4
|
+
export function formatReport(issues, root) {
|
|
5
|
+
if (issues.length === 0) return "No issues found.";
|
|
6
|
+
|
|
7
|
+
const byFile = new Map();
|
|
8
|
+
for (const issue of issues) {
|
|
9
|
+
if (!byFile.has(issue.file)) byFile.set(issue.file, []);
|
|
10
|
+
byFile.get(issue.file).push(issue);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = [];
|
|
14
|
+
const sortedFiles = [...byFile.keys()].sort((a, b) =>
|
|
15
|
+
relative(root, a).localeCompare(relative(root, b))
|
|
16
|
+
);
|
|
17
|
+
for (const file of sortedFiles) {
|
|
18
|
+
const fileIssues = byFile.get(file);
|
|
19
|
+
lines.push(relative(root, file));
|
|
20
|
+
fileIssues.sort((a, b) => a.line - b.line);
|
|
21
|
+
for (const issue of fileIssues) {
|
|
22
|
+
lines.push(` Line ${issue.line}: ${issue.message} [${issue.rule}]`);
|
|
23
|
+
const info = explanations[issue.rule];
|
|
24
|
+
if (info) {
|
|
25
|
+
lines.push(` What happened: ${info.whatHappened}`);
|
|
26
|
+
lines.push(` Proposed change: ${info.proposedChange}`);
|
|
27
|
+
}
|
|
28
|
+
lines.push("");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
lines.push(`${issues.length} issue(s) in ${byFile.size} file(s)`);
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
writeFile,
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import {
|
|
8
|
+
FIX_DIR,
|
|
9
|
+
ensureGitignoreEntry,
|
|
10
|
+
} from "./gitignore.js";
|
|
11
|
+
|
|
12
|
+
function escapeMarkdown(value) {
|
|
13
|
+
return String(value ?? "")
|
|
14
|
+
.replace(/\|/g, "\\|")
|
|
15
|
+
.replace(/\r?\n/g, " ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatMarkdownRunReport({
|
|
19
|
+
root,
|
|
20
|
+
target = root,
|
|
21
|
+
startedAt = new Date(),
|
|
22
|
+
finishedAt = new Date(),
|
|
23
|
+
filesScanned = 0,
|
|
24
|
+
issues = [],
|
|
25
|
+
proposals = [],
|
|
26
|
+
fixResults = [],
|
|
27
|
+
logs = [],
|
|
28
|
+
backupCleanup = null,
|
|
29
|
+
}) {
|
|
30
|
+
const applied = fixResults.filter(
|
|
31
|
+
(item) => item.status === "applied"
|
|
32
|
+
).length;
|
|
33
|
+
|
|
34
|
+
const skipped = fixResults.filter(
|
|
35
|
+
(item) => item.status === "skipped"
|
|
36
|
+
).length;
|
|
37
|
+
|
|
38
|
+
const rolledBack = fixResults.filter(
|
|
39
|
+
(item) => item.status === "rolled back"
|
|
40
|
+
).length;
|
|
41
|
+
|
|
42
|
+
const lines = [
|
|
43
|
+
"# Vhite CLI — Run Report",
|
|
44
|
+
"",
|
|
45
|
+
"## Run overview",
|
|
46
|
+
"",
|
|
47
|
+
`- **Project:** \`${target}\``,
|
|
48
|
+
`- **Started:** ${startedAt.toISOString()}`,
|
|
49
|
+
`- **Finished:** ${finishedAt.toISOString()}`,
|
|
50
|
+
`- **Files scanned:** ${filesScanned}`,
|
|
51
|
+
`- **Issues detected:** ${issues.length}`,
|
|
52
|
+
`- **Fix proposals:** ${proposals.length}`,
|
|
53
|
+
`- **Fixes applied:** ${applied}`,
|
|
54
|
+
`- **Fixes skipped:** ${skipped}`,
|
|
55
|
+
`- **Fixes rolled back:** ${rolledBack}`,
|
|
56
|
+
"",
|
|
57
|
+
"## Issues detected",
|
|
58
|
+
"",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
if (issues.length === 0) {
|
|
62
|
+
lines.push("No issues were detected.", "");
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(
|
|
65
|
+
"| File | Line | Rule | Finding |",
|
|
66
|
+
"|---|---:|---|---|"
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
for (const issue of issues) {
|
|
70
|
+
lines.push(
|
|
71
|
+
`| \`${escapeMarkdown(relative(root, issue.file))}\` | ${issue.line} | \`${escapeMarkdown(issue.rule)}\` | ${escapeMarkdown(issue.message)} |`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push("## Fix results", "");
|
|
79
|
+
|
|
80
|
+
if (fixResults.length === 0) {
|
|
81
|
+
lines.push("No fixes were applied during this run.", "");
|
|
82
|
+
} else {
|
|
83
|
+
lines.push(
|
|
84
|
+
"| Status | File | Line | Rule | Details |",
|
|
85
|
+
"|---|---|---:|---|---|"
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
for (const result of fixResults) {
|
|
89
|
+
const proposal = result.proposal ?? {};
|
|
90
|
+
const file = proposal.file
|
|
91
|
+
? relative(root, proposal.file)
|
|
92
|
+
: "—";
|
|
93
|
+
|
|
94
|
+
lines.push(
|
|
95
|
+
`| ${escapeMarkdown(result.status)} | \`${escapeMarkdown(file)}\` | ${proposal.line ?? "—"} | \`${escapeMarkdown(proposal.rule ?? "—")}\` | ${escapeMarkdown(result.reason ?? "No additional details")} |`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push("## Backup management", "");
|
|
103
|
+
|
|
104
|
+
if (backupCleanup) {
|
|
105
|
+
lines.push(
|
|
106
|
+
`- **Cleanup requested:** ${backupCleanup.requested ? "Yes" : "No"}`,
|
|
107
|
+
`- **Backup files deleted:** ${backupCleanup.deleted ?? 0}`,
|
|
108
|
+
`- **Backups retained:** ${backupCleanup.requested ? "No current-run backups were intentionally retained by cleanup" : "Current-run backups were kept"}`,
|
|
109
|
+
""
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
lines.push("No backup cleanup decision was recorded.", "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lines.push("## Terminal log", "", "```text");
|
|
116
|
+
|
|
117
|
+
if (logs.length === 0) {
|
|
118
|
+
lines.push("No captured terminal log entries.");
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(...logs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push(
|
|
124
|
+
"```",
|
|
125
|
+
"",
|
|
126
|
+
"## Final notes",
|
|
127
|
+
"",
|
|
128
|
+
"- Review any unresolved findings.",
|
|
129
|
+
"- A rolled-back fix means the original file was restored after verification failed.",
|
|
130
|
+
"- Run your project's tests before shipping changes.",
|
|
131
|
+
""
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function writeRunReport(options) {
|
|
138
|
+
const { root } = options;
|
|
139
|
+
|
|
140
|
+
const reportDir = join(root, FIX_DIR, "reports");
|
|
141
|
+
const gitignoreUpdated = await ensureGitignoreEntry(root);
|
|
142
|
+
|
|
143
|
+
await mkdir(reportDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
const timestamp = (options.finishedAt ?? new Date())
|
|
146
|
+
.toISOString()
|
|
147
|
+
.replace(/[:.]/g, "-");
|
|
148
|
+
|
|
149
|
+
const filename = `vhite-report-${timestamp}.md`;
|
|
150
|
+
const reportPath = join(reportDir, filename);
|
|
151
|
+
const content = formatMarkdownRunReport(options);
|
|
152
|
+
|
|
153
|
+
await writeFile(reportPath, content, "utf8");
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
reportPath,
|
|
157
|
+
content,
|
|
158
|
+
gitignoreUpdated,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function askYesNo(
|
|
163
|
+
question,
|
|
164
|
+
{ defaultYes = false } = {}
|
|
165
|
+
) {
|
|
166
|
+
const rl = createInterface({
|
|
167
|
+
input: process.stdin,
|
|
168
|
+
output: process.stdout,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const suffix = defaultYes ? " (Y/n): " : " (y/N): ";
|
|
173
|
+
const answer = (
|
|
174
|
+
await rl.question(question + suffix)
|
|
175
|
+
).trim().toLowerCase();
|
|
176
|
+
|
|
177
|
+
if (answer === "") {
|
|
178
|
+
return defaultYes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (["y", "yes"].includes(answer)) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (["n", "no"].includes(answer)) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log("Please enter y or n. Your answer defaults to No.");
|
|
190
|
+
return false;
|
|
191
|
+
} finally {
|
|
192
|
+
rl.close();
|
|
193
|
+
}
|
|
194
|
+
}
|