@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.
- package/README.md +151 -2
- package/bin/cli.js +59 -0
- package/bin/commands/baseline.js +27 -0
- package/bin/commands/hook.js +58 -0
- package/bin/commands/info.js +39 -0
- package/bin/commands/scan.js +206 -0
- package/bin/commands/shared.js +67 -0
- package/bin/commands/watch.js +44 -0
- package/bin/output/text.js +40 -0
- package/package.json +71 -4
- package/rules/a11y/a11y-click-div.js +39 -0
- package/rules/a11y/a11y-img-alt.js +36 -0
- package/rules/a11y/a11y-input-label.js +40 -0
- package/rules/core/console-log.js +44 -0
- package/rules/core/debugger-statement.js +36 -0
- package/rules/core/empty-block.js +41 -0
- package/rules/core/empty-catch.js +40 -0
- package/rules/core/hardcoded-secret.js +41 -0
- package/rules/core/leftover-todo.js +30 -0
- package/rules/core/unused-import.js +69 -0
- package/rules/index.js +27 -0
- package/rules/react/react-dangerous-html.js +30 -0
- package/rules/react/react-missing-key.js +56 -0
- package/rules/shared/jsx-helpers.js +30 -0
- package/src/core/args.js +134 -0
- package/src/core/config.js +92 -0
- package/src/core/engine.js +144 -0
- package/src/core/exit-codes.js +15 -0
- package/src/core/files.js +21 -0
- package/src/core/setup.js +114 -0
- package/src/core/version.js +6 -0
- package/src/fix/approve.js +71 -0
- package/src/fix/backup.js +73 -0
- package/src/fix/fixers/debugger-statement.js +12 -0
- package/src/fix/fixers/index.js +9 -0
- package/src/fix/fixers/leftover-todo.js +32 -0
- package/src/fix/fixers/unused-import.js +18 -0
- package/src/fix/gitignore.js +43 -0
- package/src/fix/safe-fix.js +178 -0
- package/src/output/explanations-frontend.js +22 -0
- package/src/output/explanations.js +37 -0
- package/src/output/json-output.js +47 -0
- package/src/output/reporter.js +52 -0
- package/src/output/run-report.js +194 -0
- package/src/output/sarif.js +60 -0
- package/src/scan/baseline.js +60 -0
- package/src/scan/git.js +28 -0
- package/src/scan/ignore.js +68 -0
- package/src/scan/suppress.js +53 -0
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFile,
|
|
3
|
+
writeFile,
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { runRules } from "../core/engine.js";
|
|
6
|
+
import {
|
|
7
|
+
fixers as defaultFixers,
|
|
8
|
+
} from "./fixers/index.js";
|
|
9
|
+
import {
|
|
10
|
+
prepareBackupDir,
|
|
11
|
+
saveBackup,
|
|
12
|
+
restoreBackup,
|
|
13
|
+
} from "./backup.js";
|
|
14
|
+
|
|
15
|
+
function summarize(issues) {
|
|
16
|
+
const counts = new Map();
|
|
17
|
+
|
|
18
|
+
for (const issue of issues) {
|
|
19
|
+
const key = `${issue.rule}|${issue.message}`;
|
|
20
|
+
|
|
21
|
+
counts.set(
|
|
22
|
+
key,
|
|
23
|
+
(counts.get(key) ?? 0) + 1
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return counts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isExpectedAfterFix(before, after, key) {
|
|
31
|
+
const expected = new Map(before);
|
|
32
|
+
|
|
33
|
+
if (!expected.get(key)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const remaining = expected.get(key) - 1;
|
|
38
|
+
|
|
39
|
+
if (remaining === 0) {
|
|
40
|
+
expected.delete(key);
|
|
41
|
+
} else {
|
|
42
|
+
expected.set(key, remaining);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (expected.size !== after.size) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const [name, count] of expected) {
|
|
50
|
+
if (after.get(name) !== count) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function proposeFixes(
|
|
59
|
+
issues,
|
|
60
|
+
{ fixers = defaultFixers } = {}
|
|
61
|
+
) {
|
|
62
|
+
const proposals = [];
|
|
63
|
+
|
|
64
|
+
for (const issue of issues) {
|
|
65
|
+
const fixer = fixers[issue.rule];
|
|
66
|
+
|
|
67
|
+
if (!fixer) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const source = await readFile(issue.file, "utf8");
|
|
72
|
+
|
|
73
|
+
if (fixer.apply(source, issue) === null) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lineText = (
|
|
78
|
+
source.split("\n")[issue.line - 1] ?? ""
|
|
79
|
+
).trim();
|
|
80
|
+
|
|
81
|
+
proposals.push({
|
|
82
|
+
...issue,
|
|
83
|
+
lineText,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return proposals;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function applyApprovedFixes(
|
|
91
|
+
root,
|
|
92
|
+
proposals,
|
|
93
|
+
{
|
|
94
|
+
fixers = defaultFixers,
|
|
95
|
+
disabledRules = new Set(),
|
|
96
|
+
} = {}
|
|
97
|
+
) {
|
|
98
|
+
const { changed } = await prepareBackupDir(root);
|
|
99
|
+
const results = [];
|
|
100
|
+
|
|
101
|
+
const ordered = [...proposals].sort(
|
|
102
|
+
(a, b) =>
|
|
103
|
+
a.file.localeCompare(b.file) ||
|
|
104
|
+
b.line - a.line
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
let attempt = 0;
|
|
108
|
+
|
|
109
|
+
for (const proposal of ordered) {
|
|
110
|
+
attempt += 1;
|
|
111
|
+
|
|
112
|
+
const fixer = fixers[proposal.rule];
|
|
113
|
+
|
|
114
|
+
if (!fixer) {
|
|
115
|
+
results.push({
|
|
116
|
+
proposal,
|
|
117
|
+
status: "skipped",
|
|
118
|
+
reason: "No fixer is registered for this rule",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const source = await readFile(proposal.file, "utf8");
|
|
125
|
+
const candidate = fixer.apply(source, proposal);
|
|
126
|
+
|
|
127
|
+
if (candidate === null) {
|
|
128
|
+
results.push({
|
|
129
|
+
proposal,
|
|
130
|
+
status: "skipped",
|
|
131
|
+
reason: "The line no longer matches a safe fix",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const before = summarize(
|
|
138
|
+
await runRules([proposal.file], { disabledRules })
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const backupPath = await saveBackup(
|
|
142
|
+
root,
|
|
143
|
+
proposal.file,
|
|
144
|
+
attempt
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await writeFile(proposal.file, candidate, "utf8");
|
|
148
|
+
|
|
149
|
+
const after = summarize(
|
|
150
|
+
await runRules([proposal.file], { disabledRules })
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const key = `${proposal.rule}|${proposal.message}`;
|
|
154
|
+
|
|
155
|
+
if (isExpectedAfterFix(before, after, key)) {
|
|
156
|
+
results.push({
|
|
157
|
+
proposal,
|
|
158
|
+
status: "applied",
|
|
159
|
+
backupPath,
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
await restoreBackup(proposal.file, backupPath);
|
|
163
|
+
|
|
164
|
+
results.push({
|
|
165
|
+
proposal,
|
|
166
|
+
status: "rolled back",
|
|
167
|
+
backupPath,
|
|
168
|
+
reason:
|
|
169
|
+
"The change did not fix the issue cleanly, so the original was restored",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
results,
|
|
176
|
+
gitignoreUpdated: changed,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const frontendExplanations = {
|
|
2
|
+
"react-missing-key": {
|
|
3
|
+
whatHappened: "Items in a list are created without a key. React uses keys to track items, so without them it can reuse or lose the wrong elements when the list changes.",
|
|
4
|
+
proposedChange: "Add a key prop with a value that is unique and stable for each item, such as an id from your data. Avoid using the array index when items can be added, removed, or reordered.",
|
|
5
|
+
},
|
|
6
|
+
"react-dangerous-html": {
|
|
7
|
+
whatHappened: "dangerouslySetInnerHTML inserts raw HTML into the page. If that HTML comes from users or an API, it can contain scripts that run in other people's browsers.",
|
|
8
|
+
proposedChange: "Render the content with normal JSX where possible. If raw HTML is required, sanitize it first with a trusted library such as DOMPurify.",
|
|
9
|
+
},
|
|
10
|
+
"a11y-img-alt": {
|
|
11
|
+
whatHappened: "An image without alt text is silent for people using screen readers, and it shows nothing if the image fails to load.",
|
|
12
|
+
proposedChange: "Add alt text that describes what the image shows or means. For decorative images, use alt=\"\" so screen readers skip them.",
|
|
13
|
+
},
|
|
14
|
+
"a11y-input-label": {
|
|
15
|
+
whatHappened: "A form input without a label is hard to use with a screen reader, because the user can't tell what to type.",
|
|
16
|
+
proposedChange: "Connect the input to a label element with a matching htmlFor and id, or add an aria-label that describes the field.",
|
|
17
|
+
},
|
|
18
|
+
"a11y-click-div": {
|
|
19
|
+
whatHappened: "A clickable div or span can be clicked with a mouse, but it can't be reached with a keyboard and screen readers don't announce it as a button.",
|
|
20
|
+
proposedChange: "Use a button element for actions and an a element for links. If you must use a div, add role, tabIndex, and a key handler for Enter and Space.",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { frontendExplanations } from "./explanations-frontend.js";
|
|
2
|
+
export const explanations = {
|
|
3
|
+
"leftover-todo": {
|
|
4
|
+
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.",
|
|
5
|
+
proposedChange: "Finish the task and delete the note, or move it to your issue tracker. Delete other comments that no longer explain anything.",
|
|
6
|
+
},
|
|
7
|
+
"debugger-statement": {
|
|
8
|
+
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.",
|
|
9
|
+
proposedChange: "Delete the debugger line.",
|
|
10
|
+
},
|
|
11
|
+
"console-log": {
|
|
12
|
+
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.",
|
|
13
|
+
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.",
|
|
14
|
+
},
|
|
15
|
+
"empty-catch": {
|
|
16
|
+
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.",
|
|
17
|
+
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.",
|
|
18
|
+
},
|
|
19
|
+
"unused-import": {
|
|
20
|
+
whatHappened: "This file imports something it never uses. It adds clutter and can make the file load slower.",
|
|
21
|
+
proposedChange: "Delete the unused import line.",
|
|
22
|
+
},
|
|
23
|
+
"empty-block": {
|
|
24
|
+
whatHappened: "An if, else, or loop has a body with no code in it. This often means logic was started and never finished.",
|
|
25
|
+
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.",
|
|
26
|
+
},
|
|
27
|
+
"hardcoded-secret": {
|
|
28
|
+
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.",
|
|
29
|
+
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.",
|
|
30
|
+
},
|
|
31
|
+
parser: {
|
|
32
|
+
whatHappened: "vhite couldn't read this file as JavaScript, so it skipped the checks that need to understand the code.",
|
|
33
|
+
proposedChange: "Open the file at the line shown and fix the syntax error, then scan again.",
|
|
34
|
+
},
|
|
35
|
+
...frontendExplanations,
|
|
36
|
+
};
|
|
37
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
function toPosix(path) {
|
|
4
|
+
return path.split(sep).join("/");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatJson({ root, filesScanned, issues, fixes = [], version }) {
|
|
8
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
9
|
+
for (const issue of issues) {
|
|
10
|
+
counts[issue.severity] = (counts[issue.severity] ?? 0) + 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sorted = [...issues].sort(
|
|
14
|
+
(a, b) =>
|
|
15
|
+
a.file.localeCompare(b.file) ||
|
|
16
|
+
a.line - b.line ||
|
|
17
|
+
(a.column ?? 1) - (b.column ?? 1)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return JSON.stringify(
|
|
21
|
+
{
|
|
22
|
+
version,
|
|
23
|
+
root: toPosix(root),
|
|
24
|
+
filesScanned,
|
|
25
|
+
summary: { total: issues.length, ...counts },
|
|
26
|
+
issues: sorted.map((issue) => ({
|
|
27
|
+
rule: issue.rule,
|
|
28
|
+
severity: issue.severity,
|
|
29
|
+
file: toPosix(relative(root, issue.file)),
|
|
30
|
+
line: issue.line,
|
|
31
|
+
column: issue.column ?? 1,
|
|
32
|
+
endLine: issue.endLine ?? issue.line,
|
|
33
|
+
endColumn: issue.endColumn ?? issue.column ?? 1,
|
|
34
|
+
message: issue.message,
|
|
35
|
+
snippet: issue.snippet,
|
|
36
|
+
})),
|
|
37
|
+
fixes: fixes.map((result) => ({
|
|
38
|
+
rule: result.proposal.rule,
|
|
39
|
+
file: toPosix(relative(root, result.proposal.file)),
|
|
40
|
+
line: result.proposal.line,
|
|
41
|
+
status: result.status,
|
|
42
|
+
})),
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { explanations } from "./explanations.js";
|
|
3
|
+
|
|
4
|
+
function caretFor(issue) {
|
|
5
|
+
const startColumn = issue.column ?? 1;
|
|
6
|
+
const width =
|
|
7
|
+
issue.endLine === issue.line && issue.endColumn > startColumn
|
|
8
|
+
? issue.endColumn - startColumn
|
|
9
|
+
: Math.max(1, issue.snippet.length - startColumn + 1);
|
|
10
|
+
return " ".repeat(startColumn - 1) + "^".repeat(width);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatReport(issues, root) {
|
|
14
|
+
if (issues.length === 0) return "No issues found.";
|
|
15
|
+
|
|
16
|
+
const byFile = new Map();
|
|
17
|
+
for (const issue of issues) {
|
|
18
|
+
if (!byFile.has(issue.file)) byFile.set(issue.file, []);
|
|
19
|
+
byFile.get(issue.file).push(issue);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lines = [];
|
|
23
|
+
const sortedFiles = [...byFile.keys()].sort((a, b) =>
|
|
24
|
+
relative(root, a).localeCompare(relative(root, b))
|
|
25
|
+
);
|
|
26
|
+
for (const file of sortedFiles) {
|
|
27
|
+
const fileIssues = byFile.get(file);
|
|
28
|
+
lines.push(relative(root, file));
|
|
29
|
+
fileIssues.sort(
|
|
30
|
+
(a, b) => a.line - b.line || (a.column ?? 1) - (b.column ?? 1)
|
|
31
|
+
);
|
|
32
|
+
for (const issue of fileIssues) {
|
|
33
|
+
lines.push(` Line ${issue.line}: ${issue.message} [${issue.rule}]`);
|
|
34
|
+
|
|
35
|
+
if (issue.snippet !== undefined && issue.endLine !== undefined) {
|
|
36
|
+
const gutter = String(issue.line);
|
|
37
|
+
const blank = " ".repeat(gutter.length);
|
|
38
|
+
lines.push(` ${gutter} | ${issue.snippet}`);
|
|
39
|
+
lines.push(` ${blank} | ${caretFor(issue)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const info = explanations[issue.rule];
|
|
43
|
+
if (info) {
|
|
44
|
+
lines.push(` What happened: ${info.whatHappened}`);
|
|
45
|
+
lines.push(` Proposed change: ${info.proposedChange}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push("");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
lines.push(`${issues.length} issue(s) in ${byFile.size} file(s)`);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
@@ -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 "../fix/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
|
+
}
|