@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.
Files changed (49) hide show
  1. package/README.md +151 -2
  2. package/bin/cli.js +59 -0
  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 +71 -4
  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/core/console-log.js +44 -0
  15. package/rules/core/debugger-statement.js +36 -0
  16. package/rules/core/empty-block.js +41 -0
  17. package/rules/core/empty-catch.js +40 -0
  18. package/rules/core/hardcoded-secret.js +41 -0
  19. package/rules/core/leftover-todo.js +30 -0
  20. package/rules/core/unused-import.js +69 -0
  21. package/rules/index.js +27 -0
  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/core/files.js +21 -0
  30. package/src/core/setup.js +114 -0
  31. package/src/core/version.js +6 -0
  32. package/src/fix/approve.js +71 -0
  33. package/src/fix/backup.js +73 -0
  34. package/src/fix/fixers/debugger-statement.js +12 -0
  35. package/src/fix/fixers/index.js +9 -0
  36. package/src/fix/fixers/leftover-todo.js +32 -0
  37. package/src/fix/fixers/unused-import.js +18 -0
  38. package/src/fix/gitignore.js +43 -0
  39. package/src/fix/safe-fix.js +178 -0
  40. package/src/output/explanations-frontend.js +22 -0
  41. package/src/output/explanations.js +37 -0
  42. package/src/output/json-output.js +47 -0
  43. package/src/output/reporter.js +52 -0
  44. package/src/output/run-report.js +194 -0
  45. package/src/output/sarif.js +60 -0
  46. package/src/scan/baseline.js +60 -0
  47. package/src/scan/git.js +28 -0
  48. package/src/scan/ignore.js +68 -0
  49. package/src/scan/suppress.js +53 -0
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # @vhite/vhite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }