@nitra/cursor 1.8.208 → 1.8.210

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.
@@ -25,6 +25,7 @@ import {
25
25
  offsetToLine,
26
26
  parseProgramAndCommentsOrNull,
27
27
  parseProgramOrNull,
28
+ templateQuasisText,
28
29
  walkAstWithAncestors
29
30
  } from './ast-scan-utils.mjs'
30
31
 
@@ -43,6 +44,18 @@ const ALLOW_PG_LEFTOVER_MARKER_RE = /\ballow-pg-leftover\s*:\s*\S+/u
43
44
  // формально існують і там, тому опт-аут маркером лишається доречним.
44
45
  const PG_LEFTOVER_METHOD_NAMES = new Set(['connect', 'end'])
45
46
 
47
+ // pg-format placeholders — `%L` (literal), `%I` (identifier), `%s` (raw string).
48
+ // Якщо у тілі функції з підозрілим іменем зустрічається такий літерал/regex —
49
+ // це pg-format-сумісний шим (drop-in замінник pg-format поверх Bun SQL).
50
+ const PG_FORMAT_PLACEHOLDER_RE = /%[LIs]/u
51
+ // Імена функцій-кандидатів на pg-format-шим. Спрацьовує лише у поєднанні
52
+ // з наявністю `%L` / `%I` / `%s` у тілі — щоб не плутати з невинним `format(date)`.
53
+ const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'pgFmt'])
54
+ // Імена quote/escape-хелперів — самі по собі сильний сигнал pg-format-шиму,
55
+ // без додаткової перевірки тіла. Це pg-format-специфічні API, нерідко публікуються
56
+ // як named export з модуля-обгортки.
57
+ const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
58
+
46
59
  /**
47
60
  * @param {unknown} node AST node
48
61
  * @param {string} name імʼя змінної
@@ -267,6 +280,185 @@ function asPgLeftoverCall(node) {
267
280
  return { name: /** @type {'connect' | 'end'} */ (prop.name) }
268
281
  }
269
282
 
