@nitra/cursor 1.8.130 → 1.8.131

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/auto-rules.md CHANGED
@@ -18,6 +18,8 @@ js-lint - якщо присутній хоч один js файл
18
18
 
19
19
  js-pino - якщо присутній хоч один js файл, не в монорепо проекті з vue та директорії tempo
20
20
 
21
+ js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
22
+
21
23
  k8s - якщо присутня хоч одна директорія k8s
22
24
 
23
25
  nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
package/mdc/docker.mdc CHANGED
@@ -14,7 +14,7 @@ alwaysApply: false
14
14
  Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
15
15
 
16
16
  - **backend**: `mirror.gcr.io/library/alpine:*`
17
- - **frontend**: `mirror.gcr.io/library/nginx:*`
17
+ - **frontend**: `mirror.gcr.io/library/nginx:*` aбо `mirror.gcr.io/openresty/openresty:*`
18
18
 
19
19
  Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
20
20
 
@@ -0,0 +1,59 @@
1
+ ---
2
+ description: Використання mssql в nodejs
3
+ alwaysApply: true
4
+ version: '1.1'
5
+ ---
6
+
7
+ Якщо в проекті в будь-якому `package.json` в секції `dependencies` присутній пакет **`mssql`**,
8
+ то його версія повинна бути не менше **12.5.0**.
9
+
10
+ Потрібно використовувати connection pool (sql.ConnectionPool) як singleton, а НЕ створювати підключення на кожен запит.
11
+
12
+ tagged template треба викликати на request-обʼєкті цього пулу:
13
+ ```javascript
14
+ javascript// db.js
15
+ import sql from 'mssql';
16
+
17
+ let poolPromise;
18
+
19
+ export function getPool() {
20
+ if (!poolPromise) {
21
+ const pool = new sql.ConnectionPool(config);
22
+ poolPromise = pool.connect().catch(err => {
23
+ poolPromise = undefined; // дозволити повторну спробу
24
+ throw err;
25
+ });
26
+ }
27
+ return poolPromise;
28
+ }
29
+ ```
30
+
31
+ ```javascript
32
+ // usage
33
+ import { getPool } from './db.js';
34
+
35
+ const pool = await getPool();
36
+ const userId = 42;
37
+ const status = 'active';
38
+
39
+ // Tagged template на request — це працює
40
+ const result = await pool.request().query`
41
+ SELECT * FROM users
42
+ WHERE id = ${userId} AND status = ${status}
43
+ `;
44
+
45
+ ```
46
+
47
+ Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
48
+
49
+ Що НЕ робити
50
+ javascript// ❌ Це не tagged template — це конкатенація рядка перед викликом
51
+ ```javascript
52
+ await pool.request().query(`SELECT * FROM users WHERE id = ${userId}`);
53
+ // ↑ круглі дужки замість бекті́ків = звичайна інтерполяція = SQL injection
54
+ ```
55
+
56
+ Різниця між query\...`іquery(`...`)` — критична. Перше безпечне, друге — діра.
57
+ Потрібно використовувати перше. Потрібно шукати в коді друге і заміняти на перше.
58
+
59
+ Перевірка: `npx @nitra/cursor check js-mssql`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.130",
3
+ "version": "1.8.131",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,6 +25,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
25
25
  'ga',
26
26
  'graphql',
27
27
  'js-lint',
28
+ 'js-mssql',
28
29
  'js-pino',
29
30
  'k8s',
30
31
  'nginx-default-tpl',
@@ -45,6 +46,54 @@ const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'n
45
46
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
46
47
  const DEFAULT_DISABLED_LIST = Object.freeze([])
47
48
 
