@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.
@@ -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; line: number; snippet: string }[]} */
82
- const hits = []
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
- hits.push({ rel, line: 0, snippet: '' })
122
+ candidates.push({ rel })
88
123
  })
89
124
 
90
- // ми використали hits як буфер шляхів; зараз перетворимо на реальні співпадіння
91
- /** @type {{ rel: string; line: number; snippet: string }[]} */
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 >= 30) {
116
- fail(`${prefix}показано перші 30 збігів 'esbuild' (замінити на 'rolldown')`)
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
- * Перевіряє відповідність проєкту правилам vue.mdc (корінь і всі workspace-пакети з `vue` у dependencies).
312
- * @returns {Promise<number>} 0 все OK, 1 — є проблеми
332
+ * Збирає корені пакетів, у яких у `dependencies` є `vue`.
333
+ * @param {string[]} roots усі корені пакетів monorepo
334
+ * @returns {Promise<string[]>} перелік пакетів з vue у dependencies
313
335
  */
314
- export async function check() {
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
- const pkg = JSON.parse(await readFile(p, 'utf8'))
325
- if (pkg.dependencies?.vue) vueRoots.push(r)
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
- if (vueRoots.length > 0) {
330
- if (existsSync('.vscode/extensions.json')) {
331
- const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
332
- if (ext.recommendations?.includes('Vue.volar')) {
333
- pass('extensions.json містить Vue.volar')
334
- } else {
335
- fail('extensions.json не містить Vue.volar — додай до recommendations')
336
- }
337
- } else {
338
- fail('.vscode/extensions.json не існує (для Vue-проєкту потрібна рекомендація Vue.volar)')
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
- pass('Vue.volar: пропущено repo немає пакетів з vue у dependencies)')
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
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Визначення, чи виконується поточний ESM-модуль як точка входу CLI, а не як import у тестах чи інших модулях.
3
3
  *
4
- * У Bun використовується `import.meta.main`; у Node — порівняння `import.meta.url` з `process.argv[1]`
5
- * після `resolve`, щоб `bun path/to/script.mjs` і `node path/to/script.mjs` коректно вважалися прямим запуском.
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
+ }