vibeclean 1.0.2 → 1.1.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 CHANGED
@@ -32,9 +32,11 @@ npx vibeclean --changed --base main
32
32
  - 🔀 Pattern inconsistencies (multiple HTTP clients, mixed async styles, mixed imports)
33
33
  - 📝 Naming chaos (camelCase + snake_case + mixed file naming)
34
34
  - 🗑️ AI leftovers (TODO/FIXME, console logs, placeholders, localhost URLs)
35
+ - 🔐 Security exposure (hardcoded keys/tokens, private keys, credential URLs)
35
36
  - 📦 Dependency bloat (unused packages, duplicate functionality, deprecated libs)
36
37
  - 💀 Dead code (orphan files, unused exports, stubs)
37
38
  - ⚠️ Error handling gaps (empty catches, unhandled async, mixed error patterns)
39
+ - 🧠 TypeScript quality drift (explicit `any`, ts-ignore overuse, assertion style mix)
38
40
 
39
41
  ## Example Output
40
42
 
@@ -112,6 +114,10 @@ Options:
112
114
  --baseline Compare against baseline file and detect regressions
113
115
  --baseline-file <path> Baseline file path for compare/write (default: .vibeclean-baseline.json)
114
116
  --write-baseline Write current report to baseline file
117
+ --watch Watch files and re-run audit on changes
118
+ --watch-interval <ms> Polling interval fallback for --watch (default: 1200)
119
+ --ci-init Generate a GitHub Actions workflow for vibeclean checks
120
+ --ci-force Overwrite existing workflow when used with --ci-init
115
121
  --rules Generate .vibeclean-rules.md file
116
122
  --cursor Also generate .cursorrules file
117
123
  --claude Also generate CLAUDE.md file
@@ -179,6 +185,22 @@ vibeclean --report markdown
179
185
  vibeclean --report markdown --report-file vibeclean-report.md
180
186
  ```
181
187
 
188
+ ## Watch Mode
189
+
190
+ ```bash
191
+ vibeclean --watch --profile cli
192
+ ```
193
+
194
+ This re-runs the audit when files change using lightweight polling.
195
+
196
+ ## CI Workflow Bootstrap
197
+
198
+ ```bash
199
+ vibeclean --ci-init --profile cli --baseline-file .vibeclean-baseline.json --min-score 70 --max-issues 35 --fail-on high
200
+ ```
201
+
202
+ Generates `.github/workflows/vibeclean.yml` with a PR-ready baseline + gates command.
203
+
182
204
  ## Autofix Mode
183
205
 
184
206
  ```bash
@@ -215,9 +237,11 @@ Create `.vibecleanrc` or `.vibecleanrc.json` in project root:
215
237
  "naming": true,
216
238
  "patterns": true,
217
239
  "leftovers": true,
240
+ "security": true,
218
241
  "dependencies": true,
219
242
  "deadcode": true,
220
- "errorhandling": true
243
+ "errorhandling": true,
244
+ "tsquality": true
221
245
  },
222
246
  "allowedPatterns": {
223
247
  "httpClient": "fetch",
package/bin/vibeclean.js CHANGED
@@ -5,11 +5,16 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command } from "commander";
7
7
  import chalk from "chalk";
8
+ import { glob } from "glob";
8
9
  import ora from "ora";
9
10
  import { runAudit } from "../src/index.js";
10
11
  import { writeBaselineSnapshot } from "../src/baseline.js";
12
+ import { generateCiWorkflow } from "../src/ci-init.js";
11
13
  import { renderMarkdownReport } from "../src/markdown-report.js";
12
14
  import { renderReport } from "../src/reporter.js";
15
+ import { BUILTIN_IGNORE_GLOBS, SUPPORTED_EXTENSIONS } from "../src/scanner.js";
16
+
17
+ const DEFAULT_WATCH_INTERVAL_MS = 1200;
13
18
 
