@vibecheck-ai/mcp 24.6.8 → 24.6.11

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 (41) hide show
  1. package/README.md +1 -1
  2. package/dist/APITruthEngine-IZRR3NT5-LPFUOMLD.js +9 -0
  3. package/dist/CredentialsEngine-B66ANCBB-HY5ZQTSX.js +9 -0
  4. package/dist/EnvVarEngine-ZFNW2XKP-6HRTZULP.js +9 -0
  5. package/dist/ErrorHandlingEngine-VAHVDVFG-3GDGRN3U.js +11 -0
  6. package/dist/FrameworkPackEngine-RRBJW4MC-KH7WRXXS.js +12 -0
  7. package/dist/GhostRouteEngine-UMYBCOCL-MSZOPVZY.js +9 -0
  8. package/dist/LogicGapEngine-OK5UKZQ5-YGXZDERB.js +11 -0
  9. package/dist/PhantomDepEngine-HQEXAS25-TRVEXBMF.js +10 -0
  10. package/dist/SecurityEngine-MVMRPKLH-BNP7IC46.js +9 -0
  11. package/dist/VersionHallucinationEngine-673DJ26J-BD4SK6JX.js +9 -0
  12. package/dist/chokidar-CI5VJY5M.js +2414 -0
  13. package/dist/chunk-43XAAYST.js +863 -0
  14. package/dist/chunk-5DADZJ3D.js +650 -0
  15. package/dist/chunk-DDTUTWRY.js +605 -0
  16. package/dist/chunk-DGNNNAVK.js +304 -0
  17. package/dist/chunk-F34MHA6A.js +772 -0
  18. package/dist/chunk-FGMVY5QW.js +42 -0
  19. package/dist/chunk-G3FQJC2H.js +1968 -0
  20. package/dist/chunk-J52EUKKW.js +196 -0
  21. package/dist/chunk-JZSHXEYP.js +915 -0
  22. package/dist/chunk-LQSBUKYZ.js +551 -0
  23. package/dist/chunk-MUP4JXOF.js +219 -0
  24. package/dist/chunk-NR36RTVO.js +152 -0
  25. package/dist/chunk-QFDZMUGO.js +213300 -0
  26. package/dist/chunk-QGPX6H6L.js +3044 -0
  27. package/dist/chunk-QYXENOVK.js +499 -0
  28. package/dist/chunk-RNFMO5GH.js +8861 -0
  29. package/dist/chunk-RR5ETBSV.js +66 -0
  30. package/dist/chunk-YWUMPN4Z.js +53 -0
  31. package/dist/dist-HFMJ3GIR.js +1091 -0
  32. package/dist/dist-JUOVMQEA.js +9 -0
  33. package/dist/dist-NXITTS32-O3XLWR6T.js +386 -0
  34. package/dist/dist-OUJKMVTB.js +22 -0
  35. package/dist/fingerprint-NOJ7TDB6-K6SB7LCZ.js +9 -0
  36. package/dist/index.js +4735 -3577
  37. package/dist/semantic-WW6XVII4.js +8544 -0
  38. package/dist/transformers.node-K4WKH4PR.js +45809 -0
  39. package/dist/tree-sitter-AGICL65I.js +1412 -0
  40. package/dist/tree-sitter-H5E7LKR4-MKO3NNLJ.js +9 -0
  41. package/package.json +7 -6
