@nitra/cursor 1.8.130 → 1.8.132

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.
@@ -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
+