14
19
  async function readToolVersion() {
15
20
  const currentFile = fileURLToPath(import.meta.url);
@@ -45,6 +50,10 @@ async function main() {
45
50
  .option("--rules", "Generate .vibeclean-rules.md file")
46
51
  .option("--cursor", "Also generate .cursorrules file")
47
52
  .option("--claude", "Also generate CLAUDE.md file")
53
+ .option("--watch", "Watch files and re-run audit on changes")
54
+ .option("--watch-interval <ms>", "Polling interval fallback for --watch", "1200")
55
+ .option("--ci-init", "Generate a GitHub Actions workflow for vibeclean checks")
56
+ .option("--ci-force", "Overwrite existing workflow file when used with --ci-init")
48
57
  .option("--min-severity <level>", "Minimum severity to report: low, medium, high", "low")
49
58
  .option("--fail-on <level>", "Fail with exit code 1 if findings reach this severity: low, medium, high")
50
59
  .option("--max-issues <n>", "Fail with exit code 1 if total issues exceed this number")
@@ -53,94 +62,38 @@ async function main() {
53
62
  .option("--max-files <n>", "Maximum files to scan", "500")
54
63
  .option("-q, --quiet", "Only show summary, not individual issues")
55
64
  .action(async (directory, options) => {
56
- const spinner = ora("Running vibeclean diagnostics...").start();
65
+ const rootDir = path.resolve(directory || process.cwd());
57
66
 
58
- try {
59
- const result = await runAudit(directory, {
60
- ...options,
61
- maxFiles: Number.parseInt(options.maxFiles, 10),
62
- maxIssues: Number.parseInt(options.maxIssues, 10),
63
- minScore: Number.parseInt(options.minScore, 10),
67
+ if (options.ciInit) {
68
+ const workflow = await generateCiWorkflow(rootDir, {
69
+ force: Boolean(options.ciForce),
64
70
  profile: options.profile,
65
- baseline: Boolean(options.baseline),
66
71
  baselineFile: options.baselineFile,
67
- reportFormat: options.report,
68
- reportFile: options.reportFile || null,
69
- changedOnly: Boolean(options.changed),
70
- changedBase: options.base,
71
- minSeverity: options.minSeverity,
72
- failOn: options.failOn,
73
- version
72
+ minScore: Number.parseInt(options.minScore, 10),
73
+ maxIssues: Number.parseInt(options.maxIssues, 10),
74
+ failOn: options.failOn || "high",
75
+ baseRef: options.base && options.base !== "HEAD" ? options.base : "origin/main"
74
76
  });
75
77
 
76
- spinner.stop();
77
-
78
- let baselineWriteResult = null;
79
- if (options.writeBaseline) {
80
- baselineWriteResult = await writeBaselineSnapshot(
81
- result.rootDir,
82
- options.baselineFile,
83
- result.report
84
- );
85
- }
86
-
87
- const reportFormat = options.json
88
- ? "json"
89
- : String(result.config?.reportFormat || options.report || "text").toLowerCase();
90
- const payload = {
91
- report: result.report,
92
- generatedRules: result.generatedRules
93
- };
94
-
95
- if (reportFormat === "json") {
96
- const jsonOutput = JSON.stringify(payload, null, 2);
97
- if (options.reportFile) {
98
- await fs.writeFile(options.reportFile, `${jsonOutput}\n`, "utf8");
99
- console.log(chalk.green(`Saved JSON report to ${options.reportFile}`));
100
- } else {
101
- console.log(jsonOutput);
102
- }
103
- } else if (reportFormat === "markdown") {
104
- const markdownOutput = renderMarkdownReport(result.report);
105
- if (options.reportFile) {
106
- await fs.writeFile(options.reportFile, markdownOutput, "utf8");
107
- console.log(chalk.green(`Saved Markdown report to ${options.reportFile}`));
108
- } else {
109
- console.log(markdownOutput);
110
- }
78
+ if (workflow.skipped) {
79
+ console.log(chalk.yellow(`Workflow already exists: ${workflow.path}`));
80
+ console.log(chalk.yellow("Use --ci-force to overwrite it."));
111
81
  } else {
112
- console.log(renderReport(result.report, { quiet: Boolean(options.quiet) }));
113
-
114
- if (result.generatedRules?.length) {
115
- console.log("");
116
- console.log(chalk.green.bold(" 📋 Generated rule files"));
117
- for (const item of result.generatedRules) {
118
- console.log(` ${chalk.green("✓")} ${item.path}`);
119
- }
120
- }
121
-
122
- if (baselineWriteResult) {
123
- console.log("");
124
- console.log(chalk.green.bold(" 📌 Baseline updated"));
125
- console.log(` ${chalk.green("✓")} ${baselineWriteResult.path}`);
126
- }
127
-
128
- if (result.report.gateFailures?.length) {
129
- console.log("");
130
- console.log(chalk.red.bold(" ⛔ Quality gates failed"));
131
- for (const failure of result.report.gateFailures) {
132
- console.log(` ${chalk.red("•")} ${failure}`);
133
- }
134
- }
82
+ console.log(chalk.green(`Generated GitHub Actions workflow: ${workflow.path}`));
135
83
  }
84
+ return;
85
+ }
136
86
 
137
- if (baselineWriteResult && reportFormat !== "text") {
138
- console.log(chalk.green(`Baseline updated: ${baselineWriteResult.path}`));
139
- }
87
+ if (options.watch) {
88
+ await runWatchMode(rootDir, options, version);
89
+ return;
90
+ }
140
91
 
141
- if (result.report.gateFailures?.length) {
142
- process.exitCode = 1;
143
- }
92
+ const spinner = ora("Running vibeclean diagnostics...").start();
93
+ try {
94
+ const output = await runOnce(rootDir, options, version);
95
+ spinner.stop();
96
+ await renderOutput(output, options);
144
97
  } catch (error) {
145
98
  spinner.fail("vibeclean failed");
146
99
  console.error(chalk.red(error?.message || "Unknown error"));
@@ -151,4 +104,210 @@ async function main() {
151
104
  await program.parseAsync(process.argv);
152
105
  }
153
106
 
107
+ function normalizeRunOptions(options, version) {
108
+ return {
109
+ ...options,
110
+ maxFiles: Number.parseInt(options.maxFiles, 10),
111
+ maxIssues: Number.parseInt(options.maxIssues, 10),
112
+ minScore: Number.parseInt(options.minScore, 10),
113
+ profile: options.profile,
114
+ baseline: Boolean(options.baseline),
115
+ baselineFile: options.baselineFile,
116
+ reportFormat: options.report,
117
+ reportFile: options.reportFile || null,
118
+ changedOnly: Boolean(options.changed),
119
+ changedBase: options.base,
120
+ minSeverity: options.minSeverity,
121
+ failOn: options.failOn,
122
+ version
123
+ };
124
+ }
125
+
126
+ async function runOnce(directory, options, version) {
127
+ const normalized = normalizeRunOptions(options, version);
128
+ const result = await runAudit(directory, normalized);
129
+
130
+ let baselineWriteResult = null;
131
+ if (options.writeBaseline) {
132
+ baselineWriteResult = await writeBaselineSnapshot(
133
+ result.rootDir,
134
+ options.baselineFile,
135
+ result.report
136
+ );
137
+ }
138
+
139
+ const reportFormat = options.json
140
+ ? "json"
141
+ : String(result.config?.reportFormat || options.report || "text").toLowerCase();
142
+
143
+ return {
144
+ result,
145
+ baselineWriteResult,
146
+ reportFormat
147
+ };
148
+ }
149
+
150
+ async function renderOutput(output, options) {
151
+ const { result, baselineWriteResult, reportFormat } = output;
152
+ const payload = {
153
+ report: result.report,
154
+ generatedRules: result.generatedRules
155
+ };
156
+
157
+ if (reportFormat === "json") {
158
+ const jsonOutput = JSON.stringify(payload, null, 2);
159
+ if (options.reportFile) {
160
+ await fs.writeFile(options.reportFile, `${jsonOutput}\n`, "utf8");
161
+ console.log(chalk.green(`Saved JSON report to ${options.reportFile}`));
162
+ } else {
163
+ console.log(jsonOutput);
164
+ }
165
+ } else if (reportFormat === "markdown") {
166
+ const markdownOutput = renderMarkdownReport(result.report);
167
+ if (options.reportFile) {
168
+ await fs.writeFile(options.reportFile, markdownOutput, "utf8");
169
+ console.log(chalk.green(`Saved Markdown report to ${options.reportFile}`));
170
+ } else {
171
+ console.log(markdownOutput);
172
+ }
173
+ } else {
174
+ console.log(renderReport(result.report, { quiet: Boolean(options.quiet) }));
175
+
176
+ if (result.generatedRules?.length) {
177
+ console.log("");
178
+ console.log(chalk.green.bold(" 📋 Generated rule files"));
179
+ for (const item of result.generatedRules) {
180
+ console.log(` ${chalk.green("✓")} ${item.path}`);
181
+ }
182
+ }
183
+
184
+ if (baselineWriteResult) {
185
+ console.log("");
186
+ console.log(chalk.green.bold(" 📌 Baseline updated"));
187
+ console.log(` ${chalk.green("✓")} ${baselineWriteResult.path}`);
188
+ }
189
+
190
+ if (result.report.gateFailures?.length) {
191
+ console.log("");
192
+ console.log(chalk.red.bold(" ⛔ Quality gates failed"));
193
+ for (const failure of result.report.gateFailures) {
194
+ console.log(` ${chalk.red("•")} ${failure}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ if (baselineWriteResult && reportFormat !== "text") {
200
+ console.log(chalk.green(`Baseline updated: ${baselineWriteResult.path}`));
201
+ }
202
+
203
+ if (result.report.gateFailures?.length) {
204
+ process.exitCode = 1;
205
+ }
206
+ }
207
+
208
+ async function watchFingerprint(rootDir, baselineFile = ".vibeclean-baseline.json") {
209
+ const extensionBody = [...SUPPORTED_EXTENSIONS].map((ext) => ext.slice(1)).join(",");
210
+ const sourceFiles = await glob(`**/*.{${extensionBody}}`, {
211
+ cwd: rootDir,
212
+ nodir: true,
213
+ dot: false,
214
+ ignore: BUILTIN_IGNORE_GLOBS
215
+ });
216
+ const extras = [".vibecleanrc", ".vibecleanrc.json", "package.json", baselineFile].filter(Boolean);
217
+ const candidates = [...new Set([...sourceFiles, ...extras])];
218
+
219
+ let sumMtime = 0;
220
+ let sumSize = 0;
221
+ for (const relativePath of candidates) {
222
+ try {
223
+ const stats = await fs.stat(path.join(rootDir, relativePath));
224
+ if (!stats.isFile()) {
225
+ continue;
226
+ }
227
+ sumMtime += Math.floor(stats.mtimeMs);
228
+ sumSize += stats.size;
229
+ } catch {
230
+ // Ignore deleted or absent files while watching.
231
+ }
232
+ }
233
+
234
+ return `${candidates.length}:${sumMtime}:${sumSize}`;
235
+ }
236
+
237
+ async function runWatchMode(rootDir, options, version) {
238
+ console.log(chalk.cyan("Watch mode enabled. Press Ctrl+C to exit."));
239
+
240
+ let running = false;
241
+ let queued = false;
242
+
243
+ const runCycle = async (reason = null) => {
244
+ if (running) {
245
+ queued = true;
246
+ return;
247
+ }
248
+ running = true;
249
+
250
+ if (reason) {
251
+ console.log(chalk.gray(`\n[watch] ${reason}`));
252
+ }
253
+
254
+ const spinner = ora("Running vibeclean diagnostics...").start();
255
+ try {
256
+ const output = await runOnce(rootDir, options, version);
257
+ spinner.stop();
258
+ await renderOutput(output, options);
259
+ } catch (error) {
260
+ spinner.fail("vibeclean failed");
261
+ console.error(chalk.red(error?.message || "Unknown error"));
262
+ process.exitCode = 1;
263
+ } finally {
264
+ running = false;
265
+ if (queued) {
266
+ queued = false;
267
+ setTimeout(() => {
268
+ runCycle("re-running queued changes");
269
+ }, 250);
270
+ }
271
+ }
272
+ };
273
+
274
+ await runCycle("initial run");
275
+
276
+ const watchDelay = Math.max(
277
+ 250,
278
+ Number.parseInt(options.watchInterval, 10) || DEFAULT_WATCH_INTERVAL_MS
279
+ );
280
+
281
+ let debounceTimer = null;
282
+ const scheduleDebounced = (reason) => {
283
+ if (debounceTimer) {
284
+ clearTimeout(debounceTimer);
285
+ }
286
+ debounceTimer = setTimeout(() => {
287
+ runCycle(reason);
288
+ }, watchDelay);
289
+ };
290
+
291
+ console.log(chalk.gray(`Polling for changes every ${watchDelay}ms...`));
292
+ let previousFingerprint = await watchFingerprint(rootDir, options.baselineFile);
293
+ const interval = setInterval(async () => {
294
+ try {
295
+ const currentFingerprint = await watchFingerprint(rootDir, options.baselineFile);
296
+ if (currentFingerprint !== previousFingerprint) {
297
+ previousFingerprint = currentFingerprint;
298
+ scheduleDebounced("detected file changes");
299
+ }
300
+ } catch (error) {
301
+ console.error(chalk.red(`[watch] polling error: ${error?.message || "unknown error"}`));
302
+ }
303
+ }, watchDelay);
304
+
305
+ process.on("SIGINT", () => {
306
+ clearInterval(interval);
307
+ process.exit(process.exitCode || 0);
308
+ });
309
+
310
+ await new Promise(() => {});
311
+ }
312
+
154
313
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeclean",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Audit your codebase for vibe coding mess. Detect inconsistencies, AI leftovers, and pattern chaos with one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,264 @@
1
+ import { severityFromScore, scoreFromRatio } from "./utils.js";
2
+
3
+ const MAX_LOCATIONS = 12;
4
+
5
+ const SECURITY_PATTERNS = [
6
+ {
7
+ id: "privateKey",
8
+ severity: "high",
9
+ label: "private key material",
10
+ regex: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/g
11
+ },
12
+ {
13
+ id: "awsAccessKey",
14
+ severity: "high",
15
+ label: "AWS access keys",
16
+ regex: /\bAKIA[0-9A-Z]{16}\b/g
17
+ },
18
+ {
19
+ id: "npmToken",
20
+ severity: "high",
21
+ label: "npm tokens",
22
+ regex: /\bnpm_[A-Za-z0-9]{36}\b/g
23
+ },
24
+ {
25
+ id: "githubPat",
26
+ severity: "high",
27
+ label: "GitHub personal access tokens",
28
+ regex: /\bghp_[A-Za-z0-9]{36}\b/g
29
+ },
30
+ {
31
+ id: "dbCredentialsUrl",
32
+ severity: "high",
33
+ label: "database URLs with inline credentials",
34
+ regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^:\s]+:[^@\s]+@/gi
35
+ },
36
+ {
37
+ id: "slackWebhook",
38
+ severity: "high",
39
+ label: "Slack webhook URLs",
40
+ regex: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/g
41
+ },
42
+ {
43
+ id: "jwtLike",
44
+ severity: "medium",
45
+ label: "JWT-like tokens",
46
+ regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g
47
+ },
48
+ {
49
+ id: "genericSecretAssignment",
50
+ severity: "medium",
51
+ label: "hardcoded credential assignments",
52
+ regex: /\b(?:api[_-]?key|secret|token|password|passwd)\b\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/gi
53
+ }
54
+ ];
55
+
56
+ function lineNumberAtIndex(content, index) {
57
+ return content.slice(0, index).split("\n").length;
58
+ }
59
+
60
+ function lineSnippet(content, lineNumber) {
61
+ return (content.split("\n")[lineNumber - 1] || "").trim().slice(0, 160);
62
+ }
63
+
64
+ function isDetectorDefinitionLine(content, index) {
65
+ const lineNumber = lineNumberAtIndex(content, index);
66
+ const line = (content.split("\n")[lineNumber - 1] || "").trim();
67
+ const prevLine = (content.split("\n")[lineNumber - 2] || "").trim();
68
+
69
+ if (/^const\s+[A-Z_]+(?:_RE|_PATTERN)\s*=/.test(line) && /\/.+\/[gimsuy]*;?$/.test(line)) {
70
+ return true;
71
+ }
72
+ if (/^\/.+\/[gimsuy]*;?$/.test(line) && /^const\s+[A-Z_]+(?:_RE|_PATTERN)\s*=\s*$/.test(prevLine)) {
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function shannonEntropy(value) {
79
+ if (!value) {
80
+ return 0;
81
+ }
82
+
83
+ const map = new Map();
84
+ for (const char of value) {
85
+ map.set(char, (map.get(char) || 0) + 1);
86
+ }
87
+
88
+ let entropy = 0;
89
+ for (const count of map.values()) {
90
+ const p = count / value.length;
91
+ entropy -= p * Math.log2(p);
92
+ }
93
+ return entropy;
94
+ }
95
+
96
+ function collectPatternHits(file, pattern, remainingCapacity) {
97
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
98
+ const probe = new RegExp(pattern.regex.source, flags);
99
+ const locations = [];
100
+ const seen = new Set();
101
+ let count = 0;
102
+
103
+ for (const match of file.content.matchAll(probe)) {
104
+ const index = typeof match.index === "number" ? match.index : 0;
105
+ const raw = match[0] || "";
106
+ if (isDetectorDefinitionLine(file.content, index)) {
107
+ continue;
108
+ }
109
+
110
+ if (
111
+ pattern.id === "genericSecretAssignment" &&
112
+ /\b(example|dummy|replace_me|your-|test|localhost)\b/i.test(raw)
113
+ ) {
114
+ continue;
115
+ }
116
+
117
+ count += 1;
118
+ if (locations.length >= remainingCapacity) {
119
+ continue;
120
+ }
121
+
122
+ const line = lineNumberAtIndex(file.content, index);
123
+ const key = `${file.relativePath}:${line}`;
124
+ if (seen.has(key)) {
125
+ continue;
126
+ }
127
+ seen.add(key);
128
+ locations.push({
129
+ file: file.relativePath,
130
+ line,
131
+ snippet: lineSnippet(file.content, line)
132
+ });
133
+ }
134
+
135
+ return { count, locations };
136
+ }
137
+
138
+ function detectHighEntropyStrings(file, remainingCapacity) {
139
+ const regex = /["'`]([A-Za-z0-9+/=_-]{24,})["'`]/g;
140
+ let count = 0;
141
+ const seen = new Set();
142
+ const locations = [];
143
+
144
+ for (const match of file.content.matchAll(regex)) {
145
+ const candidate = match[1] || "";
146
+ if (
147
+ /^(?:[a-f0-9]{32,}|[0-9a-f]{8}-[0-9a-f-]{27,}|[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/.test(candidate)
148
+ ) {
149
+ continue;
150
+ }
151
+
152
+ const entropy = shannonEntropy(candidate);
153
+ if (entropy < 3.8) {
154
+ continue;
155
+ }
156
+
157
+ const index = typeof match.index === "number" ? match.index : 0;
158
+ if (isDetectorDefinitionLine(file.content, index)) {
159
+ continue;
160
+ }
161
+
162
+ count += 1;
163
+ if (locations.length >= remainingCapacity) {
164
+ continue;
165
+ }
166
+
167
+ const line = lineNumberAtIndex(file.content, index);
168
+ const key = `${file.relativePath}:${line}`;
169
+ if (seen.has(key)) {
170
+ continue;
171
+ }
172
+ seen.add(key);
173
+ locations.push({
174
+ file: file.relativePath,
175
+ line,
176
+ snippet: lineSnippet(file.content, line)
177
+ });
178
+ }
179
+
180
+ return { count, locations };
181
+ }
182
+
183
+ export function analyzeSecurity(files) {
184
+ const findings = [];
185
+ let highCount = 0;
186
+ let mediumCount = 0;
187
+ let entropyCount = 0;
188
+ const filesWithSignals = new Set();
189
+
190
+ for (const pattern of SECURITY_PATTERNS) {
191
+ let count = 0;
192
+ const locations = [];
193
+
194
+ for (const file of files) {
195
+ const hit = collectPatternHits(file, pattern, MAX_LOCATIONS - locations.length);
196
+ if (hit.count > 0) {
197
+ filesWithSignals.add(file.relativePath);
198
+ }
199
+ count += hit.count;
200
+ locations.push(...hit.locations);
201
+ }
202
+
203
+ if (count === 0) {
204
+ continue;
205
+ }
206
+
207
+ if (pattern.severity === "high") {
208
+ highCount += count;
209
+ } else {
210
+ mediumCount += count;
211
+ }
212
+
213
+ findings.push({
214
+ severity: pattern.severity,
215
+ message: `${count} potential ${pattern.label} detected.`,
216
+ locations
217
+ });
218
+ }
219
+
220
+ const entropyLocations = [];
221
+ for (const file of files) {
222
+ const entropyHits = detectHighEntropyStrings(file, MAX_LOCATIONS - entropyLocations.length);
223
+ entropyCount += entropyHits.count;
224
+ if (entropyHits.count > 0) {
225
+ filesWithSignals.add(file.relativePath);
226
+ entropyLocations.push(...entropyHits.locations);
227
+ }
228
+ }
229
+ if (entropyCount > 0) {
230
+ mediumCount += entropyCount;
231
+ findings.push({
232
+ severity: "medium",
233
+ message: `${entropyCount} high-entropy hardcoded strings found (review for secrets).`,
234
+ locations: entropyLocations
235
+ });
236
+ }
237
+
238
+ const weightedSignal = highCount * 2 + mediumCount;
239
+ const score = Math.min(10, scoreFromRatio(weightedSignal / Math.max(files.length * 0.8, 1), 10));
240
+
241
+ return {
242
+ id: "security",
243
+ title: "SECURITY EXPOSURE",
244
+ score,
245
+ severity: severityFromScore(score),
246
+ totalIssues: highCount + mediumCount,
247
+ summary:
248
+ highCount + mediumCount > 0
249
+ ? `${highCount + mediumCount} potential secret exposure signals detected.`
250
+ : "No obvious hardcoded secret exposure detected.",
251
+ metrics: {
252
+ highSeveritySignals: highCount,
253
+ mediumSeveritySignals: mediumCount,
254
+ entropySignals: entropyCount,
255
+ filesWithSignals: filesWithSignals.size
256
+ },
257
+ recommendations: [
258
+ "Move secrets into environment variables or secret managers.",
259
+ "Rotate exposed credentials immediately and revoke compromised tokens.",
260
+ "Use runtime configuration injection instead of hardcoding credentials."
261
+ ],
262
+ findings
263
+ };
264
+ }
@@ -0,0 +1,158 @@
1
+ import { severityFromScore, scoreFromRatio } from "./utils.js";
2
+
3
+ const TS_EXTENSIONS = new Set([".ts", ".tsx"]);
4
+
5
+ function countMatches(content, regex) {
6
+ const matches = content.match(regex);
7
+ return matches ? matches.length : 0;
8
+ }
9
+
10
+ function styleOfAssertions(content, extension) {
11
+ const asAssertions = countMatches(content, /\bas\s+[A-Za-z_$][A-Za-z0-9_$<>,\s.[\]|&?:]*/g);
12
+ const angleAssertions =
13
+ extension === ".ts"
14
+ ? countMatches(content, /<\s*[A-Za-z_$][A-Za-z0-9_$<>,\s.[\]|&?:]*>\s*[A-Za-z_$][A-Za-z0-9_$]*/g)
15
+ : 0;
16
+ return { asAssertions, angleAssertions };
17
+ }
18
+
19
+ export function analyzeTsQuality(files) {
20
+ const tsFiles = files.filter((file) => TS_EXTENSIONS.has(file.extension));
21
+ if (tsFiles.length === 0) {
22
+ return {
23
+ id: "tsquality",
24
+ title: "TYPESCRIPT QUALITY",
25
+ score: 0,
26
+ severity: "low",
27
+ totalIssues: 0,
28
+ summary: "No TypeScript files detected. TS-specific checks were skipped.",
29
+ metrics: {
30
+ tsFileCount: 0,
31
+ explicitAnyCount: 0,
32
+ suppressionCount: 0,
33
+ asAssertions: 0,
34
+ angleAssertions: 0,
35
+ missingReturnTypeCount: 0,
36
+ nonNullAssertionCount: 0
37
+ },
38
+ recommendations: [
39
+ "Add TypeScript files to enable TS-specific consistency checks."
40
+ ],
41
+ findings: [],
42
+ skipped: true
43
+ };
44
+ }
45
+
46
+ let explicitAnyCount = 0;
47
+ let suppressionCount = 0;
48
+ let asAssertions = 0;
49
+ let angleAssertions = 0;
50
+ let missingReturnTypeCount = 0;
51
+ let nonNullAssertionCount = 0;
52
+
53
+ const filesWithAny = new Set();
54
+ const filesWithSuppressions = new Set();
55
+
56
+ for (const file of tsFiles) {
57
+ const content = file.content;
58
+
59
+ const anySignals =
60
+ countMatches(content, /:\s*any\b/g) +
61
+ countMatches(content, /\bas\s+any\b/g) +
62
+ countMatches(content, /<\s*any\s*>/g);
63
+ if (anySignals > 0) {
64
+ explicitAnyCount += anySignals;
65
+ filesWithAny.add(file.relativePath);
66
+ }
67
+
68
+ const suppressions = countMatches(content, /\/\/\s*@ts-(?:ignore|expect-error)\b/g);
69
+ if (suppressions > 0) {
70
+ suppressionCount += suppressions;
71
+ filesWithSuppressions.add(file.relativePath);
72
+ }
73
+
74
+ const assertions = styleOfAssertions(content, file.extension);
75
+ asAssertions += assertions.asAssertions;
76
+ angleAssertions += assertions.angleAssertions;
77
+
78
+ missingReturnTypeCount +=
79
+ countMatches(content, /export\s+(?:async\s+)?function\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\([^)]*\)\s*\{/g) +
80
+ countMatches(content, /export\s+const\s+[A-Za-z_$][A-Za-z0-9_$]*\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g);
81
+
82
+ nonNullAssertionCount += countMatches(content, /\b[A-Za-z_$][A-Za-z0-9_$]*!\b/g);
83
+ }
84
+
85
+ const findings = [];
86
+
87
+ if (explicitAnyCount > 0) {
88
+ findings.push({
89
+ severity: explicitAnyCount >= 6 ? "high" : "medium",
90
+ message: `${explicitAnyCount} explicit any usages detected across ${filesWithAny.size} TS files.`,
91
+ files: [...filesWithAny].slice(0, 20)
92
+ });
93
+ }
94
+
95
+ if (suppressionCount > 0) {
96
+ findings.push({
97
+ severity: suppressionCount >= 4 ? "high" : "medium",
98
+ message: `${suppressionCount} @ts-ignore/@ts-expect-error suppressions found.`,
99
+ files: [...filesWithSuppressions].slice(0, 20)
100
+ });
101
+ }
102
+
103
+ if (asAssertions > 0 && angleAssertions > 0) {
104
+ findings.push({
105
+ severity: "medium",
106
+ message: `Mixed type assertion styles detected: "as" (${asAssertions}) and angle-bracket (${angleAssertions}).`
107
+ });
108
+ }
109
+
110
+ if (missingReturnTypeCount > 0) {
111
+ findings.push({
112
+ severity: missingReturnTypeCount >= 8 ? "medium" : "low",
113
+ message: `${missingReturnTypeCount} exported TS functions appear to omit explicit return types.`
114
+ });
115
+ }
116
+
117
+ if (nonNullAssertionCount > 0) {
118
+ findings.push({
119
+ severity: nonNullAssertionCount >= 10 ? "medium" : "low",
120
+ message: `${nonNullAssertionCount} non-null assertions found ("!").`
121
+ });
122
+ }
123
+
124
+ const weightedSignal =
125
+ explicitAnyCount * 1.6 +
126
+ suppressionCount * 1.8 +
127
+ missingReturnTypeCount * 0.7 +
128
+ nonNullAssertionCount * 0.5 +
129
+ (asAssertions > 0 && angleAssertions > 0 ? 3 : 0);
130
+ const score = Math.min(10, scoreFromRatio(weightedSignal / Math.max(tsFiles.length * 2.5, 1), 10));
131
+
132
+ return {
133
+ id: "tsquality",
134
+ title: "TYPESCRIPT QUALITY",
135
+ score,
136
+ severity: severityFromScore(score),
137
+ totalIssues: findings.length,
138
+ summary:
139
+ findings.length > 0
140
+ ? "TypeScript consistency and strictness issues detected."
141
+ : "TypeScript quality signals look consistent.",
142
+ metrics: {
143
+ tsFileCount: tsFiles.length,
144
+ explicitAnyCount,
145
+ suppressionCount,
146
+ asAssertions,
147
+ angleAssertions,
148
+ missingReturnTypeCount,
149
+ nonNullAssertionCount
150
+ },
151
+ recommendations: [
152
+ "Replace explicit any with specific types or generics.",
153
+ "Use @ts-ignore/@ts-expect-error only with issue-linked justification.",
154
+ "Keep one type assertion style and prefer explicit return types for exported APIs."
155
+ ],
156
+ findings
157
+ };
158
+ }
package/src/ci-init.js ADDED
@@ -0,0 +1,123 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ function workflowTemplate(options = {}) {
5
+ const profile = options.profile || "app";
6
+ const baselineFile = options.baselineFile || ".vibeclean-baseline.json";
7
+ const minScore = Number.isFinite(options.minScore) ? options.minScore : 70;
8
+ const maxIssues = Number.isFinite(options.maxIssues) ? options.maxIssues : 35;
9
+ const failOn = options.failOn || "high";
10
+ const baseRef = options.baseRef || "origin/main";
11
+
12
+ return `name: vibeclean
13
+
14
+ on:
15
+ pull_request:
16
+ push:
17
+ branches: [main]
18
+
19
+ jobs:
20
+ audit:
21
+ runs-on: ubuntu-latest
22
+ permissions:
23
+ contents: read
24
+ pull-requests: write
25
+ issues: write
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0
31
+
32
+ - name: Setup Node
33
+ uses: actions/setup-node@v4
34
+ with:
35
+ node-version: 20
36
+ cache: npm
37
+
38
+ - name: Install deps
39
+ run: npm ci
40
+
41
+ - name: Run vibeclean
42
+ id: vibeclean
43
+ run: |
44
+ npx vibeclean . \\
45
+ --profile ${profile} \\
46
+ --changed --base ${baseRef} \\
47
+ --baseline --baseline-file ${baselineFile} \\
48
+ --fail-on ${failOn} --min-score ${minScore} --max-issues ${maxIssues} \\
49
+ --report markdown --report-file vibeclean-report.md
50
+
51
+ - name: Write job summary
52
+ if: always()
53
+ run: |
54
+ if [ -f vibeclean-report.md ]; then
55
+ cat vibeclean-report.md >> "$GITHUB_STEP_SUMMARY"
56
+ fi
57
+
58
+ - name: Comment on PR
59
+ if: always() && github.event_name == 'pull_request'
60
+ continue-on-error: true
61
+ uses: actions/github-script@v7
62
+ with:
63
+ script: |
64
+ const fs = require("fs");
65
+ if (!fs.existsSync("vibeclean-report.md")) return;
66
+ const body = fs.readFileSync("vibeclean-report.md", "utf8");
67
+ const marker = "<!-- vibeclean-report -->";
68
+ const fullBody = marker + "\\n" + body;
69
+ const { data: comments } = await github.rest.issues.listComments({
70
+ owner: context.repo.owner,
71
+ repo: context.repo.repo,
72
+ issue_number: context.issue.number,
73
+ });
74
+ const existing = comments.find((c) => c.body?.includes(marker));
75
+ if (existing) {
76
+ await github.rest.issues.updateComment({
77
+ owner: context.repo.owner,
78
+ repo: context.repo.repo,
79
+ comment_id: existing.id,
80
+ body: fullBody,
81
+ });
82
+ } else {
83
+ await github.rest.issues.createComment({
84
+ owner: context.repo.owner,
85
+ repo: context.repo.repo,
86
+ issue_number: context.issue.number,
87
+ body: fullBody,
88
+ });
89
+ }
90
+
91
+ - name: Upload report artifact
92
+ if: always()
93
+ uses: actions/upload-artifact@v4
94
+ with:
95
+ name: vibeclean-report
96
+ path: vibeclean-report.md
97
+ `;
98
+ }
99
+
100
+ export async function generateCiWorkflow(rootDir, options = {}) {
101
+ const workflowPath = path.join(rootDir, ".github", "workflows", "vibeclean.yml");
102
+ await fs.mkdir(path.dirname(workflowPath), { recursive: true });
103
+
104
+ if (!options.force) {
105
+ try {
106
+ await fs.stat(workflowPath);
107
+ return {
108
+ path: workflowPath,
109
+ created: false,
110
+ skipped: true
111
+ };
112
+ } catch {
113
+ // File does not exist: proceed.
114
+ }
115
+ }
116
+
117
+ await fs.writeFile(workflowPath, workflowTemplate(options), "utf8");
118
+ return {
119
+ path: workflowPath,
120
+ created: true,
121
+ skipped: false
122
+ };
123
+ }
package/src/config.js CHANGED
@@ -26,9 +26,11 @@ const DEFAULT_CONFIG = {
26
26
  naming: true,
27
27
  patterns: true,
28
28
  leftovers: true,
29
+ security: true,
29
30
  dependencies: true,
30
31
  deadcode: true,
31
- errorhandling: true
32
+ errorhandling: true,
33
+ tsquality: true
32
34
  },
33
35
  allowedPatterns: {
34
36
  httpClient: null,
package/src/index.js CHANGED
@@ -5,9 +5,11 @@ import { loadConfig, mergeConfig } from "./config.js";
5
5
  import { analyzeNaming } from "./analyzers/naming.js";
6
6
  import { analyzePatterns } from "./analyzers/patterns.js";
7
7
  import { analyzeLeftovers } from "./analyzers/leftovers.js";
8
+ import { analyzeSecurity } from "./analyzers/security.js";
8
9
  import { analyzeDependencies } from "./analyzers/dependencies.js";
9
10
  import { analyzeDeadCode } from "./analyzers/deadcode.js";
10
11
  import { analyzeErrorHandling } from "./analyzers/errorhandling.js";
12
+ import { analyzeTsQuality } from "./analyzers/tsquality.js";
11
13
  import { generateRulesFiles } from "./rules-generator.js";
12
14
  import { parseAstWithMeta } from "./analyzers/utils.js";
13
15
  import { applySafeFixes } from "./fixers/safe-fixes.js";
@@ -218,6 +220,9 @@ export async function runAudit(targetDir, cliOptions = {}) {
218
220
  if (enabledRules.leftovers !== false) {
219
221
  categoryResults.push(analyzeLeftovers(scanResult.files, context));
220
222
  }
223
+ if (enabledRules.security !== false) {
224
+ categoryResults.push(analyzeSecurity(scanResult.files, context));
225
+ }
221
226
  if (enabledRules.dependencies !== false) {
222
227
  categoryResults.push(await analyzeDependencies(scanResult.files, context));
223
228
  }
@@ -227,6 +232,9 @@ export async function runAudit(targetDir, cliOptions = {}) {
227
232
  if (enabledRules.errorhandling !== false) {
228
233
  categoryResults.push(analyzeErrorHandling(scanResult.files, context));
229
234
  }
235
+ if (enabledRules.tsquality !== false) {
236
+ categoryResults.push(analyzeTsQuality(scanResult.files, context));
237
+ }
230
238
 
231
239
  const filteredCategories = applySeverityFilter(categoryResults, config.severity || "low");
232
240
 
package/src/reporter.js CHANGED
@@ -93,6 +93,13 @@ function categoryDetailLines(category) {
93
93
  `└─ Estimated savings: ~${metrics.estimatedSavingsMb || 0}MB`
94
94
  ];
95
95
 
96
+ case "security":
97
+ return [
98
+ `├─ High severity signals: ${metrics.highSeveritySignals || 0}`,
99
+ `├─ Medium severity signals: ${metrics.mediumSeveritySignals || 0}`,
100
+ `└─ Files with signals: ${metrics.filesWithSignals || 0}`
101
+ ];
102
+
96
103
  case "deadcode":
97
104
  return [
98
105
  `├─ Orphan files: ${(metrics.orphanFiles || []).length}`,
@@ -107,6 +114,13 @@ function categoryDetailLines(category) {
107
114
  `└─ Unhandled await signals: ${metrics.unhandledAwait || 0}`
108
115
  ];
109
116
 
117
+ case "tsquality":
118
+ return [
119
+ `├─ TS files scanned: ${metrics.tsFileCount || 0}`,
120
+ `├─ explicit any: ${metrics.explicitAnyCount || 0}, suppressions: ${metrics.suppressionCount || 0}`,
121
+ `└─ Missing return types: ${metrics.missingReturnTypeCount || 0}`
122
+ ];
123
+
110
124
  default:
111
125
  return ["└─ No details available"];
112
126
  }