@nitra/cursor 1.8.131 → 1.8.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/auto-rules.md CHANGED
@@ -26,6 +26,8 @@ nginx-default-tpl - якщо присутній хоч один файл з пе
26
26
 
27
27
  npm-module - якщо в корені присутня директорія npm
28
28
 
29
+ php - якщо присутній хоч один php файл
30
+
29
31
  style-lint - якщо присутній хоч один vue або css файл
30
32
 
31
33
  text - завжди
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/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.132",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -30,6 +30,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
30
30
  'k8s',
31
31
  'nginx-default-tpl',
32
32
  'npm-module',
33
+ 'php',
33
34
  'style-lint',
34
35
  'text',
35
36
  'vue'
@@ -42,6 +43,7 @@ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
42
43
  const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
43
44
  const STYLE_RE = /\.(?:css|vue)$/iu
44
45
  const VUE_RE = /\.vue$/iu
46
+ const PHP_RE = /\.php$/iu
45
47
  const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
46
48
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
47
49
  const DEFAULT_DISABLED_LIST = Object.freeze([])
@@ -79,7 +81,7 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
79
81
  try {
80
82
  const parsed = JSON.parse(await readFile(absPath, 'utf8'))
81
83
  const deps = parsed?.dependencies
82
- if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.prototype.hasOwnProperty.call(deps, 'mssql')) {
84
+ if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
83
85
  found = true
84
86
  return
85
87
  }
@@ -120,6 +122,7 @@ function updateDirFacts(dirName, facts) {
120
122
  * hasDockerfile: boolean,
121
123
  * hasJsLikeSource: boolean,
122
124
  * hasNginxDefaultTplFile: boolean,
125
+ * hasPhpSource: boolean,
123
126
  * hasVueOrCssSource: boolean,
124
127
  * hasVueSource: boolean
125
128
  * }} facts агреговані факти
@@ -138,6 +141,9 @@ function updateFileFacts(fileName, relPath, facts) {
138
141
  if (VUE_RE.test(relPath)) {
139
142
  facts.hasVueSource = true
140
143
  }
144
+ if (PHP_RE.test(relPath)) {
145
+ facts.hasPhpSource = true
146
+ }
141
147
  if (STYLE_RE.test(relPath)) {
142
148
  facts.hasVueOrCssSource = true
143
149
  }
@@ -262,6 +268,7 @@ export function isMonorepoPackage(packageJson) {
262
268
  * hasK8sDir: boolean,
263
269
  * hasNginxDefaultTplFile: boolean,
264
270
  * hasTempoDir: boolean,
271
+ * hasPhpSource: boolean,
265
272
  * hasVueSource: boolean,
266
273
  * hasVueOrCssSource: boolean
267
274
  * }>} агреговані факти
@@ -275,6 +282,7 @@ export async function collectAutoRuleFacts(root) {
275
282
  hasK8sDir: false,
276
283
  hasNginxDefaultTplFile: false,
277
284
  hasTempoDir: false,
285
+ hasPhpSource: false,
278
286
  hasVueSource: false,
279
287
  hasVueOrCssSource: false
280
288
  }
@@ -387,6 +395,7 @@ export async function detectAutoRulesAndSkills({
387
395
  { enabled: facts.hasK8sDir, id: 'k8s' },
388
396
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
389
397
  { enabled: npmDirExists, id: 'npm-module' },
398
+ { enabled: facts.hasPhpSource, id: 'php' },
390
399
  { enabled: facts.hasVueOrCssSource, id: 'style-lint' }
391
400
  ]
392
401
  for (const item of autoRuleChecks) {
@@ -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) {
@@ -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
+ }