@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/safe-fix.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFile,
|
|
3
|
+
writeFile,
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { runRules } from "./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
|
+
}
|
package/src/setup.js
ADDED
|
@@ -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
|
+
}
|