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 +25 -1
- package/bin/vibeclean.js +238 -79
- package/package.json +1 -1
- package/src/analyzers/security.js +264 -0
- package/src/analyzers/tsquality.js +158 -0
- package/src/ci-init.js +123 -0
- package/src/config.js +3 -1
- package/src/index.js +8 -0
- package/src/reporter.js +14 -0
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
|
|
65
|
+
const rootDir = path.resolve(directory || process.cwd());
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
87
|
+
if (options.watch) {
|
|
88
|
+
await runWatchMode(rootDir, options, version);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
140
91
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
@@ -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
|
}
|