@nitra/cursor 1.8.147 → 1.8.151

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 CHANGED
@@ -561,7 +561,7 @@ function buildClaudeLintParallelismSectionLines() {
561
561
  '## Лінт і ESLint (без паралельних запусків)',
562
562
  '',
563
563
  'Щоб не запускати **кілька** одночасних **`eslint`** (і не перевантажувати диск/CPU), **заборонено** стартувати `bun run lint` / `lint-js` / `eslint` **паралельно** в різних Bash-задачах, **фонових** shells чи **субагентах** (Task тощо). Має бути **один** послідовний прогон на сесію; команда **`/n-lint`** — **не** ділити на паралельні підзадачі. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
564
- '',
564
+ ''
565
565
  ]
566
566
  }
567
567
 
@@ -1,4 +1,3 @@
1
-
2
1
  /**
3
2
  * CLI для перейменування розширень YAML (k8s та `.github`). Бізнес-логіка — у **`scripts/rename-yaml-extensions.mjs`**.
4
3
  *
package/mdc/docker.mdc CHANGED
@@ -189,8 +189,10 @@ jobs:
189
189
  ```yaml title=".hadolint.yaml"
190
190
  ignored:
191
191
  - DL3007
192
+ - DL3008
192
193
  - DL3018
193
194
  ```
195
+
194
196
  Де DL3007 - «Не використовуй тег latest у FROM»
195
197
  Де DL3018 - «Піни версії пакетів у apk add»
196
198
 
package/mdc/js-bun-db.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  alwaysApply: true
4
- version: '1.1'
4
+ version: '1.2'
5
5
  ---
6
6
 
7
7
  ## Підтримувані версії баз даних
@@ -72,6 +72,22 @@ const ids = [1, 2, 3]
72
72
  await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
73
73
  ```
74
74
 
75
+ ## `IN (...)`: значення з template literal — тільки через змінну + guard на пустоту
76
+
77
+ Якщо список для `IN (...)` підставляється через `${...}` у template literal, його **потрібно**:
78
+
79
+ - винести в **окрему змінну** (не підставляти вираз напряму в `${...}`);
80
+ - **перевірити на пустоту** перед запитом і **throw** (щоб не виконувати некоректний SQL або запит з неочікуваною семантикою).
81
+
82
+ Приклад:
83
+
84
+ ```javascript
85
+ const ids = inputIds.map(Number).filter(n => Number.isFinite(n))
86
+ if (!ids.length) throw new Error('ids is empty')
87
+
88
+ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
89
+ ```
90
+
75
91
  Транзакції — через `sql.begin` (auto-commit/rollback), вкладені — через `tx.savepoint`:
76
92
 
77
93
  ```javascript
@@ -81,6 +97,23 @@ await sql.begin(async tx => {
81
97
  })
82
98
  ```
83
99
 
100
+ ## Коментар під час виправлення SQL injection
101
+
102
+ Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна конкатенації/`.join(',')` на `sql(ids)` або перехід з `sql.unsafe(...)` на tagged template), **додай поруч короткий коментар** з описом причини.
103
+
104
+ Вимоги до коментаря:
105
+
106
+ - пояснити **що саме було небезпечно** (конкатенація, підмішування user input, динамічний `IN (...)`, тощо);
107
+ - пояснити **чому новий варіант безпечний** (параметризація через tagged template / `sql(...)`);
108
+ - без “романів”: 1–2 рядки, достатньо для ревʼю.
109
+
110
+ Приклад:
111
+
112
+ ```javascript
113
+ // SQLi fix: не конкатенуємо значення в `IN (...)`; Bun parameterize через `sql(ids)`.
114
+ await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
115
+ ```
116
+
84
117
  ## Що НЕ робити
85
118
 
86
119
  ### Не використовувати `sql.unsafe(...)` з конкатенацією
package/mdc/js-mssql.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання mssql в nodejs
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  ## Підтримувана версія SQL Server
@@ -17,6 +17,7 @@ version: '1.2'
17
17
  ## Як виконувати запити (безпечно)
18
18
 
19
19
  tagged template треба викликати на request-обʼєкті цього пулу:
20
+
20
21
  ```javascript
21
22
  javascript// db.js
22
23
  import sql from 'mssql';
@@ -53,11 +54,29 @@ const result = await pool.request().query`
53
54
 
54
55
  Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
55
56
 
57
+ ## Коментар під час виправлення SQL injection
58
+
59
+ Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна `query(\`...\`)` на `query\`...\`` або прибирання динамічних списків/конкатенації), **додай поруч короткий коментар** з описом причини.
60
+
61
+ Вимоги до коментаря:
62
+
63
+ - вказати **що було небезпечно** (звичайна інтерполяція в рядок, конкатенація, динамічний список);
64
+ - вказати **чому новий варіант безпечний** (tagged template / параметризація / TVP);
65
+ - 1–2 рядки, без дублювання очевидного.
66
+
67
+ Приклад:
68
+
69
+ ```javascript
70
+ // SQLi fix: query`...` (tagged template) параметризує значення; query(`...`) небезпечний через інтерполяцію.
71
+ await pool.request().query`SELECT * FROM users WHERE id = ${userId}`
72
+ ```
73
+
56
74
  ## Що НЕ робити
57
75
 
58
76
  ### Не робити `query(\`...\`)`
59
77
 
60
78
  javascript// ❌ Це не tagged template — це конкатенація рядка перед викликом
79
+
61
80
  ```javascript
62
81
  await pool.request().query(`SELECT * FROM users WHERE id = ${userId}`);
63
82
  // ↑ круглі дужки замість бекті́ків = звичайна інтерполяція = SQL injection
@@ -164,6 +183,11 @@ WHERE NOT EXISTS (
164
183
 
165
184
  Якщо `IN (...)` все ж використовується (а не `JOIN` на TVP), значення в `${...}` **обовʼязково** мають бути попередньо приведені числовим парсером і відфільтровані від `NaN`. Це знімає будь-яку можливість SQL injection: SQL-метасимволи в `Number`/`parseInt(...)` перетворюються на `NaN` і відсіюються.
166
185
 
186
+ Додатково:
187
+
188
+ - значення для `IN (${...})` потрібно **винести в окрему змінну** перед запитом (не підставляти вираз напряму в `${...}`);
189
+ - цю змінну потрібно **перевірити на пустоту** і якщо список порожній — **throw error** (щоб не виконувати некоректний запит).
190
+
167
191
  ```javascript
168
192
  // ❌ НЕ МОЖНА: значення з req.body / зовнішнього джерела без парсингу
169
193
  const outIds = pgQ.rows.flatMap(x => x.req_body.Orders.map(o => o.OutletId))
@@ -174,9 +198,11 @@ await pool.query(/* sql */ String.raw`
174
198
 
175
199
  ```javascript
