@nitra/cursor 1.8.145 → 1.8.150
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 +2 -1
- package/bin/rename-yaml-extensions.mjs +0 -2
- package/mdc/docker.mdc +2 -0
- package/mdc/js-lint.mdc +6 -4
- package/mdc/js-mssql.mdc +2 -0
- package/mdc/k8s.mdc +3 -3
- package/mdc/vue.mdc +3 -3
- package/package.json +3 -3
- package/scripts/auto-rules.mjs +46 -29
- package/scripts/check-capacitor.mjs +4 -4
- package/scripts/check-ga.mjs +27 -15
- package/scripts/check-js-bun-db.mjs +3 -3
- package/scripts/check-js-lint.mjs +114 -27
- package/scripts/check-js-mssql.mjs +155 -97
- package/scripts/check-k8s.mjs +204 -42
- package/scripts/check-nginx-default-tpl.mjs +1 -1
- package/scripts/check-php.mjs +0 -1
- package/scripts/check-text.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/ast-scan-utils.mjs +154 -0
- package/scripts/utils/bun-sql-scan.mjs +10 -144
- package/scripts/utils/bunyan-imports.mjs +2 -36
- package/scripts/utils/mssql-pool-scan.mjs +76 -187
- 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/skills/lint/SKILL.md +1 -1
package/scripts/check-php.mjs
CHANGED
package/scripts/check-text.mjs
CHANGED
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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Спільні утиліти для AST-сканерів JS/TS на oxc-parser:
|
|
3
|
+
* вибір мови за розширенням, переклад зміщення в номер рядка, стиснення сніпета,
|
|
4
|
+
* обхід AST з предками, парсинг програми з безпечним поверненням `null`,
|
|
5
|
+
* розпізнавання типових вузлів (функцій, `*.join(...)`),
|
|
6
|
+
* робота з `TemplateLiteral` (текст quasis, контекст SQL-списку).
|
|
7
|
+
*
|
|
8
|
+
* Використовується файлами `bun-sql-scan.mjs`, `mssql-pool-scan.mjs` та іншими сканерами
|
|
9
|
+
* для уникнення дублювання boilerplate.
|
|
10
|
+
*/
|
|
11
|
+
import { parseSync } from 'oxc-parser'
|
|
12
|
+
|
|
13
|
+
const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Мова для Oxc за шляхом файлу (розширення).
|
|
17
|
+
* @param {string} filePath віртуальний або реальний шлях до файлу
|
|
18
|
+
* @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
|
|
19
|
+
*/
|
|
20
|
+
export function langFromPath(filePath) {
|
|
21
|
+
const lower = filePath.toLowerCase()
|
|
22
|
+
if (lower.endsWith('.tsx')) return 'tsx'
|
|
23
|
+
if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
|
|
24
|
+
if (lower.endsWith('.jsx')) return 'jsx'
|
|
25
|
+
return 'js'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Номер рядка (1-based) за зміщенням у буфері.
|
|
30
|
+
* @param {string} content повний текст файлу
|
|
31
|
+
* @param {number} offset байтове зміщення початку фрагмента
|
|
32
|
+
* @returns {number} номер рядка від 1
|
|
33
|
+
*/
|
|
34
|
+
export function offsetToLine(content, offset) {
|
|
35
|
+
let line = 1
|
|
36
|
+
const n = Math.min(offset, content.length)
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
if (content.codePointAt(i) === 10) line++
|
|
39
|
+
}
|
|
40
|
+
return line
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Стискає пробіли для повідомлення про порушення.
|
|
45
|
+
* @param {string} s фрагмент коду
|
|
46
|
+
* @returns {string} скорочений однорядковий рядок
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeSnippet(s) {
|
|
49
|
+
return s.replaceAll(/\s+/gu, ' ').trim().slice(0, 180)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Чи є вузол функцією.
|
|
54
|
+
* @param {unknown} node AST node
|
|
55
|
+
* @returns {boolean} true, якщо це будь-який вузол-функція
|
|
56
|
+
*/
|
|
57
|
+
export function isFunctionNode(node) {
|
|
58
|
+
return (
|
|
59
|
+
!!node &&
|
|
60
|
+
typeof node === 'object' &&
|
|
61
|
+
typeof node.type === 'string' &&
|
|
62
|
+
(node.type === 'FunctionDeclaration' ||
|
|
63
|
+
node.type === 'FunctionExpression' ||
|
|
64
|
+
node.type === 'ArrowFunctionExpression')
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
|
|
70
|
+
* @param {unknown} node поточний вузол
|
|
71
|
+
* @param {unknown[]} ancestors масив предків від кореня до parent
|
|
72
|
+
* @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*/
|
|
75
|
+
export function walkAstWithAncestors(node, ancestors, visit) {
|
|
76
|
+
if (!node || typeof node !== 'object') return
|
|
77
|
+
if (Array.isArray(node)) {
|
|
78
|
+
for (const item of node) walkAstWithAncestors(item, ancestors, visit)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rec = /** @type {Record<string, unknown>} */ (node)
|
|
83
|
+
if (typeof rec.type === 'string') {
|
|
84
|
+
visit(rec, ancestors)
|
|
85
|
+
ancestors = [...ancestors, rec]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const key of Object.keys(node)) {
|
|
89
|
+
if (key === 'parent') continue
|
|
90
|
+
const v = rec[key]
|
|
91
|
+
if (v && typeof v === 'object') {
|
|
92
|
+
walkAstWithAncestors(v, ancestors, visit)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Парсить файл і повертає `program` або null, якщо є синтаксичні помилки чи виняток.
|
|
99
|
+
* @param {string} content вихідний код
|
|
100
|
+
* @param {string} virtualPath шлях для вибору `lang` (також для діагностики)
|
|
101
|
+
* @returns {unknown | null} `result.program` або null, якщо парсинг не вдався
|
|
102
|
+
*/
|
|
103
|
+
export function parseProgramOrNull(content, virtualPath) {
|
|
104
|
+
const lang = langFromPath(virtualPath || 'scan.ts')
|
|
105
|
+
let result
|
|
106
|
+
try {
|
|
107
|
+
result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
if (result.errors?.length) return null
|
|
112
|
+
return result.program
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Чи це `.join(...)` виклик (типово для динамічних списків у SQL).
|
|
117
|
+
* @param {unknown} node AST node
|
|
118
|
+
* @returns {boolean} true, якщо це CallExpression `*.join(...)`
|
|
119
|
+
*/
|
|
120
|
+
export function isJoinCall(node) {
|
|
121
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
122
|
+
const callee = node.callee
|
|
123
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
124
|
+
const prop = callee.property
|
|
125
|
+
return !!prop && prop.type === 'Identifier' && prop.name === 'join'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Текст quasis у TemplateLiteral (без expressions).
|
|
130
|
+
* @param {unknown} template TemplateLiteral
|
|
131
|
+
* @returns {string} обʼєднаний raw-текст
|
|
132
|
+
*/
|
|
133
|
+
export function templateQuasisText(template) {
|
|
134
|
+
if (!template || template.type !== 'TemplateLiteral') return ''
|
|
135
|
+
const quasis = template.quasis
|
|
136
|
+
if (!Array.isArray(quasis) || quasis.length === 0) return ''
|
|
137
|
+
let out = ''
|
|
138
|
+
for (const q of quasis) {
|
|
139
|
+
if (!q || typeof q !== 'object') continue
|
|
140
|
+
const value = q.value
|
|
141
|
+
if (!value || typeof value !== 'object') continue
|
|
142
|
+
if (typeof value.raw === 'string') out += value.raw
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
|
|
149
|
+
* @param {unknown} template TemplateLiteral
|
|
150
|
+
* @returns {boolean} true, якщо текст містить `IN (` або `VALUES (`
|
|
151
|
+
*/
|
|
152
|
+
export function isSqlListContextTemplate(template) {
|
|
153
|
+
return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
|
|
154
|
+
}
|
|
@@ -15,112 +15,19 @@
|
|
|
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
|
|
23
30
|
|
|
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
31
|
/**
|
|
125
32
|
* Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
|
|
126
33
|
* @param {unknown} node AST node
|
|
@@ -152,47 +59,6 @@ function isUnsafeCallWithInterpolatedTemplate(node) {
|
|
|
152
59
|
return Array.isArray(expressions) && expressions.length > 0
|
|
153
60
|
}
|
|
154
61
|
|
|
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
62
|
/**
|
|
197
63
|
* Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
|
|
198
64
|
* @param {string} content вихідний код
|
|
@@ -278,7 +144,7 @@ export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'sca
|
|
|
278
144
|
* Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
|
|
279
145
|
* JS/TS-файлі при зборі ознак для авто-детекту правил.
|
|
280
146
|
* @param {string} content вміст файлу
|
|
281
|
-
* @returns {boolean}
|
|
147
|
+
* @returns {boolean} true, якщо є імпорт sql або SQL з модуля bun
|
|
282
148
|
*/
|
|
283
149
|
export function textHasBunSqlImport(content) {
|
|
284
150
|
return BUN_SQL_IMPORT_RE.test(content)
|
|
@@ -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 фрагмент коду
|