@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
package/README.md
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vhite/vhite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
vhite scans front-end code for problems commonly left behind by AI code generators. It explains each finding in plain English, and it can safely fix some of them.
|
|
4
|
+
|
|
5
|
+
## Install and run
|
|
6
|
+
|
|
7
|
+
Run it on any project without installing it:
|
|
8
|
+
|
|
9
|
+
npx @vhite/vhite path/to/your/project
|
|
10
|
+
|
|
11
|
+
Or on the current folder:
|
|
12
|
+
|
|
13
|
+
npx @vhite/vhite
|
|
14
|
+
|
|
15
|
+
To install it once and use a short command:
|
|
16
|
+
|
|
17
|
+
npm install -g @vhite/vhite
|
|
18
|
+
vhite path/to/your/project
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
vhite [path] Scan a folder (the default command)
|
|
23
|
+
vhite scan [path] Scan and report
|
|
24
|
+
vhite fix [path] Scan, then choose which fixes to apply
|
|
25
|
+
vhite baseline [path] Save current problems so only new ones are reported
|
|
26
|
+
vhite rules List all rules
|
|
27
|
+
vhite explain <rule> Explain one rule
|
|
28
|
+
vhite version Show the version
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
--format text|json Output format (default: text)
|
|
33
|
+
--quiet, -q Print only errors; the exit code still applies
|
|
34
|
+
--fix Apply all safe fixes without asking, then rescan
|
|
35
|
+
--changed Scan only files changed since the last commit (Git)
|
|
36
|
+
--staged Scan only files staged for commit (Git)
|
|
37
|
+
--no-baseline Ignore vhite.baseline.json for this run
|
|
38
|
+
--rule <name> Run only this rule (repeatable)
|
|
39
|
+
--disable <name> Turn off a rule (repeatable)
|
|
40
|
+
--include <path> Scan only files under this path (repeatable)
|
|
41
|
+
--exclude <path> Skip files under this path (repeatable)
|
|
42
|
+
--help, -h Show help
|
|
43
|
+
|
|
44
|
+
## Exit codes
|
|
45
|
+
|
|
46
|
+
0 no problems
|
|
47
|
+
1 problems found (errors or warnings; info notes do not count)
|
|
48
|
+
2 configuration or runtime error
|
|
49
|
+
3 fixes applied and no problems remain
|
|
50
|
+
4 fixes were rolled back
|
|
51
|
+
|
|
52
|
+
Use these in CI. For example, a build fails when the exit code is 1 or higher.
|
|
53
|
+
|
|
54
|
+
## What it checks
|
|
55
|
+
|
|
56
|
+
JavaScript and JSX files (`.js`, `.jsx`, `.mjs`, `.cjs`):
|
|
57
|
+
|
|
58
|
+
- **debugger-statement:** `debugger` lines that pause the app when developer tools are open.
|
|
59
|
+
- **console-log:** `console.log` calls left over from debugging.
|
|
60
|
+
- **empty-catch:** errors that are caught and silently ignored.
|
|
61
|
+
- **unused-import:** imports the file never uses.
|
|
62
|
+
- **empty-block:** `if`, `else`, and loop bodies with no code.
|
|
63
|
+
- **hardcoded-secret:** passwords, API keys, and tokens written directly in the code.
|
|
64
|
+
- **leftover-todo:** comments. TODO and FIXME notes are reported separately from other comments.
|
|
65
|
+
- **react-missing-key:** list items created with `.map()` that have no `key`.
|
|
66
|
+
- **react-dangerous-html:** uses of `dangerouslySetInnerHTML`.
|
|
67
|
+
- **a11y-img-alt:** images with no `alt` attribute.
|
|
68
|
+
- **a11y-input-label:** form inputs with no label or accessible name.
|
|
69
|
+
- **a11y-click-div:** clickable `div` or `span` elements with no `role`.
|
|
70
|
+
|
|
71
|
+
HTML and CSS files (`.html`, `.css`) are checked for hardcoded secrets only.
|
|
72
|
+
|
|
73
|
+
Run `vhite rules` for the full list, with each rule's default severity and whether it has an automatic fix.
|
|
74
|
+
|
|
75
|
+
## Severity
|
|
76
|
+
|
|
77
|
+
Each rule has a severity: `error`, `warning`, or `info`. Errors and warnings count toward the exit code. Info notes are shown but don't fail a run.
|
|
78
|
+
|
|
79
|
+
Change a rule's severity in `vhite.config.json`:
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
"rules": {
|
|
83
|
+
"console-log": "error",
|
|
84
|
+
"leftover-todo": "off"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Use `"off"` to turn a rule off. You can also turn rules off with a list:
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
"disabledRules": ["console-log"]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Unknown rule names are reported as warnings, and the rest of the config still applies.
|
|
95
|
+
|
|
96
|
+
## Safe fixes
|
|
97
|
+
|
|
98
|
+
Some problems can be fixed automatically: debugger statements, unused imports, and comments. vhite never changes a file without your approval.
|
|
99
|
+
|
|
100
|
+
1. It scans the project and lists the problems it found.
|
|
101
|
+
2. It lists the fixes it can make and asks which ones to apply. Enter numbers such as `1,3`, type `all`, or press Enter to skip.
|
|
102
|
+
3. Before each change, it saves a backup in `.vhite-fix/backup/`.
|
|
103
|
+
4. After each change, it scans the file again. If the change created a new problem or didn't fix the issue, the original is restored automatically.
|
|
104
|
+
5. At the end, it can ask whether to delete the backups from this run and whether to create a Markdown report in `.vhite-fix/reports/`.
|
|
105
|
+
|
|
106
|
+
The first time a fix is applied, vhite adds `.vhite-fix/` to your `.gitignore`.
|
|
107
|
+
|
|
108
|
+
For scripts and CI, use `--fix` to apply all safe fixes without asking. `vhite fix` requires an interactive terminal unless `--fix` is given.
|
|
109
|
+
|
|
110
|
+
## Baselines
|
|
111
|
+
|
|
112
|
+
Projects that already have problems can start with a baseline:
|
|
113
|
+
|
|
114
|
+
vhite baseline
|
|
115
|
+
|
|
116
|
+
This saves the current findings to `vhite.baseline.json`. Later scans hide those known findings and report only new ones. A finding stays hidden even if its line number moves. Commit this file so your whole team shares the same baseline, and use `--no-baseline` to see everything.
|
|
117
|
+
|
|
118
|
+
## Ignoring a finding
|
|
119
|
+
|
|
120
|
+
Add a comment to skip a finding you've reviewed:
|
|
121
|
+
|
|
122
|
+
// vhite-disable-next-line console-log
|
|
123
|
+
console.log("kept on purpose");
|
|
124
|
+
|
|
125
|
+
console.log("kept on purpose"); // vhite-disable-line console-log
|
|
126
|
+
|
|
127
|
+
Leave out the rule name to skip every rule on that line.
|
|
128
|
+
|
|
129
|
+
## Scanning only changed files
|
|
130
|
+
|
|
131
|
+
vhite --changed # files changed since the last commit
|
|
132
|
+
vhite --staged # files staged for commit
|
|
133
|
+
|
|
134
|
+
Both need the project to be a Git repository.
|
|
135
|
+
|
|
136
|
+
## Settings file
|
|
137
|
+
|
|
138
|
+
Ignore patterns come from your `.gitignore`. vhite also skips `node_modules`, `dist`, `build`, and `.git` by default.
|
|
139
|
+
|
|
140
|
+
## Reports
|
|
141
|
+
|
|
142
|
+
--format json Prints results as JSON for scripts and other tools
|
|
143
|
+
|
|
144
|
+
The JSON output includes each finding's rule, severity, file, line, column, and message, plus a summary and any fixes that were applied.
|
|
145
|
+
|
|
146
|
+
## Limits
|
|
147
|
+
|
|
148
|
+
vhite finds common patterns. It can't understand your whole app, so review each fix before you commit it. Removing an unused import also stops that module from loading, which is usually what you want, but check modules that only run for their side effects. The accessibility and React rules are heuristics and may report some false positives.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
ISC
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs, UsageError } from "../src/core/args.js";
|
|
4
|
+
import { EXIT } from "../src/core/exit-codes.js";
|
|
5
|
+
import { readVersion } from "../src/core/version.js";
|
|
6
|
+
import { USAGE } from "./output/text.js";
|
|
7
|
+
import { listRules, explainRule } from "./commands/info.js";
|
|
8
|
+
import { runBaseline } from "./commands/baseline.js";
|
|
9
|
+
import { runScan } from "./commands/scan.js";
|
|
10
|
+
import { runHook } from "./commands/hook.js";
|
|
11
|
+
import { runWatch } from "./commands/watch.js";
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
let options;
|
|
15
|
+
try {
|
|
16
|
+
options = parseArgs(process.argv.slice(2));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error instanceof UsageError) {
|
|
19
|
+
console.error(`Error: ${error.message}\n`);
|
|
20
|
+
console.error(USAGE);
|
|
21
|
+
return EXIT.ERROR;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (options.command === "help") {
|
|
27
|
+
console.log(USAGE);
|
|
28
|
+
return EXIT.CLEAN;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.command === "version") {
|
|
32
|
+
console.log(await readVersion());
|
|
33
|
+
return EXIT.CLEAN;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (options.command === "rules") {
|
|
37
|
+
return listRules();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (options.command === "explain") {
|
|
41
|
+
return explainRule(options.ruleName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (options.command === "baseline") {
|
|
45
|
+
return runBaseline(options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.command === "hook") {
|
|
49
|
+
return runHook(options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (options.watch) {
|
|
53
|
+
return runWatch(options);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return runScan(options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
process.exitCode = await main();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolve, relative } from "node:path";
|
|
2
|
+
import { loadConfig } from "../../src/core/config.js";
|
|
3
|
+
import { EXIT } from "../../src/core/exit-codes.js";
|
|
4
|
+
import { BASELINE_NAME, buildBaseline, saveBaseline } from "../../src/scan/baseline.js";
|
|
5
|
+
import { selectFiles, collectIssues, disabledSetFor } from "./shared.js";
|
|
6
|
+
|
|
7
|
+
export async function runBaseline(options) {
|
|
8
|
+
const target = resolve(options.path ?? ".");
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const config = await loadConfig(target);
|
|
12
|
+
const disabled = disabledSetFor(config, options);
|
|
13
|
+
const files = await selectFiles(target, options);
|
|
14
|
+
const { issues } = await collectIssues(files, target, disabled, config, null);
|
|
15
|
+
|
|
16
|
+
const baseline = buildBaseline(issues, target);
|
|
17
|
+
const path = await saveBaseline(target, baseline);
|
|
18
|
+
const count = issues.length;
|
|
19
|
+
|
|
20
|
+
console.log(`Saved ${count} current issue(s) to ${relative(process.cwd(), path) || BASELINE_NAME}`);
|
|
21
|
+
console.log("Only new issues will be reported from now on.");
|
|
22
|
+
return EXIT.CLEAN;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error(`Error: ${error.message}`);
|
|
25
|
+
return EXIT.ERROR;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, writeFile, rm, mkdir, chmod, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { EXIT } from "../../src/core/exit-codes.js";
|
|
4
|
+
|
|
5
|
+
const MARKER = "# vhite-pre-commit-hook";
|
|
6
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
7
|
+
${MARKER}
|
|
8
|
+
# Scans staged files before each commit. Remove with: vhite hook remove
|
|
9
|
+
npx --yes @vhite/vhite --staged --no-prompt
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
async function gitDirFor(root) {
|
|
13
|
+
try {
|
|
14
|
+
const info = await stat(join(root, ".git"));
|
|
15
|
+
return info.isDirectory() ? join(root, ".git") : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runHook(options) {
|
|
22
|
+
const root = resolve(options.path ?? ".");
|
|
23
|
+
const gitDir = await gitDirFor(root);
|
|
24
|
+
|
|
25
|
+
if (!gitDir) {
|
|
26
|
+
console.error("Error: this folder is not a Git repository (no .git folder found)");
|
|
27
|
+
return EXIT.ERROR;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hookPath = join(gitDir, "hooks", "pre-commit");
|
|
31
|
+
const existing = await readFile(hookPath, "utf8").catch(() => null);
|
|
32
|
+
const isOurs = existing !== null && existing.includes(MARKER);
|
|
33
|
+
|
|
34
|
+
if (options.hookAction === "install") {
|
|
35
|
+
if (existing !== null && !isOurs && !options.force) {
|
|
36
|
+
console.error("Error: a pre-commit hook already exists. Use --force to replace it.");
|
|
37
|
+
return EXIT.ERROR;
|
|
38
|
+
}
|
|
39
|
+
await mkdir(join(gitDir, "hooks"), { recursive: true });
|
|
40
|
+
await writeFile(hookPath, HOOK_SCRIPT, "utf8");
|
|
41
|
+
await chmod(hookPath, 0o755).catch(() => {});
|
|
42
|
+
console.log(`Installed pre-commit hook at ${hookPath}`);
|
|
43
|
+
return EXIT.CLEAN;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (existing === null) {
|
|
47
|
+
console.log("No pre-commit hook to remove.");
|
|
48
|
+
return EXIT.CLEAN;
|
|
49
|
+
}
|
|
50
|
+
if (!isOurs) {
|
|
51
|
+
console.error("Error: the existing pre-commit hook was not installed by vhite, so it was left alone.");
|
|
52
|
+
return EXIT.ERROR;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await rm(hookPath);
|
|
56
|
+
console.log("Removed the vhite pre-commit hook.");
|
|
57
|
+
return EXIT.CLEAN;
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { severityOf } from "../../src/core/config.js";
|
|
2
|
+
import { EXIT } from "../../src/core/exit-codes.js";
|
|
3
|
+
import { fixers } from "../../src/fix/fixers/index.js";
|
|
4
|
+
import { explanations } from "../../src/output/explanations.js";
|
|
5
|
+
import { rules } from "../../rules/index.js";
|
|
6
|
+
import { KNOWN_RULES } from "./shared.js";
|
|
7
|
+
|
|
8
|
+
export function listRules() {
|
|
9
|
+
console.log("Rule Severity Fix Description");
|
|
10
|
+
for (const rule of rules) {
|
|
11
|
+
const severity = severityOf(rule.name, { severities: new Map() });
|
|
12
|
+
const fixable = fixers[rule.name] ? "yes" : "no";
|
|
13
|
+
console.log(
|
|
14
|
+
`${rule.name.padEnd(22)}${severity.padEnd(10)}${fixable.padEnd(6)}${rule.description}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return EXIT.CLEAN;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function explainRule(name) {
|
|
21
|
+
if (!KNOWN_RULES.has(name)) {
|
|
22
|
+
console.error(`Error: unknown rule "${name}"`);
|
|
23
|
+
console.error(`Known rules: ${[...KNOWN_RULES].join(", ")}`);
|
|
24
|
+
return EXIT.ERROR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rule = rules.find((item) => item.name === name);
|
|
28
|
+
const info = explanations[name];
|
|
29
|
+
|
|
30
|
+
console.log(name);
|
|
31
|
+
console.log(` ${rule.description}`);
|
|
32
|
+
if (info) {
|
|
33
|
+
console.log(` What happened: ${info.whatHappened}`);
|
|
34
|
+
console.log(` Proposed change: ${info.proposedChange}`);
|
|
35
|
+
}
|
|
36
|
+
console.log(` Default severity: ${severityOf(name, { severities: new Map() })}`);
|
|
37
|
+
console.log(` Automatic fix: ${fixers[name] ? "yes" : "no"}`);
|
|
38
|
+
return EXIT.CLEAN;
|
|
39
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { resolve, join, relative } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { loadConfig } from "../../src/core/config.js";
|
|
4
|
+
import { CONFIG_NAME, canAskQuestions, runFirstTimeSetup } from "../../src/core/setup.js";
|
|
5
|
+
import { proposeFixes, applyApprovedFixes } from "../../src/fix/safe-fix.js";
|
|
6
|
+
import { chooseProposals } from "../../src/fix/approve.js";
|
|
7
|
+
import { deleteRunBackups } from "../../src/fix/backup.js";
|
|
8
|
+
import { FIX_DIR } from "../../src/fix/gitignore.js";
|
|
9
|
+
import { askYesNo, writeRunReport } from "../../src/output/run-report.js";
|
|
10
|
+
import { EXIT, decideExitCode } from "../../src/core/exit-codes.js";
|
|
11
|
+
import { formatJson } from "../../src/output/json-output.js";
|
|
12
|
+
import { formatSarif } from "../../src/output/sarif.js";
|
|
13
|
+
import { formatReport } from "../../src/output/reporter.js";
|
|
14
|
+
import { BASELINE_NAME, loadBaseline } from "../../src/scan/baseline.js";
|
|
15
|
+
import { readVersion } from "../../src/core/version.js";
|
|
16
|
+
import { BANNER } from "../output/text.js";
|
|
17
|
+
import {
|
|
18
|
+
KNOWN_RULES,
|
|
19
|
+
selectFiles,
|
|
20
|
+
collectIssues,
|
|
21
|
+
disabledSetFor,
|
|
22
|
+
summaryLine,
|
|
23
|
+
} from "./shared.js";
|
|
24
|
+
|
|
25
|
+
export async function runScan(options) {
|
|
26
|
+
const target = resolve(options.path ?? ".");
|
|
27
|
+
const isText = options.format === "text";
|
|
28
|
+
const showText = isText && !options.quiet;
|
|
29
|
+
const interactive = canAskQuestions() && showText && !options.watching && !options.noPrompt;
|
|
30
|
+
const logs = [];
|
|
31
|
+
const startedAt = new Date();
|
|
32
|
+
|
|
33
|
+
const say = (message = "") => {
|
|
34
|
+
logs.push(message);
|
|
35
|
+
if (showText) console.log(message);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const unknown = [...options.rules, ...options.disable].filter(
|
|
39
|
+
(name) => !KNOWN_RULES.has(name)
|
|
40
|
+
);
|
|
41
|
+
if (unknown.length > 0) {
|
|
42
|
+
console.error(`Error: unknown rule name(s): ${unknown.join(", ")}`);
|
|
43
|
+
return EXIT.ERROR;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let issues = [];
|
|
47
|
+
let files = [];
|
|
48
|
+
let fixResults = [];
|
|
49
|
+
let backupCleanup = null;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (showText && !options.quiet) {
|
|
53
|
+
console.log(BANNER);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!existsSync(join(target, CONFIG_NAME)) && interactive) {
|
|
57
|
+
await runFirstTimeSetup(target);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const config = await loadConfig(target);
|
|
61
|
+
|
|
62
|
+
for (const warning of config.warnings) {
|
|
63
|
+
say(`Warning: ${warning}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const disabled = disabledSetFor(config, options);
|
|
67
|
+
if (disabled.size > 0 && showText) {
|
|
68
|
+
say(`Rules turned off: ${[...disabled].join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const baseline = options.useBaseline ? await loadBaseline(target) : null;
|
|
72
|
+
|
|
73
|
+
files = await selectFiles(target, options);
|
|
74
|
+
|
|
75
|
+
say(
|
|
76
|
+
`vhite found ${files.length} front-end files in ${relative(process.cwd(), target) || "."}\n`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const first = await collectIssues(files, target, disabled, config, baseline);
|
|
80
|
+
issues = first.issues;
|
|
81
|
+
|
|
82
|
+
say(formatReport(issues, target));
|
|
83
|
+
if (issues.length > 0) say(summaryLine(issues));
|
|
84
|
+
if (first.hidden > 0) {
|
|
85
|
+
say(`${first.hidden} known issue(s) hidden by ${BASELINE_NAME}`);
|
|
86
|
+
}
|
|
87
|
+
if (first.suppressed > 0) {
|
|
88
|
+
say(`${first.suppressed} issue(s) skipped by vhite-disable comments`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const proposals = await proposeFixes(issues);
|
|
92
|
+
let chosen = [];
|
|
93
|
+
|
|
94
|
+
if (proposals.length > 0) {
|
|
95
|
+
if (options.fix) {
|
|
96
|
+
chosen = proposals;
|
|
97
|
+
} else if (interactive) {
|
|
98
|
+
chosen = await chooseProposals(target, proposals);
|
|
99
|
+
} else if (options.command === "fix") {
|
|
100
|
+
console.error(
|
|
101
|
+
"Error: vhite fix needs an interactive terminal. Use --fix to apply all safe fixes."
|
|
102
|
+
);
|
|
103
|
+
return EXIT.ERROR;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (chosen.length > 0) {
|
|
108
|
+
const result = await applyApprovedFixes(target, chosen, { disabledRules: disabled });
|
|
109
|
+
fixResults = result.results;
|
|
110
|
+
|
|
111
|
+
if (result.gitignoreUpdated) {
|
|
112
|
+
say(`Added ${FIX_DIR}/ to .gitignore`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const item of fixResults) {
|
|
116
|
+
const where = `${relative(target, item.proposal.file)} line ${item.proposal.line}`;
|
|
117
|
+
if (item.status === "applied") {
|
|
118
|
+
say(`Applied: ${where}`);
|
|
119
|
+
} else {
|
|
120
|
+
const label = item.status === "skipped" ? "Skipped" : "Rolled back";
|
|
121
|
+
say(`${label}: ${where}. ${item.reason}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rescan = await collectIssues(files, target, disabled, config, baseline);
|
|
126
|
+
issues = rescan.issues;
|
|
127
|
+
say(`Final scan: ${issues.length} issue(s) remaining`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hasRunBackups = fixResults.some((item) => typeof item.backupPath === "string");
|
|
131
|
+
|
|
132
|
+
if (interactive && hasRunBackups) {
|
|
133
|
+
const shouldDelete = await askYesNo(
|
|
134
|
+
"Do you want to delete the backup files created during this run?"
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (shouldDelete) {
|
|
138
|
+
const result = await deleteRunBackups(fixResults);
|
|
139
|
+
backupCleanup = { requested: true, deleted: result.deleted };
|
|
140
|
+
say(`Deleted ${result.deleted} backup file(s) created during this run.`);
|
|
141
|
+
} else {
|
|
142
|
+
backupCleanup = { requested: false, deleted: 0 };
|
|
143
|
+
say("Kept the backup files created during this run.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (showText) say("\nVhite run completed.");
|
|
148
|
+
|
|
149
|
+
if (interactive) {
|
|
150
|
+
const shouldCreateReport = await askYesNo(
|
|
151
|
+
"Would you like to create a detailed Markdown report of this run?"
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (shouldCreateReport) {
|
|
155
|
+
const result = await writeRunReport({
|
|
156
|
+
root: target,
|
|
157
|
+
target,
|
|
158
|
+
startedAt,
|
|
159
|
+
finishedAt: new Date(),
|
|
160
|
+
filesScanned: files.length,
|
|
161
|
+
issues,
|
|
162
|
+
proposals: await proposeFixes(issues),
|
|
163
|
+
fixResults,
|
|
164
|
+
logs: [...logs],
|
|
165
|
+
backupCleanup,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (result.gitignoreUpdated) {
|
|
169
|
+
say(`Added ${FIX_DIR}/ to .gitignore`);
|
|
170
|
+
}
|
|
171
|
+
say(`Report created successfully: ${relative(target, result.reportPath)}`);
|
|
172
|
+
} else {
|
|
173
|
+
say("Report generation skipped.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (options.format === "sarif") {
|
|
178
|
+
console.log(formatSarif({ root: target, issues, version: await readVersion() }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (options.format === "json") {
|
|
182
|
+
console.log(
|
|
183
|
+
formatJson({
|
|
184
|
+
root: target,
|
|
185
|
+
filesScanned: files.length,
|
|
186
|
+
issues,
|
|
187
|
+
fixes: fixResults,
|
|
188
|
+
version: await readVersion(),
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const failing = issues.filter(
|
|
194
|
+
(issue) => issue.severity === "error" || issue.severity === "warning"
|
|
195
|
+
).length;
|
|
196
|
+
|
|
197
|
+
return decideExitCode({
|
|
198
|
+
rolledBack: fixResults.filter((item) => item.status === "rolled back").length,
|
|
199
|
+
issues: failing,
|
|
200
|
+
applied: fixResults.filter((item) => item.status === "applied").length,
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error(`Error: ${error.message}`);
|
|
204
|
+
return EXIT.ERROR;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve, relative } from "node:path";
|
|
2
|
+
import { findFiles } from "../../src/core/files.js";
|
|
3
|
+
import { runRules } from "../../src/core/engine.js";
|
|
4
|
+
import { applySeverity } from "../../src/core/config.js";
|
|
5
|
+
import { isIncluded } from "../../src/core/args.js";
|
|
6
|
+
import { loadIgnorePatterns, isIgnored } from "../../src/scan/ignore.js";
|
|
7
|
+
import { changedFiles } from "../../src/scan/git.js";
|
|
8
|
+
import { applySuppressions } from "../../src/scan/suppress.js";
|
|
9
|
+
import { applyBaseline } from "../../src/scan/baseline.js";
|
|
10
|
+
import { rules } from "../../rules/index.js";
|
|
11
|
+
|
|
12
|
+
export const KNOWN_RULES = new Set(rules.map((rule) => rule.name));
|
|
13
|
+
|
|
14
|
+
export function summaryLine(issues) {
|
|
15
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
16
|
+
for (const issue of issues) {
|
|
17
|
+
counts[issue.severity] = (counts[issue.severity] ?? 0) + 1;
|
|
18
|
+
}
|
|
19
|
+
return `Errors: ${counts.error} Warnings: ${counts.warning} Info: ${counts.info}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function selectFiles(target, options) {
|
|
23
|
+
const patterns = await loadIgnorePatterns(target);
|
|
24
|
+
const allFiles = await findFiles(target);
|
|
25
|
+
|
|
26
|
+
let files = allFiles.filter((file) => {
|
|
27
|
+
const rel = relative(target, file);
|
|
28
|
+
return !isIgnored(rel, patterns) && isIncluded(rel, options.include, options.exclude);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (options.changed || options.staged) {
|
|
32
|
+
const changed = await changedFiles(target, options.staged ? "staged" : "changed");
|
|
33
|
+
files = files.filter((file) => changed.has(resolve(file)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function collectIssues(files, target, disabled, config, baseline) {
|
|
40
|
+
const found = applySeverity(
|
|
41
|
+
await runRules(files, { disabledRules: disabled }),
|
|
42
|
+
config
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const suppressed = await applySuppressions(found, target);
|
|
46
|
+
let issues = suppressed.issues;
|
|
47
|
+
let hidden = 0;
|
|
48
|
+
|
|
49
|
+
if (baseline) {
|
|
50
|
+
const applied = applyBaseline(issues, baseline, target);
|
|
51
|
+
issues = applied.issues;
|
|
52
|
+
hidden = applied.hidden;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { issues, suppressed: suppressed.suppressed, hidden };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function disabledSetFor(config, options) {
|
|
59
|
+
const disabled = new Set([...config.disabledRules, ...options.disable]);
|
|
60
|
+
if (options.rules.length > 0) {
|
|
61
|
+
const only = new Set(options.rules);
|
|
62
|
+
for (const rule of rules) {
|
|
63
|
+
if (!only.has(rule.name)) disabled.add(rule.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return disabled;
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { runScan } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
const IGNORED_SEGMENTS = new Set(["node_modules", ".git", ".vhite-fix", "dist", "build", "coverage"]);
|
|
6
|
+
const DEBOUNCE_MS = 300;
|
|
7
|
+
|
|
8
|
+
function isIgnoredPath(filename) {
|
|
9
|
+
return filename.split(/[\\/]/).some((part) => IGNORED_SEGMENTS.has(part));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function runWatch(options) {
|
|
13
|
+
const target = resolve(options.path ?? ".");
|
|
14
|
+
const scanOptions = { ...options, watching: true };
|
|
15
|
+
let timer = null;
|
|
16
|
+
let running = false;
|
|
17
|
+
let pending = false;
|
|
18
|
+
|
|
19
|
+
const scanNow = async () => {
|
|
20
|
+
if (running) {
|
|
21
|
+
pending = true;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
running = true;
|
|
25
|
+
do {
|
|
26
|
+
pending = false;
|
|
27
|
+
console.log(`\n[${new Date().toLocaleTimeString()}] Scanning...`);
|
|
28
|
+
await runScan(scanOptions);
|
|
29
|
+
} while (pending);
|
|
30
|
+
running = false;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
watch(target, { recursive: true }, (_event, filename) => {
|
|
34
|
+
if (!filename || isIgnoredPath(filename.toString())) return;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
timer = setTimeout(scanNow, DEBOUNCE_MS);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await scanNow();
|
|
40
|
+
console.log("\nWatching for changes. Press Ctrl+C to stop.");
|
|
41
|
+
|
|
42
|
+
await new Promise(() => {});
|
|
43
|
+
return 0;
|
|
44
|
+
}
|