@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
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
85
|
+
|
|
86
|
+
return false
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
/**
|
|
59
|
-
*
|
|
60
|
-
* @param {
|
|
61
|
-
* @returns {
|
|
90
|
+
* Чи є в consequent (або в його BlockStatement) ThrowStatement.
|
|
91
|
+
* @param {unknown} consequent IfStatement.consequent
|
|
92
|
+
* @returns {boolean}
|
|
62
93
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
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}
|
|
70
|
-
* @
|
|
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
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
85
|
-
* @param {unknown}
|
|
86
|
-
* @
|
|
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
|
|
91
|
-
if (!
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
package/skills/lint/SKILL.md
CHANGED
|
@@ -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 паралельних завдань») лише заради лінту в одному репозиторії. Лінт не потребує шардінгу: один процес, послідовно.
|