@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.
@@ -15,110 +15,168 @@
15
15
  * Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
16
16
  * результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
17
17
  */
18
- import { parseSync } from 'oxc-parser'
18
+ import {
19
+ isFunctionNode,
20
+ isJoinCall,
21
+ isSqlListContextTemplate,
22
+ normalizeSnippet,
23
+ offsetToLine,
24
+ parseProgramOrNull,
25
+ walkAstWithAncestors
26
+ } from './ast-scan-utils.mjs'
19
27
 
20
28
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
21
- const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
22
29
  const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
30
+ const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
23
31
 
24
32
  /**
25
- * Мова для Oxc за шляхом файлу (розширення).
26
- * @param {string} filePath віртуальний або реальний шлях до файлу
27
- * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
33
+ * @param {unknown} node AST node
34
+ * @param {string} name імʼя змінної
35
+ * @returns {boolean} true, якщо це MemberExpression `${name}.length`
28
36
  */
29
- function langFromPath(filePath) {
30
- const lower = filePath.toLowerCase()
31
- if (lower.endsWith('.tsx')) return 'tsx'
32
- if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
33
- if (lower.endsWith('.jsx')) return 'jsx'
34
- return 'js'
37
+ function isLengthMember(node, name) {
38
+ return (
39
+ !!node &&
40
+ typeof node === 'object' &&
41
+ node.type === 'MemberExpression' &&
42
+ !node.computed &&
43
+ node.object &&
44
+ node.object.type === 'Identifier' &&
45
+ node.object.name === name &&
46
+ node.property &&
47
+ node.property.type === 'Identifier' &&
48
+ node.property.name === 'length'
49
+ )
35
50
  }
36
51
 
37
52
  /**
38
- * Номер рядка (1-based) за зміщенням у буфері.
39
- * @param {string} content повний текст файлу
40
- * @param {number} offset байтове зміщення початку фрагмента
41
- * @returns {number} номер рядка від 1
53
+ * @param {unknown} node AST node
54
+ * @returns {boolean} true, якщо це числовий 0-літерал
42
55
  */
43
- function offsetToLine(content, offset) {
44
- let line = 1
45
- const n = Math.min(offset, content.length)
46
- for (let i = 0; i < n; i++) {
47
- if (content.codePointAt(i) === 10) line++
48
- }
49
- return line
56
+ function isZeroNumberLiteral(node) {
57
+ return (
58
+ !!node &&
59
+ typeof node === 'object' &&
60
+ ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
61
+ )
50
62
  }
51
63
 
52
64
  /**
53
- * Стискає пробіли для повідомлення про порушення.
54
- * @param {string} s фрагмент коду
55
- * @returns {string} скорочений однорядковий рядок
65
+ * @param {unknown} node AST node
66
+ * @returns {boolean} true, якщо це Identifier з імʼям `sql`
56
67
  */
57
- function normalizeSnippet(s) {
58
- return s.replaceAll(/\s+/gu, ' ').trim().slice(0, 180)
68
+ function isSqlHelperIdentifier(node) {
69
+ return !!node && typeof node === 'object' && node.type === 'Identifier' && node.name === 'sql'
59
70
  }
60
71
 
61
72
  /**
62
- * Чи є вузол функцією.
63
- * @param {unknown} node AST node
64
- * @returns {boolean} true, якщо це будь-який вузол-функція
73
+ * Витягає імʼя змінної списку для `IN ...`:
74
+ * - `${ids}` `ids`
75
+ * - `${sql(ids)}` `ids`
76
+ * @param {unknown} expr template expression
77
+ * @returns {{ name: string } | { error: 'not_var' } | { error: 'sql_helper_not_var' }} імʼя змінної або причина відмови
65
78
  */
66
- function isFunctionNode(node) {
67
- return (
68
- !!node &&
69
- typeof node === 'object' &&
70
- typeof node.type === 'string' &&
71
- (node.type === 'FunctionDeclaration' ||
72
- node.type === 'FunctionExpression' ||
73
- node.type === 'ArrowFunctionExpression')
74
- )
79
+ function extractInListVarNameFromExpr(expr) {
80
+ if (!expr || typeof expr !== 'object') return { error: 'not_var' }
81
+ if (expr.type === 'Identifier' && typeof expr.name === 'string') return { name: expr.name }
82
+
83
+ if (expr.type === 'CallExpression' && isSqlHelperIdentifier(expr.callee)) {
84
+ const args = expr.arguments
85
+ if (!Array.isArray(args) || args.length === 0) return { error: 'sql_helper_not_var' }
86
+ const first = args[0]
87
+ if (first && typeof first === 'object' && first.type === 'Identifier' && typeof first.name === 'string') {
88
+ return { name: first.name }
89
+ }
90
+ return { error: 'sql_helper_not_var' }
91
+ }
92
+
93
+ return { error: 'not_var' }
75
94
  }
76
95
 
77
96
  /**
78
- * Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
79
- * @param {unknown} node поточний вузол
80
- * @param {unknown[]} ancestors масив предків від кореня до parent
81
- * @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
82
- * @returns {void}
97
+ * Чи містить тест if-умови перевірку “список порожній”.
98
+ * Підтримує базові форми:
99
+ * - `if (!ids.length) ...`
100
+ * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
101
+ * @param {unknown} test IfStatement.test
102
+ * @param {string} name імʼя змінної списку
103
+ * @returns {boolean} true, якщо це перевірка на пустоту списку
83
104
  */
84
- function walkAstWithAncestors(node, ancestors, visit) {
85
- if (!node || typeof node !== 'object') return
86
- if (Array.isArray(node)) {
87
- for (const item of node) walkAstWithAncestors(item, ancestors, visit)
88
- return
105
+ function isEmptyListTest(test, name) {
106
+ if (!test || typeof test !== 'object') return false
107
+
108
+ if (test.type === 'UnaryExpression' && test.operator === '!') {
109
+ const arg = test.argument
110
+ if (!arg || typeof arg !== 'object') return false
111
+ return isLengthMember(arg, name)
89
112
  }
90
113
 
91
- const rec = /** @type {Record<string, unknown>} */ (node)
92
- if (typeof rec.type === 'string') {
93
- visit(rec, ancestors)
94
- ancestors = [...ancestors, rec]
114
+ if (test.type === 'BinaryExpression') {
115
+ const { left, right, operator } = test
116
+ if (!['===', '==', '<=', '<'].includes(operator)) return false
117
+ if (isLengthMember(left, name) && isZeroNumberLiteral(right)) return true
118
+ // допускаємо `0 === ids.length` теж
119
+ if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '==')) return true
95
120
  }
96
121
 
97
- for (const key of Object.keys(node)) {
98
- if (key === 'parent') continue
99
- const v = rec[key]
100
- if (v && typeof v === 'object') {
101
- walkAstWithAncestors(v, ancestors, visit)
102
- }
122
+ return false
123
+ }
124
+
125
+ /**
126
+ * Чи є в consequent (або в його BlockStatement) ThrowStatement.
127
+ * @param {unknown} consequent IfStatement.consequent
128
+ * @returns {boolean} true, якщо consequent містить throw
129
+ */
130
+ function consequentHasThrow(consequent) {
131
+ if (!consequent || typeof consequent !== 'object') return false
132
+ if (consequent.type === 'ThrowStatement') return true
133
+ if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
134
+ return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
103
135
  }
136
+ return false
104
137
  }
105
138
 
106
139
  /**
107
- * Парсить файл та повертає program або null, якщо є синтаксичні помилки.
108
- * @param {string} content вихідний код
109
- * @param {string} virtualPath шлях для вибору `lang`
110
- * @returns {unknown | null} `result.program` або null
140
+ * Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
141
+ * @param {unknown} block BlockStatement
142
+ * @param {number} statementIndex індекс statement, перед яким шукаємо guard
143
+ * @param {string} name імʼя змінної списку
144
+ * @returns {boolean} true, якщо guard знайдено
145
+ */
146
+ function hasEmptyGuardBefore(block, statementIndex, name) {
147
+ if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
148
+ const body = block.body
149
+ if (!Array.isArray(body)) return false
150
+ for (let i = 0; i < statementIndex; i++) {
151
+ const st = body[i]
152
+ if (!st || typeof st !== 'object') continue
153
+ if (st.type !== 'IfStatement') continue
154
+ if (!isEmptyListTest(st.test, name)) continue
155
+ if (!consequentHasThrow(st.consequent)) continue
156
+ return true
157
+ }
158
+ return false
159
+ }
160
+
161
+ /**
162
+ * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
163
+ * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
164
+ * @returns {{ block: unknown, index: number } | null} block+індекс statement або null
111
165
  */
112
- function parseProgramOrNull(content, virtualPath) {
113
- const lang = langFromPath(virtualPath || 'scan.ts')
114
- let result
115
- try {
116
- result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
117
- } catch {
118
- return null
166
+ function findEnclosingBlockAndStatementIndex(ancestors) {
167
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return null
168
+
169
+ // statement — перший зверху вузол, який лежить у block.body
170
+ // шукаємо пару (block, statement), де statement block.body
171
+ for (let i = ancestors.length - 1; i >= 1; i--) {
172
+ const maybeStatement = ancestors[i]
173
+ const maybeBlock = ancestors[i - 1]
174
+ if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
175
+ if (!Array.isArray(maybeBlock.body)) continue
176
+ const idx = maybeBlock.body.indexOf(maybeStatement)
177
+ if (idx !== -1) return { block: maybeBlock, index: idx }
119
178
  }
120
- if (result.errors?.length) return null
121
- return result.program
179
+ return null
122
180
  }
123
181
 
124
182
  /**
@@ -152,47 +210,6 @@ function isUnsafeCallWithInterpolatedTemplate(node) {
152
210
  return Array.isArray(expressions) && expressions.length > 0
153
211
  }
154
212
 
155
- /**
156
- * Чи це `.join(...)` виклик (типово для динамічних списків у SQL).
157
- * @param {unknown} node AST node
158
- * @returns {boolean} true, якщо це CallExpression `*.join(...)`
159
- */
160
- function isJoinCall(node) {
161
- if (!node || node.type !== 'CallExpression') return false
162
- const callee = node.callee
163
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
164
- const prop = callee.property
165
- return !!prop && prop.type === 'Identifier' && prop.name === 'join'
166
- }
167
-
168
- /**
169
- * Текст quasis у TemplateLiteral (без expressions).
170
- * @param {unknown} template TemplateLiteral
171
- * @returns {string} обʼєднаний raw-текст
172
- */
173
- function templateQuasisText(template) {
174
- if (!template || template.type !== 'TemplateLiteral') return ''
175
- const quasis = template.quasis
176
- if (!Array.isArray(quasis) || quasis.length === 0) return ''
177
- let out = ''
178
- for (const q of quasis) {
179
- if (!q || typeof q !== 'object') continue
180
- const value = q.value
181
- if (!value || typeof value !== 'object') continue
182
- if (typeof value.raw === 'string') out += value.raw
183
- }
184
- return out
185
- }
186
-
187
- /**
188
- * Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
189
- * @param {unknown} template TemplateLiteral
190
- * @returns {boolean} true, якщо текст містить `IN (` або `VALUES (`
191
- */
192
- function isSqlListContextTemplate(template) {
193
- return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
194
- }
195
-
196
213
  /**
197
214
  * Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
198
215
  * @param {string} content вихідний код
@@ -273,6 +290,80 @@ export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'sca
273
290
  return out
274
291
  }
275
292
 
293
+ /**
294
+ * Збирає порушення для одного TemplateLiteral вузла: `IN ... ${...}` потребує
295
+ * змінної + guard `if (empty) throw`.
296
+ * @param {Record<string, unknown>} template TemplateLiteral
297
+ * @param {unknown[]} ancestors ancestors з walkAstWithAncestors
298
+ * @param {string} content вихідний код
299
+ * @param {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} out буфер результатів
300
+ */
301
+ function collectInListGuardViolationsFromTemplate(template, ancestors, content, out) {
302
+ const expressions = template.expressions
303
+ const quasis = template.quasis
304
+ if (!Array.isArray(expressions) || expressions.length === 0) return
305
+ if (!Array.isArray(quasis) || quasis.length === 0) return
306
+
307
+ for (const [i, expr] of expressions.entries()) {
308
+ const q = quasis[i]
309
+ const raw =
310
+ q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string' ? q.value.raw : ''
311
+ if (!IN_PLACEHOLDER_END_RE.test(raw)) continue
312
+
313
+ const extracted = extractInListVarNameFromExpr(expr)
314
+ if ('error' in extracted) {
315
+ out.push({
316
+ line: offsetToLine(content, template.start),
317
+ snippet: normalizeSnippet(content.slice(template.start, template.end)),
318
+ reason: extracted.error
319
+ })
320
+ continue
321
+ }
322
+
323
+ const place = findEnclosingBlockAndStatementIndex(ancestors)
324
+ if (!place || !hasEmptyGuardBefore(place.block, place.index, extracted.name)) {
325
+ out.push({
326
+ line: offsetToLine(content, template.start),
327
+ snippet: normalizeSnippet(content.slice(template.start, template.end)),
328
+ reason: 'missing_guard',
329
+ name: extracted.name
330
+ })
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Знаходить підстановки списків у `IN (...)`, які:
337
+ * - не винесені в окрему змінну (в `${...}` стоїть не Identifier або `sql(<non-Identifier>)`);
338
+ * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
339
+ * @param {string} content вихідний код
340
+ * @param {string} [virtualPath] шлях для вибору `lang`
341
+ * @returns {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} список порушень
342
+ */
343
+ export function findUnsafeBunSqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
344
+ const program = parseProgramOrNull(content, virtualPath)
345
+ if (!program) return []
346
+
347
+ /** @type {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} */
348
+ const out = []
349
+
350
+ walkAstWithAncestors(program, [], (node, ancestors) => {
351
+ /** @type {unknown} */
352
+ let template = null
353
+ if (node.type === 'TemplateLiteral') {
354
+ template = node
355
+ } else if (node.type === 'TaggedTemplateExpression') {
356
+ template = node.quasi
357
+ }
358
+
359
+ if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
360
+ if (!isSqlListContextTemplate(template)) return
361
+ collectInListGuardViolationsFromTemplate(template, ancestors, content, out)
362
+ })
363
+
364
+ return out
365
+ }
366
+
276
367
  /**
277
368
  * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
278
369
  * Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
@@ -11,45 +11,11 @@
11
11
  */
12
12
  import { parseSync } from 'oxc-parser'
13
13
 
14
+ import { langFromPath, offsetToLine } from './ast-scan-utils.mjs'
15
+
14
16
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
15
17
  const FORBIDDEN_MODULES = new Set(['@nitra/bunyan', 'bunyan'])
16
18
 
17
- /**
18
- * Мова для Oxc за шляхом файлу (розширення).
19
- * @param {string} filePath віртуальний або реальний шлях до файлу
20
- * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
21
- */
22
- function langFromPath(filePath) {
23
- const lower = filePath.toLowerCase()
24
- if (lower.endsWith('.tsx')) {
25
- return 'tsx'
26
- }
27
- if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) {
28
- return 'ts'
29
- }
30
- if (lower.endsWith('.jsx')) {
31
- return 'jsx'
32
- }
33
- return 'js'
34
- }
35
-
36
- /**
37
- * Номер рядка (1-based) за зміщенням у буфері.
38
- * @param {string} content повний текст файлу
39
- * @param {number} offset байтове зміщення початку фрагмента
40
- * @returns {number} номер рядка від 1
41
- */
42
- function offsetToLine(content, offset) {
43
- let line = 1
44
- const n = Math.min(offset, content.length)
45
- for (let i = 0; i < n; i++) {
46
- if (content.codePointAt(i) === 10) {
47
- line++
48
- }
49
- }
50
- return line
51
- }
52
-
53
19
  /**
54
20
  * Стискає пробіли для повідомлення про порушення.
55
21
  * @param {string} s фрагмент коду