@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.
Files changed (51) hide show
  1. package/README.md +117 -22
  2. package/bin/cli.js +43 -200
  3. package/bin/commands/baseline.js +27 -0
  4. package/bin/commands/hook.js +58 -0
  5. package/bin/commands/info.js +39 -0
  6. package/bin/commands/scan.js +206 -0
  7. package/bin/commands/shared.js +67 -0
  8. package/bin/commands/watch.js +44 -0
  9. package/bin/output/text.js +40 -0
  10. package/package.json +32 -5
  11. package/rules/a11y/a11y-click-div.js +39 -0
  12. package/rules/a11y/a11y-img-alt.js +36 -0
  13. package/rules/a11y/a11y-input-label.js +40 -0
  14. package/rules/{console-log.js → core/console-log.js} +2 -1
  15. package/rules/{debugger-statement.js → core/debugger-statement.js} +2 -1
  16. package/rules/{empty-block.js → core/empty-block.js} +2 -1
  17. package/rules/{empty-catch.js → core/empty-catch.js} +2 -1
  18. package/rules/{hardcoded-secret.js → core/hardcoded-secret.js} +9 -4
  19. package/rules/{leftover-todo.js → core/leftover-todo.js} +6 -2
  20. package/rules/{unused-import.js → core/unused-import.js} +3 -1
  21. package/rules/index.js +18 -8
  22. package/rules/react/react-dangerous-html.js +30 -0
  23. package/rules/react/react-missing-key.js +56 -0
  24. package/rules/shared/jsx-helpers.js +30 -0
  25. package/src/core/args.js +134 -0
  26. package/src/core/config.js +92 -0
  27. package/src/core/engine.js +144 -0
  28. package/src/core/exit-codes.js +15 -0
  29. package/src/{setup.js → core/setup.js} +1 -1
  30. package/src/core/version.js +6 -0
  31. package/src/{safe-fix.js → fix/safe-fix.js} +1 -1
  32. package/src/output/explanations-frontend.js +22 -0
  33. package/src/{explanations.js → output/explanations.js} +2 -0
  34. package/src/output/json-output.js +47 -0
  35. package/src/{reporter.js → output/reporter.js} +20 -1
  36. package/src/{run-report.js → output/run-report.js} +1 -1
  37. package/src/output/sarif.js +60 -0
  38. package/src/scan/baseline.js +60 -0
  39. package/src/scan/git.js +28 -0
  40. package/src/scan/ignore.js +68 -0
  41. package/src/scan/suppress.js +53 -0
  42. package/src/config.js +0 -42
  43. package/src/engine.js +0 -80
  44. /package/src/{files.js → core/files.js} +0 -0
  45. /package/src/{approve.js → fix/approve.js} +0 -0
  46. /package/src/{backup.js → fix/backup.js} +0 -0
  47. /package/src/{fixers → fix/fixers}/debugger-statement.js +0 -0
  48. /package/src/{fixers → fix/fixers}/index.js +0 -0
  49. /package/src/{fixers → fix/fixers}/leftover-todo.js +0 -0
  50. /package/src/{fixers → fix/fixers}/unused-import.js +0 -0
  51. /package/src/{gitignore.js → fix/gitignore.js} +0 -0
