@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.
- package/bin/n-cursor.js +1 -1
- package/bin/rename-yaml-extensions.mjs +0 -1
- package/mdc/docker.mdc +2 -0
- package/mdc/js-bun-db.mdc +34 -1
- package/mdc/js-mssql.mdc +27 -1
- package/mdc/k8s.mdc +1 -1
- package/package.json +1 -1
- package/scripts/check-capacitor.mjs +4 -4
- package/scripts/check-js-bun-db.mjs +26 -2
- package/scripts/check-js-lint.mjs +5 -1
- package/scripts/check-js-mssql.mjs +20 -2
- package/scripts/check-k8s.mjs +43 -10
- package/scripts/check-php.mjs +0 -1
- package/scripts/check-text.mjs +1 -1
- package/scripts/utils/ast-scan-utils.mjs +154 -0
- package/scripts/utils/bun-sql-scan.mjs +204 -113
- package/scripts/utils/bunyan-imports.mjs +2 -36
- package/scripts/utils/mssql-pool-scan.mjs +167 -116
- package/skills/lint/SKILL.md +1 -1
|
@@ -15,110 +15,168 @@
|
|
|
15
15
|
* Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
|
|
16
16
|
* результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
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
|
-
*
|
|
26
|
-
* @param {string}
|
|
27
|
-
* @returns {
|
|
33
|
+
* @param {unknown} node AST node
|
|
34
|
+
* @param {string} name імʼя змінної
|
|
35
|
+
* @returns {boolean} true, якщо це MemberExpression `${name}.length`
|
|
28
36
|
*/
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
39
|
-
* @
|
|
40
|
-
* @param {number} offset байтове зміщення початку фрагмента
|
|
41
|
-
* @returns {number} номер рядка від 1
|
|
53
|
+
* @param {unknown} node AST node
|
|
54
|
+
* @returns {boolean} true, якщо це числовий 0-літерал
|
|
42
55
|
*/
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
* @
|
|
55
|
-
* @returns {string} скорочений однорядковий рядок
|
|
65
|
+
* @param {unknown} node AST node
|
|
66
|
+
* @returns {boolean} true, якщо це Identifier з імʼям `sql`
|
|
56
67
|
*/
|
|
57
|
-
function
|
|
58
|
-
return
|
|
68
|
+
function isSqlHelperIdentifier(node) {
|
|
69
|
+
return !!node && typeof node === 'object' && node.type === 'Identifier' && node.name === 'sql'
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* @
|
|
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
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
108
|
-
* @param {
|
|
109
|
-
* @param {
|
|
110
|
-
* @
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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 фрагмент коду
|