vibeclean 1.0.2 → 1.1.1

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,12 @@ 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)
40
+ - 🧱 Framework anti-patterns (React/Next.js/Express hotspots)
38
41
 
39
42
  ## Example Output
40
43
 
@@ -112,6 +115,10 @@ Options:
112
115
  --baseline Compare against baseline file and detect regressions
113
116
  --baseline-file <path> Baseline file path for compare/write (default: .vibeclean-baseline.json)
114
117
  --write-baseline Write current report to baseline file
118
+ --watch Watch files and re-run audit on changes
119
+ --watch-interval <ms> Polling interval fallback for --watch (default: 1200)
120
+ --ci-init Generate a GitHub Actions workflow for vibeclean checks
121
+ --ci-force Overwrite existing workflow when used with --ci-init
115
122
  --rules Generate .vibeclean-rules.md file
116
123
  --cursor Also generate .cursorrules file
117
124
  --claude Also generate CLAUDE.md file
@@ -179,6 +186,22 @@ vibeclean --report markdown
179
186
  vibeclean --report markdown --report-file vibeclean-report.md
180
187
  ```
181
188
 
189
+ ## Watch Mode
190
+
191
+ ```bash
192
+ vibeclean --watch --profile cli
193
+ ```
194
+
195
+ This re-runs the audit when files change using lightweight polling.
196
+
197
+ ## CI Workflow Bootstrap
198
+
199
+ ```bash
200
+ vibeclean --ci-init --profile cli --baseline-file .vibeclean-baseline.json --min-score 70 --max-issues 35 --fail-on high
201
+ ```
202
+
203
+ Generates `.github/workflows/vibeclean.yml` with a PR-ready baseline + gates command.
204
+
182
205
  ## Autofix Mode
183
206
 
184
207
  ```bash
@@ -215,9 +238,12 @@ Create `.vibecleanrc` or `.vibecleanrc.json` in project root:
215
238
  "naming": true,
216
239
  "patterns": true,
217
240
  "leftovers": true,
241
+ "security": true,
242
+ "frameworks": true,
218
243
  "dependencies": true,
219
244
  "deadcode": true,
220
- "errorhandling": true
245
+ "errorhandling": true,
246
+ "tsquality": true
221
247
  },
