@nitra/cursor 1.8.144 → 1.8.147
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/auto-rules.md +2 -0
- package/bin/n-cursor.js +1 -0
- package/bin/rename-yaml-extensions.mjs +0 -1
- package/mdc/js-bun-db.mdc +118 -0
- package/mdc/js-lint.mdc +6 -4
- package/mdc/js-mssql.mdc +27 -1
- package/mdc/k8s.mdc +2 -2
- package/mdc/vue.mdc +3 -3
- package/package.json +3 -3
- package/scripts/auto-rules.mjs +104 -26
- package/scripts/check-ga.mjs +27 -15
- package/scripts/check-js-bun-db.mjs +213 -0
- package/scripts/check-js-lint.mjs +110 -27
- package/scripts/check-js-mssql.mjs +156 -86
- package/scripts/check-k8s.mjs +161 -32
- package/scripts/check-nginx-default-tpl.mjs +1 -1
- package/scripts/check-vue.mjs +82 -45
- package/scripts/cli-entry.mjs +2 -5
- package/scripts/upgrade-nitra-cursor-and-install.mjs +1 -1
- package/scripts/utils/bun-sql-scan.mjs +294 -0
- package/scripts/utils/mssql-pool-scan.mjs +188 -1
- package/scripts/utils/oxlint-canonical-skeleton.json +27 -0
- package/scripts/utils/oxlint-canonical.json +387 -0
- package/scripts/utils/oxlint-rules.tsv +359 -0
- package/scripts/utils/rebuild-oxlint-canonical.mjs +29 -0
package/scripts/check-vue.mjs
CHANGED
|
@@ -69,6 +69,42 @@ function isEsbuildScanFile(relPosix) {
|
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Збирає `esbuild`-матчі по рядках одного файлу, поки буфер не досягне ліміту.
|
|
74
|
+
* @param {string} rel relative path
|
|
75
|
+
* @param {string} content вміст файлу
|
|
76
|
+
* @param {{ rel: string; line: number; snippet: string }[]} matches буфер для збору матчів
|
|
77
|
+
* @param {number} maxMatches максимум елементів у буфері
|
|
78
|
+
*/
|
|
79
|
+
function appendEsbuildLineMatches(rel, content, matches, maxMatches) {
|
|
80
|
+
const lines = content.split('\n')
|
|
81
|
+
for (const [i, line] of lines.entries()) {
|
|
82
|
+
if (matches.length >= maxMatches) return
|
|
83
|
+
if (ESBUILD_RE.test(line)) {
|
|
84
|
+
matches.push({ rel, line: i + 1, snippet: line.trim() })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Перебирає вибрані файли пакета і збирає до `maxMatches` згадок `esbuild`.
|
|
91
|
+
* @param {string} absPackageRoot абсолютний шлях до кореня пакета
|
|
92
|
+
* @param {{ rel: string }[]} files перелік відносних шляхів
|
|
93
|
+
* @param {number} maxMatches максимум знайдених матчів
|
|
94
|
+
* @returns {Promise<{ rel: string; line: number; snippet: string }[]>} зібрані матчі
|
|
95
|
+
*/
|
|
96
|
+
async function collectEsbuildMatchesInFiles(absPackageRoot, files, maxMatches) {
|
|
97
|
+
/** @type {{ rel: string; line: number; snippet: string }[]} */
|
|
98
|
+
const matches = []
|
|
99
|
+
for (const { rel } of files) {
|
|
100
|
+
if (matches.length >= maxMatches) break
|
|
101
|
+
const content = await readFile(join(absPackageRoot, rel), 'utf8')
|
|
102
|
+
if (!ESBUILD_RE.test(content)) continue
|
|
103
|
+
appendEsbuildLineMatches(rel, content, matches, maxMatches)
|
|
104
|
+
}
|
|
105
|
+
return matches
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/**
|
|
73
109
|
* Сканує дерево пакета на згадки `esbuild` і підказує заміну на `rolldown`.
|
|
74
110
|
* @param {string} rootDir відносний шлях до пакета
|
|
@@ -78,31 +114,16 @@ function isEsbuildScanFile(relPosix) {
|
|
|
78
114
|
* @param {(msg: string) => void} fail callback при помилці
|
|
79
115
|
*/
|
|
80
116
|
async function checkEsbuildMentions(rootDir, absPackageRoot, prefix, passFn, fail) {
|
|
81
|
-
/** @type {{ rel: string
|
|
82
|
-
const
|
|
83
|
-
|
|
117
|
+
/** @type {{ rel: string }[]} */
|
|
118
|
+
const candidates = []
|
|
84
119
|
await walkDir(absPackageRoot, absPath => {
|
|
85
120
|
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
86
121
|
if (!isEsbuildScanFile(rel)) return
|
|
87
|
-
|
|
122
|
+
candidates.push({ rel })
|
|
88
123
|
})
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const matches = []
|
|
93
|
-
for (const { rel } of hits) {
|
|
94
|
-
const content = await readFile(join(absPackageRoot, rel), 'utf8')
|
|
95
|
-
if (!ESBUILD_RE.test(content)) continue
|
|
96
|
-
|
|
97
|
-
const lines = content.split('\n')
|
|
98
|
-
for (let i = 0; i < lines.length; i++) {
|
|
99
|
-
if (ESBUILD_RE.test(lines[i])) {
|
|
100
|
-
matches.push({ rel, line: i + 1, snippet: lines[i].trim() })
|
|
101
|
-
if (matches.length >= 30) break
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (matches.length >= 30) break
|
|
105
|
-
}
|
|
125
|
+
const maxMatches = 30
|
|
126
|
+
const matches = await collectEsbuildMatchesInFiles(absPackageRoot, candidates, maxMatches)
|
|
106
127
|
|
|
107
128
|
if (matches.length === 0) {
|
|
108
129
|
passFn(`${prefix}немає згадок 'esbuild' у джерелах пакета (очікується rolldown)`)
|
|
@@ -112,8 +133,8 @@ async function checkEsbuildMentions(rootDir, absPackageRoot, prefix, passFn, fai
|
|
|
112
133
|
for (const m of matches) {
|
|
113
134
|
fail(`${prefix}${m.rel}:${m.line} — знайдено 'esbuild'. Замінити на 'rolldown'. Фрагмент: ${m.snippet}`)
|
|
114
135
|
}
|
|
115
|
-
if (matches.length >=
|
|
116
|
-
fail(`${prefix}показано перші
|
|
136
|
+
if (matches.length >= maxMatches) {
|
|
137
|
+
fail(`${prefix}показано перші ${maxMatches} збігів 'esbuild' (замінити на 'rolldown')`)
|
|
117
138
|
}
|
|
118
139
|
}
|
|
119
140
|
|
|
@@ -308,44 +329,60 @@ async function checkVuePackage(rootDir, fail, passFn) {
|
|
|
308
329
|
}
|
|
309
330
|
|
|
310
331
|
/**
|
|
311
|
-
*
|
|
312
|
-
* @
|
|
332
|
+
* Збирає корені пакетів, у яких у `dependencies` є `vue`.
|
|
333
|
+
* @param {string[]} roots усі корені пакетів monorepo
|
|
334
|
+
* @returns {Promise<string[]>} перелік пакетів з vue у dependencies
|
|
313
335
|
*/
|
|
314
|
-
|
|
315
|
-
const reporter = createCheckReporter()
|
|
316
|
-
const { pass, fail } = reporter
|
|
317
|
-
|
|
318
|
-
const roots = await getMonorepoPackageRootDirs()
|
|
336
|
+
async function collectVueRoots(roots) {
|
|
319
337
|
/** @type {string[]} */
|
|
320
338
|
const vueRoots = []
|
|
321
339
|
for (const r of roots) {
|
|
322
340
|
const p = join(r, 'package.json')
|
|
323
|
-
if (existsSync(p))
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
341
|
+
if (!existsSync(p)) continue
|
|
342
|
+
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
|
343
|
+
if (pkg.dependencies?.vue) vueRoots.push(r)
|
|
327
344
|
}
|
|
345
|
+
return vueRoots
|
|
346
|
+
}
|
|
328
347
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Перевіряє наявність рекомендації `Vue.volar` у `.vscode/extensions.json`.
|
|
350
|
+
* @param {(msg: string) => void} pass pass callback
|
|
351
|
+
* @param {(msg: string) => void} fail fail callback
|
|
352
|
+
* @returns {Promise<void>}
|
|
353
|
+
*/
|
|
354
|
+
async function checkVueVolarRecommendation(pass, fail) {
|
|
355
|
+
if (!existsSync('.vscode/extensions.json')) {
|
|
356
|
+
fail('.vscode/extensions.json не існує (для Vue-проєкту потрібна рекомендація Vue.volar)')
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
|
|
360
|
+
if (ext.recommendations?.includes('Vue.volar')) {
|
|
361
|
+
pass('extensions.json містить Vue.volar')
|
|
340
362
|
} else {
|
|
341
|
-
|
|
363
|
+
fail('extensions.json не містить Vue.volar — додай до recommendations')
|
|
342
364
|
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Перевіряє відповідність проєкту правилам vue.mdc (корінь і всі workspace-пакети з `vue` у dependencies).
|
|
369
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
370
|
+
*/
|
|
371
|
+
export async function check() {
|
|
372
|
+
const reporter = createCheckReporter()
|
|
373
|
+
const { pass, fail } = reporter
|
|
374
|
+
|
|
375
|
+
const roots = await getMonorepoPackageRootDirs()
|
|
376
|
+
const vueRoots = await collectVueRoots(roots)
|
|
343
377
|
|
|
344
378
|
if (vueRoots.length === 0) {
|
|
379
|
+
pass('Vue.volar: пропущено (у repo немає пакетів з vue у dependencies)')
|
|
345
380
|
pass('vue не знайдено в dependencies жодного пакета (перевірка vue пропущена)')
|
|
346
381
|
return reporter.getExitCode()
|
|
347
382
|
}
|
|
348
383
|
|
|
384
|
+
await checkVueVolarRecommendation(pass, fail)
|
|
385
|
+
|
|
349
386
|
for (const r of vueRoots) {
|
|
350
387
|
await checkVuePackage(r, fail, pass)
|
|
351
388
|
}
|
package/scripts/cli-entry.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Визначення, чи виконується поточний ESM-модуль як точка входу CLI, а не як import у тестах чи інших модулях.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Порівняння `import.meta.url` з `process.argv[1]` після `resolve`, щоб `bun path/to/script.mjs`
|
|
5
|
+
* і `node path/to/script.mjs` коректно вважалися прямим запуском.
|
|
6
6
|
*/
|
|
7
7
|
import { resolve } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
@@ -12,9 +12,6 @@ import { fileURLToPath } from 'node:url'
|
|
|
12
12
|
* @returns {boolean} `true`, якщо файл запущено напряму; інакше `false`.
|
|
13
13
|
*/
|
|
14
14
|
export function isRunAsCli() {
|
|
15
|
-
if (import.meta.main === true) {
|
|
16
|
-
return true
|
|
17
|
-
}
|
|
18
15
|
try {
|
|
19
16
|
const entry = process.argv[1]
|
|
20
17
|
if (!entry) {
|
|
@@ -108,7 +108,7 @@ async function runBunInstall(projectRoot) {
|
|
|
108
108
|
} catch (error) {
|
|
109
109
|
const exitCode = typeof error?.code === 'number' ? error.code : null
|
|
110
110
|
if (exitCode !== null && exitCode !== 0) {
|
|
111
|
-
throw new Error(`bun i завершився з кодом ${exitCode}
|
|
111
|
+
throw new Error(`bun i завершився з кодом ${exitCode}`, { cause: error })
|
|
112
112
|
}
|
|
113
113
|
throw error
|
|
114
114
|
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-сканер небезпечних патернів Bun SQL (`import { sql, SQL } from 'bun'`).
|
|
3
|
+
*
|
|
4
|
+
* Знаходить:
|
|
5
|
+
* - `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля,
|
|
6
|
+
* а не на кожен виклик handler-а.
|
|
7
|
+
* - Виклик `sql.unsafe(\`...${expr}...\`)` з даними у TemplateLiteral —
|
|
8
|
+
* `sql.unsafe` приймає лише статичний SQL (плюс масив параметрів); інтерполяція
|
|
9
|
+
* у текст руйнує параметризацію і відкриває SQL injection.
|
|
10
|
+
* - Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``:
|
|
11
|
+
* навіть «через tagged template» у запит потрапляє готовий шматок SQL замість
|
|
12
|
+
* параметризованих значень — треба `sql([...])`.
|
|
13
|
+
*
|
|
14
|
+
* Семантика — через **oxc-parser**, без regex по тексту коду.
|
|
15
|
+
* Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
|
|
16
|
+
* результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
|
|
17
|
+
*/
|
|
18
|
+
import { parseSync } from 'oxc-parser'
|
|
19
|
+
|
|
20
|
+
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
21
|
+
const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
|
|
22
|
+
const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Мова для Oxc за шляхом файлу (розширення).
|
|
26
|
+
* @param {string} filePath віртуальний або реальний шлях до файлу
|
|
27
|
+
* @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
|
|
28
|
+
*/
|
|
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'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Номер рядка (1-based) за зміщенням у буфері.
|
|
39
|
+
* @param {string} content повний текст файлу
|
|
40
|
+
* @param {number} offset байтове зміщення початку фрагмента
|
|
41
|
+
* @returns {number} номер рядка від 1
|
|
42
|
+
*/
|
|
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
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Стискає пробіли для повідомлення про порушення.
|
|
54
|
+
* @param {string} s фрагмент коду
|
|
55
|
+
* @returns {string} скорочений однорядковий рядок
|
|
56
|
+
*/
|
|
57
|
+
function normalizeSnippet(s) {
|
|
58
|
+
return s.replaceAll(/\s+/gu, ' ').trim().slice(0, 180)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Чи є вузол функцією.
|
|
63
|
+
* @param {unknown} node AST node
|
|
64
|
+
* @returns {boolean} true, якщо це будь-який вузол-функція
|
|
65
|
+
*/
|
|
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
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
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}
|
|
83
|
+
*/
|
|
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
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rec = /** @type {Record<string, unknown>} */ (node)
|
|
92
|
+
if (typeof rec.type === 'string') {
|
|
93
|
+
visit(rec, ancestors)
|
|
94
|
+
ancestors = [...ancestors, rec]
|
|
95
|
+
}
|
|
96
|
+
|
|
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
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Парсить файл та повертає program або null, якщо є синтаксичні помилки.
|
|
108
|
+
* @param {string} content вихідний код
|
|
109
|
+
* @param {string} virtualPath шлях для вибору `lang`
|
|
110
|
+
* @returns {unknown | null} `result.program` або null
|
|
111
|
+
*/
|
|
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
|
|
119
|
+
}
|
|
120
|
+
if (result.errors?.length) return null
|
|
121
|
+
return result.program
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
|
|
126
|
+
* @param {unknown} node AST node
|
|
127
|
+
* @returns {boolean} true, якщо це `new SQL(...)`
|
|
128
|
+
*/
|
|
129
|
+
function isNewSqlConstructor(node) {
|
|
130
|
+
if (!node || node.type !== 'NewExpression') return false
|
|
131
|
+
const callee = node.callee
|
|
132
|
+
return !!callee && callee.type === 'Identifier' && callee.name === 'SQL'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Чи це виклик `<obj>.unsafe(...)` з TemplateLiteral як першим аргументом і expressions усередині нього.
|
|
137
|
+
* Допустимий лише `sql.unsafe('static text', [params])`; з `${...}` у TemplateLiteral — небезпечно.
|
|
138
|
+
* @param {unknown} node AST node
|
|
139
|
+
* @returns {boolean} true для небезпечного `sql.unsafe(\`... ${x} ...\`)`
|
|
140
|
+
*/
|
|
141
|
+
function isUnsafeCallWithInterpolatedTemplate(node) {
|
|
142
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
143
|
+
const callee = node.callee
|
|
144
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
145
|
+
const prop = callee.property
|
|
146
|
+
if (!prop || prop.type !== 'Identifier' || prop.name !== 'unsafe') return false
|
|
147
|
+
const args = node.arguments
|
|
148
|
+
if (!Array.isArray(args) || args.length === 0) return false
|
|
149
|
+
const first = args[0]
|
|
150
|
+
if (!first || first.type !== 'TemplateLiteral') return false
|
|
151
|
+
const expressions = first.expressions
|
|
152
|
+
return Array.isArray(expressions) && expressions.length > 0
|
|
153
|
+
}
|
|
154
|
+
|
|
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
|
+
/**
|
|
197
|
+
* Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
|
|
198
|
+
* @param {string} content вихідний код
|
|
199
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
200
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
201
|
+
*/
|
|
202
|
+
export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'scan.ts') {
|
|
203
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
204
|
+
if (!program) return []
|
|
205
|
+
|
|
206
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
207
|
+
const out = []
|
|
208
|
+
walkAstWithAncestors(program, [], (node, ancestors) => {
|
|
209
|
+
if (!isNewSqlConstructor(node)) return
|
|
210
|
+
const insideFunction = ancestors.some(n => isFunctionNode(n))
|
|
211
|
+
if (!insideFunction) return
|
|
212
|
+
out.push({
|
|
213
|
+
line: offsetToLine(content, node.start),
|
|
214
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Знаходить виклики `sql.unsafe(\`...${...}...\`)` (TemplateLiteral з expressions).
|
|
222
|
+
* @param {string} content вихідний код
|
|
223
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
224
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
225
|
+
*/
|
|
226
|
+
export function findUnsafeBunSqlUnsafeCallInText(content, virtualPath = 'scan.ts') {
|
|
227
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
228
|
+
if (!program) return []
|
|
229
|
+
|
|
230
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
231
|
+
const out = []
|
|
232
|
+
walkAstWithAncestors(program, [], node => {
|
|
233
|
+
if (!isUnsafeCallWithInterpolatedTemplate(node)) return
|
|
234
|
+
out.push({
|
|
235
|
+
line: offsetToLine(content, node.start),
|
|
236
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Знаходить динамічні SQL-списки у TaggedTemplateExpression / TemplateLiteral в контексті
|
|
244
|
+
* `IN (...)` або `VALUES (...)`, де серед expressions є виклик `.join(...)`.
|
|
245
|
+
* @param {string} content вихідний код
|
|
246
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
247
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
248
|
+
*/
|
|
249
|
+
export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'scan.ts') {
|
|
250
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
251
|
+
if (!program) return []
|
|
252
|
+
|
|
253
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
254
|
+
const out = []
|
|
255
|
+
walkAstWithAncestors(program, [], node => {
|
|
256
|
+
/** @type {unknown} */
|
|
257
|
+
let template = null
|
|
258
|
+
if (node.type === 'TemplateLiteral') {
|
|
259
|
+
template = node
|
|
260
|
+
} else if (node.type === 'TaggedTemplateExpression') {
|
|
261
|
+
template = node.quasi
|
|
262
|
+
}
|
|
263
|
+
if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
|
|
264
|
+
if (!isSqlListContextTemplate(template)) return
|
|
265
|
+
const expressions = template.expressions
|
|
266
|
+
if (!Array.isArray(expressions) || expressions.length === 0) return
|
|
267
|
+
if (!expressions.some(expr => isJoinCall(expr))) return
|
|
268
|
+
out.push({
|
|
269
|
+
line: offsetToLine(content, template.start),
|
|
270
|
+
snippet: normalizeSnippet(content.slice(template.start, template.end))
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
|
|
278
|
+
* Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
|
|
279
|
+
* JS/TS-файлі при зборі ознак для авто-детекту правил.
|
|
280
|
+
* @param {string} content вміст файлу
|
|
281
|
+
* @returns {boolean} true, якщо є імпорт sql або SQL з модуля bun
|
|
282
|
+
*/
|
|
283
|
+
export function textHasBunSqlImport(content) {
|
|
284
|
+
return BUN_SQL_IMPORT_RE.test(content)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
|
|
289
|
+
* @param {string} relativePathPosix відносний шлях (posix)
|
|
290
|
+
* @returns {boolean} true, якщо розширення підходить для AST-скану
|
|
291
|
+
*/
|
|
292
|
+
export function isBunSqlScanSourceFile(relativePathPosix) {
|
|
293
|
+
return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
|
|
294
|
+
}
|