@nitra/cursor 1.8.129 → 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
 
package/mdc/ga.mdc CHANGED
@@ -161,6 +161,37 @@ jobs:
161
161
 
162
162
  **Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
163
163
 
164
+ **Читабельність `run: >-` з циклам/умовами:** оскільки `>-` **згортає рядки в один shell-рядок**, відступи потрібні **лише для читабельності** й не впливають на виконання. Але для конструкцій bash на кшталт `while …; do … done` та `if …; then … fi` **обовʼязково**:
165
+
166
+ - став явні роздільники команд **`;`** або **`&&`** там, де при згортанні рядків інакше “злипнуться” токени (`do`/`then`/`fi`/`done` з наступною командою);
167
+ - тримай `do` і `then` в одному логічному рядку як `…; do` / `…; then`, щоб після згортання це гарантовано лишалось валідним bash;
168
+ - додавай відступи всередині `do/then/else` блоків, навіть якщо це один рядок після згортання — так workflow лишається читабельним у diff.
169
+
170
+ ### Приклад (ПРАВИЛЬНО — читабельно, `run: >-`, з `while`/`if`)
171
+
172
+ ```yaml
173
+ - name: Apply changes
174
+ shell: bash
175
+ run: >-
176
+ echo "$FILES" |
177
+ while read -r FILE; do
178
+ [ -z "$FILE" ] && continue;
179
+ dirname "$FILE";
180
+ done |
181
+ sort -u |
182
+ while read -r DIR; do
183
+ (
184
+ if [ -f "$DIR/kustomization.yaml" ]; then
185
+ printf 'Applying %s\n' "$DIR" &&
186
+ cd "$DIR" &&
187
+ kubectl apply -k .;
188
+ else
189
+ echo "Skip $DIR - no kustomization.yaml";
190
+ fi
191
+ );
192
+ done
193
+ ```
194
+
164
195
  ### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
165
196
 
166
197
  ```yaml
@@ -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.129",
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
  */
@@ -7,6 +7,10 @@
7
7
  * `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
8
8
  * перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
9
9
  *
10
+ * Також перевіряє, що ключові workflow (`clean-ga-workflows.yml`, `clean-merged-branch.yml`, `lint-ga.yml`, `git-ai.yml`)
11
+ * мають структуру й значення, узгоджені з `npm/mdc/ga.mdc`. Для цих файлів перевірка виконується структурно
12
+ * (після YAML parse), щоб не залежати від форматування/відступів.
13
+ *
10
14
  * Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