49
+ /**
50
+ * Чи є `mssql` у `dependencies` хоча б одного `package.json` у репозиторії.
51
+ * @param {string} root абсолютний шлях до кореня репозиторію
52
+ * @returns {Promise<boolean>} true, якщо знайдено `dependencies.mssql`
53
+ */
54
+ async function hasMssqlDependencyInAnyPackageJson(root) {
55
+ let found = false
56
+
57
+ /**
58
+ * Рекурсивний обхід каталогу з пропуском службових директорій.
59
+ * @param {string} dir абсолютний шлях каталогу
60
+ * @returns {Promise<void>}
61
+ */
62
+ async function walk(dir) {
63
+ if (found) return
64
+ let entries
65
+ try {
66
+ entries = await readdir(dir, { withFileTypes: true })
67
+ } catch {
68
+ return
69
+ }
70
+ for (const entry of entries) {
71
+ if (found) return
72
+ const absPath = join(dir, entry.name)
73
+ if (entry.isDirectory()) {
74
+ const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
75
+ if (!isIgnoredDir) {
76
+ await walk(absPath)
77
+ }
78
+ } else if (entry.isFile() && entry.name === 'package.json') {
79
+ try {
80
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
81
+ const deps = parsed?.dependencies
82
+ if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.prototype.hasOwnProperty.call(deps, 'mssql')) {
83
+ found = true
84
+ return
85
+ }
86
+ } catch {
87
+ /* ігноруємо пошкоджені/недоступні package.json */
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ await walk(root)
94
+ return found
95
+ }
96
+
48
97
  /**
49
98
  * Фіксує ознаки, що залежать лише від імені підкаталогу.
50
99
  * @param {string} dirName імʼя каталогу
@@ -295,6 +344,7 @@ export async function detectAutoRulesAndSkills({
295
344
  )
296
345
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
297
346
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
347
+ const hasMssqlDependency = await hasMssqlDependencyInAnyPackageJson(root)
298
348
 
299
349
  /** @type {string[]} */
300
350
  const detectedRules = []
@@ -332,6 +382,7 @@ export async function detectAutoRulesAndSkills({
332
382
  { enabled: facts.hasGaWorkflowsDir, id: 'ga' },
333
383
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
334
384
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
385
+ { enabled: hasMssqlDependency, id: 'js-mssql' },
335
386
  { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
336
387
  { enabled: facts.hasK8sDir, id: 'k8s' },
337
388
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
@@ -7,10 +7,10 @@
7
7
  * Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
8
8
  * використовує мінімальний runtime-образ:
9
9
  * - backend: `mirror.gcr.io/library/alpine:*`
10
- * - frontend: `mirror.gcr.io/library/nginx:*`
10
+ * - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
11
11
  *
12
12
  * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
13
- * runtime (alpine) або nginx.
13
+ * runtime (alpine), nginx або openresty.
14
14
  *
15
15
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
16
16
  * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
@@ -74,12 +74,16 @@ export function parseFromStages(fileContent) {
74
74
  return out
75
75
  }
76
76
 
77
- const RUNTIME_IMAGES = /** @type {const} */ (['mirror.gcr.io/library/alpine', 'mirror.gcr.io/library/nginx'])
77
+ const RUNTIME_IMAGES = /** @type {const} */ ([
78
+ 'mirror.gcr.io/library/alpine',
79
+ 'mirror.gcr.io/library/nginx',
80
+ 'mirror.gcr.io/openresty/openresty'
81
+ ])
78
82
 
79
83
  /**
80
84
  * Перевіряє базові вимоги до структури Dockerfile:
81
85
  * - multistage: мінімум 2 FROM
82
- * - фінальний FROM: alpine або nginx з mirror.gcr.io
86
+ * - фінальний FROM: alpine/nginx/openresty з mirror.gcr.io
83
87
  * @param {string} fileContent вміст Dockerfile/Containerfile
84
88
  * @returns {string | null} повідомлення помилки або null
85
89
  */
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { existsSync } from 'node:fs'
21
21
  import { readdir, readFile } from 'node:fs/promises'
22
+ import { execFileSync } from 'node:child_process'
22
23
  import { join } from 'node:path'
23
24
 
24
25
  import { createCheckReporter } from './utils/check-reporter.mjs'
@@ -54,6 +55,82 @@ const FORBIDDEN_BUN_PATTERNS = [
54
55
  /** Обовʼязкові workflow-файли (ga.mdc). */
55
56
  const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
56
57
 
58
+ /**
59
+ * Повертає true, якщо glob у GitHub Actions `on.*.paths` матчитсья хоча б на один tracked файл у репозиторії.
60
+ *
61
+ * Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
62
+ * і не сканувати файлову систему рекурсивно.
63
+ *
64
+ * @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
65
+ * @returns {boolean} true, якщо є хоча б один збіг
66
+ */
67
+ function gitHasAnyTrackedFileMatchingGlob(globPattern) {
68
+ const p = String(globPattern ?? '').trim()
69
+ if (!p) return false
70
+ if (p.startsWith('!')) return true
71
+ try {
72
+ const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8' })
73
+ return out.length > 0
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Чи варто перевіряти glob з `on.*.paths` на наявність збігів у репозиторії.
81
+ *
82
+ * У багатьох workflow (особливо лінтерах) `paths` часто містить “широкі” шаблони по розширеннях
83
+ * (наприклад `*.vue`, `*.php`), які можуть бути відсутні в конкретному репозиторії й це ок.
84
+ * Запит цієї перевірки — ловити посилання на неіснуючі директорії/шляхи (типово `some-dir/**`).
85
+ *
86
+ * @param {string} p glob з workflow
87
+ * @returns {boolean} true, якщо треба валідувати наявність файлів
88
+ */
89
+ function shouldValidateWorkflowPathsGlob(p) {
90
+ // Негативні патерни — лише виключають, їх існування не перевіряємо.
91
+ if (p.startsWith('!')) return false
92
+
93
+ // “Розширення-фільтри” (або brace-варіанти) пропускаємо: вони можуть бути заготовками.
94
+ if (p.includes('*.')) return false
95
+
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * Валідує `on.push.paths` / `on.pull_request.paths`: кожен позитивний glob має мати збіги в репозиторії.
101
+ * @param {string} relPath шлях workflow для повідомлень
102
+ * @param {Record<string, unknown>} root parsed YAML workflow
103
+ * @param {(msg: string) => void} passFn pass
104
+ * @param {(msg: string) => void} failFn fail
105
+ */
106
+ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
107
+ const on = getObjKey(root, 'on')
108
+ if (!on || typeof on !== 'object') return
109
+
110
+ /** @type {Array<[eventName: string, paths: unknown]>} */
111
+ const candidates = [
112
+ ['push', getObjKey(getObjKey(on, 'push'), 'paths')],
113
+ ['pull_request', getObjKey(getObjKey(on, 'pull_request'), 'paths')]
114
+ ]
115
+
116
+ for (const [eventName, paths] of candidates) {
117
+ if (!Array.isArray(paths)) continue
118
+ for (const raw of paths) {
119
+ const p = String(raw ?? '').trim()
120
+ if (!p) continue
121
+ if (!shouldValidateWorkflowPathsGlob(p)) {
122
+ passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
123
+ continue
124
+ }
125
+ if (gitHasAnyTrackedFileMatchingGlob(p)) {
126
+ passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
127
+ } else {
128
+ failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
129
+ }
130
+ }
131
+ }
132
+ }
133
+
57
134
  /**
58
135
  * Безпечний доступ до вкладеного поля (лише для обʼєктів).
59
136
  * @param {unknown} obj значення-кандидат на обʼєкт
@@ -61,7 +138,7 @@ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml',
61
138
  * @returns {unknown} значення поля або undefined
62
139
  */
63
140
  function getObjKey(obj, key) {
64
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined
141
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
65
142
  return /** @type {Record<string, unknown>} */ (obj)[key]
66
143
  }
67
144
 
@@ -75,6 +152,32 @@ function isExactString(v, expected) {
75
152
  return typeof v === 'string' && v === expected
76
153
  }
77
154
 
155
+ /**
156
+ * Перевіряє крок dmvict/clean-workflow-runs@v1 у `clean-ga-workflows.yml`.
157
+ * @param {unknown} step0 перший крок workflow
158
+ * @param {(msg: string) => void} passFn pass
159
+ * @param {(msg: string) => void} failFn fail
160
+ */
161
+ function validateCleanGaWorkflowsStep0(step0, passFn, failFn) {
162
+ if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
163
+ failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
164
+ }
165
+ if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
166
+ failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
167
+ }
168
+ const withObj = getObjKey(step0, 'with')
169
+ const githubToken = ['$', '{{ github.token }}'].join('')
170
+ if (
171
+ getObjKey(withObj, 'token') === githubToken &&
172
+ getObjKey(withObj, 'save_period') === 31 &&
173
+ getObjKey(withObj, 'save_min_runs_number') === 0
174
+ ) {
175
+ passFn('clean-ga-workflows.yml: jobs/steps OK')
176
+ } else {
177
+ failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
178
+ }
179
+ }
180
+
78
181
  /**
79
182
  * Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
80
183
  * @param {Record<string, unknown> | null} root parsed YAML
@@ -87,10 +190,10 @@ function validateCleanGaWorkflows(root, passFn, failFn) {
87
190
  return
88
191
  }
89
192
 
90
- if (!isExactString(root.name, 'Clean action for removing completed workflow runs')) {
91
- failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
92
- } else {
193
+ if (isExactString(root.name, 'Clean action for removing completed workflow runs')) {
93
194
  passFn('clean-ga-workflows.yml: name OK')
195
+ } else {
196
+ failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
94
197
  }
95
198
 
96
199
  const on = root.on
@@ -101,10 +204,10 @@ function validateCleanGaWorkflows(root, passFn, failFn) {
101
204
  Array.isArray(schedule) &&
102
205
  schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
103
206
 
104
- if (!hasCron) {
105
- failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
106
- } else {
207
+ if (hasCron) {
107
208
  passFn('clean-ga-workflows.yml: cron OK')
209
+ } else {
210
+ failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
108
211
  }
109
212
 
110
213
  if (!wfDispatch || typeof wfDispatch !== 'object') {
@@ -136,21 +239,58 @@ function validateCleanGaWorkflows(root, passFn, failFn) {
136
239
  return
137
240
  }
138
241
 
139
- if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
140
- failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
242
+ validateCleanGaWorkflowsStep0(step0, passFn, failFn)
243
+ }
244
+
245
+ /**
246
+ * Перевіряє крок `phpdocker-io/github-actions-delete-abandoned-branches` у `clean-merged-branch.yml`.
247
+ * @param {unknown} step0 перший крок workflow
248
+ * @param {(msg: string) => void} failFn fail
249
+ */
250
+ function validateCleanMergedBranchStep0(step0, failFn) {
251
+ if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
252
+ failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
141
253
  }
142
- if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
143
- failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
254
+ if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
255
+ failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
144
256
  }
145
257
  const withObj = getObjKey(step0, 'with')
146
- if (
147
- !(getObjKey(withObj, 'token') === '${{ github.token }}' &&
148
- getObjKey(withObj, 'save_period') === 31 &&
149
- getObjKey(withObj, 'save_min_runs_number') === 0)
150
- ) {
151
- failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
258
+ const ghToken = ['$', '{{ github.token }}'].join('')
259
+ if (getObjKey(withObj, 'github_token') !== ghToken) {
260
+ failFn(['clean-merged-branch.yml: with.github_token має бути $', '{{ github.token }} (ga.mdc)'].join(''))
261
+ }
262
+ if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
263
+ failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
264
+ }
265
+ const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
266
+ if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
267
+ failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
268
+ }
269
+ if (getObjKey(withObj, 'dry_run') !== 'no') {
270
+ failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Перевіряє крок виводу в `clean-merged-branch.yml`.
276
+ * @param {unknown} step1 другий крок workflow
277
+ * @param {(msg: string) => void} passFn pass
278
+ * @param {(msg: string) => void} failFn fail
279
+ */
280
+ function validateCleanMergedBranchStep1(step1, passFn, failFn) {
281
+ if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
282
+ failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
283
+ }
284
+ const env = getObjKey(step1, 'env')
285
+ const deletedBranchesExpr = ['$', '{{ steps.delete_stuff.outputs.deleted_branches }}'].join('')
286
+ if (getObjKey(env, 'DELETED_BRANCHES') !== deletedBranchesExpr) {
287
+ failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
288
+ }
289
+ const echoDeletedBranches = ['echo "Deleted branches: $', '{DELETED_BRANCHES}"'].join('')
290
+ if (String(getObjKey(step1, 'run') ?? '').includes(echoDeletedBranches)) {
291
+ passFn('clean-merged-branch.yml: jobs/steps OK')
152
292
  } else {
153
- passFn('clean-ga-workflows.yml: jobs/steps OK')
293
+ failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
154
294
  }
155
295
  }
156
296
 
@@ -166,10 +306,10 @@ function validateCleanMergedBranch(root, passFn, failFn) {
166
306
  return
167
307
  }
168
308
 
169
- if (!isExactString(root.name, 'Clean abandoned branches')) {
170
- failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
171
- } else {
309
+ if (isExactString(root.name, 'Clean abandoned branches')) {
172
310
  passFn('clean-merged-branch.yml: name OK')
311
+ } else {
312
+ failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
173
313
  }
174
314
 
175
315
  const on = root.on
@@ -179,10 +319,10 @@ function validateCleanMergedBranch(root, passFn, failFn) {
179
319
  Array.isArray(schedule) &&
180
320
  schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
181
321
 
182
- if (!hasCron) {
183
- failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
184
- } else {
322
+ if (hasCron) {
185
323
  passFn('clean-merged-branch.yml: cron OK')
324
+ } else {
325
+ failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
186
326
  }
187
327
 
188
328
  if (!wfDispatch || typeof wfDispatch !== 'object') {
@@ -197,7 +337,7 @@ function validateCleanMergedBranch(root, passFn, failFn) {
197
337
  }
198
338
 
199
339
  const perm = getObjKey(job, 'permissions')
200
- if (!(getObjKey(perm, 'contents') === 'write')) {
340
+ if (getObjKey(perm, 'contents') !== 'write') {
201
341
  failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
202
342
  }
203
343
 
@@ -212,47 +352,39 @@ function validateCleanMergedBranch(root, passFn, failFn) {
212
352
  failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
213
353
  return
214
354
  }
215
-
216
- if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
217
- failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
218
- }
219
- if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
220
- failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
221
- }
222
- const withObj = getObjKey(step0, 'with')
223
- if (getObjKey(withObj, 'github_token') !== '${{ github.token }}') {
224
- failFn('clean-merged-branch.yml: with.github_token має бути ${{ github.token }} (ga.mdc)')
225
- }
226
- if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
227
- failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
228
- }
229
-
230
- const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
231
- if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
232
- failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
233
- }
234
-
235
- if (getObjKey(withObj, 'dry_run') !== 'no') {
236
- failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
237
- }
355
+ validateCleanMergedBranchStep0(step0, failFn)
238
356
 
239
357
  const step1 = steps[1]
240
358
  if (!step1 || typeof step1 !== 'object') {
241
359
  failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
242
360
  return
243
361
  }
362
+ validateCleanMergedBranchStep1(step1, passFn, failFn)
363
+ }
244
364
 
245
- if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
246
- failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
365
+ /**
366
+ * Перевіряє тригери `on.push` / `on.pull_request` у `lint-ga.yml`.
367
+ * @param {unknown} on корінь `on:` з YAML
368
+ * @param {(msg: string) => void} failFn fail
369
+ */
370
+ function validateLintGaOnTriggers(on, failFn) {
371
+ const push = getObjKey(on, 'push')
372
+ const pr = getObjKey(on, 'pull_request')
373
+ const pushBranches = getObjKey(push, 'branches')
374
+ const pushPaths = getObjKey(push, 'paths')
375
+ const prBranches = getObjKey(pr, 'branches')
376
+
377
+ if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
378
+ failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
247
379
  }
248
- const env = getObjKey(step1, 'env')
249
- if (getObjKey(env, 'DELETED_BRANCHES') !== '${{ steps.delete_stuff.outputs.deleted_branches }}') {
250
- failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
380
+ if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
381
+ failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
251
382
  }
252
- if (!String(getObjKey(step1, 'run') ?? '').includes('echo "Deleted branches: ${DELETED_BRANCHES}"')) {
253
- failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
254
- } else {
255
- passFn('clean-merged-branch.yml: jobs/steps OK')
383
+ if (
384
+ !Array.isArray(pushPaths) ||
385
+ !(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))
386
+ ) {
387
+ failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
256
388
  }
257
389
  }
258
390
 
@@ -272,25 +404,10 @@ function validateLintGaWorkflowStructure(root, passFn, failFn) {
272
404
  failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
273
405
  }
274
406
 
275
- const on = root.on
276
- const push = getObjKey(on, 'push')
277
- const pr = getObjKey(on, 'pull_request')
278
- const pushBranches = getObjKey(push, 'branches')
279
- const pushPaths = getObjKey(push, 'paths')
280
- const prBranches = getObjKey(pr, 'branches')
281
-
282
- if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
283
- failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
284
- }
285
- if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
286
- failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
287
- }
288
- if (!Array.isArray(pushPaths) || !(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))) {
289
- failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
290
- }
407
+ validateLintGaOnTriggers(root.on, failFn)
291
408
 
292
409
  const conc = getObjKey(root, 'concurrency')
293
- if (!(getObjKey(conc, 'cancel-in-progress') === true)) {
410
+ if (getObjKey(conc, 'cancel-in-progress') !== true) {
294
411
  failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
295
412
  }
296
413
 
@@ -305,7 +422,7 @@ function validateLintGaWorkflowStructure(root, passFn, failFn) {
305
422
  failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
306
423
  }
307
424
  const perm = getObjKey(job, 'permissions')
308
- if (!(getObjKey(perm, 'contents') === 'read')) {
425
+ if (getObjKey(perm, 'contents') !== 'read') {
309
426
  failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
310
427
  }
311
428
 
@@ -316,22 +433,22 @@ function validateLintGaWorkflowStructure(root, passFn, failFn) {
316
433
  }
317
434
 
318
435
  const flat = flattenWorkflowSteps(root)
319
- const usesList = flat.map(s => getStepUses(s.step))
436
+ const usesList = new Set(flat.map(s => getStepUses(s.step)))
320
437
  const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
321
438
 
322
- if (!usesList.includes('actions/checkout@v6')) {
439
+ if (!usesList.has('actions/checkout@v6')) {
323
440
  failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
324
441
  }
325
- if (!usesList.includes('./.github/actions/setup-bun-deps')) {
442
+ if (!usesList.has('./.github/actions/setup-bun-deps')) {
326
443
  failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
327
444
  }
328
- if (!usesList.includes('astral-sh/setup-uv@v8.0.0')) {
445
+ if (!usesList.has('astral-sh/setup-uv@v8.0.0')) {
329
446
  failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
330
447
  }
331
- if (!runBlob.includes('bun run lint-ga')) {
332
- failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
333
- } else {
448
+ if (runBlob.includes('bun run lint-ga')) {
334
449
  passFn('lint-ga.yml: структура jobs/steps OK')
450
+ } else {
451
+ failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
335
452
  }
336
453
  }
337
454
 
@@ -370,7 +487,7 @@ function validateGitAiWorkflowStructure(root, passFn, failFn) {
370
487
  }
371
488
 
372
489
  const perm = getObjKey(job, 'permissions')
373
- if (!(getObjKey(perm, 'contents') === 'write')) {
490
+ if (getObjKey(perm, 'contents') !== 'write') {
374
491
  failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
375
492
  }
376
493
 
@@ -379,10 +496,10 @@ function validateGitAiWorkflowStructure(root, passFn, failFn) {
379
496
  if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
380
497
  failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
381
498
  }
382
- if (!runBlob.includes('git-ai ci github run')) {
383
- failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
384
- } else {
499
+ if (runBlob.includes('git-ai ci github run')) {
385
500
  passFn('git-ai.yml: структура jobs/steps OK')
501
+ } else {
502
+ failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
386
503
  }
387
504
  }
388
505
 
@@ -837,6 +954,10 @@ export async function check() {
837
954
  verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
838
955
  verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
839
956
  verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
957
+ const parsed = parseWorkflowYaml(content)
958
+ if (parsed) {
959
+ verifyWorkflowEventPathsGlobsExist(`${wfDir}/${f}`, parsed, pass, fail)
960
+ }
840
961
  }
841
962
 
842
963
  await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Перевіряє правило js-mssql.mdc.
3
+ *
4
+ * Якщо в будь-якому `package.json` у репозиторії (включно з workspace-пакетами) в секції `dependencies`
5
+ * присутній пакет `mssql`, його версія має бути не менше 12.5.0.
6
+ *
7
+ * Додатково, якщо `mssql` використовується в репозиторії, перевіряє що підключення
8
+ * не створюється “на кожен запит”: `new sql.ConnectionPool(...)` не має знаходитись
9
+ * всередині функцій. Пул має бути singleton (на рівні модуля) і повторно використовуватись.
10
+ */
11
+ import { existsSync } from 'node:fs'
12
+ import { readFile } from 'node:fs/promises'
13
+ import { join, relative, sep } from 'node:path'
14
+
15
+ import { createCheckReporter } from './utils/check-reporter.mjs'
16
+ import {
17
+ findMssqlPerRequestConnectionInText,
18
+ findUnsafeMssqlQueryTemplateCallInText,
19
+ isMssqlScanSourceFile
20
+ } from './utils/mssql-pool-scan.mjs'
21
+ import { walkDir } from './utils/walkDir.mjs'
22
+
23
+ const VERSION_PREFIX_RE = /^[\^~>=<]+\s*/u
24
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/u
25
+
26
+ /** Мінімальна дозволена версія mssql (js-mssql.mdc). */
27
+ const MIN_MSSQL_VERSION = { major: 12, minor: 5, patch: 0 }
28
+
29
+ /**
30
+ * Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
31
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
32
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
33
+ */
34
+ async function findAllPackageJsonPaths(repoRoot) {
35
+ /** @type {string[]} */
36
+ const paths = []
37
+ await walkDir(repoRoot, absPath => {
38
+ if (absPath.endsWith(`${sep}package.json`)) {
39
+ paths.push(absPath)
40
+ }
41
+ })
42
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
43
+ return paths
44
+ }
45
+
46
+ /**
47
+ * Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану mssql.
48
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
49
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
50
+ */
51
+ async function findAllSourcePathsForMssqlScan(repoRoot) {
52
+ /** @type {string[]} */
53
+ const paths = []
54
+ await walkDir(repoRoot, absPath => {
55
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
56
+ if (isMssqlScanSourceFile(rel)) {
57
+ paths.push(absPath)
58
+ }
59
+ })
60
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
61
+ return paths
62
+ }
63
+
64
+ /**
65
+ * @param {unknown} v parsed JSON
66
+ * @returns {Record<string, unknown>} object або {}
67
+ */
68
+ function asObject(v) {
69
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
70
+ return /** @type {Record<string, unknown>} */ (v)
71
+ }
72
+
73
+ /**
74
+ * Витягає рядок версії `dependencies.mssql`, якщо він існує.
75
+ * @param {unknown} deps deps з package.json
76
+ * @returns {string | null} версія або null
77
+ */
78
+ function getMssqlDependencyRange(deps) {
79
+ const o = asObject(deps)
80
+ const v = o.mssql
81
+ return typeof v === 'string' && v.trim() ? v.trim() : null
82
+ }
83
+
84
+ /**
85
+ * Парсить першу semver з діапазону виду "^12.5.0", ">=12.5.0", "12.5.0".
86
+ * @param {string} range версійний діапазон
87
+ * @returns {{ major: number, minor: number, patch: number } | null} semver або null
88
+ */
89
+ function parseLeadingSemver(range) {
90
+ const cleaned = String(range).trim().replace(VERSION_PREFIX_RE, '')
91
+ const m = cleaned.match(SEMVER_RE)
92
+ if (!m) return null
93
+ const major = Number(m[1])
94
+ const minor = Number(m[2])
95
+ const patch = Number(m[3])
96
+ if ([major, minor, patch].some(n => Number.isNaN(n))) return null
97
+ return { major, minor, patch }
98
+ }
99
+
100
+ /**
101
+ * @param {{ major: number, minor: number, patch: number }} a
102
+ * @param {{ major: number, minor: number, patch: number }} b
103
+ * @returns {boolean} true, якщо a >= b
104
+ */
105
+ function semverGte(a, b) {
106
+ if (a.major !== b.major) return a.major > b.major
107
+ if (a.minor !== b.minor) return a.minor > b.minor
108
+ return a.patch >= b.patch
109
+ }
110
+
111
+ /**
112
+ * Перевіряє відповідність проєкту правилу js-mssql.mdc
113
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
114
+ */
115
+ export async function check() {
116
+ const reporter = createCheckReporter()
117
+ const { pass, fail } = reporter
118
+
119
+ const repoRoot = process.cwd()
120
+ const rootPkg = join(repoRoot, 'package.json')
121
+ if (!existsSync(rootPkg)) {
122
+ pass('js-mssql: package.json у корені відсутній — перевірку пропущено')
123
+ return reporter.getExitCode()
124
+ }
125
+
126
+ const pkgJsonPaths = await findAllPackageJsonPaths(repoRoot)
127
+ if (pkgJsonPaths.length === 0) {
128
+ pass('js-mssql: package.json не знайдено — перевірку пропущено')
129
+ return reporter.getExitCode()
130
+ }
131
+
132
+ let found = 0
133
+ let bad = 0
134
+ for (const absPath of pkgJsonPaths) {
135
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
136
+ let parsed
137
+ try {
138
+ parsed = JSON.parse(await readFile(absPath, 'utf8'))
139
+ } catch {
140
+ fail(`js-mssql: ${rel} — невалідний JSON`)
141
+ continue
142
+ }
143
+ const range = getMssqlDependencyRange(parsed.dependencies)
144
+ if (range) {
145
+ found++
146
+ const parsedVer = parseLeadingSemver(range)
147
+ if (!parsedVer) {
148
+ bad++
149
+ fail(`js-mssql: ${rel}: dependencies.mssql має нечитабельну версію: ${JSON.stringify(range)} (js-mssql.mdc)`)
150
+ continue
151
+ }
152
+ if (semverGte(parsedVer, MIN_MSSQL_VERSION)) {
153
+ pass(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} (>=12.5.0)`)
154
+ } else {
155
+ bad++
156
+ fail(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} — має бути >=12.5.0 (js-mssql.mdc)`)
157
+ }
158
+ }
159
+ }
160
+
161
+ if (found === 0) {
162
+ pass('js-mssql: пакет mssql не знайдено в dependencies жодного package.json')
163
+ } else if (bad === 0) {
164
+ pass(`js-mssql: всі знайдені dependencies.mssql відповідають мінімальній версії 12.5.0 (${found})`)
165
+ }
166
+
167
+ if (found > 0) {
168
+ const sourcePaths = await findAllSourcePathsForMssqlScan(repoRoot)
169
+ if (sourcePaths.length === 0) {
170
+ pass('js-mssql: немає JS/TS файлів для скану singleton ConnectionPool')
171
+ return reporter.getExitCode()
172
+ }
173
+
174
+ let violations = 0
175
+ let unsafeQueryCalls = 0
176
+ for (const absPath of sourcePaths) {
177
+ const rel = relative(repoRoot, absPath).split('\\').join('/')
178
+ const content = await readFile(absPath, 'utf8')
179
+ for (const v of findMssqlPerRequestConnectionInText(content, rel)) {
180
+ violations++
181
+ fail(
182
+ `js-mssql: ${rel}:${v.line} — не створюй new sql.ConnectionPool(...) на кожен запит; використовуй singleton sql.ConnectionPool: ${v.snippet}`
183
+ )
184
+ }
185
+ for (const v of findUnsafeMssqlQueryTemplateCallInText(content, rel)) {
186
+ unsafeQueryCalls++
187
+ fail(
188
+ `js-mssql: ${rel}:${v.line} — заборонено query(\`...\`): це не tagged template; використовуй pool.request().query\`...\` (js-mssql.mdc): ${v.snippet}`
189
+ )
190
+ }
191
+ }
192
+
193
+ if (violations === 0) {
194
+ pass('js-mssql: немає створення new sql.ConnectionPool(...) всередині функцій (singleton pool)')
195
+ }
196
+ if (unsafeQueryCalls === 0) {
197
+ pass('js-mssql: немає небезпечних викликів query(`...`) (потрібно query`...`)')
198
+ }
199
+ }
200
+
201
+ return reporter.getExitCode()
202
+ }
203
+
@@ -370,9 +370,9 @@ function pushStringPaths(arr, acc) {
370
370
  const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
371
371
 
372
372
  /**
373
- * Чи послідовність непорожніх рядків зростаюча за `localeCompare` (en).
374
- * @param {string[]} paths
375
- * @returns {boolean}
373
+ * Чи послідовність непорожніх рядків відсортована за `localeCompare` (en, ascending).
374
+ * @param {string[]} paths рядки для перевірки
375
+ * @returns {boolean} `true` якщо послідовність відсортована
376
376
  */
377
377
  function stringPathsAreSortedEn(paths) {
378
378
  for (let i = 1; i < paths.length; i++) {
@@ -402,8 +402,7 @@ export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
402
402
  }
403
403
  /** @type {string[]} */
404
404
  const paths = []
405
- for (let i = 0; i < res.length; i++) {
406
- const item = res[i]
405
+ for (const [i, item] of res.entries()) {
407
406
  if (typeof item !== 'string') {
408
407
  return `Kustomization.resources[${i}] — очікується рядок-шлях (k8s.mdc)`
409
408
  }
@@ -412,7 +411,7 @@ export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
412
411
  }
413
412
  if (paths.length < 2) return null
414
413
  if (!stringPathsAreSortedEn(paths)) {
415
- const want = [...paths].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
414
+ const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
416
415
  return `Kustomization.resources має бути за алфавітом (en). Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
417
416
  }
418
417
  return null
@@ -422,17 +421,18 @@ export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
422
421
  * Усі **`kustomization.yaml`**: **`resources`**, відсортовані за en.
423
422
  * @param {string} root корінь репо
424
423
  * @param {string[]} yamlFilesAbs yaml під k8s
425
- * @param {(msg: string) => void} fail
426
- * @returns {Promise<void>}
424
+ * @param {(msg: string) => void} fail функція для фіксації порушення
425
+ * @returns {Promise<void>} завершується після перевірки всіх kustomization.yaml
427
426
  */
428
427
  async function validateKustomizationResourcesSortedAlphabetically(root, yamlFilesAbs, fail) {
429
428
  for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
430
429
  const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
431
430
  const kust = await readFirstYamlObject(kustAbs)
432
- if (kust === null) continue
433
- const v = kustomizationResourcesSortedAlphabeticallyViolation(kust)
434
- if (v !== null) {
435
- fail(`${rel}: ${v}`)
431
+ if (kust !== null) {
432
+ const v = kustomizationResourcesSortedAlphabeticallyViolation(kust)
433
+ if (v !== null) {
434
+ fail(`${rel}: ${v}`)
435
+ }
436
436
  }
437
437
  }
438
438
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Знаходить небезпечні патерни використання `mssql`, які створюють підключення/пул
3
+ * всередині функцій (наприклад handler на кожен запит), замість того щоб мати один
4
+ * singleton `sql.ConnectionPool` на рівні модуля та повторно використовувати його.
5
+ *
6
+ * Також знаходить небезпечний виклик `query(\`...\`)` — це НЕ tagged template, а звичайний
7
+ * виклик з інтерполяцією рядка, який може призвести до SQL injection. Натомість має
8
+ * використовуватись tagged template `query\`...\`` (див. js-mssql.mdc).
9
+ *
10
+ * Семантика береться з **oxc-parser** по AST, щоб не покладатися на regex.
11
+ * Якщо файл не парситься або містить синтаксичні помилки — повертаємо порожній
12
+ * результат (спочатку треба полагодити синтаксис, потім перезапустити перевірку).
13
+ */
14
+ import { parseSync } from 'oxc-parser'
15
+
16
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
17
+
18
+ /**
19
+ * Мова для Oxc за шляхом файлу (розширення).
20
+ * @param {string} filePath віртуальний або реальний шлях до файлу
21
+ * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
22
+ */
23
+ function langFromPath(filePath) {
24
+ const lower = filePath.toLowerCase()
25
+ if (lower.endsWith('.tsx')) return 'tsx'
26
+ if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
27
+ if (lower.endsWith('.jsx')) return 'jsx'
28
+ return 'js'
29
+ }
30
+
31
+ /**
32
+ * Номер рядка (1-based) за зміщенням у буфері.
33
+ * @param {string} content повний текст файлу
34
+ * @param {number} offset байтове зміщення початку фрагмента
35
+ * @returns {number} номер рядка від 1
36
+ */
37
+ function offsetToLine(content, offset) {
38
+ let line = 1
39
+ const n = Math.min(offset, content.length)
40
+ for (let i = 0; i < n; i++) {
41
+ if (content.codePointAt(i) === 10) line++
42
+ }
43
+ return line
44
+ }
45
+
46
+ /**
47
+ * Стискає пробіли для повідомлення про порушення.
48
+ * @param {string} s фрагмент коду
49
+ * @returns {string} скорочений однорядковий рядок
50
+ */
51
+ function normalizeSnippet(s) {
52
+ return s.replaceAll(/\s+/g, ' ').trim().slice(0, 180)
53
+ }
54
+
55
+ /**
56
+ * Чи є вузол функцією.
57
+ * @param {unknown} node AST node
58
+ * @returns {boolean} true, якщо це будь-який вузол-функція
59
+ */
60
+ function isFunctionNode(node) {
61
+ return (
62
+ !!node &&
63
+ typeof node === 'object' &&
64
+ typeof node.type === 'string' &&
65
+ (node.type === 'FunctionDeclaration' ||
66
+ node.type === 'FunctionExpression' ||
67
+ node.type === 'ArrowFunctionExpression')
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
73
+ * @param {unknown} node поточний вузол
74
+ * @param {unknown[]} ancestors масив предків від кореня до parent
75
+ * @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
76
+ * @returns {void}
77
+ */
78
+ function walkAstWithAncestors(node, ancestors, visit) {
79
+ if (!node || typeof node !== 'object') return
80
+ if (Array.isArray(node)) {
81
+ for (const item of node) walkAstWithAncestors(item, ancestors, visit)
82
+ return
83
+ }
84
+
85
+ const rec = /** @type {Record<string, unknown>} */ (node)
86
+ if (typeof rec.type === 'string') {
87
+ visit(rec, ancestors)
88
+ ancestors = [...ancestors, rec]
89
+ }
90
+
91
+ for (const key of Object.keys(node)) {
92
+ if (key === 'parent') {
93
+ continue
94
+ }
95
+ const v = rec[key]
96
+ if (v && typeof v === 'object') {
97
+ walkAstWithAncestors(v, ancestors, visit)
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Чи це `new sql.ConnectionPool(...)` або `new mssql.ConnectionPool(...)`.
104
+ * @param {unknown} node AST node
105
+ * @returns {boolean} true, якщо це створення ConnectionPool
106
+ */
107
+ function isNewConnectionPool(node) {
108
+ if (!node || node.type !== 'NewExpression') return false
109
+ const callee = node.callee
110
+ if (!callee || callee.type !== 'MemberExpression') return false
111
+ if (callee.computed) return false
112
+ const obj = callee.object
113
+ const prop = callee.property
114
+ if (!obj || obj.type !== 'Identifier') return false
115
+ if (!prop || prop.type !== 'Identifier' || prop.name !== 'ConnectionPool') return false
116
+ return obj.name === 'sql' || obj.name === 'mssql'
117
+ }
118
+
119
+ /**
120
+ * Чи це виклик `.query(...)` з TemplateLiteral як першим аргументом (`query(\`...\`)`).
121
+ * @param {unknown} node AST node
122
+ * @returns {boolean} true, якщо це небезпечний патерн `query(\`...\`)`
123
+ */
124
+ function isUnsafeQueryCallWithTemplateLiteral(node) {
125
+ if (!node || node.type !== 'CallExpression') return false
126
+ const callee = node.callee
127
+ if (!callee || callee.type !== 'MemberExpression') return false
128
+ if (callee.computed) return false
129
+ const prop = callee.property
130
+ if (!prop || prop.type !== 'Identifier' || prop.name !== 'query') return false
131
+ const args = node.arguments
132
+ if (!Array.isArray(args) || args.length === 0) return false
133
+ const first = args[0]
134
+ return !!first && typeof first === 'object' && first.type === 'TemplateLiteral'
135
+ }
136
+
137
+ /**
138
+ * Знаходить створення `ConnectionPool` всередині функцій.
139
+ * @param {string} content вихідний код
140
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/db.ts`)
141
+ * @returns {{ line: number, snippet: string }[]} список порушень
142
+ */
143
+ export function findMssqlPerRequestConnectionInText(content, virtualPath = 'scan.ts') {
144
+ const lang = langFromPath(virtualPath || 'scan.ts')
145
+ let result
146
+ try {
147
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
148
+ } catch {
149
+ return []
150
+ }
151
+ if (result.errors?.length) return []
152
+
153
+ /** @type {{ line: number, snippet: string }[]} */
154
+ const out = []
155
+
156
+ walkAstWithAncestors(result.program, [], (node, ancestors) => {
157
+ const insideFunction = ancestors.some(n => isFunctionNode(n))
158
+ if (!insideFunction) return
159
+
160
+ if (isNewConnectionPool(node)) {
161
+ out.push({
162
+ line: offsetToLine(content, node.start),
163
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
164
+ })
165
+ }
166
+ })
167
+
168
+ return out
169
+ }
170
+
171
+ /**
172
+ * Знаходить небезпечні виклики `query(\`...\`)` (CallExpression з TemplateLiteral-аргументом).
173
+ * @param {string} content вихідний код
174
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/db.ts`)
175
+ * @returns {{ line: number, snippet: string }[]} список порушень
176
+ */
177
+ export function findUnsafeMssqlQueryTemplateCallInText(content, virtualPath = 'scan.ts') {
178
+ const lang = langFromPath(virtualPath || 'scan.ts')
179
+ let result
180
+ try {
181
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
182
+ } catch {
183
+ return []
184
+ }
185
+ if (result.errors?.length) return []
186
+
187
+ /** @type {{ line: number, snippet: string }[]} */
188
+ const out = []
189
+ walkAstWithAncestors(result.program, [], node => {
190
+ if (isUnsafeQueryCallWithTemplateLiteral(node)) {
191
+ out.push({
192
+ line: offsetToLine(content, node.start),
193
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
194
+ })
195
+ }
196
+ })
197
+ return out
198
+ }
199
+
200
+ /**
201
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я).
202
+ * @param {string} relativePathPosix відносний шлях (posix)
203
+ * @returns {boolean} `true`, якщо розширення підходить для AST-скану
204
+ */
205
+ export function isMssqlScanSourceFile(relativePathPosix) {
206
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
207
+ }
208
+