@nitra/cursor 1.8.147 → 1.8.151

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.
@@ -22,93 +22,125 @@
22
22
  */
23
23
  import { parseSync } from 'oxc-parser'
24
24
 
25
+ import {
26
+ isFunctionNode,
27
+ isJoinCall,
28
+ isSqlListContextTemplate,
29
+ langFromPath,
30
+ normalizeSnippet,
31
+ offsetToLine,
32
+ walkAstWithAncestors
33
+ } from './ast-scan-utils.mjs'
34
+
25
35
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
26
- const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
27
36
  const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
28
37
  const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
29
38
 
30
39
  /**
31
- * Мова для Oxc за шляхом файлу (розширення).
32
- * @param {string} filePath віртуальний або реальний шлях до файлу
33
- * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
40
+ * Чи містить тест if-умови перевірку “список порожній”.
41
+ * Підтримує базові форми:
42
+ * - `if (!ids.length) ...`
43
+ * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
44
+ *
45
+ * @param {unknown} test IfStatement.test
46
+ * @param {string} name імʼя змінної списку
47
+ * @returns {boolean}
34
48
  */
35
- function langFromPath(filePath) {
36
- const lower = filePath.toLowerCase()
37
- if (lower.endsWith('.tsx')) return 'tsx'
38
- if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
39
- if (lower.endsWith('.jsx')) return 'jsx'
40
- return 'js'
41
- }
49
+ function isEmptyListTest(test, name) {
50
+ if (!test || typeof test !== 'object') return false
51
+
52
+ if (test.type === 'UnaryExpression' && test.operator === '!') {
53
+ const arg = test.argument
54
+ if (!arg || typeof arg !== 'object') return false
55
+ if (arg.type === 'MemberExpression' && !arg.computed) {
56
+ const obj = arg.object
57
+ const prop = arg.property
58
+ return !!obj && obj.type === 'Identifier' && obj.name === name && !!prop && prop.type === 'Identifier' && prop.name === 'length'
59
+ }
60
+ }
42
61
 
43
- /**
44
- * Номер рядка (1-based) за зміщенням у буфері.
45
- * @param {string} content повний текст файлу
46
- * @param {number} offset байтове зміщення початку фрагмента
47
- * @returns {number} номер рядка від 1
48
- */
49
- function offsetToLine(content, offset) {
50
- let line = 1
51
- const n = Math.min(offset, content.length)
52
- for (let i = 0; i < n; i++) {
53
- if (content.codePointAt(i) === 10) line++
62
+ if (test.type === 'BinaryExpression') {
63
+ const { left, right, operator } = test
64
+ const isLen = node =>
65
+ !!node &&
66
+ typeof node === 'object' &&
67
+ node.type === 'MemberExpression' &&
68
+ !node.computed &&
69
+ node.object &&
70
+ node.object.type === 'Identifier' &&
71
+ node.object.name === name &&
72
+ node.property &&
73
+ node.property.type === 'Identifier' &&
74
+ node.property.name === 'length'
75
+ const isZero = node =>
76
+ !!node &&
77
+ typeof node === 'object' &&
78
+ ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
79
+
80
+ if (!['===', '==', '<=', '<'].includes(operator)) return false
81
+ if (isLen(left) && isZero(right)) return true
82
+ // допускаємо `0 === ids.length` теж
83
+ if (isZero(left) && isLen(right) && (operator === '===' || operator === '==')) return true
54
84
  }
55
- return line
85
+
86
+ return false
56
87
  }
57
88
 
58
89
  /**
59
- * Стискає пробіли для повідомлення про порушення.
60
- * @param {string} s фрагмент коду
61
- * @returns {string} скорочений однорядковий рядок
90
+ * Чи є в consequent (або в його BlockStatement) ThrowStatement.
91
+ * @param {unknown} consequent IfStatement.consequent
92
+ * @returns {boolean}
62
93
  */
63
- function normalizeSnippet(s) {
64
- return s.replaceAll(/\s+/g, ' ').trim().slice(0, 180)
94
+ function consequentHasThrow(consequent) {
95
+ if (!consequent || typeof consequent !== 'object') return false
96
+ if (consequent.type === 'ThrowStatement') return true
97
+ if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
98
+ return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
99
+ }
100
+ return false
65
101
  }
66
102
 
67
103
  /**
68
- * Чи є вузол функцією.
69
- * @param {unknown} node AST node
70
- * @returns {boolean} true, якщо це будь-який вузол-функція
104
+ * Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
105
+ * @param {unknown} block BlockStatement
106
+ * @param {number} statementIndex індекс statement, перед яким шукаємо guard
107
+ * @param {string} name імʼя змінної списку
108
+ * @returns {boolean}
71
109
  */
72
- function isFunctionNode(node) {
73
- return (
74
- !!node &&
75
- typeof node === 'object' &&
76
- typeof node.type === 'string' &&
77
- (node.type === 'FunctionDeclaration' ||
78
- node.type === 'FunctionExpression' ||
79
- node.type === 'ArrowFunctionExpression')
80
- )
110
+ function hasEmptyGuardBefore(block, statementIndex, name) {
111
+ if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
112
+ const body = block.body
113
+ if (!Array.isArray(body)) return false
114
+ for (let i = 0; i < statementIndex; i++) {
115
+ const st = body[i]
116
+ if (!st || typeof st !== 'object') continue
117
+ if (st.type !== 'IfStatement') continue
118
+ if (!isEmptyListTest(st.test, name)) continue
119
+ if (!consequentHasThrow(st.consequent)) continue
120
+ return true
121
+ }
122
+ return false
81
123
  }
82
124
 
83
125
  /**
84
- * Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
85
- * @param {unknown} node поточний вузол
86
- * @param {unknown[]} ancestors масив предків від кореня до parent
87
- * @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
88
- * @returns {void}
126
+ * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
127
+ * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
128
+ * @returns {{ block: unknown, index: number } | null}
89
129
  */
90
- function walkAstWithAncestors(node, ancestors, visit) {
91
- if (!node || typeof node !== 'object') return
92
- if (Array.isArray(node)) {
93
- for (const item of node) walkAstWithAncestors(item, ancestors, visit)
94
- return
95
- }
96
-
97
- const rec = /** @type {Record<string, unknown>} */ (node)
98
- if (typeof rec.type === 'string') {
99
- visit(rec, ancestors)
100
- ancestors = [...ancestors, rec]
101
- }
102
-
103
- for (const key of Object.keys(node)) {
104
- if (key === 'parent') {
105
- continue
106
- }
107
- const v = rec[key]
108
- if (v && typeof v === 'object') {
109
- walkAstWithAncestors(v, ancestors, visit)
110
- }
130
+ function findEnclosingBlockAndStatementIndex(ancestors) {
131
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return null
132
+
133
+ // statement перший зверху вузол, який лежить у block.body
134
+ // шукаємо пару (block, statement), де statement ∈ block.body
135
+ for (let i = ancestors.length - 1; i >= 1; i--) {
136
+ const maybeStatement = ancestors[i]
137
+ const maybeBlock = ancestors[i - 1]
138
+ if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
139
+ if (!Array.isArray(maybeBlock.body)) continue
140
+ const idx = maybeBlock.body.indexOf(maybeStatement)
141
+ if (idx !== -1) return { block: maybeBlock, index: idx }
111
142
  }
143
+ return null
112
144
  }
113
145
 
114
146
  /**
@@ -160,48 +192,6 @@ function isRequestFactoryCall(node) {
160
192
  return !!prop && prop.type === 'Identifier' && prop.name === 'request'
161
193
  }
162
194
 
163
- /**
164
- * Чи це `.join(...)` виклик (часто використовується для динамічних списків в SQL).
165
- * @param {unknown} node AST node
166
- * @returns {boolean} true, якщо це CallExpression `*.join(...)`
167
- */
168
- function isJoinCall(node) {
169
- if (!node || node.type !== 'CallExpression') return false
170
- const callee = node.callee
171
- if (!callee || callee.type !== 'MemberExpression') return false
172
- if (callee.computed) return false
173
- const prop = callee.property
174
- return !!prop && prop.type === 'Identifier' && prop.name === 'join'
175
- }
176
-
177
- /**
178
- * Повертає текст quasis у TemplateLiteral (без expressions).
179
- * @param {unknown} template TemplateLiteral
180
- * @returns {string} обʼєднаний текст
181
- */
182
- function templateQuasisText(template) {
183
- if (!template || template.type !== 'TemplateLiteral') return ''
184
- const quasis = template.quasis
185
- if (!Array.isArray(quasis) || quasis.length === 0) return ''
186
- let out = ''
187
- for (const q of quasis) {
188
- if (!q || typeof q !== 'object') continue
189
- const value = q.value
190
- if (!value || typeof value !== 'object') continue
191
- if (typeof value.raw === 'string') out += value.raw
192
- }
193
- return out
194
- }
195
-
196
- /**
197
- * Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
198
- * @param {unknown} template TemplateLiteral
199
- * @returns {boolean} true, якщо текст містить `IN (` або `VALUES (`
200
- */
201
- function isSqlListContextTemplate(template) {
202
- return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
203
- }
204
-
205
195
  /**
206
196
  * Знаходить створення `ConnectionPool` всередині функцій.
207
197
  * @param {string} content вихідний код
@@ -449,13 +439,7 @@ function isInListExpressionParsed(expr, declarators, seen = new Set()) {
449
439
  const inits = declarators
450
440
  .filter(d => {
451
441
  const id = d.id
452
- return (
453
- !!id &&
454
- typeof id === 'object' &&
455
- id.type === 'Identifier' &&
456
- id.name === expr.name &&
457
- !!d.init
458
- )
442
+ return !!id && typeof id === 'object' && id.type === 'Identifier' && id.name === expr.name && !!d.init
459
443
  })
460
444
  .map(d => d.init)
461
445
  if (inits.length === 0) return false
@@ -504,6 +488,47 @@ function collectInListUnparsedFromTemplate(node, content, declarators, out) {
504
488
  }
505
489
  }
506
490
 
491
+ /**
492
+ * Збирає порушення для одного TemplateLiteral: якщо у `IN (${...})`:
493
+ * - `${...}` не є Identifier (значення не винесені у змінну);
494
+ * - або це Identifier, але перед запитом немає guard `if (empty) throw`.
495
+ *
496
+ * @param {Record<string, unknown>} node TemplateLiteral
497
+ * @param {unknown[]} ancestors ancestors від walkAstWithAncestors
498
+ * @param {string} content вихідний код
499
+ * @param {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} out буфер результатів
500
+ */
501
+ function collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out) {
502
+ if (node.type !== 'TemplateLiteral') return
503
+ const quasis = node.quasis
504
+ const expressions = node.expressions
505
+ if (!Array.isArray(quasis) || !Array.isArray(expressions) || expressions.length === 0) return
506
+
507
+ for (const [i, expr] of expressions.entries()) {
508
+ if (!IN_PLACEHOLDER_END_RE.test(quasiRawText(quasis[i]))) continue
509
+ if (!expr || typeof expr !== 'object') continue
510
+
511
+ if (expr.type !== 'Identifier' || typeof expr.name !== 'string') {
512
+ out.push({
513
+ line: offsetToLine(content, node.start),
514
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
515
+ reason: 'not_var'
516
+ })
517
+ continue
518
+ }
519
+
520
+ const place = findEnclosingBlockAndStatementIndex(ancestors)
521
+ if (!place || !hasEmptyGuardBefore(place.block, place.index, expr.name)) {
522
+ out.push({
523
+ line: offsetToLine(content, node.start),
524
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
525
+ reason: 'missing_guard',
526
+ name: expr.name
527
+ })
528
+ }
529
+ }
530
+ }
531
+
507
532
  /**
508
533
  * Знаходить підстановки IN (вираз) у TemplateLiteral, де вираз не пройшов числовий парсер.
509
534
  *
@@ -533,6 +558,33 @@ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan
533
558
  return out
534
559
  }
535
560
 
561
+ /**
562
+ * Знаходить підстановки списків у `IN (${...})`, які:
563
+ * - не винесені в окрему змінну (в `${...}` стоїть не Identifier);
564
+ * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
565
+ *
566
+ * @param {string} content вихідний код
567
+ * @param {string} [virtualPath] шлях для вибору мови парсера (lang)
568
+ * @returns {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} список порушень
569
+ */
570
+ export function findUnsafeMssqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
571
+ const lang = langFromPath(virtualPath || 'scan.ts')
572
+ let result
573
+ try {
574
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
575
+ } catch {
576
+ return []
577
+ }
578
+ if (result.errors?.length) return []
579
+
580
+ /** @type {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} */
581
+ const out = []
582
+ walkAstWithAncestors(result.program, [], (node, ancestors) =>
583
+ collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out)
584
+ )
585
+ return out
586
+ }
587
+
536
588
  /**
537
589
  * Чи сканувати цей файл за розширенням (JS/TS-сім'я).
538
590
  * @param {string} relativePathPosix відносний шлях (posix)
@@ -541,4 +593,3 @@ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan
541
593
  export function isMssqlScanSourceFile(relativePathPosix) {
542
594
  return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
543
595
  }
544
-
@@ -53,7 +53,7 @@ bun run lint
53
53
 
54
54
  **Чому взагалі кілька `eslint`:** оркестратор (Claude Code, Cursor тощо) може **розпаралелити** роботу: кілька **субагентів** / **паралельних Bash-задач** / **фонових shell**, і кожен **сам** виконує **`/n-lint`** або запускає **`eslint`**. Тоді навантаження **множиться**: не один прогон лінту, а **N прогонів** одночасно.
55
55
 
56
- **Що робити агенту під час виконання цього скілу (обов’язково)**
56
+ ### Що робити агенту під час виконання цього скілу (обов’язково)
57
57
 
58
58
  1. **Один** запуск **`bun run lint`** (або всі кроки **`lint`**, як у **`package.json`**) — у **одному** foreground shell, **без** `run_in_background` / фонових копій тієї ж команди.
59
59
  2. **Не** викликати **паралельні субагенти** (subagent, Task, «розбий на N паралельних завдань») лише заради лінту в одному репозиторії. Лінт не потребує шардінгу: один процес, послідовно.