176
200
  // ✅ МОЖНА: parseInt + filter(!isNaN) гарантує, що в SQL потраплять лише числа
201
+ // і перед запитом робимо guard на пустоту, щоб не виконувати некоректний SQL.
177
202
  const outIds = pgQ.rows
178
203
  .flatMap(x => x.req_body.Orders.map(o => parseInt(o.OutletId)))
179
204
  .filter(n => !isNaN(n))
205
+ if (!outIds.length) throw new Error('outIds is empty')
180
206
  await pool.request().query`
181
207
  SELECT ... WHERE so.OutletId IN (${outIds})
182
208
  `
package/mdc/k8s.mdc CHANGED
@@ -314,7 +314,7 @@ data:
314
314
 
315
315
  ### Прод-оверрайди у `kustomization.yaml`
316
316
 
317
- Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-накладенні `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення:
317
+ Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-накладенні `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення **лише якщо** цей оверлей наслідує base-дерево, де є **Deployment** і **HPA/PDB** (тобто base реально дає dev-like HPA/PDB, які треба підняти в проді):
318
318
 
319
319
  - для `HorizontalPodAutoscaler`: `spec.minReplicas` **і** `spec.maxReplicas` (щоб у проді вийшло ≥2).
320
320
  - для `PodDisruptionBudget`: `spec.minAvailable` (щоб у проді вийшло ≥1).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.147",
