@nitra/cursor 1.8.129 → 1.8.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auto-rules.md +2 -0
- package/mdc/docker.mdc +1 -1
- package/mdc/ga.mdc +31 -0
- 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 +505 -1
- 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/ga.mdc
CHANGED
|
@@ -161,6 +161,37 @@ jobs:
|
|
|
161
161
|
|
|
162
162
|
**Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
|
|
163
163
|
|
|
164
|
+
**Читабельність `run: >-` з циклам/умовами:** оскільки `>-` **згортає рядки в один shell-рядок**, відступи потрібні **лише для читабельності** й не впливають на виконання. Але для конструкцій bash на кшталт `while …; do … done` та `if …; then … fi` **обовʼязково**:
|
|
165
|
+
|
|
166
|
+
- став явні роздільники команд **`;`** або **`&&`** там, де при згортанні рядків інакше “злипнуться” токени (`do`/`then`/`fi`/`done` з наступною командою);
|
|
167
|
+
- тримай `do` і `then` в одному логічному рядку як `…; do` / `…; then`, щоб після згортання це гарантовано лишалось валідним bash;
|
|
168
|
+
- додавай відступи всередині `do/then/else` блоків, навіть якщо це один рядок після згортання — так workflow лишається читабельним у diff.
|
|
169
|
+
|
|
170
|
+
### Приклад (ПРАВИЛЬНО — читабельно, `run: >-`, з `while`/`if`)
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
- name: Apply changes
|
|
174
|
+
shell: bash
|
|
175
|
+
run: >-
|
|
176
|
+
echo "$FILES" |
|
|
177
|
+
while read -r FILE; do
|
|
178
|
+
[ -z "$FILE" ] && continue;
|
|
179
|
+
dirname "$FILE";
|
|
180
|
+
done |
|
|
181
|
+
sort -u |
|
|
182
|
+
while read -r DIR; do
|
|
183
|
+
(
|
|
184
|
+
if [ -f "$DIR/kustomization.yaml" ]; then
|
|
185
|
+
printf 'Applying %s\n' "$DIR" &&
|
|
186
|
+
cd "$DIR" &&
|
|
187
|
+
kubectl apply -k .;
|
|
188
|
+
else
|
|
189
|
+
echo "Skip $DIR - no kustomization.yaml";
|
|
190
|
+
fi
|
|
191
|
+
);
|
|
192
|
+
done
|
|
193
|
+
```
|
|
194
|
+
|
|
164
195
|
### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
|
|
165
196
|
|
|
166
197
|
```yaml
|
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
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
|
|
8
8
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
9
9
|
*
|
|
10
|
+
* Також перевіряє, що ключові workflow (`clean-ga-workflows.yml`, `clean-merged-branch.yml`, `lint-ga.yml`, `git-ai.yml`)
|
|
11
|
+
* мають структуру й значення, узгоджені з `npm/mdc/ga.mdc`. Для цих файлів перевірка виконується структурно
|
|
12
|
+
* (після YAML parse), щоб не залежати від форматування/відступів.
|
|
13
|
+
*
|
|
10
14
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
11
15
|
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
12
16
|
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
@@ -15,6 +19,7 @@
|
|
|
15
19
|
*/
|
|
16
20
|
import { existsSync } from 'node:fs'
|
|
17
21
|
import { readdir, readFile } from 'node:fs/promises'
|
|
22
|
+
import { execFileSync } from 'node:child_process'
|
|
18
23
|
import { join } from 'node:path'
|
|
19
24
|
|
|
20
25
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
@@ -25,6 +30,9 @@ import {
|
|
|
25
30
|
findRunStepsWithShellLineContinuationBackslash,
|
|
26
31
|
hasAnyStepUsesContaining,
|
|
27
32
|
hasCheckoutBeforeLocalSetupBunDeps,
|
|
33
|
+
flattenWorkflowSteps,
|
|
34
|
+
getStepRun,
|
|
35
|
+
getStepUses,
|
|
28
36
|
parseWorkflowYaml
|
|
29
37
|
} from './utils/gha-workflow.mjs'
|
|
30
38
|
|
|
@@ -44,6 +52,457 @@ const FORBIDDEN_BUN_PATTERNS = [
|
|
|
44
52
|
{ pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
|
|
45
53
|
]
|
|
46
54
|
|
|
55
|
+
/** Обовʼязкові workflow-файли (ga.mdc). */
|
|
56
|
+
const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Повертає true, якщо glob у GitHub Actions `on.*.paths` матчитсья хоча б на один tracked файл у репозиторії.
|
|
60
|
+
*
|
|
61
|
+
* Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
|
|
62
|
+
* і не сканувати файлову систему рекурсивно.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
|
|
65
|
+
* @returns {boolean} true, якщо є хоча б один збіг
|
|
66
|
+
*/
|
|
67
|
+
function gitHasAnyTrackedFileMatchingGlob(globPattern) {
|
|
68
|
+
const p = String(globPattern ?? '').trim()
|
|
69
|
+
if (!p) return false
|
|
70
|
+
if (p.startsWith('!')) return true
|
|
71
|
+
try {
|
|
72
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8' })
|
|
73
|
+
return out.length > 0
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Чи варто перевіряти glob з `on.*.paths` на наявність збігів у репозиторії.
|
|
81
|
+
*
|
|
82
|
+
* У багатьох workflow (особливо лінтерах) `paths` часто містить “широкі” шаблони по розширеннях
|
|
83
|
+
* (наприклад `*.vue`, `*.php`), які можуть бути відсутні в конкретному репозиторії й це ок.
|
|
84
|
+
* Запит цієї перевірки — ловити посилання на неіснуючі директорії/шляхи (типово `some-dir/**`).
|
|
85
|
+
*
|
|
86
|
+
* @param {string} p glob з workflow
|
|
87
|
+
* @returns {boolean} true, якщо треба валідувати наявність файлів
|
|
88
|
+
*/
|
|
89
|
+
function shouldValidateWorkflowPathsGlob(p) {
|
|
90
|
+
// Негативні патерни — лише виключають, їх існування не перевіряємо.
|
|
91
|
+
if (p.startsWith('!')) return false
|
|
92
|
+
|
|
93
|
+
// “Розширення-фільтри” (або brace-варіанти) пропускаємо: вони можуть бути заготовками.
|
|
94
|
+
if (p.includes('*.')) return false
|
|
95
|
+
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Валідує `on.push.paths` / `on.pull_request.paths`: кожен позитивний glob має мати збіги в репозиторії.
|
|
101
|
+
* @param {string} relPath шлях workflow для повідомлень
|
|
102
|
+
* @param {Record<string, unknown>} root parsed YAML workflow
|
|
103
|
+
* @param {(msg: string) => void} passFn pass
|
|
104
|
+
* @param {(msg: string) => void} failFn fail
|
|
105
|
+
*/
|
|
106
|
+
function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
|
|
107
|
+
const on = getObjKey(root, 'on')
|
|
108
|
+
if (!on || typeof on !== 'object') return
|
|
109
|
+
|
|
110
|
+
/** @type {Array<[eventName: string, paths: unknown]>} */
|
|
111
|
+
const candidates = [
|
|
112
|
+
['push', getObjKey(getObjKey(on, 'push'), 'paths')],
|
|
113
|
+
['pull_request', getObjKey(getObjKey(on, 'pull_request'), 'paths')]
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
for (const [eventName, paths] of candidates) {
|
|
117
|
+
if (!Array.isArray(paths)) continue
|
|
118
|
+
for (const raw of paths) {
|
|
119
|
+
const p = String(raw ?? '').trim()
|
|
120
|
+
if (!p) continue
|
|
121
|
+
if (!shouldValidateWorkflowPathsGlob(p)) {
|
|
122
|
+
passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (gitHasAnyTrackedFileMatchingGlob(p)) {
|
|
126
|
+
passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
|
|
127
|
+
} else {
|
|
128
|
+
failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Безпечний доступ до вкладеного поля (лише для обʼєктів).
|
|
136
|
+
* @param {unknown} obj значення-кандидат на обʼєкт
|
|
137
|
+
* @param {string} key ключ
|
|
138
|
+
* @returns {unknown} значення поля або undefined
|
|
139
|
+
*/
|
|
140
|
+
function getObjKey(obj, key) {
|
|
141
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
|
|
142
|
+
return /** @type {Record<string, unknown>} */ (obj)[key]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Очікує, що значення є рядком рівно `expected`.
|
|
147
|
+
* @param {unknown} v значення
|
|
148
|
+
* @param {string} expected очікуваний рядок
|
|
149
|
+
* @returns {boolean} true, якщо збігається
|
|
150
|
+
*/
|
|
151
|
+
function isExactString(v, expected) {
|
|
152
|
+
return typeof v === 'string' && v === expected
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Перевіряє крок dmvict/clean-workflow-runs@v1 у `clean-ga-workflows.yml`.
|
|
157
|
+
* @param {unknown} step0 перший крок workflow
|
|
158
|
+
* @param {(msg: string) => void} passFn pass
|
|
159
|
+
* @param {(msg: string) => void} failFn fail
|
|
160
|
+
*/
|
|
161
|
+
function validateCleanGaWorkflowsStep0(step0, passFn, failFn) {
|
|
162
|
+
if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
|
|
163
|
+
failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
|
|
164
|
+
}
|
|
165
|
+
if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
|
|
166
|
+
failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
|
|
167
|
+
}
|
|
168
|
+
const withObj = getObjKey(step0, 'with')
|
|
169
|
+
const githubToken = ['$', '{{ github.token }}'].join('')
|
|
170
|
+
if (
|
|
171
|
+
getObjKey(withObj, 'token') === githubToken &&
|
|
172
|
+
getObjKey(withObj, 'save_period') === 31 &&
|
|
173
|
+
getObjKey(withObj, 'save_min_runs_number') === 0
|
|
174
|
+
) {
|
|
175
|
+
passFn('clean-ga-workflows.yml: jobs/steps OK')
|
|
176
|
+
} else {
|
|
177
|
+
failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
|
|
183
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
184
|
+
* @param {(msg: string) => void} passFn pass
|
|
185
|
+
* @param {(msg: string) => void} failFn fail
|
|
186
|
+
*/
|
|
187
|
+
function validateCleanGaWorkflows(root, passFn, failFn) {
|
|
188
|
+
if (!root) {
|
|
189
|
+
failFn('clean-ga-workflows.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isExactString(root.name, 'Clean action for removing completed workflow runs')) {
|
|
194
|
+
passFn('clean-ga-workflows.yml: name OK')
|
|
195
|
+
} else {
|
|
196
|
+
failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const on = root.on
|
|
200
|
+
const schedule = getObjKey(on, 'schedule')
|
|
201
|
+
const wfDispatch = getObjKey(on, 'workflow_dispatch')
|
|
202
|
+
|
|
203
|
+
const hasCron =
|
|
204
|
+
Array.isArray(schedule) &&
|
|
205
|
+
schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
|
|
206
|
+
|
|
207
|
+
if (hasCron) {
|
|
208
|
+
passFn('clean-ga-workflows.yml: cron OK')
|
|
209
|
+
} else {
|
|
210
|
+
failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!wfDispatch || typeof wfDispatch !== 'object') {
|
|
214
|
+
failFn('clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)')
|
|
215
|
+
} else {
|
|
216
|
+
passFn('clean-ga-workflows.yml: workflow_dispatch OK')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const jobs = getObjKey(root, 'jobs')
|
|
220
|
+
const job = getObjKey(jobs, 'cleanup_old_workflows')
|
|
221
|
+
if (!job) {
|
|
222
|
+
failFn('clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
|
|
227
|
+
failFn('clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const perm = getObjKey(job, 'permissions')
|
|
231
|
+
if (!(getObjKey(perm, 'actions') === 'write' && getObjKey(perm, 'contents') === 'read')) {
|
|
232
|
+
failFn('clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const steps = getObjKey(job, 'steps')
|
|
236
|
+
const step0 = Array.isArray(steps) ? steps[0] : null
|
|
237
|
+
if (!step0 || typeof step0 !== 'object') {
|
|
238
|
+
failFn('clean-ga-workflows.yml: steps має містити крок з dmvict/clean-workflow-runs@v1 (ga.mdc)')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
validateCleanGaWorkflowsStep0(step0, passFn, failFn)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Перевіряє крок `phpdocker-io/github-actions-delete-abandoned-branches` у `clean-merged-branch.yml`.
|
|
247
|
+
* @param {unknown} step0 перший крок workflow
|
|
248
|
+
* @param {(msg: string) => void} failFn fail
|
|
249
|
+
*/
|
|
250
|
+
function validateCleanMergedBranchStep0(step0, failFn) {
|
|
251
|
+
if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
|
|
252
|
+
failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
|
|
253
|
+
}
|
|
254
|
+
if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
|
|
255
|
+
failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
|
|
256
|
+
}
|
|
257
|
+
const withObj = getObjKey(step0, 'with')
|
|
258
|
+
const ghToken = ['$', '{{ github.token }}'].join('')
|
|
259
|
+
if (getObjKey(withObj, 'github_token') !== ghToken) {
|
|
260
|
+
failFn(['clean-merged-branch.yml: with.github_token має бути $', '{{ github.token }} (ga.mdc)'].join(''))
|
|
261
|
+
}
|
|
262
|
+
if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
|
|
263
|
+
failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
|
|
264
|
+
}
|
|
265
|
+
const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
|
|
266
|
+
if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
|
|
267
|
+
failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
|
|
268
|
+
}
|
|
269
|
+
if (getObjKey(withObj, 'dry_run') !== 'no') {
|
|
270
|
+
failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Перевіряє крок виводу в `clean-merged-branch.yml`.
|
|
276
|
+
* @param {unknown} step1 другий крок workflow
|
|
277
|
+
* @param {(msg: string) => void} passFn pass
|
|
278
|
+
* @param {(msg: string) => void} failFn fail
|
|
279
|
+
*/
|
|
280
|
+
function validateCleanMergedBranchStep1(step1, passFn, failFn) {
|
|
281
|
+
if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
|
|
282
|
+
failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
|
|
283
|
+
}
|
|
284
|
+
const env = getObjKey(step1, 'env')
|
|
285
|
+
const deletedBranchesExpr = ['$', '{{ steps.delete_stuff.outputs.deleted_branches }}'].join('')
|
|
286
|
+
if (getObjKey(env, 'DELETED_BRANCHES') !== deletedBranchesExpr) {
|
|
287
|
+
failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
|
|
288
|
+
}
|
|
289
|
+
const echoDeletedBranches = ['echo "Deleted branches: $', '{DELETED_BRANCHES}"'].join('')
|
|
290
|
+
if (String(getObjKey(step1, 'run') ?? '').includes(echoDeletedBranches)) {
|
|
291
|
+
passFn('clean-merged-branch.yml: jobs/steps OK')
|
|
292
|
+
} else {
|
|
293
|
+
failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Перевіряє структуру workflow `clean-merged-branch.yml` (ga.mdc).
|
|
299
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
300
|
+
* @param {(msg: string) => void} passFn pass
|
|
301
|
+
* @param {(msg: string) => void} failFn fail
|
|
302
|
+
*/
|
|
303
|
+
function validateCleanMergedBranch(root, passFn, failFn) {
|
|
304
|
+
if (!root) {
|
|
305
|
+
failFn('clean-merged-branch.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (isExactString(root.name, 'Clean abandoned branches')) {
|
|
310
|
+
passFn('clean-merged-branch.yml: name OK')
|
|
311
|
+
} else {
|
|
312
|
+
failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const on = root.on
|
|
316
|
+
const schedule = getObjKey(on, 'schedule')
|
|
317
|
+
const wfDispatch = getObjKey(on, 'workflow_dispatch')
|
|
318
|
+
const hasCron =
|
|
319
|
+
Array.isArray(schedule) &&
|
|
320
|
+
schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
|
|
321
|
+
|
|
322
|
+
if (hasCron) {
|
|
323
|
+
passFn('clean-merged-branch.yml: cron OK')
|
|
324
|
+
} else {
|
|
325
|
+
failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!wfDispatch || typeof wfDispatch !== 'object') {
|
|
329
|
+
failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const jobs = getObjKey(root, 'jobs')
|
|
333
|
+
const job = getObjKey(jobs, 'cleanup_old_branches')
|
|
334
|
+
if (!job) {
|
|
335
|
+
failFn('clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)')
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const perm = getObjKey(job, 'permissions')
|
|
340
|
+
if (getObjKey(perm, 'contents') !== 'write') {
|
|
341
|
+
failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const steps = getObjKey(job, 'steps')
|
|
345
|
+
if (!Array.isArray(steps) || steps.length < 2) {
|
|
346
|
+
failFn('clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const step0 = steps[0]
|
|
351
|
+
if (!step0 || typeof step0 !== 'object') {
|
|
352
|
+
failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
validateCleanMergedBranchStep0(step0, failFn)
|
|
356
|
+
|
|
357
|
+
const step1 = steps[1]
|
|
358
|
+
if (!step1 || typeof step1 !== 'object') {
|
|
359
|
+
failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
validateCleanMergedBranchStep1(step1, passFn, failFn)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Перевіряє тригери `on.push` / `on.pull_request` у `lint-ga.yml`.
|
|
367
|
+
* @param {unknown} on корінь `on:` з YAML
|
|
368
|
+
* @param {(msg: string) => void} failFn fail
|
|
369
|
+
*/
|
|
370
|
+
function validateLintGaOnTriggers(on, failFn) {
|
|
371
|
+
const push = getObjKey(on, 'push')
|
|
372
|
+
const pr = getObjKey(on, 'pull_request')
|
|
373
|
+
const pushBranches = getObjKey(push, 'branches')
|
|
374
|
+
const pushPaths = getObjKey(push, 'paths')
|
|
375
|
+
const prBranches = getObjKey(pr, 'branches')
|
|
376
|
+
|
|
377
|
+
if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
|
|
378
|
+
failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
|
|
379
|
+
}
|
|
380
|
+
if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
|
|
381
|
+
failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
|
|
382
|
+
}
|
|
383
|
+
if (
|
|
384
|
+
!Array.isArray(pushPaths) ||
|
|
385
|
+
!(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))
|
|
386
|
+
) {
|
|
387
|
+
failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Перевіряє структуру workflow `lint-ga.yml` (ga.mdc).
|
|
393
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
394
|
+
* @param {(msg: string) => void} passFn pass
|
|
395
|
+
* @param {(msg: string) => void} failFn fail
|
|
396
|
+
*/
|
|
397
|
+
function validateLintGaWorkflowStructure(root, passFn, failFn) {
|
|
398
|
+
if (!root) {
|
|
399
|
+
failFn('lint-ga.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!isExactString(root.name, 'Lint GA')) {
|
|
404
|
+
failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
validateLintGaOnTriggers(root.on, failFn)
|
|
408
|
+
|
|
409
|
+
const conc = getObjKey(root, 'concurrency')
|
|
410
|
+
if (getObjKey(conc, 'cancel-in-progress') !== true) {
|
|
411
|
+
failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const jobs = getObjKey(root, 'jobs')
|
|
415
|
+
const job = getObjKey(jobs, 'lint-ga')
|
|
416
|
+
if (!job) {
|
|
417
|
+
failFn('lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)')
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
|
|
422
|
+
failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
|
|
423
|
+
}
|
|
424
|
+
const perm = getObjKey(job, 'permissions')
|
|
425
|
+
if (getObjKey(perm, 'contents') !== 'read') {
|
|
426
|
+
failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const steps = getObjKey(job, 'steps')
|
|
430
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
431
|
+
failFn('lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)')
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const flat = flattenWorkflowSteps(root)
|
|
436
|
+
const usesList = new Set(flat.map(s => getStepUses(s.step)))
|
|
437
|
+
const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
|
|
438
|
+
|
|
439
|
+
if (!usesList.has('actions/checkout@v6')) {
|
|
440
|
+
failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
|
|
441
|
+
}
|
|
442
|
+
if (!usesList.has('./.github/actions/setup-bun-deps')) {
|
|
443
|
+
failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
|
|
444
|
+
}
|
|
445
|
+
if (!usesList.has('astral-sh/setup-uv@v8.0.0')) {
|
|
446
|
+
failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
|
|
447
|
+
}
|
|
448
|
+
if (runBlob.includes('bun run lint-ga')) {
|
|
449
|
+
passFn('lint-ga.yml: структура jobs/steps OK')
|
|
450
|
+
} else {
|
|
451
|
+
failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Перевіряє структуру workflow `git-ai.yml` (ga.mdc).
|
|
457
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
458
|
+
* @param {(msg: string) => void} passFn pass
|
|
459
|
+
* @param {(msg: string) => void} failFn fail
|
|
460
|
+
*/
|
|
461
|
+
function validateGitAiWorkflowStructure(root, passFn, failFn) {
|
|
462
|
+
if (!root) {
|
|
463
|
+
failFn('git-ai.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!isExactString(root.name, 'Git AI')) {
|
|
468
|
+
failFn('git-ai.yml: name має бути "Git AI" (ga.mdc)')
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const on = root.on
|
|
472
|
+
const pr = getObjKey(on, 'pull_request')
|
|
473
|
+
const types = getObjKey(pr, 'types')
|
|
474
|
+
if (!Array.isArray(types) || !types.includes('closed')) {
|
|
475
|
+
failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const jobs = getObjKey(root, 'jobs')
|
|
479
|
+
const job = getObjKey(jobs, 'git-ai')
|
|
480
|
+
if (!job) {
|
|
481
|
+
failFn('git-ai.yml: jobs.git-ai відсутній (ga.mdc)')
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!String(getObjKey(job, 'if') ?? '').includes('github.event.pull_request.merged == true')) {
|
|
486
|
+
failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const perm = getObjKey(job, 'permissions')
|
|
490
|
+
if (getObjKey(perm, 'contents') !== 'write') {
|
|
491
|
+
failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const flat = flattenWorkflowSteps(root)
|
|
495
|
+
const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
|
|
496
|
+
if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
|
|
497
|
+
failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
|
|
498
|
+
}
|
|
499
|
+
if (runBlob.includes('git-ai ci github run')) {
|
|
500
|
+
passFn('git-ai.yml: структура jobs/steps OK')
|
|
501
|
+
} else {
|
|
502
|
+
failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
47
506
|
/**
|
|
48
507
|
* Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
|
|
49
508
|
* Fallback: сирий текст, якщо YAML не вдається розібрати.
|
|
@@ -323,7 +782,14 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
|
|
|
323
782
|
pass('Всі workflows мають розширення .yml')
|
|
324
783
|
}
|
|
325
784
|
|
|
326
|
-
|
|
785
|
+
const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
|
|
786
|
+
if (notYmlFiles.length > 0) {
|
|
787
|
+
for (const f of notYmlFiles) {
|
|
788
|
+
fail(`Workflow має бути з розширенням .yml: ${wfDir}/${f} (ga.mdc)`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for (const f of REQUIRED_WORKFLOWS) {
|
|
327
793
|
if (files.includes(f)) {
|
|
328
794
|
pass(`${f} існує`)
|
|
329
795
|
} else {
|
|
@@ -405,6 +871,38 @@ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
|
|
|
405
871
|
}
|
|
406
872
|
}
|
|
407
873
|
|
|
874
|
+
/**
|
|
875
|
+
* Перевіряє, що “канонічні” workflows відповідають ga.mdc (структура і значення).
|
|
876
|
+
* @param {string} wfDir директорія workflows
|
|
877
|
+
* @param {(msg: string) => void} passFn pass
|
|
878
|
+
* @param {(msg: string) => void} failFn fail
|
|
879
|
+
*/
|
|
880
|
+
async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
|
|
881
|
+
const paths = {
|
|
882
|
+
cleanGa: join(wfDir, 'clean-ga-workflows.yml'),
|
|
883
|
+
cleanMerged: join(wfDir, 'clean-merged-branch.yml'),
|
|
884
|
+
lintGa: join(wfDir, 'lint-ga.yml'),
|
|
885
|
+
gitAi: join(wfDir, 'git-ai.yml')
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (existsSync(paths.cleanGa)) {
|
|
889
|
+
const c = await readFile(paths.cleanGa, 'utf8')
|
|
890
|
+
validateCleanGaWorkflows(parseWorkflowYaml(c), passFn, failFn)
|
|
891
|
+
}
|
|
892
|
+
if (existsSync(paths.cleanMerged)) {
|
|
893
|
+
const c = await readFile(paths.cleanMerged, 'utf8')
|
|
894
|
+
validateCleanMergedBranch(parseWorkflowYaml(c), passFn, failFn)
|
|
895
|
+
}
|
|
896
|
+
if (existsSync(paths.lintGa)) {
|
|
897
|
+
const c = await readFile(paths.lintGa, 'utf8')
|
|
898
|
+
validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
|
|
899
|
+
}
|
|
900
|
+
if (existsSync(paths.gitAi)) {
|
|
901
|
+
const c = await readFile(paths.gitAi, 'utf8')
|
|
902
|
+
validateGitAiWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
408
906
|
/**
|
|
409
907
|
* Перевіряє відповідність проєкту правилам ga.mdc
|
|
410
908
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -456,8 +954,14 @@ export async function check() {
|
|
|
456
954
|
verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
|
|
457
955
|
verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
|
|
458
956
|
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
957
|
+
const parsed = parseWorkflowYaml(content)
|
|
958
|
+
if (parsed) {
|
|
959
|
+
verifyWorkflowEventPathsGlobsExist(`${wfDir}/${f}`, parsed, pass, fail)
|
|
960
|
+
}
|
|
459
961
|
}
|
|
460
962
|
|
|
963
|
+
await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
|
|
964
|
+
|
|
461
965
|
await checkZizmor(pass, fail)
|
|
462
966
|
await checkLintGaScript(pass, fail)
|
|
463
967
|
await checkLintGaWorkflow(wfDir, pass, fail)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє правило js-mssql.mdc.
|
|
3
|
+
*
|
|
4
|
+
* Якщо в будь-якому `package.json` у репозиторії (включно з workspace-пакетами) в секції `dependencies`
|
|
5
|
+
* присутній пакет `mssql`, його версія має бути не менше 12.5.0.
|
|
6
|
+
*
|
|
7
|
+
* Додатково, якщо `mssql` використовується в репозиторії, перевіряє що підключення
|
|
8
|
+
* не створюється “на кожен запит”: `new sql.ConnectionPool(...)` не має знаходитись
|
|
9
|
+
* всередині функцій. Пул має бути singleton (на рівні модуля) і повторно використовуватись.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { readFile } from 'node:fs/promises'
|
|
13
|
+
import { join, relative, sep } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
16
|
+
import {
|
|
17
|
+
findMssqlPerRequestConnectionInText,
|
|
18
|
+
findUnsafeMssqlQueryTemplateCallInText,
|
|
19
|
+
isMssqlScanSourceFile
|
|
20
|
+
} from './utils/mssql-pool-scan.mjs'
|
|
21
|
+
import { walkDir } from './utils/walkDir.mjs'
|
|
22
|
+
|
|
23
|
+
const VERSION_PREFIX_RE = /^[\^~>=<]+\s*/u
|
|
24
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/u
|
|
25
|
+
|
|
26
|
+
/** Мінімальна дозволена версія mssql (js-mssql.mdc). */
|
|
27
|
+
const MIN_MSSQL_VERSION = { major: 12, minor: 5, patch: 0 }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
|
|
31
|
+
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
32
|
+
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
33
|
+
*/
|
|
34
|
+
async function findAllPackageJsonPaths(repoRoot) {
|
|
35
|
+
/** @type {string[]} */
|
|
36
|
+
const paths = []
|
|
37
|
+
await walkDir(repoRoot, absPath => {
|
|
38
|
+
if (absPath.endsWith(`${sep}package.json`)) {
|
|
39
|
+
paths.push(absPath)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
43
|
+
return paths
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану mssql.
|
|
48
|
+
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
49
|
+
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
50
|
+
*/
|
|
51
|
+
async function findAllSourcePathsForMssqlScan(repoRoot) {
|
|
52
|
+
/** @type {string[]} */
|
|
53
|
+
const paths = []
|
|
54
|
+
await walkDir(repoRoot, absPath => {
|
|
55
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
56
|
+
if (isMssqlScanSourceFile(rel)) {
|
|
57
|
+
paths.push(absPath)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
61
|
+
return paths
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} v parsed JSON
|
|
66
|
+
* @returns {Record<string, unknown>} object або {}
|
|
67
|
+
*/
|
|
68
|
+
function asObject(v) {
|
|
69
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
|
|
70
|
+
return /** @type {Record<string, unknown>} */ (v)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Витягає рядок версії `dependencies.mssql`, якщо він існує.
|
|
75
|
+
* @param {unknown} deps deps з package.json
|
|
76
|
+
* @returns {string | null} версія або null
|
|
77
|
+
*/
|
|
78
|
+
function getMssqlDependencyRange(deps) {
|
|
79
|
+
const o = asObject(deps)
|
|
80
|
+
const v = o.mssql
|
|
81
|
+
return typeof v === 'string' && v.trim() ? v.trim() : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Парсить першу semver з діапазону виду "^12.5.0", ">=12.5.0", "12.5.0".
|
|
86
|
+
* @param {string} range версійний діапазон
|
|
87
|
+
* @returns {{ major: number, minor: number, patch: number } | null} semver або null
|
|
88
|
+
*/
|
|
89
|
+
function parseLeadingSemver(range) {
|
|
90
|
+
const cleaned = String(range).trim().replace(VERSION_PREFIX_RE, '')
|
|
91
|
+
const m = cleaned.match(SEMVER_RE)
|
|
92
|
+
if (!m) return null
|
|
93
|
+
const major = Number(m[1])
|
|
94
|
+
const minor = Number(m[2])
|
|
95
|
+
const patch = Number(m[3])
|
|
96
|
+
if ([major, minor, patch].some(n => Number.isNaN(n))) return null
|
|
97
|
+
return { major, minor, patch }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {{ major: number, minor: number, patch: number }} a
|
|
102
|
+
* @param {{ major: number, minor: number, patch: number }} b
|
|
103
|
+
* @returns {boolean} true, якщо a >= b
|
|
104
|
+
*/
|
|
105
|
+
function semverGte(a, b) {
|
|
106
|
+
if (a.major !== b.major) return a.major > b.major
|
|
107
|
+
if (a.minor !== b.minor) return a.minor > b.minor
|
|
108
|
+
return a.patch >= b.patch
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Перевіряє відповідність проєкту правилу js-mssql.mdc
|
|
113
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
114
|
+
*/
|
|
115
|
+
export async function check() {
|
|
116
|
+
const reporter = createCheckReporter()
|
|
117
|
+
const { pass, fail } = reporter
|
|
118
|
+
|
|
119
|
+
const repoRoot = process.cwd()
|
|
120
|
+
const rootPkg = join(repoRoot, 'package.json')
|
|
121
|
+
if (!existsSync(rootPkg)) {
|
|
122
|
+
pass('js-mssql: package.json у корені відсутній — перевірку пропущено')
|
|
123
|
+
return reporter.getExitCode()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pkgJsonPaths = await findAllPackageJsonPaths(repoRoot)
|
|
127
|
+
if (pkgJsonPaths.length === 0) {
|
|
128
|
+
pass('js-mssql: package.json не знайдено — перевірку пропущено')
|
|
129
|
+
return reporter.getExitCode()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let found = 0
|
|
133
|
+
let bad = 0
|
|
134
|
+
for (const absPath of pkgJsonPaths) {
|
|
135
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
136
|
+
let parsed
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
139
|
+
} catch {
|
|
140
|
+
fail(`js-mssql: ${rel} — невалідний JSON`)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const range = getMssqlDependencyRange(parsed.dependencies)
|
|
144
|
+
if (range) {
|
|
145
|
+
found++
|
|
146
|
+
const parsedVer = parseLeadingSemver(range)
|
|
147
|
+
if (!parsedVer) {
|
|
148
|
+
bad++
|
|
149
|
+
fail(`js-mssql: ${rel}: dependencies.mssql має нечитабельну версію: ${JSON.stringify(range)} (js-mssql.mdc)`)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (semverGte(parsedVer, MIN_MSSQL_VERSION)) {
|
|
153
|
+
pass(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} (>=12.5.0)`)
|
|
154
|
+
} else {
|
|
155
|
+
bad++
|
|
156
|
+
fail(`js-mssql: ${rel}: dependencies.mssql ${JSON.stringify(range)} — має бути >=12.5.0 (js-mssql.mdc)`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (found === 0) {
|
|
162
|
+
pass('js-mssql: пакет mssql не знайдено в dependencies жодного package.json')
|
|
163
|
+
} else if (bad === 0) {
|
|
164
|
+
pass(`js-mssql: всі знайдені dependencies.mssql відповідають мінімальній версії 12.5.0 (${found})`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (found > 0) {
|
|
168
|
+
const sourcePaths = await findAllSourcePathsForMssqlScan(repoRoot)
|
|
169
|
+
if (sourcePaths.length === 0) {
|
|
170
|
+
pass('js-mssql: немає JS/TS файлів для скану singleton ConnectionPool')
|
|
171
|
+
return reporter.getExitCode()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let violations = 0
|
|
175
|
+
let unsafeQueryCalls = 0
|
|
176
|
+
for (const absPath of sourcePaths) {
|
|
177
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
178
|
+
const content = await readFile(absPath, 'utf8')
|
|
179
|
+
for (const v of findMssqlPerRequestConnectionInText(content, rel)) {
|
|
180
|
+
violations++
|
|
181
|
+
fail(
|
|
182
|
+
`js-mssql: ${rel}:${v.line} — не створюй new sql.ConnectionPool(...) на кожен запит; використовуй singleton sql.ConnectionPool: ${v.snippet}`
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
for (const v of findUnsafeMssqlQueryTemplateCallInText(content, rel)) {
|
|
186
|
+
unsafeQueryCalls++
|
|
187
|
+
fail(
|
|
188
|
+
`js-mssql: ${rel}:${v.line} — заборонено query(\`...\`): це не tagged template; використовуй pool.request().query\`...\` (js-mssql.mdc): ${v.snippet}`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (violations === 0) {
|
|
194
|
+
pass('js-mssql: немає створення new sql.ConnectionPool(...) всередині функцій (singleton pool)')
|
|
195
|
+
}
|
|
196
|
+
if (unsafeQueryCalls === 0) {
|
|
197
|
+
pass('js-mssql: немає небезпечних викликів query(`...`) (потрібно query`...`)')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return reporter.getExitCode()
|
|
202
|
+
}
|
|
203
|
+
|
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
|
+
|