11
15
  * (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
12
16
  * (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
@@ -15,6 +19,7 @@
15
19
  */
16
20
  import { existsSync } from 'node:fs'
17
21
  import { readdir, readFile } from 'node:fs/promises'
22
+ import { execFileSync } from 'node:child_process'
18
23
  import { join } from 'node:path'
19
24
 
20
25
  import { createCheckReporter } from './utils/check-reporter.mjs'
@@ -25,6 +30,9 @@ import {
25
30
  findRunStepsWithShellLineContinuationBackslash,
26
31
  hasAnyStepUsesContaining,
27
32
  hasCheckoutBeforeLocalSetupBunDeps,
33
+ flattenWorkflowSteps,
34
+ getStepRun,
35
+ getStepUses,
28
36
  parseWorkflowYaml
29
37
  } from './utils/gha-workflow.mjs'
30
38
 
@@ -44,6 +52,457 @@ const FORBIDDEN_BUN_PATTERNS = [
44
52
  { pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
45
53
  ]
46
54
 
55
+ /** Обовʼязкові workflow-файли (ga.mdc). */
56
+ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
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
+
134
+ /**
135
+ * Безпечний доступ до вкладеного поля (лише для обʼєктів).
136
+ * @param {unknown} obj значення-кандидат на обʼєкт
137
+ * @param {string} key ключ
138
+ * @returns {unknown} значення поля або undefined
139
+ */
140
+ function getObjKey(obj, key) {
141
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
142
+ return /** @type {Record<string, unknown>} */ (obj)[key]
143
+ }
144
+
145
+ /**
146
+ * Очікує, що значення є рядком рівно `expected`.
147
+ * @param {unknown} v значення
148
+ * @param {string} expected очікуваний рядок
149
+ * @returns {boolean} true, якщо збігається
150
+ */
151
+ function isExactString(v, expected) {
152
+ return typeof v === 'string' && v === expected
153
+ }
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
+
181
+ /**
182
+ * Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
183
+ * @param {Record<string, unknown> | null} root parsed YAML
184
+ * @param {(msg: string) => void} passFn pass
185
+ * @param {(msg: string) => void} failFn fail
186
+ */
187
+ function validateCleanGaWorkflows(root, passFn, failFn) {
188
+ if (!root) {
189
+ failFn('clean-ga-workflows.yml: YAML не вдалося розібрати (ga.mdc)')
190
+ return
191
+ }
192
+
193
+ if (isExactString(root.name, 'Clean action for removing completed workflow runs')) {
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)')
197
+ }
198
+
199
+ const on = root.on
200
+ const schedule = getObjKey(on, 'schedule')
201
+ const wfDispatch = getObjKey(on, 'workflow_dispatch')
202
+
203
+ const hasCron =
204
+ Array.isArray(schedule) &&
205
+ schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
206
+
207
+ if (hasCron) {
208
+ passFn('clean-ga-workflows.yml: cron OK')
209
+ } else {
210
+ failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
211
+ }
212
+
213
+ if (!wfDispatch || typeof wfDispatch !== 'object') {
214
+ failFn('clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)')
215
+ } else {
216
+ passFn('clean-ga-workflows.yml: workflow_dispatch OK')
217
+ }
218
+
219
+ const jobs = getObjKey(root, 'jobs')
220
+ const job = getObjKey(jobs, 'cleanup_old_workflows')
221
+ if (!job) {
222
+ failFn('clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)')
223
+ return
224
+ }
225
+
226
+ if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
227
+ failFn('clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)')
228
+ }
229
+
230
+ const perm = getObjKey(job, 'permissions')
231
+ if (!(getObjKey(perm, 'actions') === 'write' && getObjKey(perm, 'contents') === 'read')) {
232
+ failFn('clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)')
233
+ }
234
+
235
+ const steps = getObjKey(job, 'steps')
236
+ const step0 = Array.isArray(steps) ? steps[0] : null
237
+ if (!step0 || typeof step0 !== 'object') {
238
+ failFn('clean-ga-workflows.yml: steps має містити крок з dmvict/clean-workflow-runs@v1 (ga.mdc)')
239
+ return
240
+ }
241
+
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)')
253
+ }
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')
256
+ }
257
+ const withObj = getObjKey(step0, 'with')
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')
292
+ } else {
293
+ failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Перевіряє структуру workflow `clean-merged-branch.yml` (ga.mdc).
299
+ * @param {Record<string, unknown> | null} root parsed YAML
300
+ * @param {(msg: string) => void} passFn pass
301
+ * @param {(msg: string) => void} failFn fail
302
+ */
303
+ function validateCleanMergedBranch(root, passFn, failFn) {
304
+ if (!root) {
305
+ failFn('clean-merged-branch.yml: YAML не вдалося розібрати (ga.mdc)')
306
+ return
307
+ }
308
+
309
+ if (isExactString(root.name, 'Clean abandoned branches')) {
310
+ passFn('clean-merged-branch.yml: name OK')
311
+ } else {
312
+ failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
313
+ }
314
+
315
+ const on = root.on
316
+ const schedule = getObjKey(on, 'schedule')
317
+ const wfDispatch = getObjKey(on, 'workflow_dispatch')
318
+ const hasCron =
319
+ Array.isArray(schedule) &&
320
+ schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
321
+
322
+ if (hasCron) {
323
+ passFn('clean-merged-branch.yml: cron OK')
324
+ } else {
325
+ failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
326
+ }
327
+
328
+ if (!wfDispatch || typeof wfDispatch !== 'object') {
329
+ failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
330
+ }
331
+
332
+ const jobs = getObjKey(root, 'jobs')
333
+ const job = getObjKey(jobs, 'cleanup_old_branches')
334
+ if (!job) {
335
+ failFn('clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)')
336
+ return
337
+ }
338
+
339
+ const perm = getObjKey(job, 'permissions')
340
+ if (getObjKey(perm, 'contents') !== 'write') {
341
+ failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
342
+ }
343
+
344
+ const steps = getObjKey(job, 'steps')
345
+ if (!Array.isArray(steps) || steps.length < 2) {
346
+ failFn('clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc')
347
+ return
348
+ }
349
+
350
+ const step0 = steps[0]
351
+ if (!step0 || typeof step0 !== 'object') {
352
+ failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
353
+ return
354
+ }
355
+ validateCleanMergedBranchStep0(step0, failFn)
356
+
357
+ const step1 = steps[1]
358
+ if (!step1 || typeof step1 !== 'object') {
359
+ failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
360
+ return
361
+ }
362
+ validateCleanMergedBranchStep1(step1, passFn, failFn)
363
+ }
364
+
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)')
379
+ }
380
+ if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
381
+ failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
382
+ }
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)')
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Перевіряє структуру workflow `lint-ga.yml` (ga.mdc).
393
+ * @param {Record<string, unknown> | null} root parsed YAML
394
+ * @param {(msg: string) => void} passFn pass
395
+ * @param {(msg: string) => void} failFn fail
396
+ */
397
+ function validateLintGaWorkflowStructure(root, passFn, failFn) {
398
+ if (!root) {
399
+ failFn('lint-ga.yml: YAML не вдалося розібрати (ga.mdc)')
400
+ return
401
+ }
402
+
403
+ if (!isExactString(root.name, 'Lint GA')) {
404
+ failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
405
+ }
406
+
407
+ validateLintGaOnTriggers(root.on, failFn)
408
+
409
+ const conc = getObjKey(root, 'concurrency')
410
+ if (getObjKey(conc, 'cancel-in-progress') !== true) {
411
+ failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
412
+ }
413
+
414
+ const jobs = getObjKey(root, 'jobs')
415
+ const job = getObjKey(jobs, 'lint-ga')
416
+ if (!job) {
417
+ failFn('lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)')
418
+ return
419
+ }
420
+
421
+ if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
422
+ failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
423
+ }
424
+ const perm = getObjKey(job, 'permissions')
425
+ if (getObjKey(perm, 'contents') !== 'read') {
426
+ failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
427
+ }
428
+
429
+ const steps = getObjKey(job, 'steps')
430
+ if (!Array.isArray(steps) || steps.length === 0) {
431
+ failFn('lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)')
432
+ return
433
+ }
434
+
435
+ const flat = flattenWorkflowSteps(root)
436
+ const usesList = new Set(flat.map(s => getStepUses(s.step)))
437
+ const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
438
+
439
+ if (!usesList.has('actions/checkout@v6')) {
440
+ failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
441
+ }
442
+ if (!usesList.has('./.github/actions/setup-bun-deps')) {
443
+ failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
444
+ }
445
+ if (!usesList.has('astral-sh/setup-uv@v8.0.0')) {
446
+ failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
447
+ }
448
+ if (runBlob.includes('bun run lint-ga')) {
449
+ passFn('lint-ga.yml: структура jobs/steps OK')
450
+ } else {
451
+ failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Перевіряє структуру workflow `git-ai.yml` (ga.mdc).
457
+ * @param {Record<string, unknown> | null} root parsed YAML
458
+ * @param {(msg: string) => void} passFn pass
459
+ * @param {(msg: string) => void} failFn fail
460
+ */
461
+ function validateGitAiWorkflowStructure(root, passFn, failFn) {
462
+ if (!root) {
463
+ failFn('git-ai.yml: YAML не вдалося розібрати (ga.mdc)')
464
+ return
465
+ }
466
+
467
+ if (!isExactString(root.name, 'Git AI')) {
468
+ failFn('git-ai.yml: name має бути "Git AI" (ga.mdc)')
469
+ }
470
+
471
+ const on = root.on
472
+ const pr = getObjKey(on, 'pull_request')
473
+ const types = getObjKey(pr, 'types')
474
+ if (!Array.isArray(types) || !types.includes('closed')) {
475
+ failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
476
+ }
477
+
478
+ const jobs = getObjKey(root, 'jobs')
479
+ const job = getObjKey(jobs, 'git-ai')
480
+ if (!job) {
481
+ failFn('git-ai.yml: jobs.git-ai відсутній (ga.mdc)')
482
+ return
483
+ }
484
+
485
+ if (!String(getObjKey(job, 'if') ?? '').includes('github.event.pull_request.merged == true')) {
486
+ failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
487
+ }
488
+
489
+ const perm = getObjKey(job, 'permissions')
490
+ if (getObjKey(perm, 'contents') !== 'write') {
491
+ failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
492
+ }
493
+
494
+ const flat = flattenWorkflowSteps(root)
495
+ const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
496
+ if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
497
+ failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
498
+ }
499
+ if (runBlob.includes('git-ai ci github run')) {
500
+ passFn('git-ai.yml: структура jobs/steps OK')
501
+ } else {
502
+ failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
503
+ }
504
+ }
505
+
47
506
  /**
48
507
  * Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
49
508
  * Fallback: сирий текст, якщо YAML не вдається розібрати.
@@ -323,7 +782,14 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
323
782
  pass('Всі workflows мають розширення .yml')
324
783
  }
325
784
 
326
- for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']) {
785
+ const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
786
+ if (notYmlFiles.length > 0) {
787
+ for (const f of notYmlFiles) {
788
+ fail(`Workflow має бути з розширенням .yml: ${wfDir}/${f} (ga.mdc)`)
789
+ }
790
+ }
791
+
792
+ for (const f of REQUIRED_WORKFLOWS) {
327
793
  if (files.includes(f)) {
328
794
  pass(`${f} існує`)
329
795
  } else {
@@ -405,6 +871,38 @@ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
405
871
  }
406
872
  }
407
873
 
874
+ /**
875
+ * Перевіряє, що “канонічні” workflows відповідають ga.mdc (структура і значення).
876
+ * @param {string} wfDir директорія workflows
877
+ * @param {(msg: string) => void} passFn pass
878
+ * @param {(msg: string) => void} failFn fail
879
+ */
880
+ async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
881
+ const paths = {
882
+ cleanGa: join(wfDir, 'clean-ga-workflows.yml'),
883
+ cleanMerged: join(wfDir, 'clean-merged-branch.yml'),
884
+ lintGa: join(wfDir, 'lint-ga.yml'),
885
+ gitAi: join(wfDir, 'git-ai.yml')
886
+ }
887
+
888
+ if (existsSync(paths.cleanGa)) {
889
+ const c = await readFile(paths.cleanGa, 'utf8')
890
+ validateCleanGaWorkflows(parseWorkflowYaml(c), passFn, failFn)
891
+ }
892
+ if (existsSync(paths.cleanMerged)) {
893
+ const c = await readFile(paths.cleanMerged, 'utf8')
894
+ validateCleanMergedBranch(parseWorkflowYaml(c), passFn, failFn)
895
+ }
896
+ if (existsSync(paths.lintGa)) {
897
+ const c = await readFile(paths.lintGa, 'utf8')
898
+ validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
899
+ }
900
+ if (existsSync(paths.gitAi)) {
901
+ const c = await readFile(paths.gitAi, 'utf8')
902
+ validateGitAiWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
903
+ }
904
+ }
905
+
408
906
  /**
409
907
  * Перевіряє відповідність проєкту правилам ga.mdc
410
908
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -456,8 +954,14 @@ export async function check() {
456
954
  verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
457
955
  verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
458
956
  verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
957
+ const parsed = parseWorkflowYaml(content)
958
+ if (parsed) {
959
+ verifyWorkflowEventPathsGlobsExist(`${wfDir}/${f}`, parsed, pass, fail)
960
+ }
459
961
  }
460
962
 
963
+ await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
964
+
461
965
  await checkZizmor(pass, fail)
462
966
  await checkLintGaScript(pass, fail)
463
967
  await checkLintGaWorkflow(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
+