3
+ "version": "1.8.151",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -376,9 +376,7 @@ function extractNitraObjectBodySource(source) {
376
376
  * @returns {boolean} **true**, якщо в тілі є **iosCocoaPods**…**:** **true**
377
377
  */
378
378
  function nitraObjectBodyStringAllowsCocoaPodsExempt(objectBody) {
379
- return (
380
- RE_COCOAPODS_EXEMPT_SPM.test(objectBody) === true || RE_COCOAPODS_EXEMPT_ALLOW.test(objectBody) === true
381
- )
379
+ return RE_COCOAPODS_EXEMPT_SPM.test(objectBody) === true || RE_COCOAPODS_EXEMPT_ALLOW.test(objectBody) === true
382
380
  }
383
381
 
384
382
  /**
@@ -443,7 +441,9 @@ export async function check() {
443
441
  const { byPath, anyCapacitor } = acc
444
442
 
445
443
  if (!isCapacitorRelevantForCheck(root, anyCapacitor)) {
446
- pass('Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено')
444
+ pass(
445
+ 'Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено'
446
+ )
447
447
  return getExitCode()
448
448
  }
449
449
 
@@ -20,6 +20,7 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
20
20
  import {
21
21
  findBunSqlPerRequestConnectionInText,
22
22
  findUnsafeBunSqlDynamicSqlListInText,
23
+ findUnsafeBunSqlInListMissingEmptyGuardInText,
23
24
  findUnsafeBunSqlUnsafeCallInText,
24
25
  isBunSqlScanSourceFile,
25
26
  textHasBunSqlImport
@@ -125,6 +126,7 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
125
126
  let perRequest = 0
126
127
  let unsafeCall = 0
127
128
  let dynamicList = 0
129
+ let inListGuard = 0
128
130
 
129
131
  for (const absPath of sourcePaths) {
130
132
  const rel = relative(repoRoot, absPath).split('\\').join('/')
@@ -154,9 +156,28 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
154
156
  `у IN (...) / VALUES (...); використовуй sql([...]) (js-bun-db.mdc): ${v.snippet}`
155
157
  )
156
158
  }
159
+ for (const v of findUnsafeBunSqlInListMissingEmptyGuardInText(content, rel)) {
160
+ inListGuard++
161
+ if (v.reason === 'missing_guard') {
162
+ fail(
163
+ `js-bun-db: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту ` +
164
+ `з throw (наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-bun-db.mdc): ${v.snippet}`
165
+ )
166
+ } else if (v.reason === 'sql_helper_not_var') {
167
+ fail(
168
+ `js-bun-db: ${rel}:${v.line} — IN-список у \${sql(...)} має підставлятись зі змінної (Identifier) ` +
169
+ `після валідації на пустоту + throw (js-bun-db.mdc): ${v.snippet}`
170
+ )
171
+ } else {
172
+ fail(
173
+ `js-bun-db: ${rel}:${v.line} — значення для IN (...) у template literal треба винести в окрему змінну ` +
174
+ `і перевірити на пустоту (throw), не підставляти вираз напряму (js-bun-db.mdc): ${v.snippet}`
175
+ )
176
+ }
177
+ }
157
178
  }
158
179
 
159
- return { hasBunSqlImport, perRequest, unsafeCall, dynamicList }
180
+ return { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard }
160
181
  }
161
182
 
162
183
  /**
@@ -188,7 +209,7 @@ export async function check() {
188
209
  return reporter.getExitCode()
189
210
  }
190
211
 
191
- const { hasBunSqlImport, perRequest, unsafeCall, dynamicList } = await scanSourcesForBunSqlPatterns(
212
+ const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard } = await scanSourcesForBunSqlPatterns(
192
213
  sourcePaths,
193
214
  repoRoot,
194
215
  reporter
@@ -208,6 +229,9 @@ export async function check() {
208
229
  if (dynamicList === 0) {
209
230
  pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
210
231
  }
232
+ if (inListGuard === 0) {
233
+ pass('js-bun-db: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
234
+ }
211
235
 
212
236
  return reporter.getExitCode()
213
237
  }
@@ -18,7 +18,11 @@ import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-wo
18
18
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
19
 
20
20
  /** Шлях до канонічного oxlint JSON у цьому пакеті (для перевірки та тестів). */
21
- export const OXLINT_CANONICAL_JSON_PATH = join(dirname(fileURLToPath(import.meta.url)), 'utils', 'oxlint-canonical.json')
21
+ export const OXLINT_CANONICAL_JSON_PATH = join(
22
+ dirname(fileURLToPath(import.meta.url)),
23
+ 'utils',
24
+ 'oxlint-canonical.json'
25
+ )
22
26
 
23
27
  /** Очікуваний локальний скрипт. */
24
28
  export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
@@ -19,6 +19,7 @@ import {
19
19
  findUnsafeMssqlQueryTemplateCallInText,
20
20
  findUnsafeMssqlDynamicSqlListInText,
21
21
  findUnsafeMssqlInListUnparsedInText,
22
+ findUnsafeMssqlInListMissingEmptyGuardInText,
22
23
  isMssqlScanSourceFile
23
24
  } from './utils/mssql-pool-scan.mjs'
24
25
  import { walkDir } from './utils/walkDir.mjs'
@@ -203,6 +204,20 @@ function scanMssqlOneSourceFile(rel, content, counters, fail) {
203
204
  `js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
204
205
  )
205
206
  }
207
+ for (const v of findUnsafeMssqlInListMissingEmptyGuardInText(content, rel)) {
208
+ counters.inListGuardViolations++
209
+ if (v.reason === 'missing_guard') {
210
+ fail(
211
+ `js-mssql: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту з throw ` +
212
+ `(наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-mssql.mdc): ${v.snippet}`
213
+ )
214
+ } else {
215
+ fail(
216
+ `js-mssql: ${rel}:${v.line} — значення для IN (\${...}) у template literal треба винести в окрему змінну ` +
217
+ `і перевірити на пустоту (throw), не підставляти вираз напряму (js-mssql.mdc): ${v.snippet}`
218
+ )
219
+ }
220
+ }
206
221
  }
207
222
 
208
223
  /**
@@ -226,6 +241,9 @@ function reportZeroMssqlSourceViolations(counters, pass) {
226
241
  if (counters.unparsedInLists === 0) {
227
242
  pass(`js-mssql: немає підстановок IN (\${...}) без числового парсера значень`)
228
243
  }
244
+ if (counters.inListGuardViolations === 0) {
245
+ pass('js-mssql: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
246
+ }
229
247
  }
230
248
 
231
249
  /**
@@ -247,7 +265,8 @@ async function auditMssqlSources(repoRoot, pass, fail) {
247
265
  sharedRequestViolations: 0,
248
266
  unsafeQueryCalls: 0,
249
267
  unsafeDynamicSqlLists: 0,
250
- unparsedInLists: 0
268
+ unparsedInLists: 0,
269
+ inListGuardViolations: 0
251
270
  }
252
271
  for (const absPath of sourcePaths) {
253
272
  const rel = relative(repoRoot, absPath).split('\\').join('/')
@@ -292,4 +311,3 @@ export async function check() {
292
311
 
293
312
  return reporter.getExitCode()
294
313
  }
295
-
@@ -4803,26 +4803,59 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn) {
4803
4803
  }
4804
4804
  }
4805
4805
 
4806
+ /**
4807
+ * Чи прод-оверлей **реально потребує** overrides для HPA/PDB.
4808
+ *
4809
+ * Overrides потрібні лише якщо оверлей (non-dev-like) посилається на `…/k8s/…/base` і у **base**-дереві
4810
+ * одночасно є:
4811
+ * - `Deployment` (у шарі `…/k8s/…/base/`), і
4812
+ * - `HorizontalPodAutoscaler` і/або `PodDisruptionBudget`.
4813
+ *
4814
+ * Тоді base зазвичай тримає dev-like значення (`1`/`1`/`0`), і прод-оверлей має їх підняти (див. k8s.mdc).
4815
+ * @param {string} rootNorm нормалізований корінь репозиторію
4816
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
4817
+ * @returns {Promise<boolean>} true якщо потрібні overrides, інакше false
4818
+ */
4819
+ export async function prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs) {
4820
+ const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4821
+ const segment = k8sEnvSegmentFromRelPath(rel)
4822
+ if (segment === null || isDevLikeK8sEnvSegment(segment)) return false
4823
+
4824
+ const kust = await readFirstYamlObject(kustAbs)
4825
+ if (kust === null) return false
4826
+
4827
+ const kustDir = dirname(kustAbs)
4828
+ const pathRefs = resourcePathRefsFromKustomizationObject(kust)
4829
+ const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm)
4830
+ if (baseDirs.length === 0) return false
4831
+
4832
+ const flags = await Promise.all(
4833
+ baseDirs.map(bd => kustomizeResourceTreeHpaPdbDeploymentFlags(join(bd, 'kustomization.yaml'), rootNorm))
4834
+ )
4835
+
4836
+ return flags.some(f => f.hasDeployment && (f.hasHpa || f.hasPdb))
4837
+ }
4838
+
4806
4839
  /**
4807
4840
  * Для прод kustomization.yaml вимагає patches, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
4808
- * на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**. Не застосовується
4809
- * до dev-like (base / dev / *-qa) — там ці значення беруть з base (див. k8s.mdc).
4841
+ * на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**.
4842
+ *
4843
+ * Не застосовується до dev-like (base / dev / *-qa).
4844
+ *
4845
+ * Також **не застосовується**, якщо оверлей не наслідує base з Deployment + HPA/PDB (див. `prodOverlayNeedsHpaPdbOverrides`).
4810
4846
  * @param {string} root корінь репозиторію
4811
4847
  * @param {string[]} yamlFilesAbs yaml під k8s
4812
4848
  * @param {(msg: string) => void} fail callback при помилці
4813
4849
  * @param {(msg: string) => void} passFn callback при успіху
4814
4850
  */
4815
4851
  async function validateProdKustomizationOverrides(root, yamlFilesAbs, fail, passFn) {
4852
+ const rootNorm = resolve(root)
4816
4853
  const kustFiles = yamlFilesAbs.filter(abs => basename(abs) === 'kustomization.yaml')
4817
4854
  for (const kustAbs of kustFiles) {
4818
- const rel = relative(root, kustAbs).replaceAll('\\', '/')
4819
- const segment = k8sEnvSegmentFromRelPath(rel)
4820
- if (segment !== null && !isDevLikeK8sEnvSegment(segment)) {
4821
- const kust = await readFirstYamlObject(kustAbs)
4822
- if (kust !== null) {
4823
- checkProdOverridesInKustomization(kust, rel, fail, passFn)
4824
- }
4825
- }
4855
+ const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4856
+ if (!(await prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs))) continue
4857
+ const kust = await readFirstYamlObject(kustAbs)
4858
+ if (kust !== null) checkProdOverridesInKustomization(kust, rel, fail, passFn)
4826
4859
  }
4827
4860
  }
4828
4861
 
@@ -77,4 +77,3 @@ export async function check() {
77
77
  await checkWorkflow(reporter)
78
78
  return reporter.getExitCode()
79
79
  }
80
-
@@ -40,7 +40,7 @@ const CSPELL_REQUIRED_IGNORE_PATHS = [
40
40
  '.vscode',
41
41
  'report',
42
42
  '*.svg',
43
- '**/k8s/**/*.yaml',
43
+ '**/k8s/**/*.yaml'
44
44
  ]
45
45
 
46
46
  /**
@@ -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
+ }