@nitra/cursor 1.8.131 → 1.8.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auto-rules.md +4 -0
- package/mdc/capacitor.mdc +18 -0
- package/mdc/docker.mdc +65 -0
- package/mdc/k8s.mdc +2 -0
- package/mdc/php.mdc +132 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +19 -1
- package/scripts/check-capacitor.mjs +350 -0
- package/scripts/check-docker.mjs +122 -0
- package/scripts/check-k8s.mjs +72 -1
- package/scripts/check-php.mjs +80 -0
- package/scripts/run-php.mjs +125 -0
package/bin/auto-rules.md
CHANGED
|
@@ -8,6 +8,8 @@ abie - якщо в кореневому package.json в секції "repository
|
|
|
8
8
|
|
|
9
9
|
bun - якщо в корені проекту є package.json
|
|
10
10
|
|
|
11
|
+
capacitor - якщо в проекті є хоч один файл capacitor.config.json
|
|
12
|
+
|
|
11
13
|
docker - якщо в проекті є хоч один Dockerfile
|
|
12
14
|
|
|
13
15
|
ga - якщо присутня директорія .github/workflows
|
|
@@ -26,6 +28,8 @@ nginx-default-tpl - якщо присутній хоч один файл з пе
|
|
|
26
28
|
|
|
27
29
|
npm-module - якщо в корені присутня директорія npm
|
|
28
30
|
|
|
31
|
+
php - якщо присутній хоч один php файл
|
|
32
|
+
|
|
29
33
|
style-lint - якщо присутній хоч один vue або css файл
|
|
30
34
|
|
|
31
35
|
text - завжди
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Правила для проєктів Capacitor
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Версія Capacitor
|
|
8
|
+
|
|
9
|
+
У `package.json` (у **корені** репозиторію чи **workspace**-пакеті) оголошення **`@capacitor/core`**
|
|
10
|
+
має вказувати діапазон, **сумісний лише з мажорною версією 8 і вище** (наприклад `^8.0.0`).
|
|
11
|
+
**`*`**, `latest` і діапазони, де можлива 7-мажор, — неприйнятні. Програма перевірки — **`check-capacitor.mjs`**
|
|
12
|
+
(репозиторій **@nitra/cursor**).
|
|
13
|
+
|
|
14
|
+
## iOS: лише SPM
|
|
15
|
+
|
|
16
|
+
Нативний iOS-шар **не** повинен використовувати **CocoaPods** (наприклад файл **`Podfile`**
|
|
17
|
+
поза каталогом `Pods/`) — **Swift Package Manager (SPM)**. Якщо каталога **`ios/`** немає, перевірка iOS
|
|
18
|
+
у цьому кроці пропускається.
|
package/mdc/docker.mdc
CHANGED
|
@@ -18,6 +18,71 @@ alwaysApply: false
|
|
|
18
18
|
|
|
19
19
|
Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
|
|
20
20
|
|
|
21
|
+
## компіляція
|
|
22
|
+
|
|
23
|
+
Якщо проект має bun install крок, та не є фронтенд проектом (тобто не має bun build крок), то потрібно щоб була компіляція коду, і далі у фінальному образі був тільки бінарник і Цей образ не містив компілятора, npm, Bun — тільки runtime libs. Наприклад:
|
|
24
|
+
|
|
25
|
+
```dockerfile
|
|
26
|
+
FROM mirror.gcr.io/oven/bun:alpine AS build-env
|
|
27
|
+
|
|
28
|
+
WORKDIR /app
|
|
29
|
+
|
|
30
|
+
ENV NODE_ENV=production
|
|
31
|
+
|
|
32
|
+
COPY package.json .
|
|
33
|
+
COPY bunfig.toml .
|
|
34
|
+
|
|
35
|
+
RUN bun install --production
|
|
36
|
+
|
|
37
|
+
COPY ./src ./src
|
|
38
|
+
|
|
39
|
+
# Компілюємо в бінарник
|
|
40
|
+
RUN bun build --compile --outfile app ./src/index.js
|
|
41
|
+
|
|
42
|
+
FROM mirror.gcr.io/library/alpine:latest
|
|
43
|
+
|
|
44
|
+
# (libstdc++ libgcc) для Bun runtime, (tzdata) для часового поясу
|
|
45
|
+
RUN apk add --no-cache libstdc++ libgcc tzdata
|
|
46
|
+
|
|
47
|
+
WORKDIR /app
|
|
48
|
+
|
|
49
|
+
COPY --from=build-env /app/app ./app
|
|
50
|
+
|
|
51
|
+
CMD ["./app"]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## не превілейований образ
|
|
55
|
+
|
|
56
|
+
Для всих образів потрібно щоб використовувся non root принцип, наприклад:
|
|
57
|
+
|
|
58
|
+
```dockerfile
|
|
59
|
+
# Stage 1
|
|
60
|
+
FROM oven/bun:alpine AS build-env
|
|
61
|
+
WORKDIR /app
|
|
62
|
+
COPY package.json bunfig.toml .
|
|
63
|
+
RUN bun install --production
|
|
64
|
+
COPY ./src ./src
|
|
65
|
+
RUN bun build --compile --outfile app ./src/index.js
|
|
66
|
+
|
|
67
|
+
# Stage 2
|
|
68
|
+
FROM alpine:latest
|
|
69
|
+
RUN apk add --no-cache libstdc++ libgcc tzdata
|
|
70
|
+
|
|
71
|
+
# ✅ Додати non-root user
|
|
72
|
+
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
|
|
73
|
+
|
|
74
|
+
WORKDIR /app
|
|
75
|
+
|
|
76
|
+
# ✅ Змінити власника файлу
|
|
77
|
+
COPY --from=build-env --chown=app:app /app/app ./app
|
|
78
|
+
|
|
79
|
+
# ✅ Запускати як non-root
|
|
80
|
+
USER app
|
|
81
|
+
|
|
82
|
+
CMD ["./app"]
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
|
|
21
86
|
## Область
|
|
22
87
|
|
|
23
88
|
- Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
|
package/mdc/k8s.mdc
CHANGED
|
@@ -416,6 +416,8 @@ spec:
|
|
|
416
416
|
|
|
417
417
|
У маніфестах під **`k8s`** заборонено **`apiVersion: autoscaling/v1`** (legacy HPA з єдиною метрикою CPU). Мігруй **HorizontalPodAutoscaler** на **`autoscaling/v2`**: поле **`spec.metrics`** (замість **`spec.targetCPUUtilizationPercentage`**) з **`type: Resource`** і **`target.type: Utilization`** / **`AverageUtilization`** — підтримує декілька метрик і зовнішні метрики. `check k8s` падає на будь-якому документі з **`apiVersion: autoscaling/v1`**.
|
|
418
418
|
|
|
419
|
+
Ресурси **batch** (наприклад **CronJob**, **Job**): застаріле **`apiVersion: batch/v1beta1`** у файлах під **`k8s` під час `check k8s` переписується** на **`apiVersion: batch/v1`**.
|
|
420
|
+
|
|
419
421
|
```yaml
|
|
420
422
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
|
|
421
423
|
apiVersion: autoscaling/v2
|
package/mdc/php.mdc
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: PHP
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Весь код повинен відповідати PHP 8.5, перевіряти за допомогою PHPCompatibility, конвертувати за допомогою Rector.
|
|
8
|
+
|
|
9
|
+
Код повинен бути добре документований за допомогою phpDoc-ів:
|
|
10
|
+
|
|
11
|
+
```php
|
|
12
|
+
/** @param array<int, string> $items */
|
|
13
|
+
function process($items) {
|
|
14
|
+
foreach ($items as $id => $name) {
|
|
15
|
+
// PHPStan знає, що $id — int, $name — string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Потрібно додати до lint-php
|
|
21
|
+
|
|
22
|
+
### PHP_CodeSniffer (phpcs) і пакет `squizlabs/php_codesniffer`
|
|
23
|
+
|
|
24
|
+
Стиль, базові ризики, за потреби окремі стандарти. Для **SQL injection**, **XSS**, **небезпечні функції** — PHPCS + **php-security-audit** (стандарт `Security`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
phpcs --standard=Security app/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### PHPStan
|
|
31
|
+
|
|
32
|
+
Статичний аналіз типів і смертельних слабких місць; **PHPStan Taint Analysis** (`phpstan/phpstan-taint-analysis`) — для потоку даних (taint) у стилі security-аналізу.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
vendor/bin/phpstan analyse
|
|
36
|
+
# після підключення розширення taint — у phpstan.neon/ neon.dist
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### PHP-CS-Fixer
|
|
40
|
+
|
|
41
|
+
Форматтер/фіксер коду; у **lint** зазвичай перевірка без змін (`--dry-run`).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
vendor/bin/php-cs-fixer fix --dry-run --diff
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Psalm
|
|
48
|
+
|
|
49
|
+
Альтернативний/додатковий аналізатор; **Psalm Security Plugin** (`vimeo/psalm` + пакет на кшталт `psalm/plugin-security` залежно від вибраної збірки) — для security-орієнтованих правил.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
vendor/bin/psalm
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `composer audit`
|
|
56
|
+
|
|
57
|
+
Перевірка відомих вразливостей залежностей за даними **Packagist/security-advisories**.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
composer audit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## lint-php
|
|
64
|
+
|
|
65
|
+
`composer`-інструмененти не мають єдиного CLI, який сам обходить репозиторій, тому `lint-php` зручно делегувати у JS-скрипт-обгортку (як `lint-docker`, `lint-k8s`).
|
|
66
|
+
|
|
67
|
+
```json title="package.json"
|
|
68
|
+
{
|
|
69
|
+
"scripts": {
|
|
70
|
+
"lint-php": "bun ./npm/scripts/run-php.mjs"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Скрипт `run-php.mjs`:
|
|
76
|
+
|
|
77
|
+
- якщо `composer.json` у корені відсутній — вихід 0 (перевірка пропущена);
|
|
78
|
+
- якщо `composer.json` є, але `composer` не знайдено в PATH — це помилка;
|
|
79
|
+
- `composer audit` — обовʼязковий;
|
|
80
|
+
- `vendor/bin/php-cs-fixer`, `vendor/bin/phpcs`, `vendor/bin/phpstan`, `vendor/bin/psalm` — запускаються лише якщо встановлені (інакше крок пропускається з повідомленням).
|
|
81
|
+
|
|
82
|
+
## CI: `.github/workflows/lint-php.yml`
|
|
83
|
+
|
|
84
|
+
```yaml title=".github/workflows/lint-php.yml"
|
|
85
|
+
name: Lint PHP
|
|
86
|
+
|
|
87
|
+
on:
|
|
88
|
+
push:
|
|
89
|
+
branches:
|
|
90
|
+
- dev
|
|
91
|
+
- main
|
|
92
|
+
paths:
|
|
93
|
+
- '**/*.php'
|
|
94
|
+
- 'composer.json'
|
|
95
|
+
- 'composer.lock'
|
|
96
|
+
- 'phpstan.neon'
|
|
97
|
+
- 'phpstan.neon.dist'
|
|
98
|
+
- 'psalm.xml'
|
|
99
|
+
- '.github/workflows/lint-php.yml'
|
|
100
|
+
|
|
101
|
+
pull_request:
|
|
102
|
+
branches:
|
|
103
|
+
- dev
|
|
104
|
+
- main
|
|
105
|
+
|
|
106
|
+
concurrency:
|
|
107
|
+
group: ${{ github.ref }}-${{ github.workflow }}
|
|
108
|
+
cancel-in-progress: true
|
|
109
|
+
|
|
110
|
+
jobs:
|
|
111
|
+
php:
|
|
112
|
+
runs-on: ubuntu-latest
|
|
113
|
+
permissions:
|
|
114
|
+
contents: read
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v6
|
|
117
|
+
with:
|
|
118
|
+
persist-credentials: false
|
|
119
|
+
|
|
120
|
+
- uses: ./.github/actions/setup-bun-deps
|
|
121
|
+
|
|
122
|
+
- name: Install PHP
|
|
123
|
+
uses: shivammathur/setup-php@v2
|
|
124
|
+
with:
|
|
125
|
+
php-version: '8.5'
|
|
126
|
+
|
|
127
|
+
- name: Install Composer dependencies
|
|
128
|
+
run: composer install --no-interaction --no-progress --prefer-dist
|
|
129
|
+
|
|
130
|
+
- name: Lint PHP
|
|
131
|
+
run: bun run lint-php
|
|
132
|
+
```
|
package/package.json
CHANGED
package/scripts/auto-rules.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
export const AUTO_RULE_ORDER = Object.freeze([
|
|
22
22
|
'abie',
|
|
23
23
|
'bun',
|
|
24
|
+
'capacitor',
|
|
24
25
|
'docker',
|
|
25
26
|
'ga',
|
|
26
27
|
'graphql',
|
|
@@ -30,6 +31,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
|
|
|
30
31
|
'k8s',
|
|
31
32
|
'nginx-default-tpl',
|
|
32
33
|
'npm-module',
|
|
34
|
+
'php',
|
|
33
35
|
'style-lint',
|
|
34
36
|
'text',
|
|
35
37
|
'vue'
|
|
@@ -42,6 +44,7 @@ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
|
|
|
42
44
|
const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
|
|
43
45
|
const STYLE_RE = /\.(?:css|vue)$/iu
|
|
44
46
|
const VUE_RE = /\.vue$/iu
|
|
47
|
+
const PHP_RE = /\.php$/iu
|
|
45
48
|
const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
|
|
46
49
|
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
47
50
|
const DEFAULT_DISABLED_LIST = Object.freeze([])
|
|
@@ -79,7 +82,7 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
|
|
|
79
82
|
try {
|
|
80
83
|
const parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
81
84
|
const deps = parsed?.dependencies
|
|
82
|
-
if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.
|
|
85
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
|
|
83
86
|
found = true
|
|
84
87
|
return
|
|
85
88
|
}
|
|
@@ -117,15 +120,20 @@ function updateDirFacts(dirName, facts) {
|
|
|
117
120
|
* @param {string} fileName базове імʼя файлу
|
|
118
121
|
* @param {string} relPath шлях відносно кореня
|
|
119
122
|
* @param {{
|
|
123
|
+
* hasCapacitorConfig: boolean,
|
|
120
124
|
* hasDockerfile: boolean,
|
|
121
125
|
* hasJsLikeSource: boolean,
|
|
122
126
|
* hasNginxDefaultTplFile: boolean,
|
|
127
|
+
* hasPhpSource: boolean,
|
|
123
128
|
* hasVueOrCssSource: boolean,
|
|
124
129
|
* hasVueSource: boolean
|
|
125
130
|
* }} facts агреговані факти
|
|
126
131
|
* @returns {void}
|
|
127
132
|
*/
|
|
128
133
|
function updateFileFacts(fileName, relPath, facts) {
|
|
134
|
+
if (fileName === 'capacitor.config.json') {
|
|
135
|
+
facts.hasCapacitorConfig = true
|
|
136
|
+
}
|
|
129
137
|
if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
|
|
130
138
|
facts.hasDockerfile = true
|
|
131
139
|
}
|
|
@@ -138,6 +146,9 @@ function updateFileFacts(fileName, relPath, facts) {
|
|
|
138
146
|
if (VUE_RE.test(relPath)) {
|
|
139
147
|
facts.hasVueSource = true
|
|
140
148
|
}
|
|
149
|
+
if (PHP_RE.test(relPath)) {
|
|
150
|
+
facts.hasPhpSource = true
|
|
151
|
+
}
|
|
141
152
|
if (STYLE_RE.test(relPath)) {
|
|
142
153
|
facts.hasVueOrCssSource = true
|
|
143
154
|
}
|
|
@@ -176,6 +187,7 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
|
|
|
176
187
|
* @param {string} absPath абсолютний шлях до файлу
|
|
177
188
|
* @param {string} root абсолютний шлях кореня
|
|
178
189
|
* @param {{
|
|
190
|
+
* hasCapacitorConfig: boolean,
|
|
179
191
|
* hasDockerfile: boolean,
|
|
180
192
|
* hasGqlTaggedTemplates: boolean,
|
|
181
193
|
* hasJsLikeSource: boolean,
|
|
@@ -255,6 +267,7 @@ export function isMonorepoPackage(packageJson) {
|
|
|
255
267
|
* Обходить дерево проєкту, збираючи факти для автоувімкнення правил.
|
|
256
268
|
* @param {string} root абсолютний шлях кореня репозиторію
|
|
257
269
|
* @returns {Promise<{
|
|
270
|
+
* hasCapacitorConfig: boolean,
|
|
258
271
|
* hasDockerfile: boolean,
|
|
259
272
|
* hasGaWorkflowsDir: boolean,
|
|
260
273
|
* hasGqlTaggedTemplates: boolean,
|
|
@@ -262,12 +275,14 @@ export function isMonorepoPackage(packageJson) {
|
|
|
262
275
|
* hasK8sDir: boolean,
|
|
263
276
|
* hasNginxDefaultTplFile: boolean,
|
|
264
277
|
* hasTempoDir: boolean,
|
|
278
|
+
* hasPhpSource: boolean,
|
|
265
279
|
* hasVueSource: boolean,
|
|
266
280
|
* hasVueOrCssSource: boolean
|
|
267
281
|
* }>} агреговані факти
|
|
268
282
|
*/
|
|
269
283
|
export async function collectAutoRuleFacts(root) {
|
|
270
284
|
const facts = {
|
|
285
|
+
hasCapacitorConfig: false,
|
|
271
286
|
hasDockerfile: false,
|
|
272
287
|
hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
|
|
273
288
|
hasGqlTaggedTemplates: false,
|
|
@@ -275,6 +290,7 @@ export async function collectAutoRuleFacts(root) {
|
|
|
275
290
|
hasK8sDir: false,
|
|
276
291
|
hasNginxDefaultTplFile: false,
|
|
277
292
|
hasTempoDir: false,
|
|
293
|
+
hasPhpSource: false,
|
|
278
294
|
hasVueSource: false,
|
|
279
295
|
hasVueOrCssSource: false
|
|
280
296
|
}
|
|
@@ -378,6 +394,7 @@ export async function detectAutoRulesAndSkills({
|
|
|
378
394
|
const autoRuleChecks = [
|
|
379
395
|
{ enabled: isAbie, id: 'abie' },
|
|
380
396
|
{ enabled: packageJsonExists, id: 'bun' },
|
|
397
|
+
{ enabled: facts.hasCapacitorConfig, id: 'capacitor' },
|
|
381
398
|
{ enabled: facts.hasDockerfile, id: 'docker' },
|
|
382
399
|
{ enabled: facts.hasGaWorkflowsDir, id: 'ga' },
|
|
383
400
|
{ enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
|
|
@@ -387,6 +404,7 @@ export async function detectAutoRulesAndSkills({
|
|
|
387
404
|
{ enabled: facts.hasK8sDir, id: 'k8s' },
|
|
388
405
|
{ enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
|
|
389
406
|
{ enabled: npmDirExists, id: 'npm-module' },
|
|
407
|
+
{ enabled: facts.hasPhpSource, id: 'php' },
|
|
390
408
|
{ enabled: facts.hasVueOrCssSource, id: 'style-lint' }
|
|
391
409
|
]
|
|
392
410
|
for (const item of autoRuleChecks) {
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє відповідність проєкту правилам capacitor.mdc для застосунків **Capacitor**.
|
|
3
|
+
*
|
|
4
|
+
* Якщо у репозиторії **немає** ознак Capacitor (див. наведене) — вихід **0**, перевірка не застосовується.
|
|
5
|
+
*
|
|
6
|
+
* **Ознака Capacitor:** наявні **`capacitor.config.json`**, **`capacitor.config.ts`**, **`capacitor.config.mjs`**
|
|
7
|
+
* (у корені) **або** у будь-якому `package.json` (рекурсивно, з пропуском типових каталогів) оголошено
|
|
8
|
+
* хоча б одну залежність **`@capacitor/…`** (у **`dependencies`**, **`devDependencies`**, опційно
|
|
9
|
+
* **`optionalDependencies`**, **`peerDependencies`**).
|
|
10
|
+
*
|
|
11
|
+
* **Версія мінімум 8:** у кожному `package.json`, де вказано **`@capacitor/core`**, діапазон версії
|
|
12
|
+
* мусить допускати лише **Capacitor 8+** (оцінка мінімального **major** з рядка діапазону npm, зокрема
|
|
13
|
+
* `||` і діапазонів через `-` у спрощеному вигляді). **`*`**, **latest** та нерозпізнані випадки — **порушення**:
|
|
14
|
+
* варто задати явний діапазон, наприклад **`^8.0.0`**. Якщо оголошено `capacitor.config.*` без жодного
|
|
15
|
+
* **`@capacitor/core`** у дереві `package.json` — також помилка.
|
|
16
|
+
*
|
|
17
|
+
* **iOS лише через SPM (Swift Package Manager):** якщо в корні є каталог **`ios/`** — у ньому **не** має
|
|
18
|
+
* бути файлів **Podfile** (CocoaPods) **поза** каталогом **Pods** (тобто не використовувати **Podfile**
|
|
19
|
+
* у вихідному iOS-шарі; присутній **Podfile** — порушення). Якщо **немає** `ios/` — вимогу iOS у цьому
|
|
20
|
+
* прогоні пропущено.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from 'node:fs'
|
|
23
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
24
|
+
import { join, relative } from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
27
|
+
|
|
28
|
+
/** Мінімальна допустима мажорна версія Capacitor (capacitor.mdc) */
|
|
29
|
+
const MIN_CAPACITOR_MAJOR = 8
|
|
30
|
+
|
|
31
|
+
/** @type {Set<string>} */
|
|
32
|
+
const IGNORED_DIRS_FOR_PACKAGE_JSON = new Set([
|
|
33
|
+
'node_modules',
|
|
34
|
+
'.git',
|
|
35
|
+
'dist',
|
|
36
|
+
'coverage',
|
|
37
|
+
'Pods',
|
|
38
|
+
'.turbo',
|
|
39
|
+
'.next',
|
|
40
|
+
'build'
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/** `||` у діапазоні npm-версій */
|
|
44
|
+
const NPM_OR_PARTS_RE = /\s*\|\|\s*/
|
|
45
|
+
|
|
46
|
+
/** `a - b` (діапазон діапазонів) */
|
|
47
|
+
const NPM_HYPHEN_RANGE_RE = /^(.+?)\s+-\s+(.+)$/
|
|
48
|
+
|
|
49
|
+
const FIRST_VERSION_NUM_RE = /^(?:v)?(\d+)/i
|
|
50
|
+
|
|
51
|
+
const PREFIX_GEQ_RE = /^>=\s*/u
|
|
52
|
+
const PREFIX_GT_RE = /^>\s*/u
|
|
53
|
+
const STRIP_CARET_TILDE_EQ_RE = /^[=^~]+\s*/u
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Мінімальний **major** (нижня межа) для **однієї** OR-частини діапазону npm (без `||` всередині).
|
|
57
|
+
* @param {string} segment одна частина після `||` або весь рядок
|
|
58
|
+
* @returns {number | null} null, якщо **`*` / `x` / `latest`**, або **major** **нижньої** межі
|
|
59
|
+
*/
|
|
60
|
+
export function capacitorSegmentMinMajor(segment) {
|
|
61
|
+
if (typeof segment !== 'string') {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
const s0 = segment.trim()
|
|
65
|
+
if (!s0) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const low = s0.toLowerCase()
|
|
69
|
+
if (s0 === '*' || low === 'x' || low === 'latest') {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
if (s0.startsWith('<') || s0.startsWith('<=')) {
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
if (s0.startsWith('>') && !s0.startsWith('>=')) {
|
|
76
|
+
return firstVersionMajorFromNpmValue(s0.replace(PREFIX_GT_RE, ''))
|
|
77
|
+
}
|
|
78
|
+
const rangeHyphen = s0.match(NPM_HYPHEN_RANGE_RE)
|
|
79
|
+
if (rangeHyphen) {
|
|
80
|
+
return firstVersionMajorFromNpmValue(rangeHyphen[1].trim())
|
|
81
|
+
}
|
|
82
|
+
if (s0.startsWith('^') || s0.startsWith('~') || s0.startsWith('=')) {
|
|
83
|
+
return firstVersionMajorFromNpmValue(s0.replace(STRIP_CARET_TILDE_EQ_RE, ''))
|
|
84
|
+
}
|
|
85
|
+
if (s0.startsWith('>=')) {
|
|
86
|
+
return firstVersionMajorFromNpmValue(s0.replace(PREFIX_GEQ_RE, ''))
|
|
87
|
+
}
|
|
88
|
+
return firstVersionMajorFromNpmValue(s0)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Витягує **major** з першого числа у вигляді **X** або **X.Y** / **X.Y.Z** (опційно **v**).
|
|
93
|
+
* @param {string} t рядок ділянки **версії** (без префікса **операторів**)
|
|
94
|
+
* @returns {number | null} перше **ціле** (major) або **null**
|
|
95
|
+
*/
|
|
96
|
+
function firstVersionMajorFromNpmValue(t) {
|
|
97
|
+
const s = t.trim()
|
|
98
|
+
if (!s) {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
const m = s.match(FIRST_VERSION_NUM_RE)
|
|
102
|
+
if (!m) {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
return Number.parseInt(m[1], 10)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Мінімальна можлива (нижня) **major**-версія для повного діапазону npm, у т. ч. з `||`.
|
|
110
|
+
* @param {string} versionRange повне поле `package.json` для **@capacitor/core**
|
|
111
|
+
* @returns {number | null} **null** якщо **`*` / latest** в одній з частин
|
|
112
|
+
*/
|
|
113
|
+
export function capacitorVersionRangeMinMajor(versionRange) {
|
|
114
|
+
if (typeof versionRange !== 'string') {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
const parts = versionRange.split(NPM_OR_PARTS_RE)
|
|
118
|
+
let overallMin = /** @type {number | null} */ (null)
|
|
119
|
+
for (const p of parts) {
|
|
120
|
+
const m = capacitorSegmentMinMajor(p)
|
|
121
|
+
if (m === null) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (overallMin === null || m < overallMin) {
|
|
125
|
+
overallMin = m
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return overallMin
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} versionRange рядок **версії** з `package.json`
|
|
133
|
+
* @param {number} [min] мінімальний **major** (за замовчуванням `MIN_CAPACITOR_MAJOR`)
|
|
134
|
+
* @returns {boolean} **true**, якщо нижня межа **≥** **min**
|
|
135
|
+
*/
|
|
136
|
+
export function isCapacitorCoreVersionAtLeast8(versionRange, min = MIN_CAPACITOR_MAJOR) {
|
|
137
|
+
const low = capacitorVersionRangeMinMajor(versionRange)
|
|
138
|
+
if (low === null) {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
return low >= min
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {(m: string) => void} fail друк помилки
|
|
146
|
+
* @param {(m: string) => void} pass друк успіху
|
|
147
|
+
* @param {string} rel відносний **posix**-шлях `package.json`
|
|
148
|
+
* @param {string} range поле `version` для **@capacitor/core**
|
|
149
|
+
* @returns {void}
|
|
150
|
+
*/
|
|
151
|
+
function reportOneCapacitorCoreRange(fail, pass, rel, range) {
|
|
152
|
+
if (isCapacitorCoreVersionAtLeast8(range)) {
|
|
153
|
+
pass(`«${rel}»: @capacitor/core — діапазон сумісний з ${MIN_CAPACITOR_MAJOR}+`)
|
|
154
|
+
} else {
|
|
155
|
+
fail(
|
|
156
|
+
`«${rel}»: @capacitor/core «${range}» — мінімальна допустима мажорна версія Capacitor ${MIN_CAPACITOR_MAJOR} (capacitor.mdc). Вкажи, наприклад, ^${MIN_CAPACITOR_MAJOR}.0.0`
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} absPath шлях до `package.json`
|
|
163
|
+
* @param {string} root корінь репозиторію
|
|
164
|
+
* @param {{ byPath: Map<string, string>, anyCapacitor: boolean }} out накопичувач **byPath** і **anyCapacitor**
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
export async function recordCapacitorFromOnePackageJson(absPath, root, out) {
|
|
168
|
+
let raw
|
|
169
|
+
try {
|
|
170
|
+
raw = await readFile(absPath, 'utf8')
|
|
171
|
+
} catch {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
let pkg
|
|
175
|
+
try {
|
|
176
|
+
pkg = JSON.parse(raw)
|
|
177
|
+
} catch {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const rel = (relative(root, absPath) || absPath).replaceAll('\\', '/')
|
|
181
|
+
for (const block of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
182
|
+
const rec = pkg?.[block]
|
|
183
|
+
if (rec !== null && rec !== undefined && typeof rec === 'object' && !Array.isArray(rec)) {
|
|
184
|
+
const obj = /** @type {Record<string, unknown>} */ (rec)
|
|
185
|
+
for (const [name, val] of Object.entries(obj)) {
|
|
186
|
+
if (typeof name === 'string' && name.startsWith('@capacitor/')) {
|
|
187
|
+
out.anyCapacitor = true
|
|
188
|
+
}
|
|
189
|
+
if (name === '@capacitor/core' && typeof val === 'string' && val !== '') {
|
|
190
|
+
out.byPath.set(rel, val)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Зчитує всі `package.json` з дерева, накопичує `byPath` і `anyCapacitor`.
|
|
199
|
+
* @param {string} root корінь репозиторію
|
|
200
|
+
* @param {{ byPath: Map<string, string>, anyCapacitor: boolean }} out накопичувач
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
*/
|
|
203
|
+
export async function collectCapacitorDataFromAllPackageJson(root, out) {
|
|
204
|
+
out.anyCapacitor = false
|
|
205
|
+
if (out.byPath) {
|
|
206
|
+
out.byPath.clear()
|
|
207
|
+
} else {
|
|
208
|
+
out.byPath = new Map()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} dir абсолютний **каталог** для `readdir`
|
|
213
|
+
* @returns {Promise<void>}
|
|
214
|
+
*/
|
|
215
|
+
async function walk(dir) {
|
|
216
|
+
let entries
|
|
217
|
+
try {
|
|
218
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
219
|
+
} catch {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const absPath = join(dir, entry.name)
|
|
224
|
+
if (entry.isDirectory() && !IGNORED_DIRS_FOR_PACKAGE_JSON.has(entry.name)) {
|
|
225
|
+
await walk(absPath)
|
|
226
|
+
} else if (entry.isFile() && entry.name === 'package.json') {
|
|
227
|
+
await recordCapacitorFromOnePackageJson(absPath, root, out)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await walk(root)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {string} root абсолютний або **cwd**-відносний **корінь** репозиторію
|
|
237
|
+
* @returns {boolean} **true** якщо `capacitor.config.{json,ts,mjs}` існує
|
|
238
|
+
*/
|
|
239
|
+
export function hasCapacitorConfigInRoot(root) {
|
|
240
|
+
return (
|
|
241
|
+
existsSync(join(root, 'capacitor.config.json')) ||
|
|
242
|
+
existsSync(join(root, 'capacitor.config.ts')) ||
|
|
243
|
+
existsSync(join(root, 'capacitor.config.mjs'))
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Чи варто застосовувати правила: конфіг **або** **@capacitor/** у залежностях.
|
|
249
|
+
* @param {string} root корінь
|
|
250
|
+
* @param {boolean} anyCapacitor чи зустрілось **@capacitor/** у **package.json**
|
|
251
|
+
* @returns {boolean} **true** якщо застосовуємо **check capacitor**
|
|
252
|
+
*/
|
|
253
|
+
export function isCapacitorRelevantForCheck(root, anyCapacitor) {
|
|
254
|
+
return hasCapacitorConfigInRoot(root) || anyCapacitor
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Рекурсивно шукає `Podfile` у **ios/**, **не** заходячи в **Pods** (кеш CocoaPods) і типові build-каталоги.
|
|
259
|
+
* @param {string} root корінь репозиторію
|
|
260
|
+
* @param {string} dir абсолютний каталог
|
|
261
|
+
* @param {(rel: string) => void} onPodfileRelative **callback** з **posix**-шляхом `Podfile` від **root**
|
|
262
|
+
* @returns {Promise<boolean>} **true** — знайдено **хоча б один** `Podfile`
|
|
263
|
+
*/
|
|
264
|
+
export async function walkIosForPodfileSkipPods(root, dir, onPodfileRelative) {
|
|
265
|
+
let entries
|
|
266
|
+
try {
|
|
267
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
268
|
+
} catch {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
for (const e of entries) {
|
|
272
|
+
if (e.name !== 'Pods' && e.name !== 'build' && e.name !== 'DerivedData') {
|
|
273
|
+
const abs = join(dir, e.name)
|
|
274
|
+
if (e.isFile() && e.name === 'Podfile') {
|
|
275
|
+
onPodfileRelative((relative(root, abs) || abs).replaceAll('\\', '/'))
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
if (e.isDirectory()) {
|
|
279
|
+
const found = await walkIosForPodfileSkipPods(root, abs, onPodfileRelative)
|
|
280
|
+
if (found) {
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {string} root корінь
|
|
291
|
+
* @returns {Promise<string | null>} **relative**-шлях `Podfile` (або **null**)
|
|
292
|
+
*/
|
|
293
|
+
export async function findFirstPodfileUnderIosExcludingPods(root) {
|
|
294
|
+
const iosDir = join(root, 'ios')
|
|
295
|
+
if (!existsSync(iosDir)) {
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
let first = /** @type {string | null} */ (null)
|
|
299
|
+
await walkIosForPodfileSkipPods(root, iosDir, rel => {
|
|
300
|
+
if (first === null || rel.length < first.length) {
|
|
301
|
+
first = rel
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
return first
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @returns {Promise<number>} **0** — **ok**; **1** — **fail** (див. **capacitor.mdc**)
|
|
309
|
+
*/
|
|
310
|
+
export async function check() {
|
|
311
|
+
const reporter = createCheckReporter()
|
|
312
|
+
const { pass, fail, getExitCode } = reporter
|
|
313
|
+
const root = process.cwd()
|
|
314
|
+
|
|
315
|
+
const acc = { byPath: new Map(), anyCapacitor: false }
|
|
316
|
+
await collectCapacitorDataFromAllPackageJson(root, acc)
|
|
317
|
+
const { byPath, anyCapacitor } = acc
|
|
318
|
+
|
|
319
|
+
if (!isCapacitorRelevantForCheck(root, anyCapacitor)) {
|
|
320
|
+
pass('Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено')
|
|
321
|
+
return getExitCode()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
pass('Проєкт з ознаками Capacitor — застосовую capacitor.mdc')
|
|
325
|
+
|
|
326
|
+
if (byPath.size === 0) {
|
|
327
|
+
fail(
|
|
328
|
+
`додай залежність @capacitor/core з діапазоном ^${MIN_CAPACITOR_MAJOR}.0.0 (або іншим, сумісним лише з ${MIN_CAPACITOR_MAJOR}+) у package.json (capacitor.mdc)`
|
|
329
|
+
)
|
|
330
|
+
} else {
|
|
331
|
+
for (const [rel, range] of byPath) {
|
|
332
|
+
reportOneCapacitorCoreRange(fail, pass, rel, range)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const podfileRel = await findFirstPodfileUnderIosExcludingPods(root)
|
|
337
|
+
if (podfileRel === null) {
|
|
338
|
+
if (existsSync(join(root, 'ios'))) {
|
|
339
|
+
pass('ios/ без Podfile поза Pods/ (лише SPM, capacitor.mdc)')
|
|
340
|
+
} else {
|
|
341
|
+
pass('каталог ios/ не знайдено — вимогу iOS/SPM пропущено')
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
fail(
|
|
345
|
+
`iOS: знайдено Podfile «${podfileRel}» — для Capacitor використовуй лише SPM, без CocoaPods (прибери Podfile, capacitor.mdc)`
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return getExitCode()
|
|
350
|
+
}
|
package/scripts/check-docker.mjs
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* - backend: `mirror.gcr.io/library/alpine:*`
|
|
10
10
|
* - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
|
|
11
11
|
*
|
|
12
|
+
* Якщо в Dockerfile є крок `bun install` і це не frontend-образ (фінальний stage — alpine),
|
|
13
|
+
* то очікується компіляція в один бінарник через `bun build --compile` у build stage, а у
|
|
14
|
+
* фінальному stage не повинно залишатися build tooling (Bun/Node).
|
|
15
|
+
*
|
|
12
16
|
* Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
|
|
13
17
|
* runtime (alpine), nginx або openresty.
|
|
14
18
|
*
|
|
@@ -25,6 +29,10 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
|
25
29
|
import { walkDir } from './utils/walkDir.mjs'
|
|
26
30
|
|
|
27
31
|
const NEWLINE_RE = /\r?\n/
|
|
32
|
+
const BUN_INSTALL_RE = /\bbun\s+(?:install|i)\b/iu
|
|
33
|
+
const BUN_BUILD_COMPILE_RE = /\bbun\s+build\b[^\n]*\s--compile\b/iu
|
|
34
|
+
const BUN_WORD_RE = /\bbun\b/iu
|
|
35
|
+
const USER_LINE_RE = /^\s*USER\s+([^\s#]+)/iu
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* @typedef {{
|
|
@@ -80,6 +88,28 @@ const RUNTIME_IMAGES = /** @type {const} */ ([
|
|
|
80
88
|
'mirror.gcr.io/openresty/openresty'
|
|
81
89
|
])
|
|
82
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Розбиває Dockerfile на stages за `FROM` (порожній масив, якщо FROM немає).
|
|
93
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
94
|
+
* @returns {Array<{ from: FromStage, stageContent: string }>} stages з `FROM` і вмістом stage
|
|
95
|
+
*/
|
|
96
|
+
export function splitDockerfileStages(fileContent) {
|
|
97
|
+
const stages = parseFromStages(fileContent)
|
|
98
|
+
if (stages.length === 0) return []
|
|
99
|
+
|
|
100
|
+
const lines = fileContent.split(NEWLINE_RE)
|
|
101
|
+
/** @type {Array<{ from: FromStage, stageContent: string }>} */
|
|
102
|
+
const out = []
|
|
103
|
+
|
|
104
|
+
for (const [idx, from] of stages.entries()) {
|
|
105
|
+
const startLineIdx = Math.max(0, from.line - 1)
|
|
106
|
+
const next = stages[idx + 1]
|
|
107
|
+
const endLineExclusive = next ? Math.max(0, next.line - 1) : lines.length
|
|
108
|
+
out.push({ from, stageContent: lines.slice(startLineIdx, endLineExclusive).join('\n') })
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
/**
|
|
84
114
|
* Перевіряє базові вимоги до структури Dockerfile:
|
|
85
115
|
* - multistage: мінімум 2 FROM
|
|
@@ -107,6 +137,88 @@ export function getMultistageAndRuntimeHint(fileContent) {
|
|
|
107
137
|
return null
|
|
108
138
|
}
|
|
109
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Перевіряє вимогу "компіляція в бінарник" для bun-проєктів на backend runtime.
|
|
142
|
+
*
|
|
143
|
+
* Тригер:
|
|
144
|
+
* - у Dockerfile є крок `bun install` (або `bun i`);
|
|
145
|
+
* - фінальний FROM — `mirror.gcr.io/library/alpine:*` (тобто не nginx/openresty frontend).
|
|
146
|
+
*
|
|
147
|
+
* Очікування:
|
|
148
|
+
* - у build stage є `bun build --compile`;
|
|
149
|
+
* - у фінальному stage немає викликів `bun` (залишків build tooling).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
152
|
+
* @returns {string | null} повідомлення помилки або null
|
|
153
|
+
*/
|
|
154
|
+
export function getBunCompileHint(fileContent) {
|
|
155
|
+
const stages = splitDockerfileStages(fileContent)
|
|
156
|
+
if (stages.length === 0) return null
|
|
157
|
+
|
|
158
|
+
const last = stages.at(-1)
|
|
159
|
+
const lastImage = (last?.from.image || '').split('@')[0] || ''
|
|
160
|
+
const lastLower = lastImage.toLowerCase()
|
|
161
|
+
|
|
162
|
+
const hasBunInstall = BUN_INSTALL_RE.test(fileContent)
|
|
163
|
+
const isFinalAlpine = lastLower.startsWith('mirror.gcr.io/library/alpine:')
|
|
164
|
+
const isFinalFrontend =
|
|
165
|
+
lastLower.startsWith('mirror.gcr.io/library/nginx:') || lastLower.startsWith('mirror.gcr.io/openresty/openresty:')
|
|
166
|
+
|
|
167
|
+
if (!hasBunInstall) return null
|
|
168
|
+
if (!isFinalAlpine) return null
|
|
169
|
+
if (isFinalFrontend) return null
|
|
170
|
+
|
|
171
|
+
const hasCompile = BUN_BUILD_COMPILE_RE.test(fileContent)
|
|
172
|
+
if (!hasCompile) {
|
|
173
|
+
return 'є `bun install`, але немає `bun build --compile` — для backend-образу потрібно компілювати застосунок у бінарник (docker.mdc: компіляція)'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lastStageContent = last?.stageContent || ''
|
|
177
|
+
if (BUN_WORD_RE.test(lastStageContent)) {
|
|
178
|
+
return 'фінальний stage не має містити Bun (RUN/CMD/ENTRYPOINT з `bun`) — залиш у runtime stage лише бінарник і runtime libs (docker.mdc: компіляція)'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Перевіряє вимогу "non-root" у фінальному runtime stage (docker.mdc).
|
|
186
|
+
*
|
|
187
|
+
* Очікування:
|
|
188
|
+
* - у фінальному stage має бути інструкція `USER <name|uid>`;
|
|
189
|
+
* - користувач не має бути `root` і не має бути `0`.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
192
|
+
* @returns {string | null} повідомлення помилки або null
|
|
193
|
+
*/
|
|
194
|
+
export function getNonRootRuntimeHint(fileContent) {
|
|
195
|
+
const stages = splitDockerfileStages(fileContent)
|
|
196
|
+
if (stages.length === 0) return null
|
|
197
|
+
|
|
198
|
+
const last = stages.at(-1)
|
|
199
|
+
const lastStageContent = last?.stageContent || ''
|
|
200
|
+
|
|
201
|
+
/** @type {string | null} */
|
|
202
|
+
let lastUserToken = null
|
|
203
|
+
for (const line of lastStageContent.split(NEWLINE_RE)) {
|
|
204
|
+
const m = line.match(USER_LINE_RE)
|
|
205
|
+
if (m) {
|
|
206
|
+
lastUserToken = (m[1] || '').replaceAll('"', '').replaceAll("'", '')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!lastUserToken) {
|
|
211
|
+
return 'у фінальному stage має бути `USER <non-root>` (наприклад `USER app`) — принцип non-root (docker.mdc: не превілейований образ)'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const normalized = lastUserToken.trim().toLowerCase()
|
|
215
|
+
if (normalized === 'root' || normalized === '0') {
|
|
216
|
+
return `фінальний stage має запускатися не від root: зараз \`USER ${lastUserToken}\` (docker.mdc: не превілейований образ)`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
110
222
|
/**
|
|
111
223
|
* Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
|
|
112
224
|
* @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
|
|
@@ -138,6 +250,16 @@ export async function check() {
|
|
|
138
250
|
fail(`${rel} (multistage): ${multistageHint}`)
|
|
139
251
|
}
|
|
140
252
|
|
|
253
|
+
const compileHint = getBunCompileHint(content)
|
|
254
|
+
if (compileHint) {
|
|
255
|
+
fail(`${rel} (compile): ${compileHint}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const nonRootHint = getNonRootRuntimeHint(content)
|
|
259
|
+
if (nonRootHint) {
|
|
260
|
+
fail(`${rel} (non-root): ${nonRootHint}`)
|
|
261
|
+
}
|
|
262
|
+
|
|
141
263
|
const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
|
|
142
264
|
const tail = (stdout + stderr).trim()
|
|
143
265
|
if (ok) {
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
*
|
|
24
24
|
* **`kind: Ingress`** заборонено (потрібен перехід на Gateway API).
|
|
25
25
|
* **`apiVersion: autoscaling/v1`** заборонено (мігруй **HorizontalPodAutoscaler** на **`autoscaling/v2`**).
|
|
26
|
+
* Рядок **`apiVersion: batch/v1beta1`** (CronJob, Job) **автоматично** переписується на **`apiVersion: batch/v1`**
|
|
27
|
+
* (окрім рядків-коментарів і рядків, де після значення йде наприклад `# …`).
|
|
26
28
|
*
|
|
27
29
|
* Файли під **`k8s`**, де всі YAML-документи — лише **`kind: BackendConfig`**, **видаляються** автоматично.
|
|
28
30
|
* Якщо **BackendConfig** змішано з іншими ресурсами в одному файлі — перевірка завершується помилкою (розділи маніфести).
|
|
@@ -93,7 +95,7 @@
|
|
|
93
95
|
* не з’явиться `Deployment` (k8s.mdc).
|
|
94
96
|
*/
|
|
95
97
|
import { existsSync } from 'node:fs'
|
|
96
|
-
import { readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
98
|
+
import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
97
99
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
98
100
|
|
|
99
101
|
import { parseAllDocuments } from 'yaml'
|
|
@@ -1561,6 +1563,73 @@ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
|
|
|
1561
1563
|
}
|
|
1562
1564
|
}
|
|
1563
1565
|
|
|
1566
|
+
/**
|
|
1567
|
+
* Один рядок YAML: якщо це `apiVersion` зі значенням **`batch/v1beta1`**, повертає той самий рядок із **`batch/v1`**
|
|
1568
|
+
* (з тими самими відступами/пробілами після `apiVersion:`, крім випадків з лапками — нормалізується до `apiVersion: batch/v1`).
|
|
1569
|
+
* Рядки, що після trim починаються з `#`, не змінюються.
|
|
1570
|
+
* @param {string} line
|
|
1571
|
+
* @returns {string}
|
|
1572
|
+
*/
|
|
1573
|
+
function rewriteLineBatchV1beta1ApiVersion(line) {
|
|
1574
|
+
const t = line.trimStart()
|
|
1575
|
+
if (t.startsWith('#')) {
|
|
1576
|
+
return line
|
|
1577
|
+
}
|
|
1578
|
+
const m = line.match(/^(\s*apiVersion:\s*)(?:"|')?batch\/v1beta1(?:"|')?(\s*)$/)
|
|
1579
|
+
if (m) {
|
|
1580
|
+
return `${m[1]}batch/v1${m[2]}`
|
|
1581
|
+
}
|
|
1582
|
+
return line
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* У повному тексті YAML замінює всі **цілі** рядки `apiVersion: batch/v1beta1` (за потреби в лапках) на `apiVersion: batch/v1`.
|
|
1587
|
+
* Зберігає **CRLF** / **LF** як у вихідному рядку.
|
|
1588
|
+
* @param {string} raw вміст файлу
|
|
1589
|
+
* @returns {{ changed: boolean, content: string }}
|
|
1590
|
+
*/
|
|
1591
|
+
export function replaceBatchV1beta1ApiVersionInYamlText(raw) {
|
|
1592
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n'
|
|
1593
|
+
const lines = raw.split(/\r?\n/)
|
|
1594
|
+
let changed = false
|
|
1595
|
+
const out = lines.map(line => {
|
|
1596
|
+
const n = rewriteLineBatchV1beta1ApiVersion(line)
|
|
1597
|
+
if (n !== line) {
|
|
1598
|
+
changed = true
|
|
1599
|
+
}
|
|
1600
|
+
return n
|
|
1601
|
+
})
|
|
1602
|
+
if (!changed) {
|
|
1603
|
+
return { changed: false, content: raw }
|
|
1604
|
+
}
|
|
1605
|
+
return { changed: true, content: out.join(eol) }
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Проходить усі `*.yaml` / `*.yml` під сегментом `k8s` і на диску застосовує **`replaceBatchV1beta1ApiVersionInYamlText`**.
|
|
1610
|
+
* @param {string} root корінь репозиторію
|
|
1611
|
+
* @param {(msg: string) => void} fail
|
|
1612
|
+
* @param {(msg: string) => void} pass
|
|
1613
|
+
* @returns {Promise<void>}
|
|
1614
|
+
*/
|
|
1615
|
+
async function rewriteBatchV1beta1ApiVersionInK8sYamlFiles(root, fail, pass) {
|
|
1616
|
+
const yamlFiles = await findK8sYamlFiles(root)
|
|
1617
|
+
for (const abs of yamlFiles) {
|
|
1618
|
+
const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
|
|
1619
|
+
try {
|
|
1620
|
+
const raw = await readFile(abs, 'utf8')
|
|
1621
|
+
const { changed, content } = replaceBatchV1beta1ApiVersionInYamlText(raw)
|
|
1622
|
+
if (changed) {
|
|
1623
|
+
await writeFile(abs, content, 'utf8')
|
|
1624
|
+
pass(`${rel}: оновлено apiVersion batch/v1beta1 → batch/v1 (k8s.mdc)`)
|
|
1625
|
+
}
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1628
|
+
fail(`${rel}: не вдалося прочитати/записати при заміні batch/v1beta1 → batch/v1 (${msg})`)
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1564
1633
|
/**
|
|
1565
1634
|
* Прибирає BOM і ділить на рядки.
|
|
1566
1635
|
* @param {string} content вміст файлу
|
|
@@ -4819,6 +4888,8 @@ export async function check() {
|
|
|
4819
4888
|
|
|
4820
4889
|
const root = process.cwd()
|
|
4821
4890
|
|
|
4891
|
+
await rewriteBatchV1beta1ApiVersionInK8sYamlFiles(root, fail, pass)
|
|
4892
|
+
|
|
4822
4893
|
await removeBackendConfigOnlyK8sYamlFiles(root, fail, pass)
|
|
4823
4894
|
|
|
4824
4895
|
const yamlFiles = await findK8sYamlFiles(root)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє вимоги правила php.mdc для PHP-проєктів.
|
|
3
|
+
*
|
|
4
|
+
* Очікування:
|
|
5
|
+
* - у корені є `composer.json`;
|
|
6
|
+
* - у `package.json` є скрипт `lint-php` (рекомендовано делегувати в `run-php.mjs`);
|
|
7
|
+
* - у `.github/workflows/lint-php.yml` є крок `run: bun run lint-php` (для Bun-репозиторіїв).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { readFile } from 'node:fs/promises'
|
|
11
|
+
|
|
12
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
13
|
+
import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Перевіряє наявність `composer.json`.
|
|
17
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
18
|
+
*/
|
|
19
|
+
function checkComposer(reporter) {
|
|
20
|
+
const { pass, fail } = reporter
|
|
21
|
+
if (existsSync('composer.json')) {
|
|
22
|
+
pass('composer.json існує')
|
|
23
|
+
} else {
|
|
24
|
+
fail('composer.json не знайдено в корені — додай (php.mdc)')
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Перевіряє кореневий `package.json` на скрипт `lint-php`.
|
|
30
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
31
|
+
*/
|
|
32
|
+
async function checkPackageJson(reporter) {
|
|
33
|
+
const { pass, fail } = reporter
|
|
34
|
+
if (!existsSync('package.json')) {
|
|
35
|
+
fail('package.json не знайдено в корені — додай (php.mdc)')
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const pkg = JSON.parse(await readFile('package.json', 'utf8'))
|
|
39
|
+
const lintPhp = pkg.scripts?.['lint-php']
|
|
40
|
+
if (lintPhp) {
|
|
41
|
+
pass('package.json містить скрипт lint-php')
|
|
42
|
+
} else {
|
|
43
|
+
fail('package.json: додай скрипт "lint-php" (php.mdc)')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Перевіряє workflow `lint-php.yml`.
|
|
49
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
50
|
+
*/
|
|
51
|
+
async function checkWorkflow(reporter) {
|
|
52
|
+
const { pass, fail } = reporter
|
|
53
|
+
const wfPath = '.github/workflows/lint-php.yml'
|
|
54
|
+
if (!existsSync(wfPath)) {
|
|
55
|
+
fail(`${wfPath} не існує — створи згідно php.mdc`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const content = await readFile(wfPath, 'utf8')
|
|
59
|
+
pass('lint-php.yml існує')
|
|
60
|
+
const root = parseWorkflowYaml(content)
|
|
61
|
+
const ok = root ? anyRunStepIncludes(root, 'bun run lint-php') : content.includes('bun run lint-php')
|
|
62
|
+
if (ok) {
|
|
63
|
+
pass('lint-php.yml викликає bun run lint-php')
|
|
64
|
+
} else {
|
|
65
|
+
fail('lint-php.yml має містити крок run: bun run lint-php (php.mdc)')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Перевіряє відповідність проєкту правилам php.mdc.
|
|
71
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
72
|
+
*/
|
|
73
|
+
export async function check() {
|
|
74
|
+
const reporter = createCheckReporter()
|
|
75
|
+
checkComposer(reporter)
|
|
76
|
+
await checkPackageJson(reporter)
|
|
77
|
+
await checkWorkflow(reporter)
|
|
78
|
+
return reporter.getExitCode()
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Запуск `lint-php` за правилом php.mdc: `composer audit` і, якщо встановлені пакети, запуск
|
|
3
|
+
* PHPStan, Psalm, PHP-CS-Fixer (dry-run) та PHPCS зі стандартом Security.
|
|
4
|
+
*
|
|
5
|
+
* Скрипт не вимагає, щоб усі інструменти були встановлені: якщо відповідного файла
|
|
6
|
+
* `vendor/bin/<tool>` немає, крок пропускається з повідомленням. Але якщо в корені є
|
|
7
|
+
* `composer.json`, то `composer` має бути доступний у PATH (інакше це помилка).
|
|
8
|
+
*
|
|
9
|
+
* Якщо `composer.json` у корені відсутній — вихід 0 без запуску інструментів.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { existsSync, statSync } from 'node:fs'
|
|
13
|
+
import { join, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { isRunAsCli } from './cli-entry.mjs'
|
|
16
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
17
|
+
import { resolveCmd } from './utils/resolve-cmd.mjs'
|
|
18
|
+
|
|
19
|
+
const PHPCS_CODE_DIR_CANDIDATES = ['app', 'src', 'lib', 'public', 'www']
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Каталоги коду для PHPCS (якщо типових директорій немає — перевіряємо `.`).
|
|
23
|
+
* @param {string} root корінь репозиторію
|
|
24
|
+
* @returns {string[]} перелік шляхів (відносно root), які варто передати у `phpcs`
|
|
25
|
+
*/
|
|
26
|
+
export function getPhpcsCodePaths(root) {
|
|
27
|
+
const out = []
|
|
28
|
+
for (const d of PHPCS_CODE_DIR_CANDIDATES) {
|
|
29
|
+
const p = join(root, d)
|
|
30
|
+
if (existsSync(p) && statSync(p).isDirectory()) out.push(d)
|
|
31
|
+
}
|
|
32
|
+
return out.length > 0 ? out : ['.']
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} root корінь репозиторію
|
|
37
|
+
* @param {string} name імʼя файла у vendor/bin
|
|
38
|
+
* @returns {string | null} абсолютний шлях або null, якщо файла немає
|
|
39
|
+
*/
|
|
40
|
+
function vendorBin(root, name) {
|
|
41
|
+
const p = resolve(root, 'vendor', 'bin', name)
|
|
42
|
+
return existsSync(p) ? p : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} label назва кроку для повідомлень
|
|
47
|
+
* @param {string} abs абсолютний шлях до CLI
|
|
48
|
+
* @param {string[]} args аргументи
|
|
49
|
+
* @param {(msg: string) => void} pass callback pass
|
|
50
|
+
* @param {(msg: string) => void} fail callback fail
|
|
51
|
+
* @returns {boolean} true якщо OK
|
|
52
|
+
*/
|
|
53
|
+
function runTool(label, abs, args, pass, fail) {
|
|
54
|
+
const r = spawnSync(abs, args, { stdio: 'inherit', shell: false })
|
|
55
|
+
if (r.status === 0) {
|
|
56
|
+
pass(`lint-php: ${label} — OK`)
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
const code = typeof r.status === 'number' ? r.status : 1
|
|
60
|
+
fail(`lint-php: ${label} — помилка (код ${code}, php.mdc)`)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Запускає `lint-php`.
|
|
66
|
+
* @returns {number} 0 — OK, 1 — є помилки
|
|
67
|
+
*/
|
|
68
|
+
export function run() {
|
|
69
|
+
const reporter = createCheckReporter()
|
|
70
|
+
const { pass, fail } = reporter
|
|
71
|
+
|
|
72
|
+
const root = process.cwd()
|
|
73
|
+
if (!existsSync(join(root, 'composer.json'))) {
|
|
74
|
+
pass('lint-php: немає composer.json у корені — кроки PHP пропущено')
|
|
75
|
+
return reporter.getExitCode()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const composer = resolveCmd('composer')
|
|
79
|
+
if (!composer) {
|
|
80
|
+
fail('lint-php: `composer` не знайдено в PATH (потрібен при наявному composer.json, php.mdc)')
|
|
81
|
+
return reporter.getExitCode()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!runTool('composer audit', composer, ['audit', '--no-interaction'], pass, fail)) return reporter.getExitCode()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Запускає інструмент з `vendor/bin`, якщо він встановлений.
|
|
88
|
+
* @param {string} binName імʼя файла у vendor/bin
|
|
89
|
+
* @param {string} label назва кроку
|
|
90
|
+
* @param {string[]} args аргументи CLI
|
|
91
|
+
* @returns {boolean} true, якщо крок успішний або пропущений
|
|
92
|
+
*/
|
|
93
|
+
function runOptionalVendorTool(binName, label, args) {
|
|
94
|
+
const abs = vendorBin(root, binName)
|
|
95
|
+
if (!abs) {
|
|
96
|
+
pass(`lint-php: vendor/bin/${binName} — відсутній, крок пропущено`)
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
return runTool(label, abs, args, pass, fail)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!runOptionalVendorTool('php-cs-fixer', 'PHP-CS-Fixer (dry-run)', ['fix', '--dry-run', '--diff'])) {
|
|
103
|
+
return reporter.getExitCode()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const phpcsPaths = getPhpcsCodePaths(root)
|
|
107
|
+
if (
|
|
108
|
+
!runOptionalVendorTool('phpcs', 'phpcs (Security)', [
|
|
109
|
+
'--standard=Security',
|
|
110
|
+
'--ignore=*/vendor/*,*/node_modules/*,*/.git/*',
|
|
111
|
+
...phpcsPaths
|
|
112
|
+
])
|
|
113
|
+
) {
|
|
114
|
+
return reporter.getExitCode()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!runOptionalVendorTool('phpstan', 'PHPStan', ['analyse', '--no-progress'])) return reporter.getExitCode()
|
|
118
|
+
if (!runOptionalVendorTool('psalm', 'Psalm', ['--no-cache'])) return reporter.getExitCode()
|
|
119
|
+
|
|
120
|
+
return reporter.getExitCode()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isRunAsCli()) {
|
|
124
|
+
process.exitCode = run()
|
|
125
|
+
}
|