@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.131",
3
+ "version": "1.8.135",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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.prototype.hasOwnProperty.call(deps, 'mssql')) {
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
+ }
@@ -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) {
@@ -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
+ }