@@ -0,0 +1,605 @@
1
+ import { createRequire } from 'module';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname } from 'path';
4
+ import { BaseEngine } from './chunk-RR5ETBSV.js';
5
+
6
+ createRequire(import.meta.url);
7
+ const __filename$1 = fileURLToPath(import.meta.url);
8
+ dirname(__filename$1);
9
+
10
+ // ../engines/dist/chunk-DTMHBOUB.js
11
+ var FALSY_NUMERIC_NAMES = /\b(count|total|index|idx|amount|balance|quantity|offset|limit|size|length|width|height|age|year|port|code)\b/i;
12
+ function stripLineForLogicScan(line) {
13
+ let out = "";
14
+ let i = 0;
15
+ const n = line.length;
16
+ while (i < n) {
17
+ const c = line[i];
18
+ if (c === "/" && line[i + 1] === "/") {
19
+ out += " ".repeat(n - i);
20
+ break;
21
+ }
22
+ if (c === "/" && line[i + 1] === "*") {
23
+ const end = line.indexOf("*/", i + 2);
24
+ if (end === -1) {
25
+ out += " ".repeat(n - i);
26
+ break;
27
+ }
28
+ out += " ".repeat(end + 2 - i);
29
+ i = end + 2;
30
+ continue;
31
+ }
32
+ if (c === "'" || c === '"') {
33
+ const q = c;
34
+ let j = i + 1;
35
+ while (j < n) {
36
+ if (line[j] === "\\") {
37
+ j += 2;
38
+ continue;
39
+ }
40
+ if (line[j] === q) {
41
+ j++;
42
+ break;
43
+ }
44
+ j++;
45
+ }
46
+ out += " ".repeat(j - i);
47
+ i = j;
48
+ continue;
49
+ }
50
+ if (c === "`") {
51
+ let j = i + 1;
52
+ while (j < n) {
53
+ if (line[j] === "\\") {
54
+ j += 2;
55
+ continue;
56
+ }
57
+ if (line[j] === "$" && line[j + 1] === "{") {
58
+ let depth = 1;
59
+ j += 2;
60
+ while (j < n && depth > 0) {
61
+ if (line[j] === "{") depth++;
62
+ else if (line[j] === "}") depth--;
63
+ j++;
64
+ }
65
+ continue;
66
+ }
67
+ if (line[j] === "`") {
68
+ j++;
69
+ break;
70
+ }
71
+ j++;
72
+ }
73
+ out += " ".repeat(j - i);
74
+ i = j;
75
+ continue;
76
+ }
77
+ out += c;
78
+ i++;
79
+ }
80
+ return out;
81
+ }
82
+ function visualIndentLength(line) {
83
+ let n = 0;
84
+ for (let i = 0; i < line.length; i++) {
85
+ const c = line[i];
86
+ if (c === " ") n++;
87
+ else if (c === " ") n += 2;
88
+ else break;
89
+ }
90
+ return n;
91
+ }
92
+ function isBareAssignmentAt(s, idx) {
93
+ if (s[idx] !== "=") return false;
94
+ const prev = s[idx - 1];
95
+ const next = s[idx + 1];
96
+ if (next === "=") return false;
97
+ if (prev === "=" || prev === "!" || prev === "<" || prev === ">") return false;
98
+ return true;
99
+ }
100
+ function findAssignmentInConditional(stripped, keyword) {
101
+ const re = keyword === "if" ? /\bif\s*\(/ : /\bwhile\s*\(/;
102
+ const m = stripped.match(re);
103
+ if (!m || m.index === void 0) return null;
104
+ let i = m.index + m[0].length - 1;
105
+ if (stripped[i] !== "(") return null;
106
+ let depth = 0;
107
+ const start = i;
108
+ for (; i < stripped.length; i++) {
109
+ const c = stripped[i];
110
+ if (c === "(") {
111
+ depth++;
112
+ continue;
113
+ }
114
+ if (c === ")") {
115
+ depth--;
116
+ if (depth === 0) {
117
+ const inner = stripped.slice(start + 1, i);
118
+ for (let j = 0; j < inner.length; j++) {
119
+ if (isBareAssignmentAt(inner, j)) {
120
+ return m.index + m[0].length + j;
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ continue;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ var FALLTHROUGH_COMMENT_RE = /falls?\s*through|fallthrough|intentional\s*fallthrough|fall[\s-]*through/i;
131
+ function segmentHasFallthroughComment(text) {
132
+ return FALLTHROUGH_COMMENT_RE.test(text);
133
+ }
134
+ function segmentHasExitStatement(strippedSegment) {
135
+ if (/\b(?:break|continue)\s*;/m.test(strippedSegment)) return true;
136
+ if (/\breturn\b[^;]*;/m.test(strippedSegment)) return true;
137
+ if (/\bthrow\b[^;]*;/m.test(strippedSegment)) return true;
138
+ return false;
139
+ }
140
+ function isTestLikePath(uri) {
141
+ return /\.(test|spec)\.(ts|tsx|js|jsx)$/i.test(uri) || /(?:^|\/)(?:__tests__|__mocks__|tests?|fixtures?|e2e|spec|cypress|playwright|__snapshots__|stubs?|mocks?)\//i.test(
142
+ uri
143
+ );
144
+ }
145
+ function maskEqualityOps(s) {
146
+ return s.replace(/===/g, "\0A\0").replace(/!==/g, "\0B\0").replace(/=>/g, "\0C\0").replace(/<=/g, "\0D\0").replace(/>=/g, "\0E\0");
147
+ }
148
+ function isLooseNullOrUndefinedCheck(masked, eqIdx) {
149
+ const rest = masked.slice(eqIdx + 2).trimStart();
150
+ return /^null\b/.test(rest) || /^undefined\b/.test(rest);
151
+ }
152
+ function findLooseEqualityIssues(masked) {
153
+ const out = [];
154
+ for (let i = 0; i < masked.length - 1; i++) {
155
+ if (masked[i] === "=" && masked[i + 1] === "=") {
156
+ if (i > 0 && masked[i - 1] === "=") continue;
157
+ if (i + 2 < masked.length && masked[i + 2] === "=") continue;
158
+ if (!isLooseNullOrUndefinedCheck(masked, i)) {
159
+ out.push({ col: i, op: "==" });
160
+ }
161
+ i++;
162
+ continue;
163
+ }
164
+ if (masked[i] === "!" && masked[i + 1] === "=") {
165
+ if (i > 0 && masked[i - 1] === "=") continue;
166
+ if (i + 2 < masked.length && masked[i + 2] === "=") continue;
167
+ if (!isLooseNullOrUndefinedCheck(masked, i)) {
168
+ out.push({ col: i, op: "!=" });
169
+ }
170
+ i++;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+ var DOM_UNGUARDED_RE = /document\.(?:getElementById|querySelector|querySelectorAll)\s*\([^)]+\)\s*(?:\.(?!\?)|\[)/;
176
+ var JSON_PARSE_DOT_RE = /JSON\.parse\s*\([^)]*\)\s*\.\s*(?!catch\b)\w/;
177
+ function lineHasOptionalBeforeJsonDot(line) {
178
+ return /JSON\.parse\s*\([^)]*\)\s*\?\./.test(line);
179
+ }
180
+ var ARRAY_LENGTH_OOB_RE = /\b([a-zA-Z_$][\w$]*)\[\1\.length\](?!\s*=)/;
181
+ var SUBSTRING_SAME_ARGS_RE = /\bsubstring\s*\(\s*([a-zA-Z_$][\w$]*)\s*,\s*\1\s*\)/;
182
+ var FLOAT_EQ_RE = /\b\d+\.\d+(?!\.\d)\s*(===|!==|==|!=)\s*[\w.$`'"-]+|[\w.$`'"-]+\s*(===|!==|==|!=)\s*\b\d+\.\d+(?!\.\d)\b/;
183
+ function caseLabelLeadingLength(line) {
184
+ const m = line.match(/^(\s*)(case\b[^:]*:|default\s*:)/);
185
+ if (!m) return null;
186
+ return { indent: visualIndentLength(m[1]) };
187
+ }
188
+ var LogicGapEngine = class extends BaseEngine {
189
+ id = "logic_gap";
190
+ name = "Logic Gap Engine";
191
+ version = "1.0.0";
192
+ supportedExtensions = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
193
+ async scan(delta, signal) {
194
+ const findings = [];
195
+ const uri = delta.documentUri.replace(/^file:\/\//, "");
196
+ const source = delta.fullText;
197
+ const lines = delta.lines ?? source.split("\n");
198
+ this.checkAbort(signal);
199
+ if (isTestLikePath(uri)) {
200
+ return findings;
201
+ }
202
+ if (/\/scripts?\//i.test(uri) && /@vibecheck-generated\b/i.test(source.slice(0, 900))) {
203
+ return findings;
204
+ }
205
+ if (!/(?:==|!=|switch\b|case\b|return\b|throw\b|break\b|continue\b|if\s*\(|while\s*\(|indexOf\b|getElementById|querySelector|JSON\.parse|substring|\.length\])/.test(
206
+ source
207
+ )) {
208
+ return findings;
209
+ }
210
+ const fileHasSwitch = /\bswitch\s*\(/.test(source);
211
+ for (let i = 0; i < lines.length; i++) {
212
+ this.checkAbort(signal);
213
+ const line = lines[i];
214
+ const lineNum = i + 1;
215
+ const stripped = stripLineForLogicScan(line);
216
+ if (/vibecheck:example/i.test(line) || /vibecheck:example/i.test(lines[i - 1] ?? "")) {
217
+ continue;
218
+ }
219
+ const masked = maskEqualityOps(stripped);
220
+ for (const hit of findLooseEqualityIssues(masked)) {
221
+ const evidence = line.trim().slice(0, 100);
222
+ findings.push(
223
+ this.createFinding({
224
+ id: this.deterministicId(uri, lineNum, hit.col, "LOGIC001", hit.op),
225
+ engine: "logic_gap",
226
+ ruleId: "LOGIC001",
227
+ category: "logic_gap",
228
+ message: `Use ${hit.op === "==" ? "===" : "!=="} instead of ${hit.op} to avoid coercion bugs`,
229
+ severity: "medium",
230
+ confidence: 0.88,
231
+ file: uri,
232
+ line: lineNum,
233
+ column: hit.col,
234
+ endLine: lineNum,
235
+ endColumn: hit.col + 2,
236
+ evidence,
237
+ suggestion: `Replace ${hit.op} with ${hit.op === "==" ? "===" : "!=="}. Keep == null only when intentionally matching null and undefined.`,
238
+ autoFixable: false
239
+ })
240
+ );
241
+ }
242
+ if (DOM_UNGUARDED_RE.test(stripped)) {
243
+ if (!/\)\s*[!?]\s*\./.test(line) && !/\)\s*as\s+/.test(line)) {
244
+ findings.push(
245
+ this.createFinding({
246
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC002", "dom"),
247
+ engine: "logic_gap",
248
+ ruleId: "LOGIC002",
249
+ category: "logic_gap",
250
+ message: "Property access on querySelector/getElementById result without optional chaining \u2014 may be null",
251
+ severity: "high",
252
+ confidence: 0.68,
253
+ file: uri,
254
+ line: lineNum,
255
+ column: 0,
256
+ evidence: line.trim().slice(0, 100),
257
+ suggestion: "Use ?. , a null check, or a non-null assertion only when the element is guaranteed.",
258
+ autoFixable: false
259
+ })
260
+ );
261
+ }
262
+ } else if (JSON_PARSE_DOT_RE.test(stripped) && !lineHasOptionalBeforeJsonDot(line)) {
263
+ findings.push(
264
+ this.createFinding({
265
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC002", "json"),
266
+ engine: "logic_gap",
267
+ ruleId: "LOGIC002",
268
+ category: "logic_gap",
269
+ message: "Property access on JSON.parse() result without validation or optional chaining",
270
+ severity: "high",
271
+ confidence: 0.62,
272
+ file: uri,
273
+ line: lineNum,
274
+ column: 0,
275
+ evidence: line.trim().slice(0, 100),
276
+ suggestion: "Validate with a schema (zod/io-ts), narrow the type, or use optional chaining.",
277
+ autoFixable: false
278
+ })
279
+ );
280
+ }
281
+ if (ARRAY_LENGTH_OOB_RE.test(stripped)) {
282
+ findings.push(
283
+ this.createFinding({
284
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC004", "len"),
285
+ engine: "logic_gap",
286
+ ruleId: "LOGIC004",
287
+ category: "logic_gap",
288
+ message: "Reading array[array.length] is always undefined \u2014 likely off-by-one (use length - 1 or push)",
289
+ severity: "medium",
290
+ confidence: 0.68,
291
+ file: uri,
292
+ line: lineNum,
293
+ column: 0,
294
+ evidence: line.trim().slice(0, 100),
295
+ suggestion: "Use index array.length - 1 for the last element, or array.push(...) to append.",
296
+ autoFixable: false
297
+ })
298
+ );
299
+ }
300
+ if (SUBSTRING_SAME_ARGS_RE.test(stripped)) {
301
+ findings.push(
302
+ this.createFinding({
303
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC004", "sub"),
304
+ engine: "logic_gap",
305
+ ruleId: "LOGIC004",
306
+ category: "logic_gap",
307
+ message: "substring(start, start) always yields an empty string \u2014 check indices",
308
+ severity: "low",
309
+ confidence: 0.58,
310
+ file: uri,
311
+ line: lineNum,
312
+ column: 0,
313
+ evidence: line.trim().slice(0, 100),
314
+ suggestion: "Verify both arguments; use slice if you need different semantics.",
315
+ autoFixable: false
316
+ })
317
+ );
318
+ }
319
+ const ctrl = stripped.match(
320
+ /^\s*(return\b[^;]*;\s*|throw\b[^;]*;\s*|break\s*;\s*|continue\s*;\s*)$/
321
+ );
322
+ if (ctrl) {
323
+ const ind = visualIndentLength(line);
324
+ for (let j = i + 1; j < lines.length; j++) {
325
+ const next = lines[j];
326
+ if (/vibecheck:example/i.test(next)) break;
327
+ const nt = stripLineForLogicScan(next);
328
+ if (!nt.trim()) continue;
329
+ if (/^\s*\/\//.test(next.trim())) {
330
+ if (/vibecheck:example/i.test(next)) break;
331
+ continue;
332
+ }
333
+ if (/^\s*\*/.test(next.trim())) continue;
334
+ const nj = visualIndentLength(next);
335
+ if (nj < ind) break;
336
+ if (nj > ind) continue;
337
+ const t = nt.trim();
338
+ if (/^case\b|^default\s*:/.test(t)) break;
339
+ if (t.startsWith("}")) break;
340
+ if (/^(else\b|catch\b|finally\b)/.test(t)) break;
341
+ findings.push(
342
+ this.createFinding({
343
+ id: this.deterministicId(uri, j + 1, 0, "LOGIC005", "unreachable"),
344
+ engine: "logic_gap",
345
+ ruleId: "LOGIC005",
346
+ category: "logic_gap",
347
+ message: "Unreachable code after return/throw/break/continue",
348
+ severity: "medium",
349
+ confidence: 0.74,
350
+ file: uri,
351
+ line: j + 1,
352
+ column: 0,
353
+ evidence: next.trim().slice(0, 100),
354
+ suggestion: "Remove dead code or fix control flow.",
355
+ autoFixable: false
356
+ })
357
+ );
358
+ break;
359
+ }
360
+ }
361
+ const assignCol = findAssignmentInConditional(stripped, "if") ?? findAssignmentInConditional(stripped, "while");
362
+ if (assignCol !== null) {
363
+ findings.push(
364
+ this.createFinding({
365
+ id: this.deterministicId(uri, lineNum, assignCol, "LOGIC006", "cond"),
366
+ engine: "logic_gap",
367
+ ruleId: "LOGIC006",
368
+ category: "logic_gap",
369
+ message: "Assignment inside if/while condition \u2014 often mistyped comparison (= vs ===)",
370
+ severity: "high",
371
+ confidence: 0.65,
372
+ file: uri,
373
+ line: lineNum,
374
+ column: assignCol,
375
+ evidence: line.trim().slice(0, 100),
376
+ suggestion: "If you meant comparison, use ===. If assignment is intentional, assign before the if/while.",
377
+ autoFixable: false
378
+ })
379
+ );
380
+ }
381
+ if (FLOAT_EQ_RE.test(stripped) && !/Number\.EPSILON|approx|tolerance|toBeCloseTo/i.test(line)) {
382
+ findings.push(
383
+ this.createFinding({
384
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC007", "float"),
385
+ engine: "logic_gap",
386
+ ruleId: "LOGIC007",
387
+ category: "logic_gap",
388
+ message: "Direct equality check involving floating-point literals \u2014 precision risk",
389
+ severity: "medium",
390
+ confidence: 0.58,
391
+ file: uri,
392
+ line: lineNum,
393
+ column: 0,
394
+ evidence: line.trim().slice(0, 100),
395
+ suggestion: "Compare with a tolerance, use Number.EPSILON, or round before comparing.",
396
+ autoFixable: false
397
+ })
398
+ );
399
+ }
400
+ const falsyM = stripped.match(/\bif\s*\(\s*!\s*([a-zA-Z_$][\w$]*)\s*\)/);
401
+ if (falsyM && FALSY_NUMERIC_NAMES.test(falsyM[1])) {
402
+ findings.push(
403
+ this.createFinding({
404
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC008", falsyM[1]),
405
+ engine: "logic_gap",
406
+ ruleId: "LOGIC008",
407
+ category: "logic_gap",
408
+ message: `Negated check on '${falsyM[1]}' \u2014 0 or "" are often valid for this name pattern`,
409
+ severity: "low",
410
+ confidence: 0.62,
411
+ file: uri,
412
+ line: lineNum,
413
+ column: 0,
414
+ evidence: line.trim().slice(0, 100),
415
+ suggestion: 'Prefer explicit checks: === 0, === "", or != null depending on intent.',
416
+ autoFixable: false
417
+ })
418
+ );
419
+ }
420
+ if (/(?:if|while)\s*\(/.test(stripped) && /\.indexOf\s*\(/.test(stripped)) {
421
+ if (/~\s*[^)]*\.indexOf|\.indexOf[^\n)]+\)\s*[-+*/]/.test(stripped)) ;
422
+ else if (/\.indexOf[^\n)]+\)\s*(?:!==|!=|===|==|>=|>|<=|<)/.test(stripped)) ;
423
+ else {
424
+ const im = stripped.match(/(?:if|while)\s*\(\s*([^)]*\.indexOf\s*\([^)]+\))\s*\)/);
425
+ if (im && !/!==|!=|===|==|>=|>|<=|</.test(im[1])) {
426
+ findings.push(
427
+ this.createFinding({
428
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC011", "idx"),
429
+ engine: "logic_gap",
430
+ ruleId: "LOGIC011",
431
+ category: "logic_gap",
432
+ message: "indexOf(...) used as truthiness \u2014 -1 is truthy; use !== -1, >= 0, or .includes()",
433
+ severity: "high",
434
+ confidence: 0.72,
435
+ file: uri,
436
+ line: lineNum,
437
+ column: 0,
438
+ evidence: line.trim().slice(0, 100),
439
+ suggestion: "Rewrite as str.indexOf(x) !== -1 or str.includes(x).",
440
+ autoFixable: false
441
+ })
442
+ );
443
+ }
444
+ }
445
+ }
446
+ const optionalChainMatch012 = stripped.match(/(\w{3,})\?\.\w+/);
447
+ if (optionalChainMatch012) {
448
+ const ocVar = optionalChainMatch012[1];
449
+ if (/^(this|self|ctx|obj|res|req|err|ref|val)$/.test(ocVar)) ;
450
+ else {
451
+ for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
452
+ const futureLine = lines[j];
453
+ if (/(?:function\s|=>\s*\{|\.then\s*\(|\.catch\s*\()/.test(futureLine)) break;
454
+ const futureStripped = stripLineForLogicScan(futureLine);
455
+ const unsafeAccess = new RegExp(`\\b${ocVar}\\.\\w+(?!\\?)`, "g");
456
+ if (unsafeAccess.test(futureStripped) && !new RegExp(`\\b${ocVar}\\?\\.`).test(futureStripped)) {
457
+ const guardPattern = new RegExp(`if\\s*\\(\\s*${ocVar}\\s*\\)`);
458
+ const hasGuard = lines.slice(i, j + 1).some((l) => guardPattern.test(l));
459
+ if (!hasGuard) {
460
+ findings.push(
461
+ this.createFinding({
462
+ id: this.deterministicId(uri, j + 1, 0, "LOGIC012", ocVar),
463
+ engine: "logic_gap",
464
+ ruleId: "LOGIC012",
465
+ category: "logic_gap",
466
+ message: `"${ocVar}" accessed with ?. on line ${lineNum} but without ?. here \u2014 inconsistent null safety`,
467
+ severity: "medium",
468
+ confidence: 0.6,
469
+ file: uri,
470
+ line: j + 1,
471
+ column: 0,
472
+ evidence: lines[j].trim().slice(0, 80),
473
+ suggestion: `Use ${ocVar}?. consistently or add a null guard`,
474
+ autoFixable: false
475
+ })
476
+ );
477
+ break;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+ const arrBoundsMatch = stripped.match(/(\w+)\[(\w+)\.length\]/);
484
+ if (arrBoundsMatch && arrBoundsMatch[1] === arrBoundsMatch[2]) {
485
+ findings.push(
486
+ this.createFinding({
487
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC013", arrBoundsMatch[1]),
488
+ engine: "logic_gap",
489
+ ruleId: "LOGIC013",
490
+ category: "logic_gap",
491
+ message: `${arrBoundsMatch[1]}[${arrBoundsMatch[2]}.length] is always out of bounds \u2014 arrays are 0-indexed`,
492
+ severity: "high",
493
+ confidence: 0.95,
494
+ file: uri,
495
+ line: lineNum,
496
+ column: 0,
497
+ evidence: stripped.trim().slice(0, 80),
498
+ suggestion: `Use ${arrBoundsMatch[1]}[${arrBoundsMatch[2]}.length - 1] for the last element`,
499
+ autoFixable: true
500
+ })
501
+ );
502
+ }
503
+ if (/typeof\s+\w+\s*===?\s*['"]object['"]/.test(stripped)) {
504
+ const hasNullCheck = /!==?\s*null|!= null/.test(stripped) || /\w+\s*&&\s*typeof/.test(stripped) || /&&\s*\w+\s*!==?\s*null/.test(stripped);
505
+ if (!hasNullCheck) {
506
+ findings.push(
507
+ this.createFinding({
508
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC014", "typeof-null"),
509
+ engine: "logic_gap",
510
+ ruleId: "LOGIC014",
511
+ category: "logic_gap",
512
+ message: 'typeof x === "object" is true for null \u2014 add a null check',
513
+ severity: "medium",
514
+ confidence: 0.7,
515
+ file: uri,
516
+ line: lineNum,
517
+ column: 0,
518
+ evidence: stripped.trim().slice(0, 80),
519
+ suggestion: "Add && x !== null to exclude null from the object check",
520
+ autoFixable: false
521
+ })
522
+ );
523
+ }
524
+ }
525
+ if (/on\w+=\{async\s/.test(stripped) || /addEventListener\s*\(\s*['"][^'"]+['"]\s*,\s*async\s/.test(stripped)) {
526
+ findings.push(
527
+ this.createFinding({
528
+ id: this.deterministicId(uri, lineNum, 0, "LOGIC015", "async-void"),
529
+ engine: "logic_gap",
530
+ ruleId: "LOGIC015",
531
+ category: "logic_gap",
532
+ message: "Async event handler \u2014 unhandled rejections will be silently swallowed",
533
+ severity: "medium",
534
+ confidence: 0.7,
535
+ file: uri,
536
+ line: lineNum,
537
+ column: 0,
538
+ evidence: stripped.trim().slice(0, 80),
539
+ suggestion: "Wrap the async body in try/catch, or use a helper that catches and reports errors",
540
+ autoFixable: false
541
+ })
542
+ );
543
+ }
544
+ }
545
+ if (fileHasSwitch) {
546
+ this.checkAbort(signal);
547
+ const caseIndices = [];
548
+ for (let i = 0; i < lines.length; i++) {
549
+ if (caseLabelLeadingLength(lines[i])) caseIndices.push(i);
550
+ }
551
+ for (const caseIdx of caseIndices) {
552
+ this.checkAbort(signal);
553
+ const labelInfo = caseLabelLeadingLength(lines[caseIdx]);
554
+ if (!labelInfo) continue;
555
+ const labelIndent = labelInfo.indent;
556
+ let end = lines.length;
557
+ for (let j = caseIdx + 1; j < lines.length; j++) {
558
+ const cl = caseLabelLeadingLength(lines[j]);
559
+ if (cl && cl.indent === labelIndent) {
560
+ end = j;
561
+ break;
562
+ }
563
+ const raw = lines[j];
564
+ if (/^\s*\}\s*$/.test(raw) && visualIndentLength(raw) < labelIndent) {
565
+ end = j;
566
+ break;
567
+ }
568
+ }
569
+ const segmentLines = lines.slice(caseIdx + 1, end);
570
+ if (segmentLines.length === 0) continue;
571
+ const rawSeg = segmentLines.join("\n");
572
+ const strippedSeg = segmentLines.map((l) => stripLineForLogicScan(l)).join("\n");
573
+ if (!strippedSeg.trim()) continue;
574
+ if (segmentHasFallthroughComment(rawSeg) || segmentHasFallthroughComment(strippedSeg)) continue;
575
+ if (segmentHasExitStatement(strippedSeg)) continue;
576
+ const lastNonEmpty = (() => {
577
+ for (let j = segmentLines.length - 1; j >= 0; j--) {
578
+ if (stripLineForLogicScan(segmentLines[j]).trim()) return caseIdx + 1 + j + 1;
579
+ }
580
+ return caseIdx + 1;
581
+ })();
582
+ findings.push(
583
+ this.createFinding({
584
+ id: this.deterministicId(uri, lastNonEmpty, 0, "LOGIC010", `case-${caseIdx}`),
585
+ engine: "logic_gap",
586
+ ruleId: "LOGIC010",
587
+ category: "logic_gap",
588
+ message: "Switch case falls through without break/return \u2014 add break or document intentional fallthrough",
589
+ severity: "medium",
590
+ confidence: 0.67,
591
+ file: uri,
592
+ line: lastNonEmpty,
593
+ column: 0,
594
+ evidence: lines[lastNonEmpty - 1].trim().slice(0, 100),
595
+ suggestion: 'Add break;, return, or throw \u2014 or a comment such as "fallthrough" if merging cases intentionally.',
596
+ autoFixable: false
597
+ })
598
+ );
599
+ }
600
+ }
601
+ return findings;
602
+ }
603
+ };
604
+
605
+ export { LogicGapEngine };