@nitra/cursor 1.13.52 → 1.13.57

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.
@@ -31,6 +31,16 @@ import {
31
31
 
32
32
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
33
33
  const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
34
+ // Імпорт із npm-пакета `pg` — будь-яка з форм: default, named, namespace, side-effect,
35
+ // а також `require('pg')`. `pg-format`/`pg-pool` свідомо НЕ матчаться: на них діє
36
+ // окрема заборона (denylist) і свої повідомлення. Виключення для LISTEN/NOTIFY
37
+ // стосується лише самого клієнта `pg`.
38
+ const PG_LIB_IMPORT_RE = /(?:\bimport\b[\s\S]*?\bfrom\s*["']pg["']|\brequire\s*\(\s*["']pg["']\s*\))/u
39
+ // Першоквазі-рядок або string literal, що починається з LISTEN / UNLISTEN / NOTIFY
40
+ // (case-insensitive), з опційним leading whitespace. Сигнал, що в коді запит
41
+ // `LISTEN ch` / `NOTIFY ch, 'msg'` / `UNLISTEN *` — це функціонал, якого Bun SQL
42
+ // поки не має, тож у проекті лишається легітимна потреба у клієнті `pg`.
43
+ const PG_LISTEN_NOTIFY_SQL_RE = /^\s*(LISTEN|UNLISTEN|NOTIFY)\b/iu
34
44
  const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
35
45
  // `// allow-unsafe: <reason>` — `allow-unsafe`, двокрапка, **непорожня** причина.
36
46
  // Без причини маркер не приймається: ціль — лишити слід для ревʼюера, а не «німий» прапорець.
@@ -531,6 +541,43 @@ export function findBunSqlUnsafeUseWithoutAllowMarkerInText(content, virtualPath
531
541
  return out
532
542
  }
533
543
 
544
+ /**
545
+ * Знаходить `<obj>.unsafe(template_literal_with_interpolation)` — навіть із маркером
546
+ * `// allow-unsafe`. Шаблонна підстановка `${name}` у `sql.unsafe`-рядок **не екранує**
547
+ * identifier'ів (reserved words, спецсимволи) і ніяк не біндить значення — це
548
+ * структурна injection-поверхня, яку легко не помітити в ревʼю. Канон — побудувати
549
+ * `text` через `@scaleleap/pg-format` `format('%I', name)` (для identifiers) або
550
+ * звичайні позиційні `$N`-placeholder'и (для values), і передати в `sql.unsafe(text, [params])`.
551
+ *
552
+ * Прапорує саме `TemplateLiteral` з `expressions.length > 0`; статичні рядки
553
+ * (`Literal`, `StringLiteral`, `TemplateLiteral` без `${...}`) і виклики з готовим
554
+ * `text` як змінною — не зачіпає (для них діє основна перевірка allow-unsafe).
555
+ * @param {string} content вихідний код
556
+ * @param {string} [virtualPath] шлях для вибору `lang`
557
+ * @returns {{ line: number, snippet: string }[]} список порушень
558
+ */
559
+ export function findBunSqlUnsafeWithInterpolatedTemplateInText(content, virtualPath = 'scan.ts') {
560
+ const program = parseProgramOrNull(content, virtualPath)
561
+ if (!program) return []
562
+
563
+ /** @type {{ line: number, snippet: string }[]} */
564
+ const out = []
565
+ walkAstWithAncestors(program, [], node => {
566
+ if (!isUnsafeCall(node)) return
567
+ const args = node.arguments
568
+ if (!Array.isArray(args) || args.length === 0) return
569
+ const first = args[0]
570
+ if (!first || first.type !== 'TemplateLiteral') return
571
+ const expressions = first.expressions
572
+ if (!Array.isArray(expressions) || expressions.length === 0) return
573
+ out.push({
574
+ line: offsetToLine(content, node.start),
575
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
576
+ })
577
+ })
578
+ return out
579
+ }
580
+
534
581
  /**
535
582
  * Знаходить pg-leftover виклики `<obj>.connect(...)` / `<obj>.end(...)` без маркера
536
583
  * `// allow-pg-leftover: <reason>` у файлах, де **в цьому ж файлі** є `import { sql|SQL } from 'bun'`.
@@ -685,6 +732,192 @@ export function textHasBunSqlImport(content) {
685
732
  return BUN_SQL_IMPORT_RE.test(content)
686
733
  }
687
734
 
735
+ /**
736
+ * Чи імпортує файл npm-пакет `pg` (`import ... from 'pg'` або `require('pg')`).
737
+ * Текстова перевірка — без AST, дешевий pre-filter; для строгої локалізації
738
+ * (рядок/snippet) використай `findPgLibImportInText`. Не матчить `pg-format`,
739
+ * `pg-pool`, `@types/pg` — лише сам клієнт.
740
+ * @param {string} content вміст файлу
741
+ * @returns {boolean} true, якщо у файлі є імпорт `'pg'`
742
+ */
743
+ export function textHasPgLibImport(content) {
744
+ return PG_LIB_IMPORT_RE.test(content)
745
+ }
746
+
747
+ /**
748
+ * Знаходить ImportDeclaration / CallExpression `require('pg')` для пакета `pg`
749
+ * (саме точна назва, не `pg-format` тощо). Повертає рядок і snippet — щоб у
750
+ * повідомленнях `fail` показати конкретне місце.
751
+ * @param {string} content вихідний код
752
+ * @param {string} [virtualPath] шлях для вибору `lang`
753
+ * @returns {{ line: number, snippet: string }[]} список місць, де імпортується `pg`
754
+ */
755
+ export function findPgLibImportInText(content, virtualPath = 'scan.ts') {
756
+ const program = parseProgramOrNull(content, virtualPath)
757
+ if (!program) return []
758
+
759
+ /** @type {{ line: number, snippet: string }[]} */
760
+ const out = []
761
+ walkAstWithAncestors(program, [], node => {
762
+ if (node.type === 'ImportDeclaration' && getStringLiteralValue(node.source) === 'pg') {
763
+ out.push({
764
+ line: offsetToLine(content, node.start),
765
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
766
+ })
767
+ return
768
+ }
769
+ if (node.type === 'CallExpression' && isRequireOfModule(node, 'pg')) {
770
+ out.push({
771
+ line: offsetToLine(content, node.start),
772
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
773
+ })
774
+ }
775
+ })
776
+ return out
777
+ }
778
+
779
+ /**
780
+ * Знаходить використання PostgreSQL LISTEN/NOTIFY у коді — сигнал, що проект
781
+ * потребує `pg` як виняток (Bun SQL поки не реалізує LISTEN/NOTIFY). Прапорує:
782
+ * - `<obj>.query(...)` / `<obj>.queryArray(...)` / `<obj>.queryStream(...)`, де
783
+ * перший аргумент — string literal або template literal, що починається з
784
+ * `LISTEN ` / `UNLISTEN ` / `NOTIFY ` (case-insensitive);
785
+ * - `<obj>.on('notification', ...)` — pg-listener notification-подій (другий
786
+ * аргумент — функція; перший — точно рядок `'notification'`);
787
+ * - TaggedTemplateExpression виду `sql\`LISTEN ...\`` — на випадок, якщо хтось
788
+ * використовує Bun SQL-tagged-template, а LISTEN/NOTIFY все одно лишається у
789
+ * тексті запиту (це не запрацює у Bun SQL, але як сигнал — приймаємо).
790
+ *
791
+ * Регістр SQL-слів не важливий, провідні пробіли допускаються.
792
+ * @param {string} content вихідний код
793
+ * @param {string} [virtualPath] шлях для вибору `lang`
794
+ * @returns {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} список знахідок
795
+ */
796
+ export function findPgListenNotifyUsageInText(content, virtualPath = 'scan.ts') {
797
+ const program = parseProgramOrNull(content, virtualPath)
798
+ if (!program) return []
799
+
800
+ /** @type {{ line: number, snippet: string, kind: 'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' }[]} */
801
+ const out = []
802
+ walkAstWithAncestors(program, [], node => {
803
+ const fromCall = listenNotifyFromCallExpression(node)
804
+ if (fromCall) {
805
+ out.push({
806
+ line: offsetToLine(content, node.start),
807
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
808
+ kind: fromCall
809
+ })
810
+ return
811
+ }
812
+ const fromTagged = listenNotifyFromTaggedTemplate(node)
813
+ if (fromTagged) {
814
+ out.push({
815
+ line: offsetToLine(content, node.start),
816
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
817
+ kind: fromTagged
818
+ })
819
+ }
820
+ })
821
+ return out
822
+ }
823
+
824
+ /**
825
+ * @param {Record<string, unknown>} node ImportDeclaration.source або CallExpression.arguments[0]
826
+ * @returns {string | null} значення string literal або null
827
+ */
828
+ function getStringLiteralValue(node) {
829
+ if (!node || typeof node !== 'object') return null
830
+ if ((node.type === 'Literal' || node.type === 'StringLiteral') && typeof node.value === 'string') {
831
+ return node.value
832
+ }
833
+ return null
834
+ }
835
+
836
+ /**
837
+ * Чи це `require('<moduleName>')` — CallExpression з callee Identifier `require`
838
+ * і єдиним string-літералом-аргументом.
839
+ * @param {Record<string, unknown>} node CallExpression
840
+ * @param {string} moduleName очікувана назва модуля (точне співпадіння)
841
+ * @returns {boolean} true, якщо це саме require цього модуля
842
+ */
843
+ function isRequireOfModule(node, moduleName) {
844
+ const callee = node.callee
845
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return false
846
+ const args = node.arguments
847
+ if (!Array.isArray(args) || args.length !== 1) return false
848
+ return getStringLiteralValue(args[0]) === moduleName
849
+ }
850
+
851
+ /**
852
+ * Аналізує CallExpression на предмет pg-style LISTEN/NOTIFY-запиту або listener'а
853
+ * подій `'notification'`. Повертає тип сигналу або `null`.
854
+ * @param {Record<string, unknown>} node AST node
855
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | 'notification_listener' | null} kind знахідки
856
+ */
857
+ function listenNotifyFromCallExpression(node) {
858
+ if (!node || node.type !== 'CallExpression') return null
859
+ const callee = node.callee
860
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return null
861
+ const prop = callee.property
862
+ if (!prop || prop.type !== 'Identifier' || typeof prop.name !== 'string') return null
863
+ const args = node.arguments
864
+ if (!Array.isArray(args) || args.length === 0) return null
865
+
866
+ if (prop.name === 'on') {
867
+ return getStringLiteralValue(args[0]) === 'notification' ? 'notification_listener' : null
868
+ }
869
+ if (prop.name === 'query' || prop.name === 'queryArray' || prop.name === 'queryStream') {
870
+ return sqlStringStartsWithListenNotify(args[0])
871
+ }
872
+ return null
873
+ }
874
+
875
+ /**
876
+ * Аналізує TaggedTemplateExpression `<tag>\`LISTEN ...\``: якщо перший quasi
877
+ * починається з LISTEN/UNLISTEN/NOTIFY — повертає відповідний kind.
878
+ * @param {Record<string, unknown>} node AST node
879
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
880
+ */
881
+ function listenNotifyFromTaggedTemplate(node) {
882
+ if (!node || node.type !== 'TaggedTemplateExpression') return null
883
+ const quasi = node.quasi
884
+ if (!quasi || quasi.type !== 'TemplateLiteral') return null
885
+ const text = templateQuasisText(quasi)
886
+ return kindFromListenNotifyMatch(text)
887
+ }
888
+
889
+ /**
890
+ * Перший аргумент виклику `.query(...)` — це string literal або template literal,
891
+ * текст якого починається з LISTEN / UNLISTEN / NOTIFY (case-insensitive)?
892
+ * @param {unknown} arg AST node першого аргумента
893
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки або null
894
+ */
895
+ function sqlStringStartsWithListenNotify(arg) {
896
+ if (!arg || typeof arg !== 'object') return null
897
+ if ((arg.type === 'Literal' || arg.type === 'StringLiteral') && typeof arg.value === 'string') {
898
+ return kindFromListenNotifyMatch(arg.value)
899
+ }
900
+ if (arg.type === 'TemplateLiteral') {
901
+ return kindFromListenNotifyMatch(templateQuasisText(arg))
902
+ }
903
+ return null
904
+ }
905
+
906
+ /**
907
+ * Перетворює текст SQL-рядка у kind знахідки (`listen_sql` / `notify_sql` /
908
+ * `unlisten_sql`) або `null`, якщо рядок не починається з ключового слова.
909
+ * @param {string} text SQL-текст
910
+ * @returns {'listen_sql' | 'notify_sql' | 'unlisten_sql' | null} kind знахідки
911
+ */
912
+ function kindFromListenNotifyMatch(text) {
913
+ const m = PG_LISTEN_NOTIFY_SQL_RE.exec(text)
914
+ if (!m) return null
915
+ const keyword = m[1].toUpperCase()
916
+ if (keyword === 'LISTEN') return 'listen_sql'
917
+ if (keyword === 'NOTIFY') return 'notify_sql'
918
+ return 'unlisten_sql'
919
+ }
920
+
688
921
  /**
689
922
  * Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
690
923
  * @param {string} relativePathPosix відносний шлях (posix)