getdoorman 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
@@ -0,0 +1,3175 @@
1
+ const JS_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
2
+ function isSourceFile(f) { return JS_EXTENSIONS.some(ext => f.endsWith(ext)); }
3
+ function isTestFile(f) {
4
+ return /\.(test|spec)\.[jt]sx?$/.test(f) ||
5
+ f.includes('__tests__/') ||
6
+ f.includes('__mocks__/') ||
7
+ f.includes('/test/') ||
8
+ f.includes('/tests/');
9
+ }
10
+
11
+ const rules = [
12
+ // QUAL-001: console.log left in production code
13
+ {
14
+ id: 'QUAL-001',
15
+ category: 'quality',
16
+ severity: 'low',
17
+ confidence: 'suggestion',
18
+ title: 'console.log left in production code',
19
+ check({ files }) {
20
+ const findings = [];
21
+ for (const [filepath, content] of files) {
22
+ if (!isSourceFile(filepath)) continue;
23
+ if (isTestFile(filepath)) continue;
24
+ const lines = content.split('\n');
25
+ for (let i = 0; i < lines.length; i++) {
26
+ const line = lines[i];
27
+ if (line.trim().startsWith('//')) continue;
28
+ if (line.match(/\bconsole\.(log|debug|info)\s*\(/)) {
29
+ findings.push({
30
+ ruleId: 'QUAL-001', category: 'quality', severity: 'low',
31
+ title: 'console.log left in production code',
32
+ description: 'Remove console.log statements or replace with a proper logging library (winston, pino, etc.).',
33
+ file: filepath, line: i + 1, fix: null,
34
+ });
35
+ }
36
+ }
37
+ }
38
+ return findings;
39
+ },
40
+ },
41
+
42
+ // QUAL-002: TODO/FIXME/HACK comments still in code
43
+ {
44
+ id: 'QUAL-002',
45
+ category: 'quality',
46
+ severity: 'low',
47
+ confidence: 'suggestion',
48
+ title: 'TODO/FIXME/HACK comment in code',
49
+ check({ files }) {
50
+ const findings = [];
51
+ for (const [filepath, content] of files) {
52
+ if (!isSourceFile(filepath)) continue;
53
+ const lines = content.split('\n');
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const match = lines[i].match(/\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i);
56
+ if (match) {
57
+ const tag = match[1].toUpperCase();
58
+ const detail = match[2] ? match[2].trim().substring(0, 80) : '';
59
+ findings.push({
60
+ ruleId: 'QUAL-002', category: 'quality', severity: 'low',
61
+ title: `${tag} comment found${detail ? ': ' + detail : ''}`,
62
+ description: 'Resolve or convert to a tracked issue before shipping to production.',
63
+ file: filepath, line: i + 1, fix: null,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ return findings;
69
+ },
70
+ },
71
+
72
+ // QUAL-003: Functions over 50 lines long
73
+ {
74
+ id: 'QUAL-003',
75
+ category: 'quality',
76
+ severity: 'medium',
77
+ confidence: 'suggestion',
78
+ title: 'Function exceeds 50 lines',
79
+ check({ files }) {
80
+ const findings = [];
81
+ const funcStartPattern = /^[ \t]*(export\s+)?(async\s+)?function\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(async\s+)?\(|(?:const|let|var)\s+\w+\s*=\s*(async\s+)?function/;
82
+ for (const [filepath, content] of files) {
83
+ if (!isSourceFile(filepath)) continue;
84
+ const lines = content.split('\n');
85
+ let funcStart = -1;
86
+ let braceDepth = 0;
87
+ let inFunc = false;
88
+
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const line = lines[i];
91
+ if (!inFunc && funcStartPattern.test(line) && line.includes('{')) {
92
+ funcStart = i;
93
+ inFunc = true;
94
+ braceDepth = 0;
95
+ }
96
+ if (inFunc) {
97
+ for (const ch of line) {
98
+ if (ch === '{') braceDepth++;
99
+ if (ch === '}') braceDepth--;
100
+ }
101
+ if (braceDepth <= 0 && funcStart >= 0) {
102
+ const length = i - funcStart + 1;
103
+ if (length > 50) {
104
+ findings.push({
105
+ ruleId: 'QUAL-003', category: 'quality', severity: 'medium',
106
+ title: `Function is ${length} lines long (max recommended: 50)`,
107
+ description: 'Break large functions into smaller, focused helper functions for readability and testability.',
108
+ file: filepath, line: funcStart + 1, fix: null,
109
+ });
110
+ }
111
+ inFunc = false;
112
+ funcStart = -1;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return findings;
118
+ },
119
+ },
120
+
121
+ // QUAL-004: Deeply nested callbacks (callback hell - 4+ levels)
122
+ {
123
+ id: 'QUAL-004',
124
+ category: 'quality',
125
+ severity: 'medium',
126
+ confidence: 'likely',
127
+ title: 'Deeply nested callbacks (callback hell)',
128
+ check({ files }) {
129
+ const findings = [];
130
+ for (const [filepath, content] of files) {
131
+ if (!isSourceFile(filepath)) continue;
132
+ const lines = content.split('\n');
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i];
135
+ // Count nesting by leading indentation relative to callback patterns
136
+ const callbackMatches = line.match(/(?:function\s*\(|=>\s*\{|\(\s*(?:err|error|result|data|res|req)\s*(?:,\s*\w+)*\)\s*(?:=>|{))/);
137
+ if (callbackMatches) {
138
+ // Count the brace depth at this line
139
+ let depth = 0;
140
+ for (let j = 0; j <= i; j++) {
141
+ for (const ch of lines[j]) {
142
+ if (ch === '{') depth++;
143
+ if (ch === '}') depth--;
144
+ }
145
+ }
146
+ if (depth >= 4) {
147
+ findings.push({
148
+ ruleId: 'QUAL-004', category: 'quality', severity: 'medium',
149
+ title: `Callback nested ${depth} levels deep`,
150
+ description: 'Refactor deeply nested callbacks using async/await, Promises, or extract into named functions.',
151
+ file: filepath, line: i + 1, fix: null,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+ return findings;
158
+ },
159
+ },
160
+
161
+ // QUAL-005: JS files in a TypeScript project
162
+ {
163
+ id: 'QUAL-005',
164
+ category: 'quality',
165
+ severity: 'low',
166
+ confidence: 'suggestion',
167
+ title: 'JavaScript file in TypeScript project',
168
+ check({ files }) {
169
+ const findings = [];
170
+ const hasTsConfig = [...files.keys()].some(f => f.endsWith('tsconfig.json'));
171
+ if (!hasTsConfig) return findings;
172
+
173
+ const hasTsFiles = [...files.keys()].some(f => f.endsWith('.ts') || f.endsWith('.tsx'));
174
+ if (!hasTsFiles) return findings;
175
+
176
+ for (const [filepath] of files) {
177
+ if (!filepath.endsWith('.js') && !filepath.endsWith('.jsx')) continue;
178
+ // Skip config files, build output, and tooling
179
+ if (filepath.includes('node_modules/')) continue;
180
+ if (filepath.includes('/dist/')) continue;
181
+ if (filepath.includes('/build/')) continue;
182
+ if (filepath.match(/\.(config|setup|babel|eslint)\./)) continue;
183
+ if (filepath.endsWith('.config.js') || filepath.endsWith('.setup.js')) continue;
184
+
185
+ findings.push({
186
+ ruleId: 'QUAL-005', category: 'quality', severity: 'low',
187
+ title: 'JavaScript file in a TypeScript project',
188
+ description: 'Convert this .js/.jsx file to .ts/.tsx for consistent type safety across the project.',
189
+ file: filepath, line: 1, fix: null,
190
+ });
191
+ }
192
+ return findings;
193
+ },
194
+ },
195
+
196
+ // QUAL-006: Empty catch blocks
197
+ {
198
+ id: 'QUAL-006',
199
+ category: 'quality',
200
+ severity: 'high',
201
+ confidence: 'likely',
202
+ title: 'Empty catch block swallows errors',
203
+ check({ files }) {
204
+ const findings = [];
205
+ for (const [filepath, content] of files) {
206
+ if (!isSourceFile(filepath)) continue;
207
+ const lines = content.split('\n');
208
+ for (let i = 0; i < lines.length; i++) {
209
+ const line = lines[i];
210
+ // Match catch followed by empty block on same line
211
+ if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/)) {
212
+ findings.push({
213
+ ruleId: 'QUAL-006', category: 'quality', severity: 'high',
214
+ title: 'Empty catch block swallows errors silently',
215
+ description: 'Log the error, rethrow it, or handle it explicitly. Silent failures make debugging very difficult.',
216
+ file: filepath, line: i + 1, fix: null,
217
+ });
218
+ continue;
219
+ }
220
+ // Match catch on one line with empty body on next line(s)
221
+ if (line.match(/catch\s*\([^)]*\)\s*\{\s*$/)) {
222
+ const nextLine = (lines[i + 1] || '').trim();
223
+ if (nextLine === '}') {
224
+ findings.push({
225
+ ruleId: 'QUAL-006', category: 'quality', severity: 'high',
226
+ title: 'Empty catch block swallows errors silently',
227
+ description: 'Log the error, rethrow it, or handle it explicitly. Silent failures make debugging very difficult.',
228
+ file: filepath, line: i + 1, fix: null,
229
+ });
230
+ }
231
+ }
232
+ }
233
+ }
234
+ return findings;
235
+ },
236
+ },
237
+
238
+ // QUAL-007: Magic numbers
239
+ {
240
+ id: 'QUAL-007',
241
+ category: 'quality',
242
+ severity: 'low',
243
+ confidence: 'suggestion',
244
+ title: 'Magic number in code',
245
+ check({ files }) {
246
+ const findings = [];
247
+ for (const [filepath, content] of files) {
248
+ if (!isSourceFile(filepath)) continue;
249
+ if (isTestFile(filepath)) continue;
250
+ const lines = content.split('\n');
251
+ for (let i = 0; i < lines.length; i++) {
252
+ const line = lines[i].trim();
253
+ if (line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) continue;
254
+ // Skip import/require, const declarations with descriptive names, array indices
255
+ if (line.match(/^(import|require|export)\b/)) continue;
256
+
257
+ // Skip lines with crypto/security context — magic numbers there are intentional
258
+ if (line.match(/bcrypt|argon2|scrypt|pbkdf2|randomBytes|createCipher|createHash|crypto\.|salt.*round|round.*salt/i)) continue;
259
+ // Look for numeric literals in conditions and return statements
260
+ const conditionMatch = line.match(/(?:if|else if|while|case|return)\s.*\b(\d{2,})\b/);
261
+ if (conditionMatch) {
262
+ const num = parseInt(conditionMatch[1], 10);
263
+ // Allow common acceptable numbers: 0, 1, -1, 2, 10, 100, 1000 (powers of 10), HTTP status codes, common crypto sizes
264
+ const allowed = [0, 1, 2, 10, 12, 16, 24, 32, 48, 64, 100, 128, 256, 1000, 200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503];
265
+ if (!allowed.includes(num)) {
266
+ findings.push({
267
+ ruleId: 'QUAL-007', category: 'quality', severity: 'low',
268
+ title: `Magic number ${num} used in logic`,
269
+ description: 'Extract numeric literals into named constants for clarity (e.g., const MAX_RETRIES = 3).',
270
+ file: filepath, line: i + 1, fix: null,
271
+ });
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return findings;
277
+ },
278
+ },
279
+
280
+ // QUAL-008: Duplicate string literals
281
+ {
282
+ id: 'QUAL-008',
283
+ category: 'quality',
284
+ severity: 'low',
285
+ confidence: 'likely',
286
+ title: 'Duplicate string literal',
287
+ check({ files }) {
288
+ const findings = [];
289
+ for (const [filepath, content] of files) {
290
+ if (!isSourceFile(filepath)) continue;
291
+ if (isTestFile(filepath)) continue;
292
+ const lines = content.split('\n');
293
+ const stringCounts = new Map();
294
+
295
+ for (let i = 0; i < lines.length; i++) {
296
+ const line = lines[i];
297
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
298
+ // Match string literals (single or double quoted, at least 6 chars)
299
+ const matches = line.match(/(['"])([^'"]{6,})\1/g);
300
+ if (matches) {
301
+ for (const m of matches) {
302
+ const str = m.slice(1, -1);
303
+ // Skip imports, requires, and common patterns
304
+ if (str.startsWith('./') || str.startsWith('../') || str.startsWith('http')) continue;
305
+ if (!stringCounts.has(str)) {
306
+ stringCounts.set(str, { count: 0, firstLine: i + 1 });
307
+ }
308
+ stringCounts.get(str).count++;
309
+ }
310
+ }
311
+ }
312
+
313
+ for (const [str, info] of stringCounts) {
314
+ if (info.count >= 3) {
315
+ findings.push({
316
+ ruleId: 'QUAL-008', category: 'quality', severity: 'low',
317
+ title: `String "${str.substring(0, 40)}${str.length > 40 ? '...' : ''}" repeated ${info.count} times`,
318
+ description: 'Extract repeated string literals into a shared constant to avoid typos and ease refactoring.',
319
+ file: filepath, line: info.firstLine, fix: null,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ return findings;
325
+ },
326
+ },
327
+
328
+ // QUAL-009: No test files found in project
329
+ {
330
+ id: 'QUAL-009',
331
+ category: 'quality',
332
+ severity: 'high',
333
+ confidence: 'suggestion',
334
+ title: 'No test files found',
335
+ check({ files, stack }) {
336
+ const findings = [];
337
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
338
+ const testLibs = ['jest', 'mocha', 'vitest', 'ava', 'tape', '@testing-library/react',
339
+ '@testing-library/jest-dom', 'cypress', 'playwright', '@playwright/test', 'jasmine'];
340
+ const hasTestDep = testLibs.some(lib => lib in allDeps);
341
+
342
+ const hasTestFiles = [...files.keys()].some(f => isTestFile(f));
343
+
344
+ if (!hasTestFiles) {
345
+ findings.push({
346
+ ruleId: 'QUAL-009', category: 'quality', severity: 'high',
347
+ title: 'No test files found in the project',
348
+ description: hasTestDep
349
+ ? `Test framework detected (${testLibs.find(l => l in allDeps)}) but no test files found. Add tests to ensure code correctness.`
350
+ : 'No test framework or test files found. Add a testing framework (jest, vitest, etc.) and write tests.',
351
+ fix: null,
352
+ });
353
+ }
354
+ return findings;
355
+ },
356
+ },
357
+
358
+ // QUAL-010: File over 500 lines long
359
+ {
360
+ id: 'QUAL-010',
361
+ category: 'quality',
362
+ severity: 'medium',
363
+ confidence: 'suggestion',
364
+ title: 'File exceeds 500 lines',
365
+ check({ files }) {
366
+ const findings = [];
367
+ for (const [filepath, content] of files) {
368
+ if (!isSourceFile(filepath)) continue;
369
+ const lineCount = content.split('\n').length;
370
+ if (lineCount > 500) {
371
+ findings.push({
372
+ ruleId: 'QUAL-010', category: 'quality', severity: 'medium',
373
+ title: `File is ${lineCount} lines long (max recommended: 500)`,
374
+ description: 'Split large files into smaller, focused modules. Large files are harder to navigate, review, and test.',
375
+ file: filepath, line: 1, fix: null,
376
+ });
377
+ }
378
+ }
379
+ return findings;
380
+ },
381
+ },
382
+
383
+ // QUAL-SMELL-001: eval() usage
384
+ { id: 'QUAL-SMELL-001', category: 'quality', severity: 'critical', confidence: 'likely', title: 'eval() Usage',
385
+ check({ files }) {
386
+ const findings = [];
387
+ for (const [fp, c] of files) {
388
+ if (!isSourceFile(fp)) continue;
389
+ const lines = c.split('\n');
390
+ for (let i = 0; i < lines.length; i++) {
391
+ if (lines[i].match(/\beval\s*\(/) && !lines[i].trim().startsWith('//')) {
392
+ findings.push({ ruleId: 'QUAL-SMELL-001', category: 'quality', severity: 'critical',
393
+ title: 'eval() is dangerous and a security risk', description: 'Replace eval() with JSON.parse, static functions, or a safe expression evaluator.', file: fp, line: i + 1, fix: null });
394
+ }
395
+ }
396
+ }
397
+ return findings;
398
+ },
399
+ },
400
+
401
+ // QUAL-SMELL-002: new Function() with dynamic string
402
+ { id: 'QUAL-SMELL-002', category: 'quality', severity: 'critical', confidence: 'likely', title: 'new Function() — Behaves Like eval()',
403
+ check({ files }) {
404
+ const findings = [];
405
+ for (const [fp, c] of files) {
406
+ if (!isSourceFile(fp)) continue;
407
+ const lines = c.split('\n');
408
+ for (let i = 0; i < lines.length; i++) {
409
+ if (lines[i].match(/new\s+Function\s*\(/) && !lines[i].trim().startsWith('//')) {
410
+ findings.push({ ruleId: 'QUAL-SMELL-002', category: 'quality', severity: 'critical',
411
+ title: 'new Function() executes dynamic code strings', description: 'new Function() has the same security risks as eval(). Use static functions instead.', file: fp, line: i + 1, fix: null });
412
+ }
413
+ }
414
+ }
415
+ return findings;
416
+ },
417
+ },
418
+
419
+ // QUAL-SMELL-003: Function with too many parameters
420
+ { id: 'QUAL-SMELL-003', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Function With Too Many Parameters',
421
+ check({ files }) {
422
+ const findings = [];
423
+ for (const [fp, c] of files) {
424
+ if (!isSourceFile(fp)) continue;
425
+ const lines = c.split('\n');
426
+ for (let i = 0; i < lines.length; i++) {
427
+ const m = lines[i].match(/function\s+\w+\s*\(([^)]{30,})\)/);
428
+ if (m) {
429
+ const count = m[1].split(',').length;
430
+ if (count > 5) {
431
+ findings.push({ ruleId: 'QUAL-SMELL-003', category: 'quality', severity: 'medium',
432
+ title: `Function has ${count} parameters — use an options object`, description: 'Functions with >5 parameters are hard to call and maintain. Consolidate into a single configuration object.', file: fp, line: i + 1, fix: null });
433
+ }
434
+ }
435
+ }
436
+ }
437
+ return findings;
438
+ },
439
+ },
440
+
441
+ // QUAL-SMELL-004: setTimeout/setInterval with string
442
+ { id: 'QUAL-SMELL-004', category: 'quality', severity: 'high', confidence: 'likely', title: 'setTimeout/setInterval With String Argument',
443
+ check({ files }) {
444
+ const findings = [];
445
+ for (const [fp, c] of files) {
446
+ if (!isSourceFile(fp)) continue;
447
+ const lines = c.split('\n');
448
+ for (let i = 0; i < lines.length; i++) {
449
+ if (lines[i].match(/(?:setTimeout|setInterval)\s*\(\s*["'`]/)) {
450
+ findings.push({ ruleId: 'QUAL-SMELL-004', category: 'quality', severity: 'high',
451
+ title: 'setTimeout/setInterval with a string argument — behaves like eval()',
452
+ description: 'Use a function: setTimeout(() => doThing(), 1000)', file: fp, line: i + 1, fix: null });
453
+ }
454
+ }
455
+ }
456
+ return findings;
457
+ },
458
+ },
459
+
460
+ // QUAL-SMELL-005: Math.random for security purposes
461
+ { id: 'QUAL-SMELL-005', category: 'quality', severity: 'high', confidence: 'likely', title: 'Math.random for Security-Sensitive Value',
462
+ check({ files }) {
463
+ const findings = [];
464
+ for (const [fp, c] of files) {
465
+ if (!isSourceFile(fp)) continue;
466
+ const lines = c.split('\n');
467
+ for (let i = 0; i < lines.length; i++) {
468
+ if (lines[i].match(/Math\.random\s*\(/) && lines[i].match(/token|secret|password|key|nonce|salt/i)) {
469
+ findings.push({ ruleId: 'QUAL-SMELL-005', category: 'quality', severity: 'high',
470
+ title: 'Math.random() is not cryptographically secure', description: 'Use crypto.randomBytes() or crypto.randomUUID() for security tokens and IDs.', file: fp, line: i + 1, fix: null });
471
+ }
472
+ }
473
+ }
474
+ return findings;
475
+ },
476
+ },
477
+
478
+ // QUAL-TS-001: TypeScript any type
479
+ { id: 'QUAL-TS-001', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript "any" Type Overuse',
480
+ check({ files }) {
481
+ const findings = [];
482
+ for (const [fp, c] of files) {
483
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
484
+ const count = (c.match(/:\s*any\b|\bas\s+any\b/g) || []).length;
485
+ if (count > 3) {
486
+ findings.push({ ruleId: 'QUAL-TS-001', category: 'quality', severity: 'medium',
487
+ title: `${count} uses of TypeScript "any" — defeats type safety`, description: 'Replace "any" with specific types, "unknown" + type guards, or generics.', file: fp, fix: null });
488
+ }
489
+ }
490
+ return findings;
491
+ },
492
+ },
493
+
494
+ // QUAL-TS-002: No strict mode in tsconfig
495
+ { id: 'QUAL-TS-002', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript Not in Strict Mode',
496
+ check({ files }) {
497
+ const entry = [...files.entries()].find(([f]) => f.endsWith('tsconfig.json'));
498
+ if (!entry) return [];
499
+ try {
500
+ const cfg = JSON.parse(entry[1]);
501
+ if (!cfg.compilerOptions?.strict) {
502
+ return [{ ruleId: 'QUAL-TS-002', category: 'quality', severity: 'medium',
503
+ title: 'TypeScript strict mode disabled', description: 'Add "strict": true to tsconfig.json. Strict mode catches null pointer errors, implicit any, and other common bugs.', fix: null }];
504
+ }
505
+ } catch {}
506
+ return [];
507
+ },
508
+ },
509
+
510
+ // QUAL-MAINT-001: No ESLint config
511
+ { id: 'QUAL-MAINT-001', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No ESLint Configuration',
512
+ check({ files, stack }) {
513
+ const findings = [];
514
+ if (stack.runtime !== 'node') return findings;
515
+ const has = [...files.keys()].some(f => f.includes('.eslintrc') || f.endsWith('eslint.config.js') || f.endsWith('eslint.config.mjs'));
516
+ if (!has && !('eslint' in (stack.devDependencies || {}))) {
517
+ findings.push({ ruleId: 'QUAL-MAINT-001', category: 'quality', severity: 'medium',
518
+ title: 'No ESLint configuration found', description: 'Add ESLint to catch bugs and enforce code style automatically.', fix: null });
519
+ }
520
+ return findings;
521
+ },
522
+ },
523
+
524
+ // QUAL-MAINT-002: No Prettier config
525
+ { id: 'QUAL-MAINT-002', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Prettier Configuration',
526
+ check({ files, stack }) {
527
+ const findings = [];
528
+ if (stack.runtime !== 'node') return findings;
529
+ const has = [...files.keys()].some(f => f.includes('.prettierrc') || f.endsWith('prettier.config.js') || f.endsWith('prettier.config.mjs'));
530
+ if (!has) {
531
+ findings.push({ ruleId: 'QUAL-MAINT-002', category: 'quality', severity: 'low',
532
+ title: 'No Prettier code formatter configured', description: 'Add Prettier for automatic formatting. Eliminates style debates and noisy diffs in PRs.', fix: null });
533
+ }
534
+ return findings;
535
+ },
536
+ },
537
+
538
+ // QUAL-MAINT-003: No pre-commit hooks
539
+ { id: 'QUAL-MAINT-003', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Pre-Commit Hooks',
540
+ check({ files, stack }) {
541
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
542
+ const has = 'husky' in allDeps || 'lint-staged' in allDeps || 'lefthook' in allDeps ||
543
+ [...files.keys()].some(f => f.includes('.husky') || f.includes('lefthook'));
544
+ if (!has && stack.runtime === 'node') {
545
+ return [{ ruleId: 'QUAL-MAINT-003', category: 'quality', severity: 'low',
546
+ title: 'No pre-commit hooks (husky/lint-staged) configured', description: 'Pre-commit hooks catch lint errors and failed tests before they enter version control.', fix: null }];
547
+ }
548
+ return [];
549
+ },
550
+ },
551
+
552
+ // QUAL-DOC-001: No README.md
553
+ { id: 'QUAL-DOC-001', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No README.md',
554
+ check({ files }) {
555
+ const has = [...files.keys()].some(f => f.match(/(?:^|\/)README\.md$/i));
556
+ if (!has) {
557
+ return [{ ruleId: 'QUAL-DOC-001', category: 'quality', severity: 'medium',
558
+ title: 'No README.md found', description: 'Add a README with project description, setup instructions, and usage examples.', fix: null }];
559
+ }
560
+ return [];
561
+ },
562
+ },
563
+
564
+ // QUAL-DOC-002: No CHANGELOG
565
+ { id: 'QUAL-DOC-002', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No CHANGELOG',
566
+ check({ files }) {
567
+ const has = [...files.keys()].some(f => f.match(/CHANGELOG/i));
568
+ if (!has) {
569
+ return [{ ruleId: 'QUAL-DOC-002', category: 'quality', severity: 'low',
570
+ title: 'No CHANGELOG.md', description: 'Add a CHANGELOG to track what changed between releases. Use conventional commits and auto-generation tools.', fix: null }];
571
+ }
572
+ return [];
573
+ },
574
+ },
575
+
576
+ // QUAL-SEC-001: Prototype pollution
577
+ { id: 'QUAL-SEC-001', category: 'quality', severity: 'high', confidence: 'likely', title: 'Prototype Pollution Risk',
578
+ check({ files }) {
579
+ const findings = [];
580
+ for (const [fp, c] of files) {
581
+ if (!isSourceFile(fp)) continue;
582
+ const lines = c.split('\n');
583
+ for (let i = 0; i < lines.length; i++) {
584
+ if (lines[i].match(/\[['"]__proto__['"]\]|\.__proto__\s*=|\['constructor'\]/)) {
585
+ findings.push({ ruleId: 'QUAL-SEC-001', category: 'quality', severity: 'high',
586
+ title: 'Potential prototype pollution vulnerability', description: 'Validate object keys before property assignment. Block __proto__, constructor, and prototype as keys from user input.', file: fp, line: i + 1, fix: null });
587
+ }
588
+ // Bracket property assignment with user-controlled key
589
+ if (lines[i].match(/\w+\[\s*req\.(body|query|params)\.\w+\s*\]\s*=/) && !lines[i].match(/\/\//)) {
590
+ findings.push({ ruleId: 'QUAL-SEC-001', category: 'quality', severity: 'high',
591
+ title: 'Bracket property assignment with user-controlled key — prototype pollution risk', description: 'Validate the key before use: reject __proto__, constructor, prototype. Unvalidated bracket assignment with user input is the primary prototype pollution vector.', file: fp, line: i + 1, fix: null });
592
+ }
593
+ }
594
+ }
595
+ return findings;
596
+ },
597
+ },
598
+
599
+ // QUAL-TS-011: Missing return type on exported functions
600
+ { id: 'QUAL-TS-011', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Exported Function Without Return Type',
601
+ check({ files }) {
602
+ const findings = [];
603
+ for (const [fp, c] of files) {
604
+ if (!fp.match(/\.(ts|tsx)$/) || fp.includes('test') || fp.includes('spec')) continue;
605
+ const lines = c.split('\n');
606
+ for (let i = 0; i < lines.length; i++) {
607
+ if (lines[i].match(/^export\s+(async\s+)?function\s+\w+\s*\([^)]*\)\s*\{/) && !lines[i].match(/\)\s*:/)) {
608
+ findings.push({ ruleId: 'QUAL-TS-011', category: 'quality', severity: 'low', title: 'Exported function missing explicit return type annotation', description: 'Add return type: export function foo(): Promise<User>. Explicit return types catch unintended changes to public API contracts.', file: fp, line: i + 1, fix: null });
609
+ }
610
+ }
611
+ }
612
+ return findings;
613
+ },
614
+ },
615
+
616
+ // QUAL-TS-012: Non-null assertion overuse
617
+ { id: 'QUAL-TS-012', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Excessive Non-Null Assertions (!)',
618
+ check({ files }) {
619
+ const findings = [];
620
+ for (const [fp, c] of files) {
621
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
622
+ const count = (c.match(/[^!=]!/g) || []).filter(() => true).length;
623
+ if (count > 15) {
624
+ findings.push({ ruleId: 'QUAL-TS-012', category: 'quality', severity: 'medium', title: `${count} non-null assertions (!) — excessive use defeats TypeScript's null safety`, description: 'Replace ! with proper null checks or optional chaining. Each ! is a promise to TypeScript that could throw at runtime.', file: fp, fix: null });
625
+ }
626
+ }
627
+ return findings;
628
+ },
629
+ },
630
+
631
+ // QUAL-TS-003: Using Object type instead of specific type
632
+ { id: 'QUAL-TS-003', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Using Object or {} as TypeScript Type',
633
+ check({ files }) {
634
+ const findings = [];
635
+ for (const [fp, c] of files) {
636
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
637
+ const lines = c.split('\n');
638
+ for (let i = 0; i < lines.length; i++) {
639
+ if (lines[i].match(/:\s*Object\b|:\s*\{\}(?!\s*=>)/) && !lines[i].match(/\/\//)) {
640
+ findings.push({ ruleId: 'QUAL-TS-003', category: 'quality', severity: 'low', title: "Using 'Object' or '{}' type — too broad, loses type safety", description: "Define a specific interface or use 'Record<string, unknown>'. '{}' accepts almost everything, nullifying type checking.", file: fp, line: i + 1, fix: null });
641
+ }
642
+ }
643
+ }
644
+ return findings;
645
+ },
646
+ },
647
+
648
+ // QUAL-TS-004: Enum used where union type is better
649
+ { id: 'QUAL-TS-004', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'TypeScript Enum (prefer union types)',
650
+ check({ files }) {
651
+ const findings = [];
652
+ for (const [fp, c] of files) {
653
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
654
+ const lines = c.split('\n');
655
+ for (let i = 0; i < lines.length; i++) {
656
+ if (lines[i].match(/^enum\s+\w+|^export\s+enum\s+\w+/) && !lines[i].match(/const\s+enum/)) {
657
+ findings.push({ ruleId: 'QUAL-TS-004', category: 'quality', severity: 'low', title: 'TypeScript enum — prefer string union types or const enum', description: "Replace: type Status = 'active' | 'inactive'. String unions are erased at compile time, generate no JS, and are more flexible.", file: fp, line: i + 1, fix: null });
658
+ }
659
+ }
660
+ }
661
+ return findings;
662
+ },
663
+ },
664
+
665
+ // QUAL-TS-005: Missing interface for props
666
+ { id: 'QUAL-TS-005', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'React Component Props Not Typed',
667
+ check({ files }) {
668
+ const findings = [];
669
+ for (const [fp, c] of files) {
670
+ if (!fp.match(/\.(tsx)$/)) continue;
671
+ const lines = c.split('\n');
672
+ for (let i = 0; i < lines.length; i++) {
673
+ if (lines[i].match(/^(?:export\s+)?(?:default\s+)?function\s+[A-Z]\w*\s*\(\s*\{/) && !lines[i].match(/Props>/)) {
674
+ if (!lines[i - 1]?.match(/Props>/) && !lines[i].match(/:\s*[A-Z]\w*Props/)) {
675
+ findings.push({ ruleId: 'QUAL-TS-005', category: 'quality', severity: 'low', title: 'React component props not typed with interface', description: 'Define interface Props { ... } and type the component: function MyComp({ foo }: Props). Typed props prevent prop name typos.', file: fp, line: i + 1, fix: null });
676
+ }
677
+ }
678
+ }
679
+ }
680
+ return findings;
681
+ },
682
+ },
683
+
684
+ // QUAL-CODE-001: Dead code - commented out blocks
685
+ { id: 'QUAL-CODE-001', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Large Commented-Out Code Blocks',
686
+ check({ files }) {
687
+ const findings = [];
688
+ for (const [fp, c] of files) {
689
+ if (!isSourceFile(fp)) continue;
690
+ const lines = c.split('\n');
691
+ let commentBlock = 0;
692
+ for (let i = 0; i < lines.length; i++) {
693
+ if (lines[i].match(/^\s*\/\//)) commentBlock++;
694
+ else commentBlock = 0;
695
+ if (commentBlock === 10) {
696
+ findings.push({ ruleId: 'QUAL-CODE-001', category: 'quality', severity: 'low', title: '10+ consecutive commented-out lines — dead code accumulation', description: 'Remove commented-out code. Use git history to recover it. Dead code confuses future developers and triggers false positives in security scanners.', file: fp, line: i - 9, fix: null });
697
+ commentBlock = 0;
698
+ }
699
+ }
700
+ }
701
+ return findings;
702
+ },
703
+ },
704
+
705
+ // QUAL-CODE-002: Magic numbers
706
+ { id: 'QUAL-CODE-002', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Magic Numbers in Business Logic',
707
+ check({ files }) {
708
+ const findings = [];
709
+ for (const [fp, c] of files) {
710
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
711
+ const lines = c.split('\n');
712
+ for (let i = 0; i < lines.length; i++) {
713
+ if (lines[i].match(/[=><]\s*\d{3,}/) && !lines[i].match(/\/\/|port|PORT|status|Status|\.length|timeout|Timeout|1000|1024|8080|3000|443|80/)) {
714
+ findings.push({ ruleId: 'QUAL-CODE-002', category: 'quality', severity: 'low', title: 'Magic number in business logic — extract to named constant', description: 'Replace magic numbers with named constants: const MAX_RETRY_COUNT = 5. Makes intent clear and enables single-point changes.', file: fp, line: i + 1, fix: null });
715
+ }
716
+ }
717
+ }
718
+ return findings;
719
+ },
720
+ },
721
+
722
+ // QUAL-CODE-003: console.log left in production code
723
+ { id: 'QUAL-CODE-003', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'console.log in Production Code',
724
+ check({ files }) {
725
+ const findings = [];
726
+ for (const [fp, c] of files) {
727
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
728
+ const lines = c.split('\n');
729
+ for (let i = 0; i < lines.length; i++) {
730
+ if (lines[i].match(/console\.log\(/) && !lines[i].match(/\/\//)) {
731
+ findings.push({ ruleId: 'QUAL-CODE-003', category: 'quality', severity: 'low', title: 'console.log() in production code — replace with structured logger', description: "Use a logger library (pino, winston) instead of console.log. Structured logging enables filtering, aggregation, and level control.", file: fp, line: i + 1, fix: null });
732
+ }
733
+ }
734
+ }
735
+ return findings;
736
+ },
737
+ },
738
+
739
+ // QUAL-CODE-004: Deeply nested callbacks (callback hell)
740
+ { id: 'QUAL-CODE-004', category: 'quality', severity: 'medium', confidence: 'likely', title: 'Deeply Nested Callbacks',
741
+ check({ files }) {
742
+ const findings = [];
743
+ for (const [fp, c] of files) {
744
+ if (!isSourceFile(fp)) continue;
745
+ const lines = c.split('\n');
746
+ for (let i = 0; i < lines.length; i++) {
747
+ const indent = (lines[i].match(/^\s+/) || [''])[0].length;
748
+ if (indent > 24 && lines[i].match(/function|callback|=>/) && !lines[i].match(/\/\//)) {
749
+ findings.push({ ruleId: 'QUAL-CODE-004', category: 'quality', severity: 'medium', title: 'Deeply nested callback — refactor to async/await or named functions', description: 'Use async/await to flatten nested callbacks. Deeply nested code (callback hell) is hard to read, test, and maintain.', file: fp, line: i + 1, fix: null });
750
+ }
751
+ }
752
+ }
753
+ return findings;
754
+ },
755
+ },
756
+
757
+ // QUAL-CODE-005: Function too long
758
+ { id: 'QUAL-CODE-005', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Function Exceeds 100 Lines',
759
+ check({ files }) {
760
+ const findings = [];
761
+ for (const [fp, c] of files) {
762
+ if (!isSourceFile(fp)) continue;
763
+ const lines = c.split('\n');
764
+ let funcStart = -1;
765
+ let braceDepth = 0;
766
+ for (let i = 0; i < lines.length; i++) {
767
+ if (lines[i].match(/^(?:export\s+)?(?:async\s+)?function\s+\w+/) || lines[i].match(/^\s*(?:async\s+)?\w+\s*(?:=\s*(?:async\s+)?function|\([^)]*\)\s*(?::\s*\S+\s*)?\s*=>)/)) {
768
+ if (funcStart === -1) { funcStart = i; braceDepth = 0; }
769
+ }
770
+ braceDepth += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length;
771
+ if (funcStart >= 0 && braceDepth <= 0 && i > funcStart) {
772
+ if (i - funcStart > 100) {
773
+ findings.push({ ruleId: 'QUAL-CODE-005', category: 'quality', severity: 'medium', title: `Function at line ${funcStart + 1} is ${i - funcStart} lines long — exceeds 100 line guideline`, description: 'Extract helper functions to keep each function under 50 lines. Long functions are hard to test and understand.', file: fp, line: funcStart + 1, fix: null });
774
+ }
775
+ funcStart = -1;
776
+ }
777
+ }
778
+ }
779
+ return findings;
780
+ },
781
+ },
782
+
783
+ // QUAL-CODE-006: Unused variables in TypeScript
784
+ { id: 'QUAL-CODE-006', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'TypeScript Configured Without Unused Variable Checks',
785
+ check({ files }) {
786
+ const findings = [];
787
+ for (const [fp, c] of files) {
788
+ if (!fp.endsWith('tsconfig.json')) continue;
789
+ try {
790
+ const tsconfig = JSON.parse(c);
791
+ const opts = tsconfig.compilerOptions || {};
792
+ if (!opts.noUnusedLocals || !opts.noUnusedParameters) {
793
+ findings.push({ ruleId: 'QUAL-CODE-006', category: 'quality', severity: 'low', title: 'tsconfig.json missing noUnusedLocals/noUnusedParameters', description: 'Add "noUnusedLocals": true, "noUnusedParameters": true to compilerOptions. These flags catch dead code at compile time.', file: fp, fix: null });
794
+ }
795
+ } catch {}
796
+ }
797
+ return findings;
798
+ },
799
+ },
800
+
801
+ // QUAL-CODE-007: Missing strictNullChecks
802
+ { id: 'QUAL-CODE-007', category: 'quality', severity: 'high', confidence: 'likely', title: 'TypeScript strictNullChecks Disabled',
803
+ check({ files }) {
804
+ const findings = [];
805
+ for (const [fp, c] of files) {
806
+ if (!fp.endsWith('tsconfig.json')) continue;
807
+ try {
808
+ const tsconfig = JSON.parse(c);
809
+ const opts = tsconfig.compilerOptions || {};
810
+ if (opts.strictNullChecks === false || (opts.strict === false && !opts.strictNullChecks)) {
811
+ findings.push({ ruleId: 'QUAL-CODE-007', category: 'quality', severity: 'high', title: 'strictNullChecks disabled — null/undefined errors not caught at compile time', description: 'Enable strictNullChecks in tsconfig.json. This is the most valuable TypeScript check — prevents entire classes of null pointer exceptions.', file: fp, fix: null });
812
+ }
813
+ } catch {}
814
+ }
815
+ return findings;
816
+ },
817
+ },
818
+
819
+ // QUAL-TEST-001: No mock reset between tests
820
+ { id: 'QUAL-TEST-001', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No Mock Reset Between Tests',
821
+ check({ files }) {
822
+ const findings = [];
823
+ for (const [fp, c] of files) {
824
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
825
+ const hasMocks = c.match(/jest\.fn\(\)|sinon\.stub|vi\.fn\(\)|mock\(/i);
826
+ const hasReset = c.match(/clearAllMocks|resetAllMocks|restoreAllMocks|afterEach.*mock|mock.*reset|vi\.clearAllMocks/i);
827
+ if (hasMocks && !hasReset) {
828
+ findings.push({ ruleId: 'QUAL-TEST-001', category: 'quality', severity: 'medium', title: 'Mocks created without clearAllMocks/resetAllMocks in afterEach — test pollution', description: "Add jest.clearAllMocks() or vi.clearAllMocks() in afterEach(). Shared mock state between tests causes flaky, order-dependent tests.", file: fp, fix: null });
829
+ }
830
+ }
831
+ return findings;
832
+ },
833
+ },
834
+
835
+ // QUAL-TEST-002: Test with no describe block
836
+ { id: 'QUAL-TEST-002', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Test File Without Describe Block',
837
+ check({ files }) {
838
+ const findings = [];
839
+ for (const [fp, c] of files) {
840
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
841
+ const hasIt = c.match(/\bit\s*\(|\btest\s*\(/);
842
+ const hasDescribe = c.match(/\bdescribe\s*\(/);
843
+ if (hasIt && !hasDescribe) {
844
+ findings.push({ ruleId: 'QUAL-TEST-002', category: 'quality', severity: 'low', title: 'Test file without describe() block — poor organization', description: 'Wrap related tests in describe() blocks. Groups tests logically and shows unit under test in failure messages.', file: fp, fix: null });
845
+ }
846
+ }
847
+ return findings;
848
+ },
849
+ },
850
+
851
+ // QUAL-TEST-003: Hardcoded test data (magic strings)
852
+ { id: 'QUAL-TEST-003', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Magic Strings in Test Assertions',
853
+ check({ files }) {
854
+ const findings = [];
855
+ for (const [fp, c] of files) {
856
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
857
+ const lines = c.split('\n');
858
+ let magicCount = 0;
859
+ for (const line of lines) {
860
+ if (line.match(/expect.*toBe\(['"][a-z0-9_]{20,}['"]/) || line.match(/assert.*equal.*['"][a-z0-9_]{20,}['"]/i)) {
861
+ magicCount++;
862
+ }
863
+ }
864
+ if (magicCount > 5) {
865
+ findings.push({ ruleId: 'QUAL-TEST-003', category: 'quality', severity: 'low', title: `${magicCount} hardcoded magic strings in test assertions — extract to constants`, description: 'Extract test fixtures to constants or factory functions. Magic strings make tests fragile and hard to understand.', file: fp, fix: null });
866
+ }
867
+ }
868
+ return findings;
869
+ },
870
+ },
871
+
872
+ // QUAL-TEST-004: Missing error case tests
873
+ { id: 'QUAL-TEST-004', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Tests Only Cover Happy Path',
874
+ check({ files }) {
875
+ const findings = [];
876
+ for (const [fp, c] of files) {
877
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
878
+ const hasHappy = c.match(/\bit\s*\(|\btest\s*\(/);
879
+ const hasError = c.match(/throw|reject|error|Error|invalid|fail|400|401|403|404|500/i);
880
+ if (hasHappy && !hasError) {
881
+ findings.push({ ruleId: 'QUAL-TEST-004', category: 'quality', severity: 'medium', title: 'Test file appears to test only happy paths — add error/edge case tests', description: 'Add tests for: invalid inputs, boundary values, error conditions, and unauthorized access. Error paths are where bugs hide.', file: fp, fix: null });
882
+ }
883
+ }
884
+ return findings;
885
+ },
886
+ },
887
+
888
+ // QUAL-ARCH-001: Circular dependency risk
889
+ { id: 'QUAL-ARCH-001', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Potential Circular Import',
890
+ check({ files }) {
891
+ const findings = [];
892
+ const importMap = new Map();
893
+ for (const [fp, c] of files) {
894
+ if (!isSourceFile(fp)) continue;
895
+ const imports = [];
896
+ const lines = c.split('\n');
897
+ for (const line of lines) {
898
+ const m = line.match(/(?:import|require)\s*(?:\(?\s*)?['"]([./][^'"]+)['"]/);
899
+ if (m) imports.push(m[1]);
900
+ }
901
+ importMap.set(fp, imports);
902
+ }
903
+ for (const [fp, imports] of importMap) {
904
+ for (const imp of imports) {
905
+ const resolved = imp.replace(/^\.\//, fp.substring(0, fp.lastIndexOf('/') + 1));
906
+ const impFile = [...importMap.keys()].find(k => k.startsWith(resolved));
907
+ if (impFile && importMap.get(impFile)?.some(i => fp.includes(i.replace(/^\.\//, '')))) {
908
+ findings.push({ ruleId: 'QUAL-ARCH-001', category: 'quality', severity: 'medium', title: `Potential circular import between ${fp.split('/').pop()} and ${impFile.split('/').pop()}`, description: 'Circular imports cause initialization order bugs. Refactor to break the cycle — extract shared types to a separate module.', file: fp, fix: null });
909
+ }
910
+ }
911
+ }
912
+ return findings;
913
+ },
914
+ },
915
+
916
+ // QUAL-ARCH-002: Business logic in route handler
917
+ { id: 'QUAL-ARCH-002', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Business Logic in HTTP Route Handler',
918
+ check({ files }) {
919
+ const findings = [];
920
+ for (const [fp, c] of files) {
921
+ if (!isSourceFile(fp)) continue;
922
+ if (!fp.match(/route|controller|handler|router/i)) continue;
923
+ const lines = c.split('\n');
924
+ for (let i = 0; i < lines.length; i++) {
925
+ if (lines[i].match(/router\.(get|post|put|delete|patch)\s*\(/i)) {
926
+ const handler = lines.slice(i, i + 40).join('\n');
927
+ const dbCalls = (handler.match(/\.find|\.save|\.create|\.update|query|\.exec/gi) || []).length;
928
+ if (dbCalls > 3) {
929
+ findings.push({ ruleId: 'QUAL-ARCH-002', category: 'quality', severity: 'medium', title: 'Route handler contains business logic — extract to service layer', description: 'Move business logic to service classes. Route handlers should only: parse request, call service, return response. Enables testing without HTTP layer.', file: fp, line: i + 1, fix: null });
930
+ }
931
+ }
932
+ }
933
+ }
934
+ return findings;
935
+ },
936
+ },
937
+
938
+ // QUAL-ARCH-003: Global state mutation
939
+ { id: 'QUAL-ARCH-003', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Global State Mutation',
940
+ check({ files }) {
941
+ const findings = [];
942
+ for (const [fp, c] of files) {
943
+ if (!isSourceFile(fp)) continue;
944
+ const lines = c.split('\n');
945
+ for (let i = 0; i < lines.length; i++) {
946
+ if (lines[i].match(/global\.\w+\s*=|globalThis\.\w+\s*=/) && !lines[i].match(/global\.__express|global\.fetch|global\.TextEncoder/)) {
947
+ findings.push({ ruleId: 'QUAL-ARCH-003', category: 'quality', severity: 'medium', title: 'Global state mutation — causes test pollution and concurrency bugs', description: 'Use module-scoped variables or dependency injection. Global state mutations cause hard-to-trace bugs in concurrent environments.', file: fp, line: i + 1, fix: null });
948
+ }
949
+ }
950
+ }
951
+ return findings;
952
+ },
953
+ },
954
+
955
+ // QUAL-ARCH-004: Hardcoded environment detection
956
+ { id: 'QUAL-ARCH-004', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Hardcoded Environment String Comparison',
957
+ check({ files }) {
958
+ const findings = [];
959
+ for (const [fp, c] of files) {
960
+ if (!isSourceFile(fp)) continue;
961
+ const lines = c.split('\n');
962
+ for (let i = 0; i < lines.length; i++) {
963
+ if (lines[i].match(/===?\s*['"]production['"]|===?\s*['"]staging['"]|===?\s*['"]development['"]/)) {
964
+ if (!lines[i].match(/NODE_ENV|APP_ENV|ENVIRONMENT/)) {
965
+ findings.push({ ruleId: 'QUAL-ARCH-004', category: 'quality', severity: 'medium', title: 'Hardcoded environment name comparison — fragile environment detection', description: "Use process.env.NODE_ENV === 'production'. Hardcoded environment strings scatter across codebase and miss new env names.", file: fp, line: i + 1, fix: null });
966
+ }
967
+ }
968
+ }
969
+ }
970
+ return findings;
971
+ },
972
+ },
973
+
974
+ // QUAL-MAINT-015: TODO/FIXME without ticket reference
975
+ { id: 'QUAL-MAINT-015', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'TODO/FIXME Without Issue Tracker Reference',
976
+ check({ files }) {
977
+ const findings = [];
978
+ for (const [fp, c] of files) {
979
+ if (!isSourceFile(fp)) continue;
980
+ const lines = c.split('\n');
981
+ for (let i = 0; i < lines.length; i++) {
982
+ if (lines[i].match(/TODO|FIXME|HACK|XXX/i) && !lines[i].match(/#\d+|JIRA|https?:\/\/|ticket|issue/i)) {
983
+ findings.push({ ruleId: 'QUAL-MAINT-015', category: 'quality', severity: 'low', title: 'TODO/FIXME without issue tracker reference — may never be resolved', description: 'Add issue reference: // TODO(#123): Fix this. Untracked TODOs accumulate and are never resolved.', file: fp, line: i + 1, fix: null });
984
+ }
985
+ }
986
+ }
987
+ return findings;
988
+ },
989
+ },
990
+
991
+ // QUAL-MAINT-016: Long files (>500 lines)
992
+ { id: 'QUAL-MAINT-016', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'File Exceeds 500 Lines',
993
+ check({ files }) {
994
+ const findings = [];
995
+ for (const [fp, c] of files) {
996
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
997
+ const lineCount = c.split('\n').length;
998
+ if (lineCount > 500) {
999
+ findings.push({ ruleId: 'QUAL-MAINT-016', category: 'quality', severity: 'low', title: `File has ${lineCount} lines — consider splitting into modules`, description: 'Split large files into focused modules. Files over 500 lines suggest mixed responsibilities that should be separated.', file: fp, fix: null });
1000
+ }
1001
+ }
1002
+ return findings;
1003
+ },
1004
+ },
1005
+
1006
+ // QUAL-MAINT-017: Inconsistent error handling (mix of throw and return null)
1007
+ { id: 'QUAL-MAINT-017', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Inconsistent Error Handling Pattern',
1008
+ check({ files }) {
1009
+ const findings = [];
1010
+ for (const [fp, c] of files) {
1011
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
1012
+ const throwCount = (c.match(/throw new Error/g) || []).length;
1013
+ const returnNullCount = (c.match(/return null|return undefined|return false/g) || []).length;
1014
+ if (throwCount > 3 && returnNullCount > 3) {
1015
+ findings.push({ ruleId: 'QUAL-MAINT-017', category: 'quality', severity: 'medium', title: `Mixed error handling: ${throwCount} throws and ${returnNullCount} return-null patterns`, description: 'Standardize error handling in this module. Choose either exceptions or Result types consistently. Mixed patterns confuse callers.', file: fp, fix: null });
1016
+ }
1017
+ }
1018
+ return findings;
1019
+ },
1020
+ },
1021
+
1022
+ // QUAL-MAINT-004: No API versioning
1023
+ { id: 'QUAL-MAINT-004', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No API Versioning',
1024
+ check({ files }) {
1025
+ const findings = [];
1026
+ const allCode = [...files.values()].join('\n');
1027
+ const hasRoutes = allCode.match(/router\.(get|post|put|delete)|app\.(get|post|put|delete)/i);
1028
+ const hasVersioning = allCode.match(/\/v1\/|\/v2\/|api.*version|versionedRouter|ApiVersion/i);
1029
+ if (hasRoutes && !hasVersioning) {
1030
+ findings.push({ ruleId: 'QUAL-MAINT-004', category: 'quality', severity: 'medium', title: 'API routes without versioning — breaking changes impossible without coordination', description: 'Version your API: /api/v1/users. Unversioned APIs force all clients to update simultaneously when you make breaking changes.', fix: null });
1031
+ }
1032
+ return findings;
1033
+ },
1034
+ },
1035
+
1036
+ // QUAL-MAINT-005: No database schema documentation
1037
+ { id: 'QUAL-MAINT-005', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Database Schema Documentation',
1038
+ check({ files }) {
1039
+ const findings = [];
1040
+ const allPaths = [...files.keys()].join(' ');
1041
+ const hasSchema = allPaths.match(/schema\.(sql|md|dbml)|erd\.|entity-relationship|database.*diagram/i);
1042
+ const hasDB = [...files.values()].some(c => c.match(/CREATE TABLE|mongoose\.Schema|@Entity\(|@Table\(/i));
1043
+ if (hasDB && !hasSchema) {
1044
+ findings.push({ ruleId: 'QUAL-MAINT-005', category: 'quality', severity: 'low', title: 'Database schema without documentation', description: 'Add schema.dbml or schema.sql documentation. Consider dbdocs.io or Prisma ERD generator. Schema docs speed onboarding and catch design issues.', fix: null });
1045
+ }
1046
+ return findings;
1047
+ },
1048
+ },
1049
+
1050
+ // QUAL-CODE-008: Copy-paste code (duplicated logic)
1051
+ { id: 'QUAL-CODE-008', category: 'quality', severity: 'medium', confidence: 'likely', title: 'Duplicated Validation Logic Across Files',
1052
+ check({ files }) {
1053
+ const findings = [];
1054
+ const validationPatterns = [];
1055
+ for (const [fp, c] of files) {
1056
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
1057
+ const patterns = c.match(/if\s*\(![\w.]+\)\s*\{?\s*(?:throw|return|res\.)/g) || [];
1058
+ for (const p of patterns) {
1059
+ if (validationPatterns.includes(p)) {
1060
+ findings.push({ ruleId: 'QUAL-CODE-008', category: 'quality', severity: 'medium', title: 'Duplicated validation logic across files — extract to shared validator', description: 'Extract repeated validation to shared functions. Duplicated logic diverges over time, causing inconsistent behavior.', file: fp, fix: null });
1061
+ break;
1062
+ }
1063
+ }
1064
+ validationPatterns.push(...patterns.slice(0, 5));
1065
+ }
1066
+ return findings;
1067
+ },
1068
+ },
1069
+
1070
+ // QUAL-CODE-009: Switch statement without default case
1071
+ { id: 'QUAL-CODE-009', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Switch Statement Without Default Case',
1072
+ check({ files }) {
1073
+ const findings = [];
1074
+ for (const [fp, c] of files) {
1075
+ if (!isSourceFile(fp)) continue;
1076
+ const lines = c.split('\n');
1077
+ let inSwitch = false;
1078
+ let braceDepth = 0;
1079
+ let switchStart = -1;
1080
+ let hasDefault = false;
1081
+ for (let i = 0; i < lines.length; i++) {
1082
+ if (lines[i].match(/\bswitch\s*\(/)) { inSwitch = true; switchStart = i; hasDefault = false; braceDepth = 0; }
1083
+ if (inSwitch) {
1084
+ braceDepth += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length;
1085
+ if (lines[i].match(/\bdefault\s*:/)) hasDefault = true;
1086
+ if (braceDepth <= 0 && switchStart >= 0) {
1087
+ if (!hasDefault) findings.push({ ruleId: 'QUAL-CODE-009', category: 'quality', severity: 'low', title: 'switch statement without default case — unhandled values silently ignored', description: 'Add default: throw new Error(`Unexpected value: ${value}`). Default cases prevent silent failures when new enum values are added.', file: fp, line: switchStart + 1, fix: null });
1088
+ inSwitch = false; switchStart = -1;
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ return findings;
1094
+ },
1095
+ },
1096
+
1097
+ // QUAL-CODE-010: Using delete for cache clearing
1098
+ { id: 'QUAL-CODE-010', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Using delete Operator on Object Properties',
1099
+ check({ files }) {
1100
+ const findings = [];
1101
+ for (const [fp, c] of files) {
1102
+ if (!isSourceFile(fp)) continue;
1103
+ const lines = c.split('\n');
1104
+ for (let i = 0; i < lines.length; i++) {
1105
+ if (lines[i].match(/\bdelete\s+\w+\.\w+/) && !lines[i].match(/\/\//)) {
1106
+ findings.push({ ruleId: 'QUAL-CODE-010', category: 'quality', severity: 'low', title: 'delete operator used on object property — de-optimizes V8 object shape', description: 'Set to undefined instead: obj.prop = undefined. Or use Reflect.deleteProperty. The delete operator causes V8 to fall back to slower property lookup.', file: fp, line: i + 1, fix: null });
1107
+ }
1108
+ }
1109
+ }
1110
+ return findings;
1111
+ },
1112
+ },
1113
+
1114
+ // QUAL-TS-006: Type assertion (as any) overuse
1115
+ { id: 'QUAL-TS-006', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Overuse of TypeScript Type Assertions',
1116
+ check({ files }) {
1117
+ const findings = [];
1118
+ for (const [fp, c] of files) {
1119
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
1120
+ const count = (c.match(/\bas\s+(?:any|unknown)\b/g) || []).length;
1121
+ if (count > 10) {
1122
+ findings.push({ ruleId: 'QUAL-TS-006', category: 'quality', severity: 'medium', title: `${count} 'as any'/'as unknown' type assertions — undermines type safety`, description: 'Use proper type narrowing (type guards, generics) instead of as any. Each assertion is a runtime bug waiting to happen.', file: fp, fix: null });
1123
+ }
1124
+ }
1125
+ return findings;
1126
+ },
1127
+ },
1128
+
1129
+ // QUAL-TS-007: Missing generic types on collections
1130
+ { id: 'QUAL-TS-007', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Untyped Collections (Array/Map/Set without generics)',
1131
+ check({ files }) {
1132
+ const findings = [];
1133
+ for (const [fp, c] of files) {
1134
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
1135
+ const lines = c.split('\n');
1136
+ for (let i = 0; i < lines.length; i++) {
1137
+ if (lines[i].match(/:\s*Array(?!<)|:\s*Map(?!<)|:\s*Set(?!<)/) || lines[i].match(/new\s+Map\(\)|new\s+Set\(\)|new\s+Array\(\)/)) {
1138
+ findings.push({ ruleId: 'QUAL-TS-007', category: 'quality', severity: 'low', title: 'Untyped collection — use generic parameters for type safety', description: 'Add generics: Map<string, User>, Set<number>, Array<Item>. Untyped collections accept any value and return unknown.', file: fp, line: i + 1, fix: null });
1139
+ }
1140
+ }
1141
+ }
1142
+ return findings;
1143
+ },
1144
+ },
1145
+
1146
+ // QUAL-ARCH-005: Middleware applied globally instead of per-route
1147
+ { id: 'QUAL-ARCH-005', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Expensive Middleware Applied Globally',
1148
+ check({ files }) {
1149
+ const findings = [];
1150
+ for (const [fp, c] of files) {
1151
+ if (!isSourceFile(fp)) continue;
1152
+ const lines = c.split('\n');
1153
+ for (let i = 0; i < lines.length; i++) {
1154
+ if (lines[i].match(/app\.use\(/) && lines[i].match(/authenticate|authorize|checkPermission|requireAuth|jwtAuth/i)) {
1155
+ const before = lines.slice(Math.max(0, i - 5), i).join('\n');
1156
+ if (!before.match(/\/api\/|\/admin\/|router\./)) {
1157
+ findings.push({ ruleId: 'QUAL-ARCH-005', category: 'quality', severity: 'low', title: 'Auth middleware applied globally — may affect public routes unexpectedly', description: 'Apply auth middleware per-route or per-router. Global auth middleware blocks health checks and public endpoints.', file: fp, line: i + 1, fix: null });
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+ return findings;
1163
+ },
1164
+ },
1165
+
1166
+ // QUAL-ARCH-006: No dependency injection
1167
+ { id: 'QUAL-ARCH-006', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Hard Dependencies Instead of Dependency Injection',
1168
+ check({ files }) {
1169
+ const findings = [];
1170
+ for (const [fp, c] of files) {
1171
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
1172
+ const lines = c.split('\n');
1173
+ for (let i = 0; i < lines.length; i++) {
1174
+ if (lines[i].match(/class\s+\w+/) && !fp.match(/index|app|server/i)) {
1175
+ const classBody = lines.slice(i, i + 30).join('\n');
1176
+ if (classBody.match(/new\s+(?:EmailService|UserRepository|DatabaseService|PaymentService|NotificationService)/)) {
1177
+ findings.push({ ruleId: 'QUAL-ARCH-006', category: 'quality', severity: 'low', title: 'Class instantiates dependencies directly — hard to test and mock', description: 'Inject dependencies via constructor: constructor(private emailService: EmailService). Hard dependencies cannot be replaced in tests.', file: fp, line: i + 1, fix: null });
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ return findings;
1183
+ },
1184
+ },
1185
+
1186
+ // QUAL-SEC-002: Insecure random for tokens
1187
+ { id: 'QUAL-SEC-002', category: 'quality', severity: 'critical', confidence: 'likely', title: 'Math.random() Used for Security Tokens',
1188
+ check({ files }) {
1189
+ const findings = [];
1190
+ for (const [fp, c] of files) {
1191
+ if (!isSourceFile(fp)) continue;
1192
+ const lines = c.split('\n');
1193
+ for (let i = 0; i < lines.length; i++) {
1194
+ if (lines[i].match(/Math\.random\(\)/) && lines[i].match(/token|secret|key|nonce|salt|id|code/i)) {
1195
+ findings.push({ ruleId: 'QUAL-SEC-002', category: 'quality', severity: 'critical', title: 'Math.random() used for security token — cryptographically weak', description: 'Use crypto.randomBytes(32).toString("hex"). Math.random() is predictable and must never be used for tokens, nonces, or secrets.', file: fp, line: i + 1, fix: null });
1196
+ }
1197
+ }
1198
+ }
1199
+ return findings;
1200
+ },
1201
+ },
1202
+
1203
+ // QUAL-SEC-003: Regex without anchors (partial match)
1204
+ { id: 'QUAL-SEC-003', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Email/URL Validation Regex Without Anchors',
1205
+ check({ files }) {
1206
+ const findings = [];
1207
+ for (const [fp, c] of files) {
1208
+ if (!isSourceFile(fp)) continue;
1209
+ const lines = c.split('\n');
1210
+ for (let i = 0; i < lines.length; i++) {
1211
+ if (lines[i].match(/\/.*@.*\.\w+\//) && !lines[i].match(/\/\^.*\$\/|test|validate|email/i)) {
1212
+ findings.push({ ruleId: 'QUAL-SEC-003', category: 'quality', severity: 'medium', title: 'Validation regex without ^ and $ anchors — partial string match may pass invalid input', description: 'Add ^ and $ anchors to validation regexes. Without anchors, "evil@evil.com.hacker.com" would match /\\w+@\\w+\\.\\w+/.', file: fp, line: i + 1, fix: null });
1213
+ }
1214
+ }
1215
+ }
1216
+ return findings;
1217
+ },
1218
+ },
1219
+
1220
+ // QUAL-SEC-004: Using innerHTML to build DOM
1221
+ { id: 'QUAL-SEC-004', category: 'quality', severity: 'high', confidence: 'likely', title: 'DOM Built via innerHTML Concatenation',
1222
+ check({ files }) {
1223
+ const findings = [];
1224
+ for (const [fp, c] of files) {
1225
+ if (!isSourceFile(fp)) continue;
1226
+ const lines = c.split('\n');
1227
+ for (let i = 0; i < lines.length; i++) {
1228
+ if (lines[i].match(/innerHTML\s*\+=|innerHTML\s*=\s*['"`][^'"`]*\$\{/)) {
1229
+ findings.push({ ruleId: 'QUAL-SEC-004', category: 'quality', severity: 'high', title: 'DOM built via innerHTML string concatenation — XSS risk', description: 'Use document.createElement() and textContent for dynamic DOM. Or use DOMPurify.sanitize() if HTML is required. innerHTML + interpolation = XSS.', file: fp, line: i + 1, fix: null });
1230
+ }
1231
+ }
1232
+ }
1233
+ return findings;
1234
+ },
1235
+ },
1236
+
1237
+ // QUAL-CODE-011: Overly broad try/catch
1238
+ { id: 'QUAL-CODE-011', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Overly Broad try/catch Block',
1239
+ check({ files }) {
1240
+ const findings = [];
1241
+ for (const [fp, c] of files) {
1242
+ if (!isSourceFile(fp) || fp.includes('test')) continue;
1243
+ const lines = c.split('\n');
1244
+ for (let i = 0; i < lines.length; i++) {
1245
+ if (lines[i].match(/^\s*try\s*\{/)) {
1246
+ let braces = 0, tryEnd = -1;
1247
+ for (let j = i; j < Math.min(lines.length, i + 80); j++) {
1248
+ braces += (lines[j].match(/{/g) || []).length - (lines[j].match(/}/g) || []).length;
1249
+ if (j > i && braces <= 0) { tryEnd = j; break; }
1250
+ }
1251
+ if (tryEnd > i + 30) {
1252
+ findings.push({ ruleId: 'QUAL-CODE-011', category: 'quality', severity: 'medium', title: `try block spans ${tryEnd - i} lines — too broad, catches unexpected errors`, description: 'Keep try blocks small (3-5 lines). Wrap only the specific operation that can throw. Broad catches hide bugs in unrelated code.', file: fp, line: i + 1, fix: null });
1253
+ }
1254
+ }
1255
+ }
1256
+ }
1257
+ return findings;
1258
+ },
1259
+ },
1260
+
1261
+ // QUAL-CODE-012: Boolean parameter anti-pattern
1262
+ { id: 'QUAL-CODE-012', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Boolean Parameter Anti-Pattern (Flag Argument)',
1263
+ check({ files }) {
1264
+ const findings = [];
1265
+ for (const [fp, c] of files) {
1266
+ if (!isSourceFile(fp)) continue;
1267
+ const lines = c.split('\n');
1268
+ for (let i = 0; i < lines.length; i++) {
1269
+ if (lines[i].match(/function\s+\w+\s*\([^)]*(?:,\s*(?:is|has|should|can)\w+\s*(?:=\s*(?:true|false))?|,\s*(?:true|false))/)) {
1270
+ findings.push({ ruleId: 'QUAL-CODE-012', category: 'quality', severity: 'low', title: 'Function with boolean flag parameter — split into two functions', description: 'Replace doThing(true) with doThingSync() / doThingAsync(). Boolean flags indicate the function does two different things.', file: fp, line: i + 1, fix: null });
1271
+ }
1272
+ }
1273
+ }
1274
+ return findings;
1275
+ },
1276
+ },
1277
+
1278
+ // QUAL-CODE-013: Nested ternary
1279
+ { id: 'QUAL-CODE-013', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Nested Ternary Operators',
1280
+ check({ files }) {
1281
+ const findings = [];
1282
+ for (const [fp, c] of files) {
1283
+ if (!isSourceFile(fp)) continue;
1284
+ const lines = c.split('\n');
1285
+ for (let i = 0; i < lines.length; i++) {
1286
+ const ternaryCount = (lines[i].match(/\?(?!=)/g) || []).length;
1287
+ if (ternaryCount >= 2) {
1288
+ findings.push({ ruleId: 'QUAL-CODE-013', category: 'quality', severity: 'medium', title: 'Nested ternary operators — hard to read and debug', description: 'Replace nested ternaries with if/else or a lookup table. Nested ternaries cause logic errors when conditions change.', file: fp, line: i + 1, fix: null });
1289
+ }
1290
+ }
1291
+ }
1292
+ return findings;
1293
+ },
1294
+ },
1295
+
1296
+ // QUAL-CODE-014: console.error without structured data
1297
+ { id: 'QUAL-CODE-014', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'console.error Without Error Object',
1298
+ check({ files }) {
1299
+ const findings = [];
1300
+ for (const [fp, c] of files) {
1301
+ if (!isSourceFile(fp) || fp.includes('test')) continue;
1302
+ const lines = c.split('\n');
1303
+ for (let i = 0; i < lines.length; i++) {
1304
+ if (lines[i].match(/console\.error\s*\(\s*['"`]/) && !lines[i].match(/err|error|e\b/i)) {
1305
+ findings.push({ ruleId: 'QUAL-CODE-014', category: 'quality', severity: 'low', title: 'console.error called with string but no error object', description: 'Pass the error: console.error("Failed:", err). Without the error object, stack trace is lost making production debugging impossible.', file: fp, line: i + 1, fix: null });
1306
+ }
1307
+ }
1308
+ }
1309
+ return findings;
1310
+ },
1311
+ },
1312
+
1313
+ // QUAL-ARCH-007: Config values in source code
1314
+ { id: 'QUAL-ARCH-007', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Configuration Values Hardcoded in Source',
1315
+ check({ files }) {
1316
+ const findings = [];
1317
+ for (const [fp, c] of files) {
1318
+ if (!isSourceFile(fp) || fp.includes('test') || fp.includes('config')) continue;
1319
+ const lines = c.split('\n');
1320
+ for (let i = 0; i < lines.length; i++) {
1321
+ if (lines[i].match(/const\s+\w*(?:url|host|endpoint|domain)\w*\s*=\s*['"]https?:\/\//i)) {
1322
+ findings.push({ ruleId: 'QUAL-ARCH-007', category: 'quality', severity: 'medium', title: 'URL/endpoint hardcoded in source — not configurable per environment', description: 'Extract URLs to config/environment: process.env.API_URL. Hardcoded URLs require code changes to switch environments.', file: fp, line: i + 1, fix: null });
1323
+ }
1324
+ }
1325
+ }
1326
+ return findings;
1327
+ },
1328
+ },
1329
+
1330
+ // QUAL-ARCH-008: Service accessing another service's database directly
1331
+ { id: 'QUAL-ARCH-008', category: 'quality', severity: 'high', confidence: 'likely', title: 'Cross-Service Direct Database Access',
1332
+ check({ files }) {
1333
+ const findings = [];
1334
+ for (const [fp, c] of files) {
1335
+ if (!isSourceFile(fp)) continue;
1336
+ if (c.match(/service[A-Z]|serviceA|serviceB/i) && c.match(/mongoose\.connect|new Pool|createConnection/i)) {
1337
+ const dbUrls = c.match(/mongodb:\/\/.*\/(\w+)|postgresql:\/\/.*\/(\w+)/g) || [];
1338
+ if (dbUrls.length > 1) {
1339
+ findings.push({ ruleId: 'QUAL-ARCH-008', category: 'quality', severity: 'high', title: 'Service connecting to multiple databases — possible cross-service coupling', description: 'Each service should own its database. Cross-service DB access creates tight coupling. Use APIs or events to communicate between services.', file: fp, fix: null });
1340
+ }
1341
+ }
1342
+ }
1343
+ return findings;
1344
+ },
1345
+ },
1346
+
1347
+ // QUAL-TEST-005: Snapshot tests without review
1348
+ { id: 'QUAL-TEST-005', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Overuse of Snapshot Tests',
1349
+ check({ files }) {
1350
+ const findings = [];
1351
+ for (const [fp, c] of files) {
1352
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
1353
+ const snapshots = (c.match(/toMatchSnapshot\(\)|toMatchInlineSnapshot/g) || []).length;
1354
+ const assertions = (c.match(/expect\(/g) || []).length;
1355
+ if (snapshots > 5 || (snapshots > 0 && assertions > 0 && snapshots / assertions > 0.8)) {
1356
+ findings.push({ ruleId: 'QUAL-TEST-005', category: 'quality', severity: 'low', title: `${snapshots} snapshot tests — snapshots are often accepted blindly`, description: 'Replace snapshot tests with explicit assertions where possible. Snapshots catch changes but do not assert correctness. Use for complex output only.', file: fp, fix: null });
1357
+ }
1358
+ }
1359
+ return findings;
1360
+ },
1361
+ },
1362
+
1363
+ // QUAL-SEC-005: Hardcoded JWT secret
1364
+ { id: 'QUAL-SEC-005', category: 'quality', severity: 'critical', confidence: 'likely', title: 'Hardcoded JWT Secret',
1365
+ check({ files }) {
1366
+ const findings = [];
1367
+ for (const [fp, c] of files) {
1368
+ if (!isSourceFile(fp)) continue;
1369
+ const lines = c.split('\n');
1370
+ for (let i = 0; i < lines.length; i++) {
1371
+ if (lines[i].match(/jwt\.(sign|verify)\s*\(/) && lines[i].match(/['"][a-zA-Z0-9]{8,}['"]/)) {
1372
+ if (!lines[i].match(/process\.env\.|config\./)) {
1373
+ findings.push({ ruleId: 'QUAL-SEC-005', category: 'quality', severity: 'critical', title: 'JWT signed/verified with hardcoded secret', description: 'Use process.env.JWT_SECRET and rotate regularly. Hardcoded secrets committed to git are permanently compromised.', file: fp, line: i + 1, fix: null });
1374
+ }
1375
+ }
1376
+ }
1377
+ }
1378
+ return findings;
1379
+ },
1380
+ },
1381
+
1382
+ // QUAL-SEC-006: open redirect vulnerability
1383
+ { id: 'QUAL-SEC-006', category: 'quality', severity: 'high', confidence: 'likely', title: 'Open Redirect Vulnerability',
1384
+ check({ files }) {
1385
+ const findings = [];
1386
+ for (const [fp, c] of files) {
1387
+ if (!isSourceFile(fp)) continue;
1388
+ const lines = c.split('\n');
1389
+ for (let i = 0; i < lines.length; i++) {
1390
+ if (lines[i].match(/res\.redirect\s*\(/) && lines[i].match(/req\.(query|params|body)\./)) {
1391
+ if (!lines[i].match(/allowedHosts|whitelist|startsWith\(['"]\/|isInternal/)) {
1392
+ findings.push({ ruleId: 'QUAL-SEC-006', category: 'quality', severity: 'high', title: 'Open redirect: res.redirect() with user-supplied URL', description: 'Validate redirect URLs against allowlist. Open redirects enable phishing attacks where malicious sites appear to come from your domain.', file: fp, line: i + 1, fix: null });
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1397
+ return findings;
1398
+ },
1399
+ },
1400
+
1401
+ // QUAL-MAINT-006: Inconsistent naming conventions
1402
+ { id: 'QUAL-MAINT-006', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Mixed Naming Conventions',
1403
+ check({ files }) {
1404
+ const findings = [];
1405
+ for (const [fp, c] of files) {
1406
+ if (!isSourceFile(fp) || fp.includes('test')) continue;
1407
+ const camelCaseVars = (c.match(/(?:const|let|var)\s+[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*/g) || []).length;
1408
+ const snakeCaseVars = (c.match(/(?:const|let|var)\s+[a-z][a-z0-9]*_[a-z][a-z0-9]*/g) || []).length;
1409
+ if (camelCaseVars > 5 && snakeCaseVars > 5) {
1410
+ findings.push({ ruleId: 'QUAL-MAINT-006', category: 'quality', severity: 'low', title: `Mixed camelCase (${camelCaseVars}) and snake_case (${snakeCaseVars}) — inconsistent naming`, description: 'Pick one convention and enforce with ESLint camelcase rule. Mixing naming styles reduces readability and makes search harder.', file: fp, fix: null });
1411
+ }
1412
+ }
1413
+ return findings;
1414
+ },
1415
+ },
1416
+
1417
+ // QUAL-MAINT-007: No linting for new team members
1418
+ { id: 'QUAL-MAINT-007', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No Editor Config File',
1419
+ check({ files }) {
1420
+ const findings = [];
1421
+ const hasEditorConfig = [...files.keys()].some(f => f.endsWith('.editorconfig'));
1422
+ const hasManyFiles = [...files.keys()].filter(f => f.match(/\.(js|ts|jsx|tsx)$/)).length > 10;
1423
+ if (!hasEditorConfig && hasManyFiles) {
1424
+ findings.push({ ruleId: 'QUAL-MAINT-007', category: 'quality', severity: 'medium', title: 'No .editorconfig — inconsistent indentation and line endings across editors', description: 'Add .editorconfig with indent_style, indent_size, end_of_line settings. Ensures consistency across VSCode, IntelliJ, Vim, and other editors.', fix: null });
1425
+ }
1426
+ return findings;
1427
+ },
1428
+ },
1429
+
1430
+ // QUAL-MAINT-008: No API documentation
1431
+ { id: 'QUAL-MAINT-008', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No API Documentation (OpenAPI/Swagger)',
1432
+ check({ files, stack }) {
1433
+ const findings = [];
1434
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1435
+ const hasAPIDocs = ['swagger-jsdoc', 'swagger-ui-express', '@nestjs/swagger', 'fastify-swagger', 'hapi-swagger', 'openapi-generator', 'tsoa'].some(d => d in allDeps) || [...files.keys()].some(f => f.match(/openapi\.(ya?ml|json)|swagger\.(ya?ml|json)/));
1436
+ const hasAPI = stack.framework;
1437
+ if (hasAPI && !hasAPIDocs) {
1438
+ findings.push({ ruleId: 'QUAL-MAINT-008', category: 'quality', severity: 'medium', title: 'No OpenAPI/Swagger documentation for API', description: 'Add swagger-jsdoc or use tsoa to generate OpenAPI docs. API docs reduce integration time for consumers and enable automated testing.', fix: null });
1439
+ }
1440
+ return findings;
1441
+ },
1442
+ },
1443
+
1444
+ // QUAL-CODE-015: Using arguments object instead of rest params
1445
+ { id: 'QUAL-CODE-015', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Using arguments Object (Prefer Rest Parameters)',
1446
+ check({ files }) {
1447
+ const findings = [];
1448
+ for (const [fp, c] of files) {
1449
+ if (!isSourceFile(fp)) continue;
1450
+ const lines = c.split('\n');
1451
+ for (let i = 0; i < lines.length; i++) {
1452
+ if (lines[i].match(/\barguments\[|\barguments\.length|\barguments\.caller/) && !lines[i].match(/\/\//)) {
1453
+ findings.push({ ruleId: 'QUAL-CODE-015', category: 'quality', severity: 'low', title: "Using 'arguments' object — use rest parameters (...args) instead", description: 'Replace with rest params: function foo(...args). arguments is not available in arrow functions and lacks Array methods without spread.', file: fp, line: i + 1, fix: null });
1454
+ }
1455
+ }
1456
+ }
1457
+ return findings;
1458
+ },
1459
+ },
1460
+ // QUAL-CODE-016: Mutable default parameter
1461
+ { id: 'QUAL-CODE-016', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Mutable Object/Array as Default Parameter',
1462
+ check({ files }) {
1463
+ const findings = [];
1464
+ for (const [fp, c] of files) {
1465
+ if (!isSourceFile(fp)) continue;
1466
+ const lines = c.split('\n');
1467
+ for (let i = 0; i < lines.length; i++) {
1468
+ if (lines[i].match(/function\s+\w+\s*\([^)]*=\s*(?:\[\]|\{\})/)) {
1469
+ findings.push({ ruleId: 'QUAL-CODE-016', category: 'quality', severity: 'medium', title: 'Mutable default parameter ([] or {}) — shared between calls in some cases', description: 'While JS recreates default params each call (unlike Python), this pattern is confusing. Use null and default inside: options = options ?? {}', file: fp, line: i + 1, fix: null });
1470
+ }
1471
+ }
1472
+ }
1473
+ return findings;
1474
+ },
1475
+ },
1476
+ // QUAL-CODE-017: Conditional assignment instead of short circuit
1477
+ { id: 'QUAL-CODE-017', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Verbose Conditional Assignment Pattern',
1478
+ check({ files }) {
1479
+ const findings = [];
1480
+ for (const [fp, c] of files) {
1481
+ if (!isSourceFile(fp)) continue;
1482
+ const count = (c.match(/if\s*\(\w+\s*===?\s*null\s*\|\|\s*\w+\s*===?\s*undefined\)/g) || []).length;
1483
+ if (count > 5) {
1484
+ findings.push({ ruleId: 'QUAL-CODE-017', category: 'quality', severity: 'low', title: `${count} verbose null/undefined checks — use nullish coalescing (??)`, description: 'Replace: if (x === null || x === undefined) x = default; with: x ??= default. Nullish coalescing is more concise and intention-revealing.', file: fp, fix: null });
1485
+ }
1486
+ }
1487
+ return findings;
1488
+ },
1489
+ },
1490
+ // QUAL-TS-008: Index signature typed as any
1491
+ { id: 'QUAL-TS-008', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript Index Signature Typed as any',
1492
+ check({ files }) {
1493
+ const findings = [];
1494
+ for (const [fp, c] of files) {
1495
+ if (!fp.match(/\.(ts|tsx)$/)) continue;
1496
+ const lines = c.split('\n');
1497
+ for (let i = 0; i < lines.length; i++) {
1498
+ if (lines[i].match(/\[key:\s*string\]:\s*any/)) {
1499
+ findings.push({ ruleId: 'QUAL-TS-008', category: 'quality', severity: 'medium', title: "TypeScript index signature typed as [key: string]: any", description: 'Use [key: string]: unknown or define specific types. Index signature: any disables all type checking on object access.', file: fp, line: i + 1, fix: null });
1500
+ }
1501
+ }
1502
+ }
1503
+ return findings;
1504
+ },
1505
+ },
1506
+ // QUAL-ARCH-009: Using global require() in module
1507
+ { id: 'QUAL-ARCH-009', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Dynamic require() Inside Function Body',
1508
+ check({ files }) {
1509
+ const findings = [];
1510
+ for (const [fp, c] of files) {
1511
+ if (!isSourceFile(fp)) continue;
1512
+ const lines = c.split('\n');
1513
+ for (let i = 0; i < lines.length; i++) {
1514
+ if (lines[i].match(/^\s{2,}(?:const|let|var)\s+\w+\s*=\s*require\s*\(/)) {
1515
+ findings.push({ ruleId: 'QUAL-ARCH-009', category: 'quality', severity: 'low', title: 'Dynamic require() inside function — deferred module loading', description: 'Move require() to the top of the file. Inline requires cause require() to run on each call and prevent static analysis of dependencies.', file: fp, line: i + 1, fix: null });
1516
+ }
1517
+ }
1518
+ }
1519
+ return findings;
1520
+ },
1521
+ },
1522
+ // QUAL-SEC-007: Command injection via exec
1523
+ { id: 'QUAL-SEC-007', category: 'quality', severity: 'critical', confidence: 'likely', title: 'Command Injection via exec/spawn',
1524
+ check({ files }) {
1525
+ const findings = [];
1526
+ for (const [fp, c] of files) {
1527
+ if (!isSourceFile(fp)) continue;
1528
+ const lines = c.split('\n');
1529
+ for (let i = 0; i < lines.length; i++) {
1530
+ if (lines[i].match(/exec\s*\(`|exec\s*\(.*\$\{|spawn\s*\(.*\$\{/i)) {
1531
+ findings.push({ ruleId: 'QUAL-SEC-007', category: 'quality', severity: 'critical', title: 'Command injection risk: template literal in exec/spawn', description: 'Use execFile() with separate args array: execFile(cmd, [arg1, arg2]). Template literals in exec enable shell injection if any value comes from user input.', file: fp, line: i + 1, fix: null });
1532
+ }
1533
+ }
1534
+ }
1535
+ return findings;
1536
+ },
1537
+ },
1538
+ // QUAL-TEST-006: No performance test for critical paths
1539
+ { id: 'QUAL-TEST-006', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Performance Assertions in Critical Path Tests',
1540
+ check({ files }) {
1541
+ const findings = [];
1542
+ for (const [fp, c] of files) {
1543
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
1544
+ if (c.match(/checkout|payment|search|login|auth/i) && !c.match(/timeout|duration|elapsed|performance\.now|Date\.now.*time/i)) {
1545
+ findings.push({ ruleId: 'QUAL-TEST-006', category: 'quality', severity: 'low', title: 'Critical path tests without performance assertions', description: 'Add timing assertions for critical flows: expect(duration).toBeLessThan(200). Performance regression tests catch slowdowns before deployment.', file: fp, fix: null });
1546
+ }
1547
+ }
1548
+ return findings;
1549
+ },
1550
+ },
1551
+ // QUAL-MAINT-009: No auto-format on commit
1552
+ { id: 'QUAL-MAINT-009', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Auto-Format on Git Commit',
1553
+ check({ files }) {
1554
+ const findings = [];
1555
+ const hasHusky = [...files.keys()].some(f => f.match(/\.husky\/|husky\s+install/));
1556
+ const hasLintStaged = [...files.keys()].some(f => f.match(/\.lintstagedrc|lint-staged/));
1557
+ if (!hasHusky && !hasLintStaged) {
1558
+ findings.push({ ruleId: 'QUAL-MAINT-009', category: 'quality', severity: 'low', title: 'No pre-commit hooks (husky + lint-staged) — unformatted code can be committed', description: 'Add husky + lint-staged to auto-format and lint on commit. Prevents style inconsistencies and catches issues before CI.', fix: null });
1559
+ }
1560
+ return findings;
1561
+ },
1562
+ },
1563
+ // QUAL-MAINT-010: No OpenAPI/Swagger spec
1564
+ { id: 'QUAL-MAINT-010', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No API Documentation (OpenAPI/Swagger)',
1565
+ check({ files, stack }) {
1566
+ const findings = [];
1567
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1568
+ const hasSwagger = ['swagger-ui-express', '@nestjs/swagger', 'fastify-swagger', 'express-openapi'].some(d => d in allDeps) || [...files.keys()].some(f => f.match(/swagger|openapi/i));
1569
+ const hasRoutes = [...files.values()].some(c => c.match(/router\.(get|post|put|delete|patch)\s*\(/));
1570
+ if (hasRoutes && !hasSwagger) {
1571
+ findings.push({ ruleId: 'QUAL-MAINT-010', category: 'quality', severity: 'medium', title: 'No OpenAPI/Swagger documentation for API routes', description: 'Add swagger-ui-express or @nestjs/swagger. Undocumented APIs slow down integration, increase support burden, and make onboarding harder.', fix: null });
1572
+ }
1573
+ return findings;
1574
+ },
1575
+ },
1576
+ // QUAL-MAINT-011: Inconsistent async/await vs promise chains
1577
+ { id: 'QUAL-MAINT-011', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Mixed async/await and Promise Chain Styles',
1578
+ check({ files }) {
1579
+ const findings = [];
1580
+ for (const [fp, c] of files) {
1581
+ if (!isSourceFile(fp)) continue;
1582
+ const hasAsync = (c.match(/async\s+function|async\s*\(|async\s*\w+\s*=>/g) || []).length;
1583
+ const hasThen = (c.match(/\.then\s*\(/g) || []).length;
1584
+ if (hasAsync > 5 && hasThen > 5) {
1585
+ findings.push({ ruleId: 'QUAL-MAINT-011', category: 'quality', severity: 'low', title: 'Mixed async/await and .then() chains in same file', description: 'Choose one async style and stick to it. Mixing styles makes code harder to read, debug, and refactor.', file: fp, fix: null });
1586
+ }
1587
+ }
1588
+ return findings;
1589
+ },
1590
+ },
1591
+ // QUAL-MAINT-012: No changelog maintained
1592
+ { id: 'QUAL-MAINT-012', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No CHANGELOG File',
1593
+ check({ files }) {
1594
+ const findings = [];
1595
+ const hasChangelog = [...files.keys()].some(f => f.match(/CHANGELOG|CHANGES|HISTORY/i));
1596
+ const hasConventionalCommits = [...files.keys()].some(f => f.match(/\.commitlintrc|commitlint\.config/));
1597
+ if (!hasChangelog && !hasConventionalCommits) {
1598
+ findings.push({ ruleId: 'QUAL-MAINT-012', category: 'quality', severity: 'low', title: 'No CHANGELOG — release history not tracked', description: 'Maintain a CHANGELOG.md or use conventional commits with standard-version/release-please. Changelogs communicate changes to users and stakeholders.', fix: null });
1599
+ }
1600
+ return findings;
1601
+ },
1602
+ },
1603
+ // QUAL-CODE-018: String concatenation instead of template literals
1604
+ { id: 'QUAL-CODE-018', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'String Concatenation Instead of Template Literals',
1605
+ check({ files }) {
1606
+ const findings = [];
1607
+ for (const [fp, c] of files) {
1608
+ if (!isSourceFile(fp)) continue;
1609
+ const lines = c.split('\n');
1610
+ for (let i = 0; i < lines.length; i++) {
1611
+ if (lines[i].match(/"[^"]*"\s*\+\s*\w+\s*\+\s*"[^"]*"/) && !lines[i].match(/\/\//)) {
1612
+ findings.push({ ruleId: 'QUAL-CODE-018', category: 'quality', severity: 'low', title: 'String concatenation — use template literals instead', description: 'Replace string concatenation with template literals. Template literals are more readable and reduce quote-escaping errors.', file: fp, line: i + 1, fix: null });
1613
+ }
1614
+ }
1615
+ }
1616
+ return findings;
1617
+ },
1618
+ },
1619
+ // QUAL-CODE-019: Using var instead of let/const
1620
+ { id: 'QUAL-CODE-019', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Using var Instead of let/const',
1621
+ check({ files }) {
1622
+ const findings = [];
1623
+ for (const [fp, c] of files) {
1624
+ if (!isSourceFile(fp)) continue;
1625
+ const lines = c.split('\n');
1626
+ for (let i = 0; i < lines.length; i++) {
1627
+ if (lines[i].match(/^\s*var\s+\w/) && !lines[i].match(/\/\//)) {
1628
+ findings.push({ ruleId: 'QUAL-CODE-019', category: 'quality', severity: 'medium', title: 'var declaration — use let or const', description: 'Replace var with let or const. var has function scope and hoisting behavior that causes subtle bugs. const/let have block scope.', file: fp, line: i + 1, fix: null });
1629
+ break;
1630
+ }
1631
+ }
1632
+ }
1633
+ return findings;
1634
+ },
1635
+ },
1636
+ // QUAL-CODE-020: Deep object destructuring
1637
+ { id: 'QUAL-CODE-020', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Deep Nested Object Access Without Destructuring',
1638
+ check({ files }) {
1639
+ const findings = [];
1640
+ for (const [fp, c] of files) {
1641
+ if (!isSourceFile(fp)) continue;
1642
+ const lines = c.split('\n');
1643
+ for (let i = 0; i < lines.length; i++) {
1644
+ if (lines[i].match(/\w+\.\w+\.\w+\.\w+\.\w+/) && !lines[i].match(/\/\/|import |require\(/)) {
1645
+ findings.push({ ruleId: 'QUAL-CODE-020', category: 'quality', severity: 'low', title: 'Deep property access chain — consider destructuring', description: 'Use destructuring for deeply nested properties. Reduces repetition and makes the intent clearer.', file: fp, line: i + 1, fix: null });
1646
+ break;
1647
+ }
1648
+ }
1649
+ }
1650
+ return findings;
1651
+ },
1652
+ },
1653
+ // QUAL-CODE-021: Synchronous sleep/delay pattern
1654
+ { id: 'QUAL-CODE-021', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Synchronous Busy-Wait Sleep Pattern',
1655
+ check({ files }) {
1656
+ const findings = [];
1657
+ for (const [fp, c] of files) {
1658
+ if (!isSourceFile(fp)) continue;
1659
+ const lines = c.split('\n');
1660
+ for (let i = 0; i < lines.length; i++) {
1661
+ if (lines[i].match(/while\s*\(.*Date\.now|for\s*\(.*Date\.now/) && !lines[i].match(/\/\//)) {
1662
+ findings.push({ ruleId: 'QUAL-CODE-021', category: 'quality', severity: 'medium', title: 'Busy-wait loop using Date.now() — blocks event loop', description: 'Use async sleep with setTimeout promise. Busy-wait loops block the Node.js event loop, preventing all other requests from being processed.', file: fp, line: i + 1, fix: null });
1663
+ }
1664
+ }
1665
+ }
1666
+ return findings;
1667
+ },
1668
+ },
1669
+ // QUAL-CODE-022: Error swallowing with empty .catch()
1670
+ { id: 'QUAL-CODE-022', category: 'quality', severity: 'high', confidence: 'likely', title: 'Empty .catch() Swallows Errors Silently',
1671
+ check({ files }) {
1672
+ const findings = [];
1673
+ for (const [fp, c] of files) {
1674
+ if (!isSourceFile(fp)) continue;
1675
+ const lines = c.split('\n');
1676
+ for (let i = 0; i < lines.length; i++) {
1677
+ if (lines[i].match(/\.catch\s*\(\s*\(\s*\)\s*=>\s*\{\s*\}|\\.catch\s*\(\s*\(\s*_\s*\)\s*=>\s*\{\s*\}|\\.catch\s*\(\s*\(\s*e\s*\)\s*=>\s*\{\s*\}/) && !lines[i].match(/\/\//)) {
1678
+ findings.push({ ruleId: 'QUAL-CODE-022', category: 'quality', severity: 'high', title: 'Empty .catch() silently swallows errors', description: 'Always log or rethrow in .catch(). Silent error swallowing hides failures and makes debugging extremely difficult.', file: fp, line: i + 1, fix: null });
1679
+ }
1680
+ }
1681
+ }
1682
+ return findings;
1683
+ },
1684
+ },
1685
+ // QUAL-TEST-007: No test coverage threshold configured
1686
+ { id: 'QUAL-TEST-007', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No Test Coverage Threshold Configured',
1687
+ check({ files }) {
1688
+ const findings = [];
1689
+ const hasTests = [...files.keys()].some(f => f.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/));
1690
+ const hasCoverage = [...files.values()].some(c => c.match(/coverage.*threshold|coverageThreshold|branches.*\d|lines.*\d.*functions/));
1691
+ if (hasTests && !hasCoverage) {
1692
+ findings.push({ ruleId: 'QUAL-TEST-007', category: 'quality', severity: 'medium', title: 'No coverage threshold — test coverage may silently degrade', description: 'Set coverageThreshold in jest.config.js. Without a minimum threshold, coverage silently degrades and technical debt accumulates.', fix: null });
1693
+ }
1694
+ return findings;
1695
+ },
1696
+ },
1697
+ // QUAL-TEST-008: No mutation testing
1698
+ { id: 'QUAL-TEST-008', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Mutation Testing Configuration',
1699
+ check({ files, stack }) {
1700
+ const findings = [];
1701
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1702
+ const hasUnitTests = [...files.keys()].some(f => f.match(/\.(test|spec)\.(js|ts)$/));
1703
+ const hasMutation = ['stryker-cli', '@stryker-mutator/core', 'stryker'].some(d => d in allDeps);
1704
+ if (hasUnitTests && !hasMutation) {
1705
+ findings.push({ ruleId: 'QUAL-TEST-008', category: 'quality', severity: 'low', title: 'No mutation testing — tests may not actually verify behavior', description: 'Consider adding Stryker for mutation testing. Mutation testing reveals tests that pass even when code is broken, catching inadequate assertions.', fix: null });
1706
+ }
1707
+ return findings;
1708
+ },
1709
+ },
1710
+ // QUAL-ARCH-010: No feature flag system
1711
+ { id: 'QUAL-ARCH-010', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No Feature Flag System',
1712
+ check({ files, stack }) {
1713
+ const findings = [];
1714
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1715
+ const hasFlags = ['launchdarkly-node-server-sdk', '@unleash/client', 'flagsmith', 'posthog-node', 'split-node-sdk'].some(d => d in allDeps) || [...files.values()].some(c => c.match(/featureFlag|feature_flag|isFeatureEnabled|getFlag/i));
1716
+ const hasMultipleEnvs = [...files.values()].some(c => c.match(/NODE_ENV.*production.*staging|staging.*production/));
1717
+ if (!hasFlags && hasMultipleEnvs) {
1718
+ findings.push({ ruleId: 'QUAL-ARCH-010', category: 'quality', severity: 'low', title: 'No feature flag system — risky big-bang releases', description: 'Add LaunchDarkly, Unleash, or Flagsmith. Feature flags enable safe progressive rollouts, instant rollbacks, and A/B testing without code deploys.', fix: null });
1719
+ }
1720
+ return findings;
1721
+ },
1722
+ },
1723
+ // QUAL-ARCH-011: Route handlers with no input validation middleware
1724
+ { id: 'QUAL-ARCH-011', category: 'quality', severity: 'high', confidence: 'likely', title: 'Route Handler Without Validation Middleware',
1725
+ check({ files }) {
1726
+ const findings = [];
1727
+ for (const [fp, c] of files) {
1728
+ if (!isSourceFile(fp)) continue;
1729
+ const lines = c.split('\n');
1730
+ for (let i = 0; i < lines.length; i++) {
1731
+ if (lines[i].match(/router\.(post|put|patch)\s*\(\s*['"`]/) && !lines[i].match(/validate|schema|joi|yup|zod/i)) {
1732
+ const hasValidation = lines.slice(Math.max(0, i - 2), i + 5).some(l => l.match(/validate|schema|joi|yup|zod|celebrate|express-validator/i));
1733
+ if (!hasValidation) {
1734
+ findings.push({ ruleId: 'QUAL-ARCH-011', category: 'quality', severity: 'high', title: 'POST/PUT route without visible input validation', description: 'Add validation middleware (Joi, Zod, express-validator). Unvalidated inputs are the root cause of injection attacks and data corruption bugs.', file: fp, line: i + 1, fix: null });
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ return findings;
1740
+ },
1741
+ },
1742
+ // QUAL-ARCH-012: Hardcoded pagination limit
1743
+ { id: 'QUAL-ARCH-012', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No Maximum Pagination Limit Enforced',
1744
+ check({ files }) {
1745
+ const findings = [];
1746
+ for (const [fp, c] of files) {
1747
+ if (!isSourceFile(fp)) continue;
1748
+ const lines = c.split('\n');
1749
+ for (let i = 0; i < lines.length; i++) {
1750
+ if (lines[i].match(/limit\s*=\s*req\.(query|body)\.limit|parseInt.*req\.(query|body)\.limit/) && !lines[i].match(/Math\.min|> \d+|<= \d+|maximum|maxLimit/i)) {
1751
+ findings.push({ ruleId: 'QUAL-ARCH-012', category: 'quality', severity: 'medium', title: 'Pagination limit taken from user input without maximum cap', description: 'Add Math.min(userLimit, MAX_LIMIT). Unbounded pagination allows users to dump entire tables in a single request.', file: fp, line: i + 1, fix: null });
1752
+ }
1753
+ }
1754
+ }
1755
+ return findings;
1756
+ },
1757
+ },
1758
+ // QUAL-TS-009: Missing interface for function parameters
1759
+ { id: 'QUAL-TS-009', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Inline Object Types Instead of Named Interfaces',
1760
+ check({ files }) {
1761
+ const findings = [];
1762
+ for (const [fp, c] of files) {
1763
+ if (!fp.endsWith('.ts') && !fp.endsWith('.tsx')) continue;
1764
+ const lines = c.split('\n');
1765
+ for (let i = 0; i < lines.length; i++) {
1766
+ if (lines[i].match(/function\s+\w+\s*\(\s*\{[^}]{50,}/) || lines[i].match(/=>\s*\{\s*$/) && lines[i - 1]?.match(/\(\s*\{[^}]{50,}/)) {
1767
+ findings.push({ ruleId: 'QUAL-TS-009', category: 'quality', severity: 'low', title: 'Long inline destructured parameter — extract to named interface', description: 'Extract complex parameter types to named interfaces. Named interfaces are reusable, improve IDE navigation, and self-document function contracts.', file: fp, line: i + 1, fix: null });
1768
+ break;
1769
+ }
1770
+ }
1771
+ }
1772
+ return findings;
1773
+ },
1774
+ },
1775
+ // QUAL-TS-010: Missing discriminated union
1776
+ { id: 'QUAL-TS-010', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Union Type Without Discriminant Property',
1777
+ check({ files }) {
1778
+ const findings = [];
1779
+ for (const [fp, c] of files) {
1780
+ if (!fp.endsWith('.ts') && !fp.endsWith('.tsx')) continue;
1781
+ const lines = c.split('\n');
1782
+ for (let i = 0; i < lines.length; i++) {
1783
+ if (lines[i].match(/type\s+\w+\s*=\s*\{[^}]+\}\s*\|\s*\{/) && !lines[i].match(/type:\s*['"`]/)) {
1784
+ findings.push({ ruleId: 'QUAL-TS-010', category: 'quality', severity: 'low', title: 'Union type without discriminant — use discriminated union', description: 'Add a type or kind literal property to each union member. Discriminated unions enable exhaustive type narrowing and better TypeScript inference.', file: fp, line: i + 1, fix: null });
1785
+ }
1786
+ }
1787
+ }
1788
+ return findings;
1789
+ },
1790
+ },
1791
+ // QUAL-SEC-008: Missing rate-limit on authentication endpoints
1792
+ { id: 'QUAL-SEC-008', category: 'quality', severity: 'high', confidence: 'likely', title: 'Auth Endpoint Without Rate Limiting',
1793
+ check({ files }) {
1794
+ const findings = [];
1795
+ for (const [fp, c] of files) {
1796
+ if (!isSourceFile(fp)) continue;
1797
+ const lines = c.split('\n');
1798
+ for (let i = 0; i < lines.length; i++) {
1799
+ if (lines[i].match(/router\.(post|get)\s*\(\s*['"`].*\/(login|signin|auth|token|password)/i)) {
1800
+ const context = lines.slice(Math.max(0, i - 5), i + 10).join('\n');
1801
+ if (!context.match(/rateLimit|rate-limit|express-rate-limit|throttle/i)) {
1802
+ findings.push({ ruleId: 'QUAL-SEC-008', category: 'quality', severity: 'high', title: 'Authentication route without rate limiting — brute-force attack possible', description: 'Add express-rate-limit on auth routes. Without rate limiting, an attacker can try millions of passwords per second against login endpoints.', file: fp, line: i + 1, fix: null });
1803
+ }
1804
+ }
1805
+ }
1806
+ }
1807
+ return findings;
1808
+ },
1809
+ },
1810
+ // QUAL-SEC-009: Sensitive data in URL query parameters
1811
+ { id: 'QUAL-SEC-009', category: 'quality', severity: 'high', confidence: 'likely', title: 'Sensitive Data Passed in URL Query Parameters',
1812
+ check({ files }) {
1813
+ const findings = [];
1814
+ for (const [fp, c] of files) {
1815
+ if (!isSourceFile(fp)) continue;
1816
+ const lines = c.split('\n');
1817
+ for (let i = 0; i < lines.length; i++) {
1818
+ if (lines[i].match(/[?&](token|api_key|apikey|password|secret|auth)=/i) && !lines[i].match(/\/\//)) {
1819
+ findings.push({ ruleId: 'QUAL-SEC-009', category: 'quality', severity: 'high', title: 'Sensitive data in URL query params — logged in server/proxy access logs', description: 'Use POST body or Authorization header for sensitive values. URL query parameters are logged in server logs, proxy logs, browser history, and referer headers.', file: fp, line: i + 1, fix: null });
1820
+ }
1821
+ }
1822
+ }
1823
+ return findings;
1824
+ },
1825
+ },
1826
+ // QUAL-SEC-010: Using eval() with user-controlled input
1827
+ { id: 'QUAL-SEC-010', category: 'quality', severity: 'critical', confidence: 'likely', title: 'eval() Used with Potentially User-Controlled Data',
1828
+ check({ files }) {
1829
+ const findings = [];
1830
+ for (const [fp, c] of files) {
1831
+ if (!isSourceFile(fp)) continue;
1832
+ const lines = c.split('\n');
1833
+ for (let i = 0; i < lines.length; i++) {
1834
+ if (lines[i].match(/\beval\s*\(/) && !lines[i].match(/\/\//) && lines[i].match(/req\.|user\.|body\.|query\.|param\.|input/i)) {
1835
+ findings.push({ ruleId: 'QUAL-SEC-010', category: 'quality', severity: 'critical', title: 'eval() with user-influenced data — remote code execution vulnerability', description: 'Never use eval() with user data. Use JSON.parse for data, or a proper expression parser for formulas. eval() allows arbitrary code execution.', file: fp, line: i + 1, fix: null });
1836
+ }
1837
+ }
1838
+ }
1839
+ return findings;
1840
+ },
1841
+ },
1842
+ // QUAL-MAINT-013: No README in project root
1843
+ { id: 'QUAL-MAINT-013', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No README.md in Project Root',
1844
+ check({ files }) {
1845
+ const findings = [];
1846
+ const hasReadme = [...files.keys()].some(f => f.match(/^README\.md$|^readme\.md$/i));
1847
+ if (!hasReadme) {
1848
+ findings.push({ ruleId: 'QUAL-MAINT-013', category: 'quality', severity: 'low', title: 'No README.md — project setup and purpose undocumented', description: 'Add a README with project purpose, setup instructions, and architecture overview. Missing README increases onboarding time and reduces contributor confidence.', fix: null });
1849
+ }
1850
+ return findings;
1851
+ },
1852
+ },
1853
+ // QUAL-ARCH-013: No request ID for distributed tracing
1854
+ { id: 'QUAL-ARCH-013', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No Request ID Propagation for Distributed Tracing',
1855
+ check({ files, stack }) {
1856
+ const findings = [];
1857
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1858
+ const hasRequestId = ['express-request-id', 'cls-hooked', 'async-local-storage', '@opentelemetry/api'].some(d => d in allDeps) || [...files.values()].some(c => c.match(/requestId|request_id|x-request-id|correlationId|traceId/i));
1859
+ const hasExpressOrFastify = 'express' in allDeps || 'fastify' in allDeps || 'koa' in allDeps;
1860
+ if (hasExpressOrFastify && !hasRequestId) {
1861
+ findings.push({ ruleId: 'QUAL-ARCH-013', category: 'quality', severity: 'medium', title: 'No request ID — cannot correlate logs for a single request', description: 'Add request ID middleware (express-request-id or uuid v4 in middleware). Include requestId in all log statements to trace a request through all services and log entries.', fix: null });
1862
+ }
1863
+ return findings;
1864
+ },
1865
+ },
1866
+ // QUAL-CODE-023: Using for...in on arrays
1867
+ { id: 'QUAL-CODE-023', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'for...in Loop Used on Array',
1868
+ check({ files }) {
1869
+ const findings = [];
1870
+ for (const [fp, c] of files) {
1871
+ if (!isSourceFile(fp)) continue;
1872
+ const lines = c.split('\n');
1873
+ for (let i = 0; i < lines.length; i++) {
1874
+ if (lines[i].match(/for\s*\(\s*(?:var|let|const)?\s*\w+\s+in\s+\w+/) && !lines[i].match(/\/\//)) {
1875
+ const ctx = lines.slice(Math.max(0, i - 15), i + 3).join('\n');
1876
+ if (ctx.match(/\[[\d'"]|Array\s*\(|new Array|\.push\s*\(|\.map\s*\(|\.filter\s*\(|=\s*\[/)) {
1877
+ findings.push({ ruleId: 'QUAL-CODE-023', category: 'quality', severity: 'medium', title: 'for...in used on array — iterates inherited properties', description: 'Use for...of, .forEach(), or indexed for loop for arrays. for...in iterates all enumerable properties including inherited ones, and does not guarantee order.', file: fp, line: i + 1, fix: null });
1878
+ }
1879
+ }
1880
+ }
1881
+ }
1882
+ return findings;
1883
+ },
1884
+ },
1885
+ // QUAL-TEST-009: Tests with no assertions
1886
+ { id: 'QUAL-TEST-009', category: 'quality', severity: 'high', confidence: 'likely', title: 'Test Without Any Assertions',
1887
+ check({ files }) {
1888
+ const findings = [];
1889
+ for (const [fp, c] of files) {
1890
+ if (!fp.match(/\.(test|spec)\.(js|ts|jsx|tsx)$/)) continue;
1891
+ const lines = c.split('\n');
1892
+ let inTest = false;
1893
+ let testStart = 0;
1894
+ let braceDepth = 0;
1895
+ let testContent = '';
1896
+ for (let i = 0; i < lines.length; i++) {
1897
+ if (lines[i].match(/\bit\s*\(|\btest\s*\(/)) {
1898
+ inTest = true;
1899
+ testStart = i;
1900
+ braceDepth = 0;
1901
+ testContent = '';
1902
+ }
1903
+ if (inTest) {
1904
+ testContent += lines[i];
1905
+ braceDepth += (lines[i].match(/\{/g) || []).length - (lines[i].match(/\}/g) || []).length;
1906
+ if (braceDepth <= 0 && testStart !== i) {
1907
+ if (!testContent.match(/expect\s*\(|assert\.|should\.|toBe|toEqual|toHave|toMatch|toThrow|toReturn/)) {
1908
+ findings.push({ ruleId: 'QUAL-TEST-009', category: 'quality', severity: 'high', title: 'Test with no assertions — always passes regardless of behavior', description: 'Add at least one expect() assertion. Tests without assertions pass even when the code under test is broken, providing false confidence.', file: fp, line: testStart + 1, fix: null });
1909
+ }
1910
+ inTest = false;
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+ return findings;
1916
+ },
1917
+ },
1918
+ // QUAL-MAINT-014: Unused exported function
1919
+ { id: 'QUAL-MAINT-014', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Dead Export — Exported Function Never Imported',
1920
+ check({ files }) {
1921
+ const findings = [];
1922
+ const exportedNames = new Set();
1923
+ const importedNames = new Set();
1924
+ for (const [fp, c] of files) {
1925
+ if (!isSourceFile(fp)) continue;
1926
+ for (const m of c.matchAll(/export\s+(?:const|function|class)\s+(\w+)/g)) exportedNames.add(m[1]);
1927
+ for (const m of c.matchAll(/import\s+\{([^}]+)\}/g)) m[1].split(',').forEach(n => importedNames.add(n.trim()));
1928
+ for (const m of c.matchAll(/require\(['"`][^'"`]+['"`]\)\s*\.\s*(\w+)/g)) importedNames.add(m[1]);
1929
+ }
1930
+ for (const name of exportedNames) {
1931
+ if (!importedNames.has(name) && !['default', 'handler', 'routes', 'router', 'app', 'server', 'middleware'].includes(name)) {
1932
+ findings.push({ ruleId: 'QUAL-MAINT-014', category: 'quality', severity: 'low', title: `Exported '${name}' appears to be unused — consider removing dead export`, description: 'Remove unused exports to reduce bundle size and improve codebase clarity. Dead exports create confusion about what surface area is intentionally public.', fix: null });
1933
+ if (findings.length >= 3) break;
1934
+ }
1935
+ }
1936
+ return findings;
1937
+ },
1938
+ },
1939
+ // QUAL-SEC-011: Insecure deserialization
1940
+ { id: 'QUAL-SEC-011', category: 'quality', severity: 'critical', confidence: 'likely', title: 'Insecure Deserialization with node-serialize',
1941
+ check({ files, stack }) {
1942
+ const findings = [];
1943
+ const allDeps = { ...stack.dependencies, ...stack.devDependencies };
1944
+ if ('node-serialize' in allDeps || 'serialize-javascript' in allDeps) {
1945
+ for (const [fp, c] of files) {
1946
+ if (!isSourceFile(fp)) continue;
1947
+ if (c.match(/unserialize\s*\(|deserialize\s*\(/)) {
1948
+ findings.push({ ruleId: 'QUAL-SEC-011', category: 'quality', severity: 'critical', title: 'Potentially unsafe deserialization — possible RCE via IIFE in serialized data', description: 'Avoid node-serialize for untrusted data. node-serialize executes IIFEs embedded in serialized objects, enabling remote code execution. Use JSON.parse or a safe alternative.', file: fp, fix: null });
1949
+ }
1950
+ }
1951
+ }
1952
+ return findings;
1953
+ },
1954
+ },
1955
+ // QUAL-ARCH-014: No input sanitization before DB queries
1956
+ { id: 'QUAL-ARCH-014', category: 'quality', severity: 'critical', confidence: 'likely', title: 'String Template Used in Database Query',
1957
+ check({ files }) {
1958
+ const findings = [];
1959
+ for (const [fp, c] of files) {
1960
+ if (!isSourceFile(fp)) continue;
1961
+ const lines = c.split('\n');
1962
+ for (let i = 0; i < lines.length; i++) {
1963
+ if (lines[i].match(/`SELECT|`INSERT|`UPDATE|`DELETE|`CREATE/) && lines[i].match(/\$\{.*req\.|template.*sql|query.*\`.*\$\{/i)) {
1964
+ findings.push({ ruleId: 'QUAL-ARCH-014', category: 'quality', severity: 'critical', title: 'SQL query built with template literal containing user input — SQL injection', description: 'Use parameterized queries or an ORM. Never interpolate user input into SQL strings. SQL injection is the #1 web application security risk (OWASP A03:2021).', file: fp, line: i + 1, fix: null });
1965
+ }
1966
+ }
1967
+ }
1968
+ return findings;
1969
+ },
1970
+ },
1971
+ ];
1972
+
1973
+ export default rules;
1974
+
1975
+ // QUAL-015: TypeScript 'any' type overuse
1976
+ rules.push({
1977
+ id: 'QUAL-015', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript "any" type used — disables type checking',
1978
+ check({ files }) {
1979
+ const findings = [];
1980
+ for (const [fp, c] of files) {
1981
+ if (!fp.match(/\.tsx?$/)) continue;
1982
+ if (isTestFile(fp)) continue;
1983
+ const lines = c.split('\n');
1984
+ for (let i = 0; i < lines.length; i++) {
1985
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
1986
+ if (/:\s*any\b|as\s+any\b|<any>/.test(lines[i]) && !/\/\/.*any|@ts-ignore/.test(lines[i])) {
1987
+ findings.push({ ruleId: 'QUAL-015', category: 'quality', severity: 'medium', title: '"any" type used — defeats TypeScript type safety', description: 'The "any" type disables type checking for that value. Use specific types, generics, or "unknown" with type narrowing instead.', file: fp, line: i + 1, fix: null });
1988
+ }
1989
+ }
1990
+ }
1991
+ return findings;
1992
+ },
1993
+ });
1994
+
1995
+ // QUAL-016: Missing TypeScript strict mode
1996
+ rules.push({
1997
+ id: 'QUAL-016', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript project without strict mode enabled',
1998
+ check({ files }) {
1999
+ const findings = [];
2000
+ for (const [fp, c] of files) {
2001
+ if (!fp.match(/tsconfig.*\.json$/)) continue;
2002
+ try {
2003
+ const config = JSON.parse(c);
2004
+ if (!config?.compilerOptions?.strict) {
2005
+ findings.push({ ruleId: 'QUAL-016', category: 'quality', severity: 'medium', title: 'tsconfig.json without "strict": true — many safety checks disabled', description: 'Enable strict mode in tsconfig.json to catch null/undefined errors, implicit any, and other common issues at compile time.', file: fp, fix: null });
2006
+ }
2007
+ } catch {}
2008
+ }
2009
+ return findings;
2010
+ },
2011
+ });
2012
+
2013
+ // QUAL-017: Deep nesting (> 4 levels)
2014
+ rules.push({
2015
+ id: 'QUAL-017', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Code nested more than 4 levels deep — hard to read and test',
2016
+ check({ files }) {
2017
+ const findings = [];
2018
+ for (const [fp, c] of files) {
2019
+ if (!isSourceFile(fp)) continue;
2020
+ if (isTestFile(fp)) continue;
2021
+ const lines = c.split('\n');
2022
+ for (let i = 0; i < lines.length; i++) {
2023
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2024
+ const indent = (lines[i].match(/^(\s*)/) || ['', ''])[1].length;
2025
+ if (indent >= 20) { // 4+ levels of 4-space indentation, or 5+ levels of 2-space
2026
+ findings.push({ ruleId: 'QUAL-017', category: 'quality', severity: 'low', title: 'Code nested >4 levels — refactor with early returns or extract functions', description: 'Deep nesting makes code hard to read, test, and maintain. Use early returns (guard clauses) or extract logic into smaller functions.', file: fp, line: i + 1, fix: null });
2027
+ i += 10; // Skip ahead to avoid flooding
2028
+ }
2029
+ }
2030
+ }
2031
+ return findings;
2032
+ },
2033
+ });
2034
+
2035
+ // QUAL-018: Function too long (> 100 lines)
2036
+ rules.push({
2037
+ id: 'QUAL-018', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Function longer than 100 lines — violates single responsibility',
2038
+ check({ files }) {
2039
+ const findings = [];
2040
+ for (const [fp, c] of files) {
2041
+ if (!isSourceFile(fp)) continue;
2042
+ if (isTestFile(fp)) continue;
2043
+ const lines = c.split('\n');
2044
+ for (let i = 0; i < lines.length; i++) {
2045
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2046
+ if (/(?:function\s+\w+|const\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>)\s*\{/.test(lines[i])) {
2047
+ let depth = 0;
2048
+ let end = i;
2049
+ for (let j = i; j < lines.length; j++) {
2050
+ depth += (lines[j].match(/\{/g) || []).length;
2051
+ depth -= (lines[j].match(/\}/g) || []).length;
2052
+ if (j > i && depth <= 0) { end = j; break; }
2053
+ }
2054
+ if (end - i > 100) {
2055
+ findings.push({ ruleId: 'QUAL-018', category: 'quality', severity: 'low', title: `Function is ${end - i} lines long — break into smaller functions`, description: 'Functions over 100 lines are hard to understand and test. Extract logical sections into well-named helper functions.', file: fp, line: i + 1, fix: null });
2056
+ }
2057
+ }
2058
+ }
2059
+ }
2060
+ return findings;
2061
+ },
2062
+ });
2063
+
2064
+ // QUAL-019: Switch without default case
2065
+ rules.push({
2066
+ id: 'QUAL-019', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'switch statement without default case',
2067
+ check({ files }) {
2068
+ const findings = [];
2069
+ for (const [fp, c] of files) {
2070
+ if (!isSourceFile(fp)) continue;
2071
+ if (isTestFile(fp)) continue;
2072
+ const lines = c.split('\n');
2073
+ for (let i = 0; i < lines.length; i++) {
2074
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2075
+ if (/\bswitch\s*\(/.test(lines[i])) {
2076
+ const block = lines.slice(i, Math.min(lines.length, i + 50)).join('\n');
2077
+ if (!/\bdefault\s*:/.test(block)) {
2078
+ findings.push({ ruleId: 'QUAL-019', category: 'quality', severity: 'low', title: 'switch without default case — unhandled values silently ignored', description: 'Add a default case to handle unexpected values. At minimum, log a warning or throw an error for unexpected input.', file: fp, line: i + 1, fix: null });
2079
+ }
2080
+ }
2081
+ }
2082
+ }
2083
+ return findings;
2084
+ },
2085
+ });
2086
+
2087
+ // QUAL-020: TODO/FIXME without issue tracker reference
2088
+ rules.push({
2089
+ id: 'QUAL-020', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'TODO/FIXME comment without issue tracker reference',
2090
+ check({ files }) {
2091
+ const findings = [];
2092
+ const todoPattern = /\/\/\s*(?:TODO|FIXME|HACK|XXX|BUG)\b(?!.*(?:#\d+|https?:\/\/|JIRA|TICKET|GH-))/i;
2093
+ for (const [fp, c] of files) {
2094
+ if (!isSourceFile(fp)) continue;
2095
+ const lines = c.split('\n');
2096
+ for (let i = 0; i < lines.length; i++) {
2097
+ if (todoPattern.test(lines[i])) {
2098
+ findings.push({ ruleId: 'QUAL-020', category: 'quality', severity: 'low', title: 'TODO/FIXME without issue tracker link — technical debt untracked', description: 'Reference an issue tracker ticket in TODO comments so technical debt is visible and prioritized: // TODO(GH-123): fix this.', file: fp, line: i + 1, fix: null });
2099
+ }
2100
+ }
2101
+ }
2102
+ return findings;
2103
+ },
2104
+ });
2105
+
2106
+ // QUAL-021: Shadowed variable
2107
+ rules.push({
2108
+ id: 'QUAL-021', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Variable shadows outer scope variable',
2109
+ check({ files }) {
2110
+ const findings = [];
2111
+ for (const [fp, c] of files) {
2112
+ if (!isSourceFile(fp)) continue;
2113
+ if (isTestFile(fp)) continue;
2114
+ const lines = c.split('\n');
2115
+ const outerVars = new Set();
2116
+ for (let i = 0; i < lines.length; i++) {
2117
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2118
+ const topLevel = lines[i].match(/^(?:const|let|var)\s+(\w+)/);
2119
+ if (topLevel) outerVars.add(topLevel[1]);
2120
+ const innerDecl = lines[i].match(/^\s{4,}(?:const|let|var)\s+(\w+)/);
2121
+ if (innerDecl && outerVars.has(innerDecl[1])) {
2122
+ findings.push({ ruleId: 'QUAL-021', category: 'quality', severity: 'low', title: `Variable '${innerDecl[1]}' shadows outer scope variable`, description: 'Shadowed variables cause confusion and bugs. Rename the inner variable to something more specific.', file: fp, line: i + 1, fix: null });
2123
+ }
2124
+ }
2125
+ }
2126
+ return findings;
2127
+ },
2128
+ });
2129
+
2130
+ // QUAL-022: Missing null coalescing (|| with falsy risk)
2131
+ rules.push({
2132
+ id: 'QUAL-022', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Logical OR used as default — may discard falsy but valid values (0, "")',
2133
+ check({ files }) {
2134
+ const findings = [];
2135
+ for (const [fp, c] of files) {
2136
+ if (!fp.match(/\.tsx?$/)) continue;
2137
+ if (isTestFile(fp)) continue;
2138
+ const lines = c.split('\n');
2139
+ for (let i = 0; i < lines.length; i++) {
2140
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2141
+ // Detect: const x = someValue || defaultValue where 0 or "" might be valid
2142
+ if (/(?:const|let)\s+\w+\s*=\s*\w+(?:\.\w+)?\s*\|\|\s*(?:\d+|['"][^'"]*['"])/.test(lines[i])) {
2143
+ findings.push({ ruleId: 'QUAL-022', category: 'quality', severity: 'low', title: 'Logical || for default value — use ?? to preserve 0 and empty string', description: 'x || default returns the default when x is 0, "", or false — which may be valid. Use x ?? default (nullish coalescing) to only fall back on null/undefined.', file: fp, line: i + 1, fix: null });
2144
+ }
2145
+ }
2146
+ }
2147
+ return findings;
2148
+ },
2149
+ });
2150
+
2151
+ // QUAL-023: Unused import
2152
+ rules.push({
2153
+ id: 'QUAL-023', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Potentially unused import',
2154
+ check({ files }) {
2155
+ const findings = [];
2156
+ for (const [fp, c] of files) {
2157
+ if (!isSourceFile(fp)) continue;
2158
+ if (isTestFile(fp)) continue;
2159
+ const lines = c.split('\n');
2160
+ for (let i = 0; i < lines.length; i++) {
2161
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2162
+ const m = lines[i].match(/^import\s+(?:\{\s*(\w+)\s*\}|(\w+))\s+from\s+['"][^'"]+['"]/);
2163
+ if (m) {
2164
+ const name = m[1] || m[2];
2165
+ if (name && name !== '_') {
2166
+ const rest = c.slice(c.indexOf(lines[i]) + lines[i].length);
2167
+ const usageCount = (rest.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
2168
+ if (usageCount === 0) {
2169
+ findings.push({ ruleId: 'QUAL-023', category: 'quality', severity: 'low', title: `Import '${name}' may be unused — remove to reduce bundle size`, description: 'Unused imports increase bundle size and create confusion. Remove imports that are never referenced in the file.', file: fp, line: i + 1, fix: null });
2170
+ }
2171
+ }
2172
+ }
2173
+ }
2174
+ }
2175
+ return findings;
2176
+ },
2177
+ });
2178
+
2179
+ // QUAL-024: console.error without stack trace
2180
+ rules.push({
2181
+ id: 'QUAL-024', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'console.error() called without error object — stack trace lost',
2182
+ check({ files }) {
2183
+ const findings = [];
2184
+ for (const [fp, c] of files) {
2185
+ if (!isSourceFile(fp)) continue;
2186
+ if (isTestFile(fp)) continue;
2187
+ const lines = c.split('\n');
2188
+ for (let i = 0; i < lines.length; i++) {
2189
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2190
+ if (/console\.error\s*\(\s*['"`]/.test(lines[i]) && !/\berr\b|\berror\b|\be\b/.test(lines[i])) {
2191
+ findings.push({ ruleId: 'QUAL-024', category: 'quality', severity: 'low', title: 'console.error() with string only — pass the error object for stack trace', description: 'console.error("message") loses the original error and stack trace. Use console.error("message", err) to preserve debugging information.', file: fp, line: i + 1, fix: null });
2192
+ }
2193
+ }
2194
+ }
2195
+ return findings;
2196
+ },
2197
+ });
2198
+
2199
+ // QUAL-025: Magic numbers without named constants
2200
+ rules.push({
2201
+ id: 'QUAL-025', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Magic number used inline — extract to named constant',
2202
+ check({ files }) {
2203
+ const findings = [];
2204
+ for (const [fp, c] of files) {
2205
+ if (!isSourceFile(fp)) continue;
2206
+ if (isTestFile(fp)) continue;
2207
+ const lines = c.split('\n');
2208
+ for (let i = 0; i < lines.length; i++) {
2209
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2210
+ // Numbers > 9 that aren't version numbers, port numbers in obvious places, or array indexes
2211
+ const m = lines[i].match(/[^'"a-zA-Z\d.]\b([1-9]\d{3,})\b/);
2212
+ if (m && !/CONST|const\s+[A-Z]|port|PORT|timeout|TIMEOUT|version/.test(lines[i])) {
2213
+ findings.push({ ruleId: 'QUAL-025', category: 'quality', severity: 'low', title: `Magic number ${m[1]} — extract to a named constant`, description: 'Magic numbers make code hard to understand and maintain. Extract to a named constant: const MAX_RETRY_COUNT = 3000.', file: fp, line: i + 1, fix: null });
2214
+ break; // Only one per line
2215
+ }
2216
+ }
2217
+ }
2218
+ return findings;
2219
+ },
2220
+ });
2221
+
2222
+ // QUAL-026: Type assertion without runtime check
2223
+ rules.push({
2224
+ id: 'QUAL-026', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript type assertion (as X) without runtime validation',
2225
+ check({ files }) {
2226
+ const findings = [];
2227
+ for (const [fp, c] of files) {
2228
+ if (!fp.match(/\.tsx?$/)) continue;
2229
+ if (isTestFile(fp)) continue;
2230
+ const lines = c.split('\n');
2231
+ for (let i = 0; i < lines.length; i++) {
2232
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2233
+ if (/\bas\s+\w+(?:<[^>]+>)?\b/.test(lines[i]) && !/as\s+const\b|as\s+(?:string|number|boolean|any|unknown)/.test(lines[i])) {
2234
+ const ctx = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 3)).join('\n');
2235
+ if (/JSON\.parse|fetch|axios|req\.body/.test(ctx)) {
2236
+ findings.push({ ruleId: 'QUAL-026', category: 'quality', severity: 'medium', title: 'Type assertion on parsed/external data without runtime validation', description: 'TypeScript type assertions don\'t validate at runtime. Use Zod, io-ts, or manual validation before asserting types on external data.', file: fp, line: i + 1, fix: null });
2237
+ }
2238
+ }
2239
+ }
2240
+ }
2241
+ return findings;
2242
+ },
2243
+ });
2244
+
2245
+ // QUAL-027: Missing return type annotation on exported function
2246
+ rules.push({
2247
+ id: 'QUAL-027', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Exported function without explicit return type annotation',
2248
+ check({ files }) {
2249
+ const findings = [];
2250
+ for (const [fp, c] of files) {
2251
+ if (!fp.match(/\.tsx?$/)) continue;
2252
+ if (isTestFile(fp)) continue;
2253
+ const lines = c.split('\n');
2254
+ for (let i = 0; i < lines.length; i++) {
2255
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2256
+ if (/^export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\)\s*\{/.test(lines[i]) && !/\):\s*\w/.test(lines[i])) {
2257
+ findings.push({ ruleId: 'QUAL-027', category: 'quality', severity: 'low', title: 'Exported function missing explicit return type — inferred type may widen unintentionally', description: 'Explicit return types serve as documentation and prevent unintended changes to the public API. Add return type annotations to exported functions.', file: fp, line: i + 1, fix: null });
2258
+ }
2259
+ }
2260
+ }
2261
+ return findings;
2262
+ },
2263
+ });
2264
+
2265
+ // QUAL-028: Non-null assertion operator overuse
2266
+ rules.push({
2267
+ id: 'QUAL-028', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript non-null assertion (!) used — runtime null error risk',
2268
+ check({ files }) {
2269
+ const findings = [];
2270
+ for (const [fp, c] of files) {
2271
+ if (!fp.match(/\.tsx?$/)) continue;
2272
+ if (isTestFile(fp)) continue;
2273
+ const lines = c.split('\n');
2274
+ for (let i = 0; i < lines.length; i++) {
2275
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2276
+ // Match patterns like obj!.property or value!
2277
+ if (/\w+!\.\w+|\w+!\s*[;,)]/.test(lines[i]) && !/\/\/|['"`]/.test(lines[i].split('!')[0].slice(-1))) {
2278
+ findings.push({ ruleId: 'QUAL-028', category: 'quality', severity: 'medium', title: 'Non-null assertion (!) bypasses null safety — add proper null check', description: 'The ! operator tells TypeScript to trust you that a value is not null, but provides no runtime safety. Use optional chaining (?.) or explicit null checks instead.', file: fp, line: i + 1, fix: null });
2279
+ }
2280
+ }
2281
+ }
2282
+ return findings;
2283
+ },
2284
+ });
2285
+
2286
+ // QUAL-029: Inconsistent async error handling
2287
+ rules.push({
2288
+ id: 'QUAL-029', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Mixed async error handling patterns in same file',
2289
+ check({ files }) {
2290
+ const findings = [];
2291
+ for (const [fp, c] of files) {
2292
+ if (!isSourceFile(fp)) continue;
2293
+ if (isTestFile(fp)) continue;
2294
+ const hasTryCatch = /try\s*\{[\s\S]*?\}\s*catch/.test(c);
2295
+ const hasPromiseCatch = /\.catch\s*\(/.test(c) && /async\s+function|async\s*\(/.test(c);
2296
+ const hasCallback = /callback|cb\)\s*\{|\(err,/.test(c) && /async\s+function|async\s*\(/.test(c);
2297
+ if (hasTryCatch && hasPromiseCatch && hasCallback) {
2298
+ findings.push({ ruleId: 'QUAL-029', category: 'quality', severity: 'medium', title: 'Mixed try/catch, .catch(), and callback error handling patterns', description: 'Mixing three error handling styles makes code unpredictable. Choose one: async/await with try/catch for new code, and refactor legacy callbacks with promisify().', file: fp, fix: null });
2299
+ }
2300
+ }
2301
+ return findings;
2302
+ },
2303
+ });
2304
+
2305
+ // QUAL-030: Floating point equality comparison
2306
+ rules.push({
2307
+ id: 'QUAL-030', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Direct floating-point equality comparison — unreliable',
2308
+ check({ files }) {
2309
+ const findings = [];
2310
+ for (const [fp, c] of files) {
2311
+ if (!isSourceFile(fp)) continue;
2312
+ if (isTestFile(fp)) continue;
2313
+ const lines = c.split('\n');
2314
+ for (let i = 0; i < lines.length; i++) {
2315
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2316
+ if (/(?:===|==)\s*0\.\d+|0\.\d+\s*(?:===|==)/.test(lines[i]) || /\.\d+\s*===\s*\.\d+/.test(lines[i])) {
2317
+ findings.push({ ruleId: 'QUAL-030', category: 'quality', severity: 'low', title: 'Floating-point direct equality — use Math.abs(a-b) < epsilon', description: 'Floating-point arithmetic is imprecise. Use Math.abs(a - b) < Number.EPSILON or a tolerance threshold for float comparisons.', file: fp, line: i + 1, fix: null });
2318
+ }
2319
+ }
2320
+ }
2321
+ return findings;
2322
+ },
2323
+ });
2324
+
2325
+ // QUAL-031: Hardcoded file paths
2326
+ rules.push({
2327
+ id: 'QUAL-031', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Hardcoded absolute file path — not portable across environments',
2328
+ check({ files }) {
2329
+ const findings = [];
2330
+ for (const [fp, c] of files) {
2331
+ if (!isSourceFile(fp)) continue;
2332
+ if (isTestFile(fp)) continue;
2333
+ const lines = c.split('\n');
2334
+ for (let i = 0; i < lines.length; i++) {
2335
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2336
+ if (/['"](\/home\/\w+|\/Users\/\w+|C:\\\\)/.test(lines[i])) {
2337
+ findings.push({ ruleId: 'QUAL-031', category: 'quality', severity: 'low', title: 'Hardcoded absolute path — use path.join(__dirname, ...) or config', description: 'Hardcoded absolute paths break on other machines and in containers. Use path.join(__dirname, "relative/path") or an environment-based config.', file: fp, line: i + 1, fix: null });
2338
+ }
2339
+ }
2340
+ }
2341
+ return findings;
2342
+ },
2343
+ });
2344
+
2345
+ // QUAL-032 through QUAL-058: Additional quality rules
2346
+
2347
+ // QUAL-032: Deeply nested callbacks (callback hell)
2348
+ rules.push({
2349
+ id: 'QUAL-032', category: 'quality', severity: 'medium', confidence: 'likely', title: 'Deeply nested callbacks — callback hell pattern',
2350
+ check({ files }) {
2351
+ const findings = [];
2352
+ for (const [fp, c] of files) {
2353
+ if (!isSourceFile(fp)) continue;
2354
+ if (isTestFile(fp)) continue;
2355
+ const lines = c.split('\n');
2356
+ for (let i = 0; i < lines.length; i++) {
2357
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2358
+ const indent = (lines[i].match(/^(\s*)/) || ['', ''])[1].length;
2359
+ if (indent >= 24 && /function\s*\(|=>\s*\{/.test(lines[i])) {
2360
+ findings.push({ ruleId: 'QUAL-032', category: 'quality', severity: 'medium', title: 'Deeply nested callbacks — refactor with async/await or Promises', description: 'Callback nesting beyond 4 levels (callback hell) makes code unmaintainable. Refactor using async/await or Promise chains.', file: fp, line: i + 1, fix: null });
2361
+ i += 15;
2362
+ }
2363
+ }
2364
+ }
2365
+ return findings;
2366
+ },
2367
+ });
2368
+
2369
+ // QUAL-033: Missing type guard function
2370
+ rules.push({
2371
+ id: 'QUAL-033', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Type narrowing via typeof only — consider proper type guard',
2372
+ check({ files }) {
2373
+ const findings = [];
2374
+ for (const [fp, c] of files) {
2375
+ if (!fp.match(/\.tsx?$/)) continue;
2376
+ if (isTestFile(fp)) continue;
2377
+ const lines = c.split('\n');
2378
+ for (let i = 0; i < lines.length; i++) {
2379
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2380
+ // Pattern: (value as SomeType).property — unsafe assertion on complex type
2381
+ if (/\)\s+as\s+[A-Z]\w+\s*\)\./.test(lines[i])) {
2382
+ findings.push({ ruleId: 'QUAL-033', category: 'quality', severity: 'low', title: 'Chained type assertion — use type guard function instead', description: 'Create a type guard: function isUser(v: unknown): v is User { return typeof v === "object" && v !== null && "id" in v; }', file: fp, line: i + 1, fix: null });
2383
+ }
2384
+ }
2385
+ }
2386
+ return findings;
2387
+ },
2388
+ });
2389
+
2390
+ // QUAL-034: Mutable exports
2391
+ rules.push({
2392
+ id: 'QUAL-034', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Exported mutable variable — external code can modify module state',
2393
+ check({ files }) {
2394
+ const findings = [];
2395
+ for (const [fp, c] of files) {
2396
+ if (!isSourceFile(fp)) continue;
2397
+ if (isTestFile(fp)) continue;
2398
+ const lines = c.split('\n');
2399
+ for (let i = 0; i < lines.length; i++) {
2400
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2401
+ if (/^export\s+let\s+/.test(lines[i])) {
2402
+ findings.push({ ruleId: 'QUAL-034', category: 'quality', severity: 'low', title: '"export let" — mutable export allows external mutation of module state', description: 'Use "export const" for exported values. Mutable exports let external modules change your module\'s internal state unexpectedly.', file: fp, line: i + 1, fix: null });
2403
+ }
2404
+ }
2405
+ }
2406
+ return findings;
2407
+ },
2408
+ });
2409
+
2410
+ // QUAL-035: Object mutation in place
2411
+ rules.push({
2412
+ id: 'QUAL-035', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Direct mutation of function parameter object',
2413
+ check({ files }) {
2414
+ const findings = [];
2415
+ for (const [fp, c] of files) {
2416
+ if (!isSourceFile(fp)) continue;
2417
+ if (isTestFile(fp)) continue;
2418
+ const lines = c.split('\n');
2419
+ for (let i = 0; i < lines.length; i++) {
2420
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2421
+ // Detect param mutation: param.x = something or delete param.x
2422
+ if (/(?:delete\s+\w+\.\w+|\w+\s*\.\s*\w+\s*=)/.test(lines[i])) {
2423
+ const fnCtx = lines.slice(Math.max(0, i - 10), i).join('\n');
2424
+ const fnMatch = fnCtx.match(/function\s+\w+\s*\((\w+)/);
2425
+ if (fnMatch) {
2426
+ const paramName = fnMatch[1];
2427
+ if (lines[i].match(new RegExp(`\\b${paramName}\\s*\\.\\s*\\w+\\s*=`))) {
2428
+ findings.push({ ruleId: 'QUAL-035', category: 'quality', severity: 'low', title: 'Function parameter mutated directly — use immutable patterns', description: 'Mutating function parameters creates side effects. Return a new object: return { ...param, field: newValue } instead of param.field = newValue.', file: fp, line: i + 1, fix: null });
2429
+ }
2430
+ }
2431
+ }
2432
+ }
2433
+ }
2434
+ return findings;
2435
+ },
2436
+ });
2437
+
2438
+ // QUAL-036: Missing default export
2439
+ rules.push({
2440
+ id: 'QUAL-036', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Module exports only named exports but no default — inconsistent import patterns',
2441
+ check({ files }) {
2442
+ const findings = [];
2443
+ for (const [fp, c] of files) {
2444
+ if (!fp.match(/\.(js|ts)$/) || isTestFile(fp)) continue;
2445
+ if (c.match(/^export\s+(?:const|function|class|type|interface)/m) && !c.match(/^export\s+default/m)) {
2446
+ const exportCount = (c.match(/^export\s+(?:const|function|class)/gm) || []).length;
2447
+ if (exportCount === 1) {
2448
+ findings.push({ ruleId: 'QUAL-036', category: 'quality', severity: 'low', title: 'Single named export without default — consider adding default export for easier importing', description: 'Files with a single primary export benefit from a default export, allowing cleaner import syntax: import MyClass from "./myClass".', file: fp, fix: null });
2449
+ }
2450
+ }
2451
+ }
2452
+ return findings;
2453
+ },
2454
+ });
2455
+
2456
+ // QUAL-037: No JSDoc on complex public functions
2457
+ rules.push({
2458
+ id: 'QUAL-037', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Exported function without documentation comment',
2459
+ check({ files }) {
2460
+ const findings = [];
2461
+ for (const [fp, c] of files) {
2462
+ if (!isSourceFile(fp)) continue;
2463
+ if (isTestFile(fp)) continue;
2464
+ const lines = c.split('\n');
2465
+ for (let i = 1; i < lines.length; i++) {
2466
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2467
+ if (/^export\s+(?:async\s+)?function\s+\w+\s*\([^)]{20,}\)/.test(lines[i])) {
2468
+ if (!/\/\*\*|\/\//.test(lines[i - 1])) {
2469
+ findings.push({ ruleId: 'QUAL-037', category: 'quality', severity: 'low', title: 'Complex exported function without JSDoc', description: 'Add JSDoc comments to exported functions with multiple parameters to document their purpose, parameters, and return values.', file: fp, line: i + 1, fix: null });
2470
+ }
2471
+ }
2472
+ }
2473
+ }
2474
+ return findings;
2475
+ },
2476
+ });
2477
+
2478
+ // QUAL-038: Overly broad catch clause
2479
+ rules.push({
2480
+ id: 'QUAL-038', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Catch clause catches all errors without type narrowing',
2481
+ check({ files }) {
2482
+ const findings = [];
2483
+ for (const [fp, c] of files) {
2484
+ if (!fp.match(/\.tsx?$/)) continue;
2485
+ if (isTestFile(fp)) continue;
2486
+ const lines = c.split('\n');
2487
+ for (let i = 0; i < lines.length; i++) {
2488
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2489
+ if (/\}\s*catch\s*\(\s*e\s*\)\s*\{|\}\s*catch\s*\(\s*error\s*\)\s*\{/.test(lines[i])) {
2490
+ const body = lines.slice(i + 1, Math.min(lines.length, i + 8)).join('\n');
2491
+ if (!/instanceof|e\s+as|error\s+as|typeof.*error/.test(body)) {
2492
+ findings.push({ ruleId: 'QUAL-038', category: 'quality', severity: 'low', title: 'Catch-all error handler without instanceof check — may mask programming errors', description: 'Use instanceof to distinguish expected errors from unexpected ones: if (error instanceof NetworkError) { ... } else throw error;', file: fp, line: i + 1, fix: null });
2493
+ }
2494
+ }
2495
+ }
2496
+ }
2497
+ return findings;
2498
+ },
2499
+ });
2500
+
2501
+ // QUAL-039: Boolean parameters
2502
+ rules.push({
2503
+ id: 'QUAL-039', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Function with boolean flag parameter — consider separate functions',
2504
+ check({ files }) {
2505
+ const findings = [];
2506
+ for (const [fp, c] of files) {
2507
+ if (!isSourceFile(fp)) continue;
2508
+ if (isTestFile(fp)) continue;
2509
+ const lines = c.split('\n');
2510
+ for (let i = 0; i < lines.length; i++) {
2511
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2512
+ if (/^(?:export\s+)?(?:async\s+)?function\s+\w+\s*\([^)]*,\s*(?:is\w+|has\w+|should\w+|enable\w+)\s*[,:)]/.test(lines[i])) {
2513
+ findings.push({ ruleId: 'QUAL-039', category: 'quality', severity: 'low', title: 'Boolean flag parameter in function — consider two separate functions', description: 'Boolean flag parameters like isAdmin or shouldCache indicate the function does two things. Split into two focused functions for clarity.', file: fp, line: i + 1, fix: null });
2514
+ }
2515
+ }
2516
+ }
2517
+ return findings;
2518
+ },
2519
+ });
2520
+
2521
+ // QUAL-040: Using Date.now() for performance measurement
2522
+ rules.push({
2523
+ id: 'QUAL-040', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Date.now() used for performance timing — use performance.now() instead',
2524
+ check({ files }) {
2525
+ const findings = [];
2526
+ for (const [fp, c] of files) {
2527
+ if (!isSourceFile(fp)) continue;
2528
+ if (isTestFile(fp)) continue;
2529
+ const lines = c.split('\n');
2530
+ for (let i = 0; i < lines.length; i++) {
2531
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2532
+ if (/Date\.now\s*\(\s*\)/.test(lines[i])) {
2533
+ const ctx = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 2)).join('\n');
2534
+ if (/elapsed|duration|timing|bench|perf|measure/i.test(ctx)) {
2535
+ findings.push({ ruleId: 'QUAL-040', category: 'quality', severity: 'low', title: 'Date.now() for performance timing — use performance.now() for higher precision', description: 'performance.now() returns a DOMHighResTimeStamp with sub-millisecond precision. Date.now() has only millisecond resolution and can be affected by system clock adjustments.', file: fp, line: i + 1, fix: null });
2536
+ }
2537
+ }
2538
+ }
2539
+ }
2540
+ return findings;
2541
+ },
2542
+ });
2543
+
2544
+ // QUAL-041: Using var instead of let/const
2545
+ rules.push({
2546
+ id: 'QUAL-041', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'var declaration — use let or const instead',
2547
+ check({ files }) {
2548
+ const findings = [];
2549
+ for (const [fp, c] of files) {
2550
+ if (!isSourceFile(fp)) continue;
2551
+ if (isTestFile(fp)) continue;
2552
+ const lines = c.split('\n');
2553
+ for (let i = 0; i < lines.length; i++) {
2554
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2555
+ if (/^\s*var\s+\w+/.test(lines[i])) {
2556
+ findings.push({ ruleId: 'QUAL-041', category: 'quality', severity: 'low', title: '"var" declaration — use "let" or "const" for block scoping', description: 'var is function-scoped and hoisted, leading to subtle bugs. Use const for values that don\'t change and let for mutable variables.', file: fp, line: i + 1, fix: null });
2557
+ }
2558
+ }
2559
+ }
2560
+ return findings;
2561
+ },
2562
+ });
2563
+
2564
+ // QUAL-042 through QUAL-062
2565
+
2566
+ // QUAL-042: Missing async/await error handling
2567
+ rules.push({
2568
+ id: 'QUAL-042', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Async arrow function without error boundary',
2569
+ check({ files }) {
2570
+ const findings = [];
2571
+ for (const [fp, c] of files) {
2572
+ if (!isSourceFile(fp)) continue;
2573
+ if (isTestFile(fp)) continue;
2574
+ const lines = c.split('\n');
2575
+ for (let i = 0; i < lines.length; i++) {
2576
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2577
+ if (/const\s+\w+\s*=\s*async\s*\([^)]*\)\s*=>/.test(lines[i])) {
2578
+ const block = lines.slice(i, Math.min(lines.length, i + 15)).join('\n');
2579
+ if (/await\s+/.test(block) && !/try\s*\{/.test(block)) {
2580
+ findings.push({ ruleId: 'QUAL-042', category: 'quality', severity: 'medium', title: 'Async function with await but no try/catch', description: 'Async functions that await without try/catch will cause unhandled rejections on failure. Wrap in try/catch or add error handling.', file: fp, line: i + 1, fix: null });
2581
+ }
2582
+ }
2583
+ }
2584
+ }
2585
+ return findings;
2586
+ },
2587
+ });
2588
+
2589
+ // QUAL-043: Long parameter list
2590
+ rules.push({
2591
+ id: 'QUAL-043', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Function with too many parameters — use options object instead',
2592
+ check({ files }) {
2593
+ const findings = [];
2594
+ for (const [fp, c] of files) {
2595
+ if (!isSourceFile(fp)) continue;
2596
+ if (isTestFile(fp)) continue;
2597
+ const lines = c.split('\n');
2598
+ for (let i = 0; i < lines.length; i++) {
2599
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2600
+ const m = lines[i].match(/function\s+\w+\s*\(([^)]+)\)/);
2601
+ if (m) {
2602
+ const params = m[1].split(',').filter(p => p.trim());
2603
+ if (params.length > 5) {
2604
+ findings.push({ ruleId: 'QUAL-043', category: 'quality', severity: 'low', title: `Function with ${params.length} parameters — refactor to options object`, description: 'Functions with many parameters are hard to call correctly. Refactor to accept a single options object: function foo({ a, b, c, d, e }).', file: fp, line: i + 1, fix: null });
2605
+ }
2606
+ }
2607
+ }
2608
+ }
2609
+ return findings;
2610
+ },
2611
+ });
2612
+
2613
+ // QUAL-044: Duplicate code blocks
2614
+ rules.push({
2615
+ id: 'QUAL-044', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Repeated code pattern detected — extract to reusable function',
2616
+ check({ files }) {
2617
+ const findings = [];
2618
+ for (const [fp, c] of files) {
2619
+ if (!isSourceFile(fp)) continue;
2620
+ if (isTestFile(fp)) continue;
2621
+ // Simple heuristic: same multi-line patterns repeated
2622
+ const tryCatchCount = (c.match(/try\s*\{[\s\S]*?\}\s*catch/g) || []).length;
2623
+ if (tryCatchCount > 5) {
2624
+ findings.push({ ruleId: 'QUAL-044', category: 'quality', severity: 'low', title: `${tryCatchCount} try/catch blocks — consider a centralized error handler`, description: 'Many try/catch blocks indicate repeated error handling logic. Extract common error handling into a shared utility or middleware.', file: fp, fix: null });
2625
+ }
2626
+ }
2627
+ return findings;
2628
+ },
2629
+ });
2630
+
2631
+ // QUAL-045: Missing return statement in non-void function
2632
+ rules.push({
2633
+ id: 'QUAL-045', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Function with conditional return — may return undefined',
2634
+ check({ files }) {
2635
+ const findings = [];
2636
+ for (const [fp, c] of files) {
2637
+ if (!fp.match(/\.tsx?$/)) continue;
2638
+ if (isTestFile(fp)) continue;
2639
+ const lines = c.split('\n');
2640
+ for (let i = 0; i < lines.length; i++) {
2641
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2642
+ if (/^(?:export\s+)?function\s+\w+\s*\([^)]*\):\s*\w+/.test(lines[i]) && !/\):\s*void\b|\):\s*Promise<void>/.test(lines[i])) {
2643
+ const body = lines.slice(i, Math.min(lines.length, i + 30)).join('\n');
2644
+ const returnCount = (body.match(/\breturn\s+/g) || []).length;
2645
+ if (returnCount > 0 && body.match(/\bif\s*\(/) && !body.match(/return\s+[^;]+;\s*$/m)) {
2646
+ findings.push({ ruleId: 'QUAL-045', category: 'quality', severity: 'medium', title: 'Function with typed return but conditional returns — may return undefined', description: 'Ensure all code paths in non-void TypeScript functions return a value. Add a default return or throw at the end.', file: fp, line: i + 1, fix: null });
2647
+ }
2648
+ }
2649
+ }
2650
+ }
2651
+ return findings;
2652
+ },
2653
+ });
2654
+
2655
+ // QUAL-046: Too many files in single directory
2656
+ rules.push({
2657
+ id: 'QUAL-046', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Too many files in single directory — consider restructuring',
2658
+ check({ files }) {
2659
+ const findings = [];
2660
+ const dirCounts = {};
2661
+ for (const fp of files.keys()) {
2662
+ const dir = fp.replace(/\/[^/]+$/, '');
2663
+ dirCounts[dir] = (dirCounts[dir] || 0) + 1;
2664
+ }
2665
+ for (const [dir, count] of Object.entries(dirCounts)) {
2666
+ if (count > 30 && !dir.match(/node_modules|dist|build/)) {
2667
+ findings.push({ ruleId: 'QUAL-046', category: 'quality', severity: 'low', title: `Directory "${dir}" has ${count} files — consider domain-based restructuring`, description: 'Large directories are hard to navigate. Organize files by domain or feature: src/users/, src/payments/, src/notifications/.', fix: null });
2668
+ }
2669
+ }
2670
+ return findings;
2671
+ },
2672
+ });
2673
+
2674
+ // QUAL-047: Object destructuring in tight loops
2675
+ rules.push({
2676
+ id: 'QUAL-047', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Complex destructuring inside loop — consider simplifying',
2677
+ check({ files }) {
2678
+ const findings = [];
2679
+ for (const [fp, c] of files) {
2680
+ if (!isSourceFile(fp)) continue;
2681
+ if (isTestFile(fp)) continue;
2682
+ const lines = c.split('\n');
2683
+ let loopDepth = 0;
2684
+ for (let i = 0; i < lines.length; i++) {
2685
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2686
+ if (/\b(?:for|while)\s*\(/.test(lines[i])) loopDepth++;
2687
+ if (/^\s*\}/.test(lines[i]) && loopDepth > 0) loopDepth--;
2688
+ if (loopDepth > 0 && /const\s*\{\s*\w+\s*,\s*\w+\s*,\s*\w+.*\}\s*=/.test(lines[i])) {
2689
+ findings.push({ ruleId: 'QUAL-047', category: 'quality', severity: 'low', title: 'Deep destructuring inside loop — creates new bindings each iteration', description: 'Complex destructuring in loops creates multiple variable bindings each iteration. Consider extracting to a function call outside the loop.', file: fp, line: i + 1, fix: null });
2690
+ }
2691
+ }
2692
+ }
2693
+ return findings;
2694
+ },
2695
+ });
2696
+
2697
+ // QUAL-048: Magic numbers without named constants
2698
+ rules.push({
2699
+ id: 'QUAL-048', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Magic number used directly in code — use named constant',
2700
+ check({ files }) {
2701
+ const findings = [];
2702
+ for (const [fp, c] of files) {
2703
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2704
+ const lines = c.split('\n');
2705
+ for (let i = 0; i < lines.length; i++) {
2706
+ if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
2707
+ if (/(?:===|!==|==|!=|>=|<=|>|<)\s*\d{3,}|\d{3,}\s*(?:===|!==|==|!=|>=|<=|>|<)/.test(lines[i]) && !/const\s+[A-Z_]+\s*=/.test(lines[i])) {
2708
+ findings.push({ ruleId: 'QUAL-048', category: 'quality', severity: 'low', title: 'Magic number in comparison — extract to named constant', description: 'Define numeric constants with descriptive names: const MAX_RETRY_COUNT = 3.', file: fp, line: i + 1, fix: null });
2709
+ }
2710
+ }
2711
+ }
2712
+ return findings;
2713
+ },
2714
+ });
2715
+
2716
+ // QUAL-049: Overly complex ternary expressions
2717
+ rules.push({
2718
+ id: 'QUAL-049', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Nested ternary expressions — hard to read',
2719
+ check({ files }) {
2720
+ const findings = [];
2721
+ for (const [fp, c] of files) {
2722
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2723
+ const lines = c.split('\n');
2724
+ for (let i = 0; i < lines.length; i++) {
2725
+ const ternaries = (lines[i].match(/\?[^:]+:/g) || []).length;
2726
+ if (ternaries >= 2) findings.push({ ruleId: 'QUAL-049', category: 'quality', severity: 'low', title: 'Nested ternary expressions — use if/else for readability', description: 'Replace nested ternary expressions with if/else statements or a switch for clarity.', file: fp, line: i + 1, fix: null });
2727
+ }
2728
+ }
2729
+ return findings;
2730
+ },
2731
+ });
2732
+
2733
+ // QUAL-050: Long lines exceeding 120 characters
2734
+ rules.push({
2735
+ id: 'QUAL-050', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Line exceeds 120 characters — reduces readability',
2736
+ check({ files }) {
2737
+ const findings = [];
2738
+ for (const [fp, c] of files) {
2739
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2740
+ const lines = c.split('\n');
2741
+ let count = 0;
2742
+ for (let i = 0; i < lines.length; i++) {
2743
+ if (lines[i].length > 120 && !/^\s*(\/\/|\/\*|\*)/.test(lines[i]) && !/https?:\/\//.test(lines[i])) {
2744
+ if (count === 0) findings.push({ ruleId: 'QUAL-050', category: 'quality', severity: 'low', title: `Line ${i + 1} is ${lines[i].length} characters — exceeds 120 char limit`, description: 'Break long lines to improve readability. Configure a line length limit in ESLint/prettier.', file: fp, line: i + 1, fix: null });
2745
+ count++;
2746
+ if (count >= 3) break;
2747
+ }
2748
+ }
2749
+ }
2750
+ return findings;
2751
+ },
2752
+ });
2753
+
2754
+ // QUAL-051: Commented-out code blocks
2755
+ rules.push({
2756
+ id: 'QUAL-051', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Large commented-out code block — should be removed',
2757
+ check({ files }) {
2758
+ const findings = [];
2759
+ for (const [fp, c] of files) {
2760
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2761
+ const lines = c.split('\n');
2762
+ let commentCount = 0;
2763
+ let startLine = -1;
2764
+ for (let i = 0; i < lines.length; i++) {
2765
+ if (/^\s*\/\/\s*(?:const|let|var|function|if|for|return|import|export)/.test(lines[i])) {
2766
+ if (commentCount === 0) startLine = i;
2767
+ commentCount++;
2768
+ } else {
2769
+ if (commentCount >= 5) findings.push({ ruleId: 'QUAL-051', category: 'quality', severity: 'low', title: `${commentCount} consecutive commented-out code lines — remove dead code`, description: 'Remove commented-out code. Version control preserves history if you need to recover it.', file: fp, line: startLine + 1, fix: null });
2770
+ commentCount = 0;
2771
+ }
2772
+ }
2773
+ }
2774
+ return findings;
2775
+ },
2776
+ });
2777
+
2778
+ // QUAL-052: Missing JSDoc on exported functions
2779
+ rules.push({
2780
+ id: 'QUAL-052', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Exported function without JSDoc comment',
2781
+ check({ files }) {
2782
+ const findings = [];
2783
+ for (const [fp, c] of files) {
2784
+ if (!isSourceFile(fp) || isTestFile(fp) || !fp.match(/\.[jt]s$/)) continue;
2785
+ const lines = c.split('\n');
2786
+ for (let i = 0; i < lines.length; i++) {
2787
+ if (/^export\s+(?:async\s+)?function\s+\w+/.test(lines[i])) {
2788
+ const prevLine = i > 0 ? lines[i-1] : '';
2789
+ if (!/\*\//.test(prevLine) && !/@param|@returns|@description/.test(prevLine)) {
2790
+ findings.push({ ruleId: 'QUAL-052', category: 'quality', severity: 'low', title: 'Exported function without JSDoc documentation', description: 'Add JSDoc comments to exported functions to document parameters, return values, and purpose.', file: fp, line: i + 1, fix: null });
2791
+ }
2792
+ }
2793
+ }
2794
+ }
2795
+ return findings;
2796
+ },
2797
+ });
2798
+
2799
+ // QUAL-053: Circular dependency detection
2800
+ rules.push({
2801
+ id: 'QUAL-053', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Potential circular dependency — module imports itself indirectly',
2802
+ check({ files }) {
2803
+ const findings = [];
2804
+ const importMap = new Map();
2805
+ for (const [fp, c] of files) {
2806
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2807
+ const imports = [];
2808
+ const lines = c.split('\n');
2809
+ for (const line of lines) {
2810
+ const m = line.match(/(?:import|require)\s*(?:\([^)]*\)|['"]([^'"]+)['"])/);
2811
+ if (m && m[1] && m[1].startsWith('.')) imports.push(m[1]);
2812
+ }
2813
+ importMap.set(fp, imports);
2814
+ }
2815
+ for (const [fp, imports] of importMap) {
2816
+ for (const imp of imports) {
2817
+ if (imp.includes(fp.replace(/\.[jt]sx?$/, '').split('/').pop() || '')) {
2818
+ findings.push({ ruleId: 'QUAL-053', category: 'quality', severity: 'medium', title: 'Possible circular dependency detected', description: 'Refactor to break circular dependencies. Use dependency injection or event emitters to decouple modules.', file: fp, fix: null });
2819
+ break;
2820
+ }
2821
+ }
2822
+ }
2823
+ return findings;
2824
+ },
2825
+ });
2826
+
2827
+ // QUAL-054: Boolean parameters (boolean traps)
2828
+ rules.push({
2829
+ id: 'QUAL-054', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Function with multiple boolean parameters — boolean trap',
2830
+ check({ files }) {
2831
+ const findings = [];
2832
+ for (const [fp, c] of files) {
2833
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2834
+ const lines = c.split('\n');
2835
+ for (let i = 0; i < lines.length; i++) {
2836
+ const m = lines[i].match(/function\s+\w+\s*\(([^)]+)\)/);
2837
+ if (m) {
2838
+ const boolParams = (m[1].match(/\bbool\b|\bisActive\b|\bisEnabled\b|\bforce\b|\bskip\b/g) || []).length;
2839
+ if (boolParams >= 2) findings.push({ ruleId: 'QUAL-054', category: 'quality', severity: 'low', title: 'Function with multiple boolean parameters — use options object instead', description: 'Replace boolean flags with a named options object: processData({ force: true, skip: false }).', file: fp, line: i + 1, fix: null });
2840
+ }
2841
+ }
2842
+ }
2843
+ return findings;
2844
+ },
2845
+ });
2846
+
2847
+ // QUAL-055: Empty catch block
2848
+ rules.push({
2849
+ id: 'QUAL-055', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Empty catch block — error silently swallowed',
2850
+ check({ files }) {
2851
+ const findings = [];
2852
+ for (const [fp, c] of files) {
2853
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2854
+ const lines = c.split('\n');
2855
+ for (let i = 0; i < lines.length; i++) {
2856
+ if (/\}\s*catch\s*\([^)]*\)\s*\{/.test(lines[i])) {
2857
+ const block = lines.slice(i, Math.min(lines.length, i + 3)).join('\n');
2858
+ if (/catch[^{]*\{\s*\}/.test(block)) findings.push({ ruleId: 'QUAL-055', category: 'quality', severity: 'medium', title: 'Empty catch block — error is silently swallowed', description: 'Handle caught errors: log them, rethrow, or add a comment explaining why they are intentionally ignored.', file: fp, line: i + 1, fix: null });
2859
+ }
2860
+ }
2861
+ }
2862
+ return findings;
2863
+ },
2864
+ });
2865
+
2866
+ // QUAL-056: Function reassignment (var being reassigned)
2867
+ rules.push({
2868
+ id: 'QUAL-056', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'var used instead of const/let — avoid mutable bindings',
2869
+ check({ files }) {
2870
+ const findings = [];
2871
+ for (const [fp, c] of files) {
2872
+ if (!isSourceFile(fp) || isTestFile(fp) || !fp.match(/\.[jt]sx?$/)) continue;
2873
+ const lines = c.split('\n');
2874
+ let count = 0;
2875
+ for (let i = 0; i < lines.length; i++) {
2876
+ if (/^\s*var\s+\w+/.test(lines[i]) && !/^\s*(\/\/|\/\*)/.test(lines[i])) {
2877
+ count++;
2878
+ }
2879
+ }
2880
+ if (count > 3) findings.push({ ruleId: 'QUAL-056', category: 'quality', severity: 'medium', title: `${count} var declarations — use const or let instead`, description: 'Replace var with const for immutable bindings and let for mutable ones. Enables block scoping and prevents hoisting issues.', file: fp, fix: null });
2881
+ }
2882
+ return findings;
2883
+ },
2884
+ });
2885
+
2886
+ // QUAL-057: Switch statement without default case
2887
+ rules.push({
2888
+ id: 'QUAL-057', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Switch statement without default case',
2889
+ check({ files }) {
2890
+ const findings = [];
2891
+ for (const [fp, c] of files) {
2892
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2893
+ const lines = c.split('\n');
2894
+ for (let i = 0; i < lines.length; i++) {
2895
+ if (/^\s*switch\s*\(/.test(lines[i])) {
2896
+ const block = lines.slice(i, Math.min(lines.length, i + 30)).join('\n');
2897
+ if (!/\bdefault\s*:/.test(block)) findings.push({ ruleId: 'QUAL-057', category: 'quality', severity: 'low', title: 'Switch statement without default case — unhandled values', description: 'Add a default case to switch statements to handle unexpected values gracefully.', file: fp, line: i + 1, fix: null });
2898
+ }
2899
+ }
2900
+ }
2901
+ return findings;
2902
+ },
2903
+ });
2904
+
2905
+ // QUAL-058: Inconsistent return types in function
2906
+ rules.push({
2907
+ id: 'QUAL-058', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Function returns different types in different code paths',
2908
+ check({ files }) {
2909
+ const findings = [];
2910
+ for (const [fp, c] of files) {
2911
+ if (!isSourceFile(fp) || isTestFile(fp) || !fp.match(/\.[jt]s$/)) continue;
2912
+ const lines = c.split('\n');
2913
+ for (let i = 0; i < lines.length; i++) {
2914
+ if (/^(?:export\s+)?(?:async\s+)?function\s+\w+/.test(lines[i])) {
2915
+ const body = lines.slice(i, Math.min(lines.length, i + 30)).join('\n');
2916
+ const returns = body.match(/\breturn\s+(.+?);/g) || [];
2917
+ const types = new Set(returns.map(r => {
2918
+ if (/return\s+null/.test(r)) return 'null';
2919
+ if (/return\s+(?:true|false)/.test(r)) return 'boolean';
2920
+ if (/return\s+\d/.test(r)) return 'number';
2921
+ if (/return\s+['"`]/.test(r)) return 'string';
2922
+ if (/return\s+\[/.test(r)) return 'array';
2923
+ if (/return\s+\{/.test(r)) return 'object';
2924
+ return 'other';
2925
+ }).filter(t => t !== 'other'));
2926
+ if (types.size >= 3) findings.push({ ruleId: 'QUAL-058', category: 'quality', severity: 'medium', title: 'Function returns multiple different types — inconsistent interface', description: 'Ensure functions return consistent types. Consider using TypeScript for type safety.', file: fp, line: i + 1, fix: null });
2927
+ }
2928
+ }
2929
+ }
2930
+ return findings;
2931
+ },
2932
+ });
2933
+
2934
+ // QUAL-059: No unit tests for utility functions
2935
+ rules.push({
2936
+ id: 'QUAL-059', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Utility module without corresponding test file',
2937
+ check({ files }) {
2938
+ const findings = [];
2939
+ for (const [fp, c] of files) {
2940
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2941
+ if (!fp.match(/util|helper|service|lib/i)) continue;
2942
+ const base = fp.replace(/\.[jt]sx?$/, '');
2943
+ const hasTest = [...files.keys()].some(f => f.includes(base.split('/').pop() || '') && isTestFile(f));
2944
+ if (!hasTest && /^export\s+/m.test(c)) {
2945
+ findings.push({ ruleId: 'QUAL-059', category: 'quality', severity: 'medium', title: 'Utility module without test file — exported functions untested', description: 'Add unit tests for utility/helper modules to ensure correctness and prevent regressions.', file: fp, fix: null });
2946
+ }
2947
+ }
2948
+ return findings;
2949
+ },
2950
+ });
2951
+
2952
+ // QUAL-060: Missing input validation in service layer
2953
+ rules.push({
2954
+ id: 'QUAL-060', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Service function without input validation',
2955
+ check({ files }) {
2956
+ const findings = [];
2957
+ for (const [fp, c] of files) {
2958
+ if (!isSourceFile(fp) || isTestFile(fp) || !fp.match(/service|Service/)) continue;
2959
+ const lines = c.split('\n');
2960
+ for (let i = 0; i < lines.length; i++) {
2961
+ if (/^(?:export\s+)?(?:async\s+)?function\s+\w+\s*\([^)]+\)/.test(lines[i])) {
2962
+ const body = lines.slice(i, Math.min(lines.length, i + 15)).join('\n');
2963
+ if (!/if\s*\(|throw|validate|assert|schema\./i.test(body)) {
2964
+ findings.push({ ruleId: 'QUAL-060', category: 'quality', severity: 'medium', title: 'Service function without input validation — invalid data may propagate to database', description: 'Validate service function inputs at the service layer to enforce business rules.', file: fp, line: i + 1, fix: null });
2965
+ break;
2966
+ }
2967
+ }
2968
+ }
2969
+ }
2970
+ return findings;
2971
+ },
2972
+ });
2973
+
2974
+ // QUAL-061: Function doing too many things (high line count)
2975
+ rules.push({
2976
+ id: 'QUAL-061', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Very long function — violates single responsibility principle',
2977
+ check({ files }) {
2978
+ const findings = [];
2979
+ for (const [fp, c] of files) {
2980
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
2981
+ const lines = c.split('\n');
2982
+ for (let i = 0; i < lines.length; i++) {
2983
+ if (/^(?:export\s+)?(?:async\s+)?function\s+\w+|(?:const|let)\s+\w+\s*=\s*(?:async\s+)?\(/.test(lines[i])) {
2984
+ let depth = 0;
2985
+ let end = i;
2986
+ for (let j = i; j < Math.min(lines.length, i + 200); j++) {
2987
+ depth += (lines[j].match(/\{/g) || []).length;
2988
+ depth -= (lines[j].match(/\}/g) || []).length;
2989
+ if (depth <= 0 && j > i) { end = j; break; }
2990
+ }
2991
+ if (end - i > 80) findings.push({ ruleId: 'QUAL-061', category: 'quality', severity: 'medium', title: `Function at line ${i+1} is ${end-i} lines long — consider breaking it up`, description: 'Functions longer than 80 lines should be refactored into smaller, focused functions.', file: fp, line: i + 1, fix: null });
2992
+ }
2993
+ }
2994
+ }
2995
+ return findings;
2996
+ },
2997
+ });
2998
+
2999
+ // QUAL-062: Missing error messages in validation
3000
+ rules.push({
3001
+ id: 'QUAL-062', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Validation error thrown without descriptive message',
3002
+ check({ files }) {
3003
+ const findings = [];
3004
+ for (const [fp, c] of files) {
3005
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
3006
+ const lines = c.split('\n');
3007
+ for (let i = 0; i < lines.length; i++) {
3008
+ if (/throw\s+new\s+Error\s*\(\s*\)/.test(lines[i])) {
3009
+ findings.push({ ruleId: 'QUAL-062', category: 'quality', severity: 'low', title: 'throw new Error() without message — unhelpful for debugging', description: 'Always provide a descriptive error message: throw new Error("Expected userId to be a string").', file: fp, line: i + 1, fix: null });
3010
+ }
3011
+ }
3012
+ }
3013
+ return findings;
3014
+ },
3015
+ });
3016
+
3017
+ // QUAL-063: Hardcoded URLs in source code
3018
+ rules.push({
3019
+ id: 'QUAL-063', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'Hardcoded URL in source code — should be configuration',
3020
+ check({ files }) {
3021
+ const findings = [];
3022
+ for (const [fp, c] of files) {
3023
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
3024
+ const lines = c.split('\n');
3025
+ for (let i = 0; i < lines.length; i++) {
3026
+ if (/['"`]https?:\/\/(?!localhost|127\.0\.0\.1)[^'"`]+\.(?:com|io|org|net)[^'"`]*['"`]/.test(lines[i]) && !/process\.env|config\.|comment|doc|link/.test(lines[i])) {
3027
+ findings.push({ ruleId: 'QUAL-063', category: 'quality', severity: 'medium', title: 'Hardcoded external URL — use environment variable or configuration', description: 'Extract URLs to environment variables or configuration files for flexibility across environments.', file: fp, line: i + 1, fix: null });
3028
+ break;
3029
+ }
3030
+ }
3031
+ }
3032
+ return findings;
3033
+ },
3034
+ });
3035
+
3036
+ // QUAL-064: Missing index.js barrel exports
3037
+ rules.push({
3038
+ id: 'QUAL-064', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Module directory without index.js barrel file',
3039
+ check({ files }) {
3040
+ const findings = [];
3041
+ const dirs = new Map();
3042
+ for (const [fp] of files) {
3043
+ if (!isSourceFile(fp)) continue;
3044
+ const dir = fp.replace(/\/[^/]+$/, '');
3045
+ if (!dirs.has(dir)) dirs.set(dir, []);
3046
+ dirs.get(dir).push(fp);
3047
+ }
3048
+ for (const [dir, dirFiles] of dirs) {
3049
+ if (dirFiles.length >= 3 && !dirFiles.some(f => f.endsWith('/index.js') || f.endsWith('/index.ts'))) {
3050
+ findings.push({ ruleId: 'QUAL-064', category: 'quality', severity: 'low', title: `Directory with ${dirFiles.length} files but no index.js barrel — imports are verbose`, description: 'Add an index.js/ts barrel file to re-export module contents for cleaner import paths.', file: dir + '/', fix: null });
3051
+ }
3052
+ }
3053
+ return findings;
3054
+ },
3055
+ });
3056
+
3057
+ // QUAL-065: Inconsistent error handling style
3058
+ rules.push({
3059
+ id: 'QUAL-065', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Mixed async patterns (callbacks and Promises) in same file',
3060
+ check({ files }) {
3061
+ const findings = [];
3062
+ for (const [fp, c] of files) {
3063
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
3064
+ const hasCallback = /function\s*\([^)]*callback|function\s*\([^)]*cb,|\.then\s*\(\s*function/.test(c);
3065
+ const hasAsync = /async\s+function|await\s+\w+/.test(c);
3066
+ if (hasCallback && hasAsync) findings.push({ ruleId: 'QUAL-065', category: 'quality', severity: 'low', title: 'Mixed callback and async/await patterns in same file', description: 'Standardize on async/await for consistency. Wrap callback-based APIs with util.promisify().', file: fp, fix: null });
3067
+ }
3068
+ return findings;
3069
+ },
3070
+ });
3071
+
3072
+ // QUAL-066: No TypeScript strict mode
3073
+ rules.push({
3074
+ id: 'QUAL-066', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'TypeScript project without strict mode enabled',
3075
+ check({ files }) {
3076
+ const findings = [];
3077
+ const tsconfigFile = [...files.keys()].find(f => f.endsWith('tsconfig.json'));
3078
+ if (!tsconfigFile) return findings;
3079
+ const c = files.get(tsconfigFile) || '';
3080
+ if (/"strict"\s*:\s*false/.test(c) || (!/["']strict["']/.test(c) && !/["']noImplicitAny["']/.test(c))) {
3081
+ findings.push({ ruleId: 'QUAL-066', category: 'quality', severity: 'medium', title: 'TypeScript without strict mode — many type safety benefits disabled', description: 'Enable "strict": true in tsconfig.json to catch type errors, null reference bugs, and implicit any types.', file: tsconfigFile, fix: null });
3082
+ }
3083
+ return findings;
3084
+ },
3085
+ });
3086
+
3087
+ // QUAL-067: Missing .editorconfig for consistent formatting
3088
+ rules.push({
3089
+ id: 'QUAL-067', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No .editorconfig file — inconsistent formatting across editors',
3090
+ check({ files }) {
3091
+ const findings = [];
3092
+ const hasEditorconfig = [...files.keys()].some(f => f.endsWith('.editorconfig'));
3093
+ const hasPrettier = [...files.keys()].some(f => f.endsWith('.prettierrc') || f.includes('prettier.config'));
3094
+ if (!hasEditorconfig && !hasPrettier) {
3095
+ const pkgJson = [...files.keys()].find(f => f.endsWith('package.json'));
3096
+ if (pkgJson) findings.push({ ruleId: 'QUAL-067', category: 'quality', severity: 'low', title: 'No .editorconfig or prettier config — inconsistent formatting across team', description: 'Add .editorconfig or .prettierrc to enforce consistent code style across all editors.', file: pkgJson, fix: null });
3097
+ }
3098
+ return findings;
3099
+ },
3100
+ });
3101
+
3102
+ // QUAL-068: Missing test coverage threshold
3103
+ rules.push({
3104
+ id: 'QUAL-068', category: 'quality', severity: 'medium', confidence: 'suggestion', title: 'No test coverage threshold configured',
3105
+ check({ files }) {
3106
+ const findings = [];
3107
+ const hasCoverageThreshold = [...files.values()].some(c => /coverageThreshold|coverage.threshold|--coverage.*threshold/i.test(c));
3108
+ if (!hasCoverageThreshold) {
3109
+ const pkgJson = [...files.keys()].find(f => f.endsWith('package.json'));
3110
+ if (pkgJson) {
3111
+ const c = files.get(pkgJson) || '';
3112
+ if (/jest|vitest|nyc|istanbul/i.test(c)) findings.push({ ruleId: 'QUAL-068', category: 'quality', severity: 'medium', title: 'Test framework without coverage threshold — coverage may decrease unnoticed', description: 'Configure a minimum coverage threshold in jest/vitest configuration to enforce test coverage standards.', file: pkgJson, fix: null });
3113
+ }
3114
+ }
3115
+ return findings;
3116
+ },
3117
+ });
3118
+
3119
+ // QUAL-069: Shadowed variable names
3120
+ rules.push({
3121
+ id: 'QUAL-069', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'Variable name shadows outer scope variable',
3122
+ check({ files }) {
3123
+ const findings = [];
3124
+ const commonShadows = ['data', 'error', 'result', 'response', 'user', 'event', 'item'];
3125
+ for (const [fp, c] of files) {
3126
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
3127
+ const lines = c.split('\n');
3128
+ for (let i = 0; i < lines.length; i++) {
3129
+ for (const name of commonShadows) {
3130
+ const outer = new RegExp(`^(?:const|let|var)\\s+${name}\\s*=`);
3131
+ const inner = new RegExp(`\\(.*\\b${name}\\b.*\\)|(?:const|let|var)\\s+${name}\\s*=`);
3132
+ if (i > 5 && outer.test(lines[i-5] || '') && inner.test(lines[i])) {
3133
+ findings.push({ ruleId: 'QUAL-069', category: 'quality', severity: 'low', title: `Variable '${name}' shadows outer scope — may cause confusion`, description: 'Use distinct variable names to avoid shadowing outer scope variables.', file: fp, line: i + 1, fix: null });
3134
+ break;
3135
+ }
3136
+ }
3137
+ }
3138
+ }
3139
+ return findings;
3140
+ },
3141
+ });
3142
+
3143
+ // QUAL-070: Async function not awaited (fire and forget without handling)
3144
+ rules.push({
3145
+ id: 'QUAL-070', category: 'quality', severity: 'high', confidence: 'likely', title: 'Async function called without await — unhandled promise',
3146
+ check({ files }) {
3147
+ const findings = [];
3148
+ for (const [fp, c] of files) {
3149
+ if (!isSourceFile(fp) || isTestFile(fp)) continue;
3150
+ const lines = c.split('\n');
3151
+ for (let i = 0; i < lines.length; i++) {
3152
+ if (/^\s*(?!await|return|const|let|var|throw|if|while|for)[a-zA-Z_$][\w$]*(?:\.\w+)*\s*\(/.test(lines[i]) && !/\/\/|=|:/.test(lines[i])) {
3153
+ if (/Async\s*\(|async\s+\w+\s*\(/.test(lines[Math.max(0, i-5)] + lines[i])) {
3154
+ findings.push({ ruleId: 'QUAL-070', category: 'quality', severity: 'high', title: 'Async function may be called without await — rejected promise unhandled', description: 'Always await async function calls or explicitly handle the returned promise with .catch().', file: fp, line: i + 1, fix: null });
3155
+ }
3156
+ }
3157
+ }
3158
+ }
3159
+ return findings;
3160
+ },
3161
+ });
3162
+
3163
+ // QUAL-071: Missing README documentation
3164
+ rules.push({
3165
+ id: 'QUAL-071', category: 'quality', severity: 'low', confidence: 'suggestion', title: 'No README.md file — project documentation missing',
3166
+ check({ files }) {
3167
+ const findings = [];
3168
+ const hasReadme = [...files.keys()].some(f => /README\.md|readme\.md/i.test(f));
3169
+ if (!hasReadme) {
3170
+ const pkgJson = [...files.keys()].find(f => f.endsWith('package.json'));
3171
+ if (pkgJson) findings.push({ ruleId: 'QUAL-071', category: 'quality', severity: 'low', title: 'No README.md — project missing documentation', description: 'Add a README.md with setup instructions, usage examples, and contribution guidelines.', file: pkgJson, fix: null });
3172
+ }
3173
+ return findings;
3174
+ },
3175
+ });