@@ -0,0 +1,60 @@
1
+ import { relative, sep } from "node:path";
2
+ import { rules } from "../../rules/index.js";
3
+
4
+ const LEVELS = { error: "error", warning: "warning", info: "note" };
5
+
6
+ function toPosix(path) {
7
+ return path.split(sep).join("/");
8
+ }
9
+
10
+ export function formatSarif({ root, issues, version }) {
11
+ const descriptions = new Map(rules.map((rule) => [rule.name, rule.description]));
12
+ const ruleIds = [...new Set(issues.map((issue) => issue.rule))].sort();
13
+
14
+ const results = issues.map((issue) => {
15
+ const column = issue.column ?? 1;
16
+ return {
17
+ ruleId: issue.rule,
18
+ level: LEVELS[issue.severity] ?? "warning",
19
+ message: { text: issue.message },
20
+ locations: [
21
+ {
22
+ physicalLocation: {
23
+ artifactLocation: { uri: toPosix(relative(root, issue.file)) },
24
+ region: {
25
+ startLine: issue.line,
26
+ startColumn: column,
27
+ endLine: issue.endLine ?? issue.line,
28
+ endColumn: issue.endColumn ?? column,
29
+ },
30
+ },
31
+ },
32
+ ],
33
+ };
34
+ });
35
+
36
+ return JSON.stringify(
37
+ {
38
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
39
+ version: "2.1.0",
40
+ runs: [
41
+ {
42
+ tool: {
43
+ driver: {
44
+ name: "vhite",
45
+ version,
46
+ informationUri: "https://github.com/Wilson-Edem/vhite",
47
+ rules: ruleIds.map((id) => ({
48
+ id,
49
+ shortDescription: { text: descriptions.get(id) ?? id },
50
+ })),
51
+ },
52
+ },
53
+ results,
54
+ },
55
+ ],
56
+ },
57
+ null,
58
+ 2
59
+ );
60
+ }
@@ -0,0 +1,60 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+
4
+ export const BASELINE_NAME = "vhite.baseline.json";
5
+
6
+ function toPosix(path) {
7
+ return path.split(sep).join("/");
8
+ }
9
+
10
+ export function fingerprintOf(issue, root) {
11
+ const text = (issue.snippet ?? issue.message).trim();
12
+ return `${issue.rule}|${toPosix(relative(root, issue.file))}|${text}`;
13
+ }
14
+
15
+ export function buildBaseline(issues, root) {
16
+ const fingerprints = {};
17
+ for (const issue of issues) {
18
+ const key = fingerprintOf(issue, root);
19
+ fingerprints[key] = (fingerprints[key] ?? 0) + 1;
20
+ }
21
+ return { version: 1, fingerprints };
22
+ }
23
+
24
+ export async function saveBaseline(root, baseline) {
25
+ const path = join(root, BASELINE_NAME);
26
+ await writeFile(path, JSON.stringify(baseline, null, 2) + "\n", "utf8");
27
+ return path;
28
+ }
29
+
30
+ export async function loadBaseline(root) {
31
+ try {
32
+ const text = await readFile(join(root, BASELINE_NAME), "utf8");
33
+ const data = JSON.parse(text);
34
+ if (typeof data.fingerprints !== "object" || data.fingerprints === null) {
35
+ throw new Error(`${BASELINE_NAME} has no fingerprints`);
36
+ }
37
+ return data;
38
+ } catch (error) {
39
+ if (error.code === "ENOENT") return null;
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ export function applyBaseline(issues, baseline, root) {
45
+ const remaining = { ...baseline.fingerprints };
46
+ const fresh = [];
47
+ let hidden = 0;
48
+
49
+ for (const issue of issues) {
50
+ const key = fingerprintOf(issue, root);
51
+ if ((remaining[key] ?? 0) > 0) {
52
+ remaining[key] -= 1;
53
+ hidden += 1;
54
+ } else {
55
+ fresh.push(issue);
56
+ }
57
+ }
58
+
59
+ return { issues: fresh, hidden };
60
+ }
@@ -0,0 +1,28 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { resolve } from "node:path";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ async function git(root, args) {
8
+ try {
9
+ const { stdout } = await run("git", args, { cwd: root });
10
+ return stdout.split(/\r?\n/).filter((line) => line.trim() !== "");
11
+ } catch (error) {
12
+ throw new Error(
13
+ "This option needs a Git repository with at least one commit, and Git must be installed"
14
+ );
15
+ }
16
+ }
17
+
18
+ export async function changedFiles(root, mode) {
19
+ let names;
20
+ if (mode === "staged") {
21
+ names = await git(root, ["diff", "--name-only", "--relative", "--cached"]);
22
+ } else {
23
+ const tracked = await git(root, ["diff", "--name-only", "--relative", "HEAD"]);
24
+ const untracked = await git(root, ["ls-files", "--others", "--exclude-standard"]);
25
+ names = [...tracked, ...untracked];
26
+ }
27
+ return new Set(names.map((name) => resolve(root, name)));
28
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { normalizePath } from "../core/args.js";
4
+
5
+ const DEFAULT_IGNORES = [
6
+ "node_modules",
7
+ ".git",
8
+ "dist",
9
+ "build",
10
+ "coverage",
11
+ ".next",
12
+ "vendor",
13
+ ".vhite-fix",
14
+ ];
15
+
16
+ function globToRegex(glob) {
17
+ let source = "";
18
+ for (let index = 0; index < glob.length; index += 1) {
19
+ const char = glob[index];
20
+ if (char === "*" && glob[index + 1] === "*") {
21
+ source += ".*";
22
+ index += 1;
23
+ } else if (char === "*") {
24
+ source += "[^/]*";
25
+ } else if (char === "?") {
26
+ source += "[^/]";
27
+ } else {
28
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
29
+ }
30
+ }
31
+ return new RegExp(`^${source}$`);
32
+ }
33
+
34
+ function prefixesOf(path) {
35
+ const parts = path.split("/");
36
+ return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
37
+ }
38
+
39
+ function matchesPattern(relativePath, pattern) {
40
+ const anchored = pattern.includes("/");
41
+ const clean = pattern.replace(/^\//, "").replace(/\/$/, "");
42
+ const regex = globToRegex(clean);
43
+
44
+ if (anchored) {
45
+ return prefixesOf(relativePath).some((prefix) => regex.test(prefix));
46
+ }
47
+ return relativePath.split("/").some((segment) => regex.test(segment));
48
+ }
49
+
50
+ export async function loadIgnorePatterns(root) {
51
+ const patterns = [...DEFAULT_IGNORES];
52
+ try {
53
+ const text = await readFile(join(root, ".gitignore"), "utf8");
54
+ for (const raw of text.split(/\r?\n/)) {
55
+ const line = raw.trim();
56
+ if (line === "" || line.startsWith("#") || line.startsWith("!")) continue;
57
+ patterns.push(line);
58
+ }
59
+ } catch (error) {
60
+ if (error.code !== "ENOENT") throw error;
61
+ }
62
+ return patterns;
63
+ }
64
+
65
+ export function isIgnored(relativePath, patterns) {
66
+ const rel = normalizePath(relativePath);
67
+ return patterns.some((pattern) => matchesPattern(rel, pattern));
68
+ }
@@ -0,0 +1,53 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const DIRECTIVE = /vhite-disable-(next-line|line)(?:\s+([A-Za-z0-9_,\- ]+))?/;
4
+
5
+ function parseRules(text) {
6
+ if (!text || text.trim() === "") return null;
7
+ return new Set(
8
+ text
9
+ .split(/[, ]+/)
10
+ .map((name) => name.trim())
11
+ .filter((name) => name !== "")
12
+ );
13
+ }
14
+
15
+ function directivesFor(source) {
16
+ const lines = source.split(/\r?\n/);
17
+ const map = new Map();
18
+
19
+ lines.forEach((text, index) => {
20
+ const match = DIRECTIVE.exec(text);
21
+ if (!match) return;
22
+
23
+ const target = match[1] === "next-line" ? index + 2 : index + 1;
24
+ map.set(target, parseRules(match[2]));
25
+ });
26
+
27
+ return map;
28
+ }
29
+
30
+ export async function applySuppressions(issues, root) {
31
+ const cache = new Map();
32
+ const kept = [];
33
+ let suppressed = 0;
34
+
35
+ for (const issue of issues) {
36
+ if (!cache.has(issue.file)) {
37
+ const source = await readFile(issue.file, "utf8");
38
+ cache.set(issue.file, directivesFor(source));
39
+ }
40
+
41
+ const directives = cache.get(issue.file);
42
+ const entry = directives.get(issue.line);
43
+ const hides = entry !== undefined && (entry === null || entry.has(issue.rule));
44
+
45
+ if (hides) {
46
+ suppressed += 1;
47
+ } else {
48
+ kept.push(issue);
49
+ }
50
+ }
51
+
52
+ return { issues: kept, suppressed };
53
+ }
package/src/config.js DELETED
@@ -1,42 +0,0 @@
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 DELETED
@@ -1,80 +0,0 @@
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
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes