@vhite/vhite 0.1.0 → 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 +117 -22
- package/bin/cli.js +43 -200
- 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 +32 -5
- 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/{console-log.js → core/console-log.js} +2 -1
- package/rules/{debugger-statement.js → core/debugger-statement.js} +2 -1
- package/rules/{empty-block.js → core/empty-block.js} +2 -1
- package/rules/{empty-catch.js → core/empty-catch.js} +2 -1
- package/rules/{hardcoded-secret.js → core/hardcoded-secret.js} +9 -4
- package/rules/{leftover-todo.js → core/leftover-todo.js} +6 -2
- package/rules/{unused-import.js → core/unused-import.js} +3 -1
- package/rules/index.js +18 -8
- 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/{setup.js → core/setup.js} +1 -1
- package/src/core/version.js +6 -0
- package/src/{safe-fix.js → fix/safe-fix.js} +1 -1
- package/src/output/explanations-frontend.js +22 -0
- package/src/{explanations.js → output/explanations.js} +2 -0
- package/src/output/json-output.js +47 -0
- package/src/{reporter.js → output/reporter.js} +20 -1
- package/src/{run-report.js → output/run-report.js} +1 -1
- 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
- package/src/config.js +0 -42
- package/src/engine.js +0 -80
- /package/src/{files.js → core/files.js} +0 -0
- /package/src/{approve.js → fix/approve.js} +0 -0
- /package/src/{backup.js → fix/backup.js} +0 -0
- /package/src/{fixers → fix/fixers}/debugger-statement.js +0 -0
- /package/src/{fixers → fix/fixers}/index.js +0 -0
- /package/src/{fixers → fix/fixers}/leftover-todo.js +0 -0
- /package/src/{fixers → fix/fixers}/unused-import.js +0 -0
- /package/src/{gitignore.js → fix/gitignore.js} +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { childNodes } from "../shared/jsx-helpers.js";
|
|
2
|
+
|
|
3
|
+
const reactDangerousHtml = {
|
|
4
|
+
name: "react-dangerous-html",
|
|
5
|
+
description: "Flags uses of dangerouslySetInnerHTML, which can allow script injection",
|
|
6
|
+
extensions: [".js", ".jsx", ".mjs", ".cjs"],
|
|
7
|
+
requiresAst: true,
|
|
8
|
+
check({ ast }) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
|
|
11
|
+
function visit(node) {
|
|
12
|
+
if (!node || typeof node !== "object") return;
|
|
13
|
+
|
|
14
|
+
if (node.type === "JSXAttribute" && node.name?.name === "dangerouslySetInnerHTML") {
|
|
15
|
+
issues.push({
|
|
16
|
+
start: node.range?.[0],
|
|
17
|
+
end: node.range?.[1],
|
|
18
|
+
message: "dangerouslySetInnerHTML can run injected scripts; render normal JSX or sanitize the HTML first",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const child of childNodes(node)) visit(child);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
visit(ast);
|
|
26
|
+
return issues;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default reactDangerousHtml;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { childNodes, attributeNamed, hasSpread } from "../shared/jsx-helpers.js";
|
|
2
|
+
|
|
3
|
+
function returnedJsx(fn) {
|
|
4
|
+
if (fn.body.type === "JSXElement") return fn.body;
|
|
5
|
+
if (fn.body.type !== "BlockStatement") return null;
|
|
6
|
+
|
|
7
|
+
for (const statement of fn.body.body) {
|
|
8
|
+
if (statement.type === "ReturnStatement" && statement.argument?.type === "JSXElement") {
|
|
9
|
+
return statement.argument;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const reactMissingKey = {
|
|
16
|
+
name: "react-missing-key",
|
|
17
|
+
description: "Flags list items created inside a .map() call without a key prop",
|
|
18
|
+
extensions: [".js", ".jsx", ".mjs", ".cjs"],
|
|
19
|
+
requiresAst: true,
|
|
20
|
+
check({ ast }) {
|
|
21
|
+
const issues = [];
|
|
22
|
+
|
|
23
|
+
function visit(node) {
|
|
24
|
+
if (!node || typeof node !== "object") return;
|
|
25
|
+
|
|
26
|
+
const callback = node.arguments?.[0];
|
|
27
|
+
if (
|
|
28
|
+
node.type === "CallExpression" &&
|
|
29
|
+
node.callee?.type === "MemberExpression" &&
|
|
30
|
+
node.callee.property?.name === "map" &&
|
|
31
|
+
callback &&
|
|
32
|
+
["ArrowFunctionExpression", "FunctionExpression"].includes(callback.type)
|
|
33
|
+
) {
|
|
34
|
+
const element = returnedJsx(callback);
|
|
35
|
+
if (
|
|
36
|
+
element &&
|
|
37
|
+
!hasSpread(element.openingElement) &&
|
|
38
|
+
!attributeNamed(element.openingElement, "key")
|
|
39
|
+
) {
|
|
40
|
+
issues.push({
|
|
41
|
+
start: element.range?.[0],
|
|
42
|
+
end: element.range?.[1],
|
|
43
|
+
message: "List item needs a unique key prop so React can track it",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const child of childNodes(node)) visit(child);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
visit(ast);
|
|
52
|
+
return issues;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default reactMissingKey;
|
|
@@ -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
|
+
}
|
package/src/core/args.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "node:fs/promises";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
|
-
import { rules } from "
|
|
8
|
+
import { rules } from "../../rules/index.js";
|
|
9
9
|
|
|
10
10
|
export const CONFIG_NAME = "vhite.config.json";
|
|
11
11
|
|
|
@@ -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
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { frontendExplanations } from "./explanations-frontend.js";
|
|
1
2
|
export const explanations = {
|
|
2
3
|
"leftover-todo": {
|
|
3
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.",
|
|
@@ -31,5 +32,6 @@ export const explanations = {
|
|
|
31
32
|
whatHappened: "vhite couldn't read this file as JavaScript, so it skipped the checks that need to understand the code.",
|
|
32
33
|
proposedChange: "Open the file at the line shown and fix the syntax error, then scan again.",
|
|
33
34
|
},
|
|
35
|
+
...frontendExplanations,
|
|
34
36
|
};
|
|
35
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
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { explanations } from "./explanations.js";
|
|
3
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
|
+
|
|
4
13
|
export function formatReport(issues, root) {
|
|
5
14
|
if (issues.length === 0) return "No issues found.";
|
|
6
15
|
|
|
@@ -17,9 +26,19 @@ export function formatReport(issues, root) {
|
|
|
17
26
|
for (const file of sortedFiles) {
|
|
18
27
|
const fileIssues = byFile.get(file);
|
|
19
28
|
lines.push(relative(root, file));
|
|
20
|
-
fileIssues.sort(
|
|
29
|
+
fileIssues.sort(
|
|
30
|
+
(a, b) => a.line - b.line || (a.column ?? 1) - (b.column ?? 1)
|
|
31
|
+
);
|
|
21
32
|
for (const issue of fileIssues) {
|
|
22
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
|
+
|
|
23
42
|
const info = explanations[issue.rule];
|
|
24
43
|
if (info) {
|
|
25
44
|
lines.push(` What happened: ${info.whatHappened}`);
|