@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 +2 -0
- package/mdc/docker.mdc +65 -0
- package/mdc/php.mdc +132 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +10 -1
- package/scripts/check-docker.mjs +122 -0
- package/scripts/check-php.mjs +80 -0
- package/scripts/run-php.mjs +125 -0
package/bin/auto-rules.md
CHANGED
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
package/scripts/auto-rules.mjs
CHANGED
|
@@ -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.
|
|
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) {
|
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) {
|
|
@@ -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
|
+
}
|