@vhite/vhite 0.6.0 → 0.8.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 +2 -0
- package/bin/commands/scan.js +55 -4
- package/bin/output/text.js +2 -0
- package/package.json +1 -1
- package/src/core/args.js +6 -0
- package/src/smart/consent.js +34 -0
- package/src/smart/fixes.js +143 -0
- package/src/smart/notes.js +73 -0
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ Unknown rule names are reported as warnings, and the rest of the config still ap
|
|
|
95
95
|
|
|
96
96
|
## Safe fixes
|
|
97
97
|
|
|
98
|
+
Smart notes and smart fixes are optional. When you use them, the lines of code around each finding are sent to Google’s free service, with secrets removed first.
|
|
99
|
+
|
|
98
100
|
Some problems can be fixed automatically: debugger statements, unused imports, and comments. vhite never changes a file without your approval.
|
|
99
101
|
|
|
100
102
|
1. It scans the project and lists the problems it found.
|
package/bin/commands/scan.js
CHANGED
|
@@ -10,6 +10,9 @@ import { askYesNo, writeRunReport } from "../../src/output/run-report.js";
|
|
|
10
10
|
import { EXIT, decideExitCode } from "../../src/core/exit-codes.js";
|
|
11
11
|
import { formatJson } from "../../src/output/json-output.js";
|
|
12
12
|
import { formatSarif } from "../../src/output/sarif.js";
|
|
13
|
+
import { noteAll } from "../../src/smart/notes.js";
|
|
14
|
+
import { proposeSmartFixes, smartFixersFor } from "../../src/smart/fixes.js";
|
|
15
|
+
import { readSmartFixSetting, saveSmartFixSetting, askSmartFixConsent } from "../../src/smart/consent.js";
|
|
13
16
|
import { formatReport } from "../../src/output/reporter.js";
|
|
14
17
|
import { BASELINE_NAME, loadBaseline } from "../../src/scan/baseline.js";
|
|
15
18
|
import { readVersion } from "../../src/core/version.js";
|
|
@@ -22,6 +25,15 @@ import {
|
|
|
22
25
|
summaryLine,
|
|
23
26
|
} from "./shared.js";
|
|
24
27
|
|
|
28
|
+
async function resolveSmartFixSetting(target, interactive) {
|
|
29
|
+
const saved = await readSmartFixSetting(target);
|
|
30
|
+
if (saved !== undefined) return saved;
|
|
31
|
+
if (!interactive) return false;
|
|
32
|
+
const allowed = await askSmartFixConsent();
|
|
33
|
+
await saveSmartFixSetting(target, allowed);
|
|
34
|
+
return allowed;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export async function runScan(options) {
|
|
26
38
|
const target = resolve(options.path ?? ".");
|
|
27
39
|
const isText = options.format === "text";
|
|
@@ -88,7 +100,27 @@ export async function runScan(options) {
|
|
|
88
100
|
say(`${first.suppressed} issue(s) skipped by vhite-disable comments`);
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
const
|
|
103
|
+
const errorFindings = issues.filter((issue) => issue.severity === "error");
|
|
104
|
+
if (showText && errorFindings.length > 0 && !options.noPrompt && !options.watching) {
|
|
105
|
+
const wantsAi =
|
|
106
|
+
options.smartNotes ||
|
|
107
|
+
(canAskQuestions() &&
|
|
108
|
+
(await askYesNo("Want smart notes for the errors?")));
|
|
109
|
+
if (wantsAi) {
|
|
110
|
+
const explained = await noteAll(errorFindings, say);
|
|
111
|
+
say(`Smart notes added: ${explained}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const deterministic = await proposeFixes(issues);
|
|
116
|
+
const smartSetting = options.noSmartFixes ? false : await resolveSmartFixSetting(target, interactive);
|
|
117
|
+
const handled = new Set(deterministic.map((item) => `${item.file}:${item.line}`));
|
|
118
|
+
let smartProposals = [];
|
|
119
|
+
if (smartSetting && (interactive || options.fix)) {
|
|
120
|
+
smartProposals = await proposeSmartFixes(issues, say, { skipLines: handled });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const proposals = [...deterministic, ...smartProposals];
|
|
92
124
|
let chosen = [];
|
|
93
125
|
|
|
94
126
|
if (proposals.length > 0) {
|
|
@@ -105,10 +137,29 @@ export async function runScan(options) {
|
|
|
105
137
|
}
|
|
106
138
|
|
|
107
139
|
if (chosen.length > 0) {
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
let gitignoreUpdated = false;
|
|
141
|
+
const results = [];
|
|
142
|
+
|
|
143
|
+
const plainChosen = chosen.filter((item) => !item.smartFix);
|
|
144
|
+
if (plainChosen.length > 0) {
|
|
145
|
+
const applied = await applyApprovedFixes(target, plainChosen, { disabledRules: disabled });
|
|
146
|
+
results.push(...applied.results);
|
|
147
|
+
gitignoreUpdated = gitignoreUpdated || applied.gitignoreUpdated;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const smartChosen = chosen.filter((item) => item.smartFix);
|
|
151
|
+
if (smartChosen.length > 0) {
|
|
152
|
+
const applied = await applyApprovedFixes(target, smartChosen, {
|
|
153
|
+
disabledRules: disabled,
|
|
154
|
+
fixers: smartFixersFor(smartChosen),
|
|
155
|
+
});
|
|
156
|
+
results.push(...applied.results);
|
|
157
|
+
gitignoreUpdated = gitignoreUpdated || applied.gitignoreUpdated;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fixResults = results;
|
|
110
161
|
|
|
111
|
-
if (
|
|
162
|
+
if (gitignoreUpdated) {
|
|
112
163
|
say(`Added ${FIX_DIR}/ to .gitignore`);
|
|
113
164
|
}
|
|
114
165
|
|
package/bin/output/text.js
CHANGED
|
@@ -18,6 +18,8 @@ Options:
|
|
|
18
18
|
--disable <name> Turn off a rule (repeatable)
|
|
19
19
|
--include <path> Scan only files under this path (repeatable)
|
|
20
20
|
--exclude <path> Skip files under this path (repeatable)
|
|
21
|
+
--smart-notes Add short notes for errors (asked when not given)
|
|
22
|
+
--no-smart-fixes Skip smart fixes for this run
|
|
21
23
|
--help, -h Show this help
|
|
22
24
|
|
|
23
25
|
Exit codes:
|
package/package.json
CHANGED
package/src/core/args.js
CHANGED
|
@@ -19,6 +19,8 @@ export function parseArgs(argv) {
|
|
|
19
19
|
watch: false,
|
|
20
20
|
noPrompt: false,
|
|
21
21
|
force: false,
|
|
22
|
+
smartNotes: false,
|
|
23
|
+
noSmartFixes: false,
|
|
22
24
|
rules: [],
|
|
23
25
|
disable: [],
|
|
24
26
|
include: [],
|
|
@@ -50,6 +52,10 @@ export function parseArgs(argv) {
|
|
|
50
52
|
options.noPrompt = true;
|
|
51
53
|
} else if (arg === "--watch") {
|
|
52
54
|
options.watch = true;
|
|
55
|
+
} else if (arg === "--no-smart-fixes") {
|
|
56
|
+
options.noSmartFixes = true;
|
|
57
|
+
} else if (arg === "--smart-notes") {
|
|
58
|
+
options.smartNotes = true;
|
|
53
59
|
} else if (arg === "--force") {
|
|
54
60
|
options.force = true;
|
|
55
61
|
} else if (VALUE_FLAGS.has(arg)) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
|
|
5
|
+
const CONFIG_NAME = "vhite.config.json";
|
|
6
|
+
|
|
7
|
+
async function readConfigObject(root) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(await readFile(join(root, CONFIG_NAME), "utf8"));
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readSmartFixSetting(root) {
|
|
16
|
+
const data = await readConfigObject(root);
|
|
17
|
+
return typeof data.smartFixes === "boolean" ? data.smartFixes : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function saveSmartFixSetting(root, value) {
|
|
21
|
+
const data = await readConfigObject(root);
|
|
22
|
+
data.smartFixes = value;
|
|
23
|
+
await writeFile(join(root, CONFIG_NAME), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function askSmartFixConsent() {
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
try {
|
|
29
|
+
const answer = (await rl.question("Enable smart fixes for files with findings? (Y/n): ")).trim().toLowerCase();
|
|
30
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
31
|
+
} finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { redact } from "./notes.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_FIX_ENDPOINT = "https://victor-edem.vercel.app/api/smart-fixes";
|
|
5
|
+
const MAX_CODE_CHARS = 12000;
|
|
6
|
+
const MAX_FINDINGS_PER_REQUEST = 30;
|
|
7
|
+
const CONCURRENCY = 4;
|
|
8
|
+
const FIXABLE = new Set(["error", "warning"]);
|
|
9
|
+
|
|
10
|
+
export function groupByFile(issues) {
|
|
11
|
+
const groups = new Map();
|
|
12
|
+
for (const issue of issues) {
|
|
13
|
+
if (!FIXABLE.has(issue.severity)) continue;
|
|
14
|
+
if (!groups.has(issue.file)) groups.set(issue.file, []);
|
|
15
|
+
groups.get(issue.file).push(issue);
|
|
16
|
+
}
|
|
17
|
+
return groups;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateEdits(edits, text, findings) {
|
|
21
|
+
const lines = text.split("\n");
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const accepted = [];
|
|
24
|
+
|
|
25
|
+
for (const edit of Array.isArray(edits) ? edits : []) {
|
|
26
|
+
if (!edit || !Number.isInteger(edit.line) || seen.has(edit.line)) continue;
|
|
27
|
+
const finding = findings.find((item) => item.line === edit.line);
|
|
28
|
+
if (!finding) continue;
|
|
29
|
+
if (lines[edit.line - 1] !== edit.original) continue;
|
|
30
|
+
if (typeof edit.replacement !== "string" || edit.replacement.includes("\n")) continue;
|
|
31
|
+
if (edit.replacement.trim() === edit.original.trim()) continue;
|
|
32
|
+
seen.add(edit.line);
|
|
33
|
+
accepted.push({ line: edit.line, original: edit.original, replacement: edit.replacement, finding });
|
|
34
|
+
}
|
|
35
|
+
return accepted;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toProposals(file, accepted) {
|
|
39
|
+
return accepted.map((edit) => ({
|
|
40
|
+
file,
|
|
41
|
+
line: edit.line,
|
|
42
|
+
rule: edit.finding.rule,
|
|
43
|
+
message: edit.finding.message,
|
|
44
|
+
lineText: edit.original.trim(),
|
|
45
|
+
original: edit.original,
|
|
46
|
+
replacement: edit.replacement,
|
|
47
|
+
smartFix: true,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function smartFixerFor(rule) {
|
|
52
|
+
return {
|
|
53
|
+
rule,
|
|
54
|
+
apply(source, issue) {
|
|
55
|
+
const lines = source.split("\n");
|
|
56
|
+
if (lines[issue.line - 1] !== issue.original) return null;
|
|
57
|
+
if (issue.replacement === "") {
|
|
58
|
+
lines.splice(issue.line - 1, 1);
|
|
59
|
+
} else {
|
|
60
|
+
lines[issue.line - 1] = issue.replacement;
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function smartFixersFor(proposals) {
|
|
68
|
+
const fixers = {};
|
|
69
|
+
for (const proposal of proposals) {
|
|
70
|
+
fixers[proposal.rule] = smartFixerFor(proposal.rule);
|
|
71
|
+
}
|
|
72
|
+
return fixers;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function requestEdits(text, findings, { endpoint, fetchImpl }) {
|
|
76
|
+
const response = await fetchImpl(endpoint, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "Content-Type": "application/json" },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
language: "javascript",
|
|
81
|
+
code: text,
|
|
82
|
+
findings: findings.slice(0, MAX_FINDINGS_PER_REQUEST).map((item) => ({
|
|
83
|
+
line: item.line,
|
|
84
|
+
rule: item.rule,
|
|
85
|
+
message: item.message,
|
|
86
|
+
})),
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const data = await response.json().catch(() => ({}));
|
|
91
|
+
if (response.status === 429) return { limited: true, edits: [] };
|
|
92
|
+
if (!response.ok) throw new Error(data.error ?? `Smart service returned ${response.status}`);
|
|
93
|
+
return { edits: data.edits ?? [] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function proposeSmartFixes(issues, say, { endpoint = DEFAULT_FIX_ENDPOINT, fetchImpl = fetch, skipLines = new Set() } = {}) {
|
|
97
|
+
const groups = [...groupByFile(issues).entries()];
|
|
98
|
+
const proposals = [];
|
|
99
|
+
let stopped = false;
|
|
100
|
+
|
|
101
|
+
const worker = async () => {
|
|
102
|
+
while (groups.length > 0 && !stopped) {
|
|
103
|
+
const [file, findings] = groups.shift();
|
|
104
|
+
const name = file;
|
|
105
|
+
|
|
106
|
+
let text;
|
|
107
|
+
try {
|
|
108
|
+
text = await readFile(file, "utf8");
|
|
109
|
+
} catch {
|
|
110
|
+
say(`Smart fixes skipped for ${name}: file could not be read`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (text.length > MAX_CODE_CHARS) {
|
|
115
|
+
say(`Smart fixes skipped for ${name}: file is too large to send`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (redact(text) !== text) {
|
|
119
|
+
say(`Smart fixes skipped for ${name}: it contains a secret-looking value`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const usable = findings.filter((item) => !skipLines.has(`${file}:${item.line}`));
|
|
124
|
+
if (usable.length === 0) continue;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const result = await requestEdits(text, usable, { endpoint, fetchImpl });
|
|
128
|
+
if (result.limited) {
|
|
129
|
+
say("Daily smart fix limit reached. Try again tomorrow.");
|
|
130
|
+
stopped = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const accepted = validateEdits(result.edits, text, usable);
|
|
134
|
+
proposals.push(...toProposals(file, accepted));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
say(`Smart fixes unavailable for ${name}: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
142
|
+
return proposals;
|
|
143
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ENDPOINT = "https://victor-edem.vercel.app/api/smart-notes";
|
|
4
|
+
const CONTEXT_LINES = 5;
|
|
5
|
+
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
/\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|password)\b\s*[:=]\s*["'`][^"'`\s]{8,}["'`]/gi,
|
|
8
|
+
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/g,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function redact(text) {
|
|
12
|
+
let output = text;
|
|
13
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
14
|
+
output = output.replace(pattern, "[REDACTED]");
|
|
15
|
+
}
|
|
16
|
+
return output;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function contextFor(source, line) {
|
|
20
|
+
const lines = source.split("\n");
|
|
21
|
+
const start = Math.max(0, line - 1 - CONTEXT_LINES);
|
|
22
|
+
const end = Math.min(lines.length, line + CONTEXT_LINES);
|
|
23
|
+
return lines.slice(start, end).join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function noteIssue(issue, source, { endpoint, fetchImpl = fetch } = {}) {
|
|
27
|
+
const snippet = redact(contextFor(source, issue.line));
|
|
28
|
+
const url = endpoint || process.env.VHITE_AI_URL || "https://victor-edem.vercel.app/api/smart-notes";
|
|
29
|
+
const response = await fetchImpl(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
rule: issue.rule,
|
|
34
|
+
message: issue.message,
|
|
35
|
+
snippet,
|
|
36
|
+
language: "javascript",
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const data = await response.json().catch(() => ({}));
|
|
41
|
+
if (response.status === 429) return { limited: true };
|
|
42
|
+
if (!response.ok) throw new Error(data.error ?? `Smart service returned ${response.status}`);
|
|
43
|
+
return { text: data.explanation, remaining: data.remaining };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function noteAll(issues, say, { fetchImpl = fetch, endpoint } = {}) {
|
|
47
|
+
const cache = new Map();
|
|
48
|
+
let explained = 0;
|
|
49
|
+
|
|
50
|
+
for (const issue of issues) {
|
|
51
|
+
if (!cache.has(issue.file)) {
|
|
52
|
+
cache.set(issue.file, await readFile(issue.file, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await noteIssue(issue, cache.get(issue.file), { endpoint, fetchImpl });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
say(`Smart notes unavailable: ${error.message}`);
|
|
60
|
+
return explained;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (result.limited) {
|
|
64
|
+
say("Daily smart notes limit reached (20 per day). Try again tomorrow.");
|
|
65
|
+
return explained;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
say(` Smart note for line ${issue.line} [${issue.rule}]: ${result.text}`);
|
|
69
|
+
explained += 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return explained;
|
|
73
|
+
}
|