222
248
  "allowedPatterns": {
223
249
  "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.1",
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,274 @@
1
+ import { severityFromScore, scoreFromRatio } from "./utils.js";
2
+
3
+ const MAX_LOCATIONS = 12;
4
+
5
+ function hasImport(content, pattern) {
6
+ return pattern.test(content);
7
+ }
8
+
9
+ function lineNumberAtIndex(content, index) {
10
+ return content.slice(0, index).split("\n").length;
11
+ }
12
+
13
+ function lineSnippet(content, lineNumber) {
14
+ return (content.split("\n")[lineNumber - 1] || "").trim().slice(0, 160);
15
+ }
16
+
17
+ function collectMatches(file, regex, limit = MAX_LOCATIONS) {
18
+ const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
19
+ const probe = new RegExp(regex.source, flags);
20
+ const locations = [];
21
+ const seen = new Set();
22
+ let count = 0;
23
+
24
+ for (const match of file.content.matchAll(probe)) {
25
+ count += 1;
26
+ if (locations.length >= limit) {
27
+ continue;
28
+ }
29
+
30
+ const index = typeof match.index === "number" ? match.index : 0;
31
+ const line = lineNumberAtIndex(file.content, index);
32
+ const key = `${file.relativePath}:${line}`;
33
+ if (seen.has(key)) {
34
+ continue;
35
+ }
36
+ seen.add(key);
37
+ locations.push({
38
+ file: file.relativePath,
39
+ line,
40
+ snippet: lineSnippet(file.content, line)
41
+ });
42
+ }
43
+
44
+ return { count, locations };
45
+ }
46
+
47
+ function addFinding(findings, severity, message, locations = []) {
48
+ findings.push({
49
+ severity,
50
+ message,
51
+ locations: locations.slice(0, MAX_LOCATIONS)
52
+ });
53
+ }
54
+
55
+ function detectFrameworks(files, packageJson = {}) {
56
+ const deps = {
57
+ ...(packageJson.dependencies || {}),
58
+ ...(packageJson.devDependencies || {})
59
+ };
60
+
61
+ let react = "react" in deps || "react-dom" in deps;
62
+ let next = "next" in deps;
63
+ let express = "express" in deps;
64
+
65
+ for (const file of files) {
66
+ const content = file.content;
67
+ if (
68
+ !react &&
69
+ hasImport(content, /from\s+["'`]react["'`]|require\(\s*["'`]react["'`]\s*\)/)
70
+ ) {
71
+ react = true;
72
+ }
73
+ if (
74
+ !next &&
75
+ hasImport(content, /from\s+["'`]next(?:\/[^"'`]+)?["'`]|require\(\s*["'`]next(?:\/[^"'`]+)?["'`]\s*\)/)
76
+ ) {
77
+ next = true;
78
+ }
79
+ if (
80
+ !express &&
81
+ hasImport(content, /from\s+["'`]express["'`]|require\(\s*["'`]express["'`]\s*\)/)
82
+ ) {
83
+ express = true;
84
+ }
85
+ }
86
+
87
+ return {
88
+ react,
89
+ next,
90
+ express
91
+ };
92
+ }
93
+
94
+ export function analyzeFrameworks(files, context = {}) {
95
+ const frameworkUse = detectFrameworks(files, context.packageJson || {});
96
+ const findings = [];
97
+
98
+ let reactDangerousHtmlCount = 0;
99
+ let reactAsyncUseEffectCount = 0;
100
+ let reactIndexKeyCount = 0;
101
+ let nextLegacyDataCount = 0;
102
+ let nextRouterInAppDirCount = 0;
103
+ let expressWildcardCorsCount = 0;
104
+ let expressErrorLeakCount = 0;
105
+ let expressSyncFsInRouteCount = 0;
106
+
107
+ const reactDangerousLocations = [];
108
+ const reactAsyncEffectLocations = [];
109
+ const reactIndexKeyLocations = [];
110
+ const nextLegacyLocations = [];
111
+ const nextRouterAppLocations = [];
112
+ const expressCorsLocations = [];
113
+ const expressErrorLeakLocations = [];
114
+ const expressSyncFsLocations = [];
115
+
116
+ for (const file of files) {
117
+ const { content, relativePath } = file;
118
+ const inAppDir = relativePath.startsWith("app/");
119
+
120
+ if (frameworkUse.react) {
121
+ const dangerous = collectMatches(file, /dangerouslySetInnerHTML\s*=\s*\{\s*\{/g);
122
+ reactDangerousHtmlCount += dangerous.count;
123
+ reactDangerousLocations.push(...dangerous.locations);
124
+
125
+ const asyncEffect = collectMatches(file, /useEffect\s*\(\s*async\s*\(/g);
126
+ reactAsyncUseEffectCount += asyncEffect.count;
127
+ reactAsyncEffectLocations.push(...asyncEffect.locations);
128
+
129
+ const indexKey = collectMatches(file, /\bkey\s*=\s*\{?\s*index\s*\}?/g);
130
+ reactIndexKeyCount += indexKey.count;
131
+ reactIndexKeyLocations.push(...indexKey.locations);
132
+ }
133
+
134
+ if (frameworkUse.next) {
135
+ const legacyData = collectMatches(file, /\bgetInitialProps\b/g);
136
+ nextLegacyDataCount += legacyData.count;
137
+ nextLegacyLocations.push(...legacyData.locations);
138
+
139
+ if (inAppDir) {
140
+ const routerImport = collectMatches(file, /from\s+["'`]next\/router["'`]/g);
141
+ nextRouterInAppDirCount += routerImport.count;
142
+ nextRouterAppLocations.push(...routerImport.locations);
143
+ }
144
+ }
145
+
146
+ if (frameworkUse.express) {
147
+ const corsWildcard = collectMatches(file, /\bapp\.use\s*\(\s*cors\s*\(\s*\)\s*\)/g);
148
+ expressWildcardCorsCount += corsWildcard.count;
149
+ expressCorsLocations.push(...corsWildcard.locations);
150
+
151
+ const errorLeak = collectMatches(file, /\bres\.(?:send|json)\s*\(\s*err(?:or)?\s*\)/g);
152
+ expressErrorLeakCount += errorLeak.count;
153
+ expressErrorLeakLocations.push(...errorLeak.locations);
154
+
155
+ if (/(?:app|router)\.(?:get|post|put|patch|delete)\s*\(/.test(content)) {
156
+ const syncFs = collectMatches(file, /\bfs\.(?:readFileSync|writeFileSync)\s*\(/g);
157
+ expressSyncFsInRouteCount += syncFs.count;
158
+ expressSyncFsLocations.push(...syncFs.locations);
159
+ }
160
+ }
161
+ }
162
+
163
+ if (reactDangerousHtmlCount > 0) {
164
+ addFinding(
165
+ findings,
166
+ "high",
167
+ `${reactDangerousHtmlCount} React dangerouslySetInnerHTML usage(s) found.`,
168
+ reactDangerousLocations
169
+ );
170
+ }
171
+ if (reactAsyncUseEffectCount > 0) {
172
+ addFinding(
173
+ findings,
174
+ "medium",
175
+ `${reactAsyncUseEffectCount} async useEffect callback pattern(s) found.`,
176
+ reactAsyncEffectLocations
177
+ );
178
+ }
179
+ if (reactIndexKeyCount > 0) {
180
+ addFinding(
181
+ findings,
182
+ reactIndexKeyCount >= 8 ? "medium" : "low",
183
+ `${reactIndexKeyCount} React list key(s) appear to use array index.`,
184
+ reactIndexKeyLocations
185
+ );
186
+ }
187
+ if (nextLegacyDataCount > 0) {
188
+ addFinding(
189
+ findings,
190
+ "medium",
191
+ `${nextLegacyDataCount} Next.js getInitialProps usage(s) found (legacy API).`,
192
+ nextLegacyLocations
193
+ );
194
+ }
195
+ if (nextRouterInAppDirCount > 0) {
196
+ addFinding(
197
+ findings,
198
+ "medium",
199
+ `${nextRouterInAppDirCount} next/router import(s) found under app/ directory.`,
200
+ nextRouterAppLocations
201
+ );
202
+ }
203
+ if (expressWildcardCorsCount > 0) {
204
+ addFinding(
205
+ findings,
206
+ "medium",
207
+ `${expressWildcardCorsCount} Express cors() middleware usage(s) with default wildcard policy found.`,
208
+ expressCorsLocations
209
+ );
210
+ }
211
+ if (expressErrorLeakCount > 0) {
212
+ addFinding(
213
+ findings,
214
+ "high",
215
+ `${expressErrorLeakCount} Express response(s) appear to directly return error objects.`,
216
+ expressErrorLeakLocations
217
+ );
218
+ }
219
+ if (expressSyncFsInRouteCount > 0) {
220
+ addFinding(
221
+ findings,
222
+ "medium",
223
+ `${expressSyncFsInRouteCount} synchronous fs call(s) found inside Express route files.`,
224
+ expressSyncFsLocations
225
+ );
226
+ }
227
+
228
+ const detectedFrameworks = Object.entries(frameworkUse)
229
+ .filter(([, enabled]) => enabled)
230
+ .map(([name]) => name);
231
+
232
+ const weightedSignals =
233
+ reactDangerousHtmlCount * 2 +
234
+ reactAsyncUseEffectCount +
235
+ reactIndexKeyCount * 0.5 +
236
+ nextLegacyDataCount +
237
+ nextRouterInAppDirCount +
238
+ expressWildcardCorsCount +
239
+ expressErrorLeakCount * 2 +
240
+ expressSyncFsInRouteCount * 1.2;
241
+ const score = Math.min(10, scoreFromRatio(weightedSignals / Math.max(files.length * 0.7, 1), 10));
242
+
243
+ const noFrameworkDetected = detectedFrameworks.length === 0;
244
+ return {
245
+ id: "frameworks",
246
+ title: "FRAMEWORK ANTI-PATTERNS",
247
+ score: noFrameworkDetected ? 0 : score,
248
+ severity: noFrameworkDetected ? "low" : severityFromScore(score),
249
+ totalIssues: findings.length,
250
+ summary: noFrameworkDetected
251
+ ? "No supported frameworks detected (React, Next.js, Express)."
252
+ : findings.length > 0
253
+ ? `Detected ${findings.length} framework-specific anti-pattern signals.`
254
+ : "No major framework-specific anti-patterns detected.",
255
+ metrics: {
256
+ detectedFrameworks,
257
+ reactDangerousHtmlCount,
258
+ reactAsyncUseEffectCount,
259
+ reactIndexKeyCount,
260
+ nextLegacyDataCount,
261
+ nextRouterInAppDirCount,
262
+ expressWildcardCorsCount,
263
+ expressErrorLeakCount,
264
+ expressSyncFsInRouteCount
265
+ },
266
+ recommendations: [
267
+ "Apply framework-specific best practices and avoid legacy APIs.",
268
+ "Use explicit sanitization for HTML injection and avoid returning raw errors.",
269
+ "Avoid synchronous I/O in request/response paths."
270
+ ],
271
+ findings,
272
+ skipped: noFrameworkDetected
273
+ };
274
+ }
@@ -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,12 @@ const DEFAULT_CONFIG = {
26
26
  naming: true,
27
27
  patterns: true,
28
28
  leftovers: true,
29
+ security: true,
30
+ frameworks: true,
29
31
  dependencies: true,
30
32
  deadcode: true,
31
- errorhandling: true
33
+ errorhandling: true,
34
+ tsquality: true
32
35
  },
33
36
  allowedPatterns: {
34
37
  httpClient: null,
package/src/index.js CHANGED
@@ -5,9 +5,12 @@ 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";
9
+ import { analyzeFrameworks } from "./analyzers/frameworks.js";
8
10
  import { analyzeDependencies } from "./analyzers/dependencies.js";
9
11
  import { analyzeDeadCode } from "./analyzers/deadcode.js";
10
12
  import { analyzeErrorHandling } from "./analyzers/errorhandling.js";
13
+ import { analyzeTsQuality } from "./analyzers/tsquality.js";
11
14
  import { generateRulesFiles } from "./rules-generator.js";
12
15
  import { parseAstWithMeta } from "./analyzers/utils.js";
13
16
  import { applySafeFixes } from "./fixers/safe-fixes.js";
@@ -218,6 +221,12 @@ export async function runAudit(targetDir, cliOptions = {}) {
218
221
  if (enabledRules.leftovers !== false) {
219
222
  categoryResults.push(analyzeLeftovers(scanResult.files, context));
220
223
  }
224
+ if (enabledRules.security !== false) {
225
+ categoryResults.push(analyzeSecurity(scanResult.files, context));
226
+ }
227
+ if (enabledRules.frameworks !== false) {
228
+ categoryResults.push(analyzeFrameworks(scanResult.files, context));
229
+ }
221
230
  if (enabledRules.dependencies !== false) {
222
231
  categoryResults.push(await analyzeDependencies(scanResult.files, context));
223
232
  }
@@ -227,6 +236,9 @@ export async function runAudit(targetDir, cliOptions = {}) {
227
236
  if (enabledRules.errorhandling !== false) {
228
237
  categoryResults.push(analyzeErrorHandling(scanResult.files, context));
229
238
  }
239
+ if (enabledRules.tsquality !== false) {
240
+ categoryResults.push(analyzeTsQuality(scanResult.files, context));
241
+ }
230
242
 
231
243
  const filteredCategories = applySeverityFilter(categoryResults, config.severity || "low");
232
244
 
package/src/reporter.js CHANGED
@@ -93,6 +93,24 @@ 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
+
103
+ case "frameworks": {
104
+ const frameworks = Array.isArray(metrics.detectedFrameworks)
105
+ ? metrics.detectedFrameworks.join(", ")
106
+ : "";
107
+ return [
108
+ `├─ Frameworks detected: ${frameworks || "none"}`,
109
+ `├─ React/Next signals: ${(metrics.reactDangerousHtmlCount || 0) + (metrics.reactAsyncUseEffectCount || 0) + (metrics.reactIndexKeyCount || 0) + (metrics.nextLegacyDataCount || 0) + (metrics.nextRouterInAppDirCount || 0)}`,
110
+ `└─ Express signals: ${(metrics.expressWildcardCorsCount || 0) + (metrics.expressErrorLeakCount || 0) + (metrics.expressSyncFsInRouteCount || 0)}`
111
+ ];
112
+ }
113
+
96
114
  case "deadcode":
97
115
  return [
98
116
  `├─ Orphan files: ${(metrics.orphanFiles || []).length}`,
@@ -107,6 +125,13 @@ function categoryDetailLines(category) {
107
125
  `└─ Unhandled await signals: ${metrics.unhandledAwait || 0}`
108
126
  ];
109
127
 
128
+ case "tsquality":
129
+ return [
130
+ `├─ TS files scanned: ${metrics.tsFileCount || 0}`,
131
+ `├─ explicit any: ${metrics.explicitAnyCount || 0}, suppressions: ${metrics.suppressionCount || 0}`,
132
+ `└─ Missing return types: ${metrics.missingReturnTypeCount || 0}`
133
+ ];
134
+
110
135
  default:
111
136
  return ["└─ No details available"];
112
137
  }