283
+ /**
284
+ * Чи це CallExpression `<obj>.unsafe(...)` (для пошуку в тілі query-шиму).
285
+ * Дублює `isUnsafeCall` з основного скану, але локально — щоб не залежати
286
+ * від порядку оголошень у файлі.
287
+ * @param {unknown} node AST node
288
+ * @returns {boolean} true для `<obj>.unsafe(...)`
289
+ */
290
+ function isUnsafeCallNode(node) {
291
+ if (!node || node.type !== 'CallExpression') return false
292
+ const callee = node.callee
293
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
294
+ const prop = callee.property
295
+ return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
296
+ }
297
+
298
+ /**
299
+ * Чи містить піддерево вузла рядковий або regex-літерал з `%L` / `%I` / `%s`.
300
+ * Покриває:
301
+ * - `Literal` зі строковим `value`,
302
+ * - `StringLiteral` (oxc),
303
+ * - `TemplateLiteral` (через текст quasis),
304
+ * - `RegExpLiteral` / `Literal` з `regex.pattern`.
305
+ * @param {unknown} root корінь піддерева (зазвичай тіло функції)
306
+ * @returns {boolean} true, якщо знайдено pg-format-плейсхолдер
307
+ */
308
+ function nodeContainsPgFormatPlaceholder(root) {
309
+ let found = false
310
+ walkAstWithAncestors(root, [], n => {
311
+ if (found) return
312
+ const t = n.type
313
+ if (t === 'Literal' || t === 'StringLiteral') {
314
+ if (typeof n.value === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.value)) {
315
+ found = true
316
+ return
317
+ }
318
+ const regex = n.regex
319
+ if (regex && typeof regex.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(regex.pattern)) {
320
+ found = true
321
+ return
322
+ }
323
+ }
324
+ if (t === 'RegExpLiteral' && typeof n.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.pattern)) {
325
+ found = true
326
+ return
327
+ }
328
+ if (t === 'TemplateLiteral') {
329
+ if (PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
330
+ found = true
331
+ }
332
+ }
333
+ })
334
+ return found
335
+ }
336
+
337
+ /**
338
+ * Витягає (name, body) з вузла, що оголошує функцію верхнього рівня:
339
+ * - `function format(...) {...}`,
340
+ * - `const format = (...) => {...}` / `= function(...) {...}`.
341
+ * @param {Record<string, unknown>} node AST node
342
+ * @returns {{ name: string, body: unknown } | null} ім'я та тіло, або null
343
+ */
344
+ function asNamedFunctionDecl(node) {
345
+ if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
346
+ return { name: node.id.name, body: node.body }
347
+ }
348
+ if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
349
+ const init = node.init
350
+ if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
351
+ return { name: node.id.name, body: init.body }
352
+ }
353
+ }
354
+ return null
355
+ }
356
+
357
+ /**
358
+ * Знаходить визначення pg-format-сумісних шимів у джерелі. Прапорує:
359
+ * - функції з іменами `format` / `pgFormat` / `sqlFormat` / `pgFmt`, у тілі яких
360
+ * зустрічається літерал/regex з `%L` / `%I` / `%s` — це drop-in pg-format;
361
+ * - функції з іменами `quoteLiteral` / `quoteIdent` / `escapeLiteral` / `escapeIdent`
362
+ * незалежно від тіла — це pg-format-специфічні API, не потрібні з Bun SQL.
363
+ *
364
+ * Скан запускається лише в файлах, де є `import { sql|SQL } from 'bun'`, щоб
365
+ * не плутати, наприклад, форматер дат чи URL-escape з SQL-шимом.
366
+ * @param {string} content вихідний код
367
+ * @param {string} [virtualPath] шлях для вибору `lang`
368
+ * @returns {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} список порушень
369
+ */
370
+ export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts') {
371
+ if (!textHasBunSqlImport(content)) return []
372
+ const program = parseProgramOrNull(content, virtualPath)
373
+ if (!program) return []
374
+
375
+ /** @type {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} */
376
+ const out = []
377
+ walkAstWithAncestors(program, [], node => {
378
+ const decl = asNamedFunctionDecl(node)
379
+ if (!decl) return
380
+ /** @type {'format_function' | 'quote_helper' | null} */
381
+ let kind = null
382
+ if (QUOTE_HELPER_NAMES.has(decl.name)) {
383
+ kind = 'quote_helper'
384
+ } else if (PG_FORMAT_SHIM_FUNC_NAMES.has(decl.name) && nodeContainsPgFormatPlaceholder(decl.body)) {
385
+ kind = 'format_function'
386
+ }
387
+ if (!kind) return
388
+ out.push({
389
+ line: offsetToLine(content, node.start),
390
+ snippet: normalizeSnippet(content.slice(node.start, Math.min(node.end, node.start + 240))),
391
+ kind,
392
+ name: decl.name
393
+ })
394
+ })
395
+ return out
396
+ }
397
+
398
+ /**
399
+ * Знаходить pg-сумісні query-обгортки виду
400
+ * `{ query(text, params) { return <sql>.unsafe(text, params) } }`
401
+ * у файлах, що імпортують Bun SQL. Така обгортка маскує `unsafe` під
402
+ * «безпечним» ім'ям і повертає injection-поверхню в код.
403
+ *
404
+ * Спрацьовує, коли всі умови виконані:
405
+ * - вузол — `Property` з `key.name === 'query'` всередині `ObjectExpression`;
406
+ * - значення — функція з 1–2 параметрами, перший — Identifier з типовим
407
+ * pg-іменем (`text` / `sql` / `query`);
408
+ * - у тілі функції є виклик `<obj>.unsafe(...)`.
409
+ *
410
+ * @param {string} content вихідний код
411
+ * @param {string} [virtualPath] шлях для вибору `lang`
412
+ * @returns {{ line: number, snippet: string }[]} список порушень
413
+ */
414
+ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.ts') {
415
+ if (!textHasBunSqlImport(content)) return []
416
+ const program = parseProgramOrNull(content, virtualPath)
417
+ if (!program) return []
418
+
419
+ /** @type {{ line: number, snippet: string }[]} */
420
+ const out = []
421
+ walkAstWithAncestors(program, [], node => {
422
+ if (node.type !== 'ObjectExpression') return
423
+ const properties = node.properties
424
+ if (!Array.isArray(properties)) return
425
+ for (const prop of properties) {
426
+ if (!prop || prop.type !== 'Property') continue
427
+ const key = prop.key
428
+ const keyName =
429
+ key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
430
+ if (keyName !== 'query') continue
431
+ const value = prop.value
432
+ if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
433
+ const params = value.params
434
+ const firstName = Array.isArray(params) && params[0]?.type === 'Identifier' ? params[0].name : null
435
+ const looksLikePgQuery =
436
+ Array.isArray(params) && params.length >= 1 && params.length <= 2 && /^(text|sql|query)$/u.test(firstName || '')
437
+ if (!looksLikePgQuery) continue
438
+ if (!nodeContainsUnsafeCall(value.body)) continue
439
+ out.push({
440
+ line: offsetToLine(content, prop.start),
441
+ snippet: normalizeSnippet(content.slice(prop.start, prop.end))
442
+ })
443
+ }
444
+ })
445
+ return out
446
+ }
447
+
448
+ /**
449
+ * Чи є у піддереві виклик `<obj>.unsafe(...)`.
450
+ * @param {unknown} root корінь піддерева
451
+ * @returns {boolean} true, якщо знайдено
452
+ */
453
+ function nodeContainsUnsafeCall(root) {
454
+ let found = false
455
+ walkAstWithAncestors(root, [], n => {
456
+ if (found) return
457
+ if (isUnsafeCallNode(n)) found = true
458
+ })
459
+ return found
460
+ }
461
+
270
462
  /**
271
463
  * Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
272
464
  * @param {string} content вихідний код