@nitra/cursor 1.8.130 → 1.8.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auto-rules.md +2 -0
- package/mdc/docker.mdc +1 -1
- package/mdc/js-mssql.mdc +59 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +51 -0
- package/scripts/check-docker.mjs +8 -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/utils/mssql-pool-scan.mjs +208 -0
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/js-mssql.mdc
ADDED
|
@@ -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
package/scripts/auto-rules.mjs
CHANGED
|
@@ -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' },
|
package/scripts/check-docker.mjs
CHANGED
|
@@ -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) або
|
|
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} */ ([
|
|
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
|
|
86
|
+
* - фінальний FROM: alpine/nginx/openresty з mirror.gcr.io
|
|
83
87
|
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
84
88
|
* @returns {string | null} повідомлення помилки або null
|
|
85
89
|
*/
|
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
|
+
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -370,9 +370,9 @@ function pushStringPaths(arr, acc) {
|
|
|
370
370
|
const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
|
-
* Чи послідовність непорожніх рядків
|
|
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 (
|
|
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 =
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
+
|