@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.
- package/bin/auto-rules.md +4 -0
- package/mdc/docker.mdc +66 -1
- package/mdc/js-mssql.mdc +59 -0
- package/mdc/php.mdc +132 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +60 -0
- package/scripts/check-docker.mjs +130 -4
- package/scripts/check-ga.mjs +207 -86
- package/scripts/check-js-mssql.mjs +203 -0
- package/scripts/check-k8s.mjs +12 -12
- package/scripts/check-php.mjs +80 -0
- package/scripts/run-php.mjs +125 -0
- package/scripts/utils/mssql-pool-scan.mjs +208 -0
package/scripts/check-ga.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
140
|
-
|
|
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'), '
|
|
143
|
-
failFn('clean-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
) {
|
|
151
|
-
failFn('clean-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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 (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
439
|
+
if (!usesList.has('actions/checkout@v6')) {
|
|
323
440
|
failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
|
|
324
441
|
}
|
|
325
|
-
if (!usesList.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
+
|