@nitra/cursor 1.8.130 → 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
@@ -18,12 +18,16 @@ js-lint - якщо присутній хоч один js файл
18
18
 
19
19
  js-pino - якщо присутній хоч один js файл, не в монорепо проекті з vue та директорії tempo
20
20
 
21
+ js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
22
+
21
23
  k8s - якщо присутня хоч одна директорія k8s
22
24
 
23
25
  nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
24
26
 
25
27
  npm-module - якщо в корені присутня директорія npm
26
28
 
29
+ php - якщо присутній хоч один php файл
30
+
27
31
  style-lint - якщо присутній хоч один vue або css файл
28
32
 
29
33
  text - завжди
package/mdc/docker.mdc CHANGED
@@ -14,10 +14,75 @@ alwaysApply: false
14
14
  Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
15
15
 
16
16
  - **backend**: `mirror.gcr.io/library/alpine:*`
17
- - **frontend**: `mirror.gcr.io/library/nginx:*`
17
+ - **frontend**: `mirror.gcr.io/library/nginx:*` aбо `mirror.gcr.io/openresty/openresty:*`
18
18
 
19
19
  Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
20
20
 
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`**.
@@ -0,0 +1,59 @@
1
+ ---
2
+ description: Використання mssql в nodejs
3
+ alwaysApply: true
4
+ version: '1.1'
5
+ ---
6
+
7
+ Якщо в проекті в будь-якому `package.json` в секції `dependencies` присутній пакет **`mssql`**,
8
+ то його версія повинна бути не менше **12.5.0**.
9
+
10
+ Потрібно використовувати connection pool (sql.ConnectionPool) як singleton, а НЕ створювати підключення на кожен запит.
11
+
12
+ tagged template треба викликати на request-обʼєкті цього пулу:
13
+ ```javascript
14
+ javascript// db.js
15
+ import sql from 'mssql';
16
+
17
+ let poolPromise;
18
+
19
+ export function getPool() {
20
+ if (!poolPromise) {
21
+ const pool = new sql.ConnectionPool(config);
22
+ poolPromise = pool.connect().catch(err => {
23
+ poolPromise = undefined; // дозволити повторну спробу
24
+ throw err;
25
+ });
26
+ }
27
+ return poolPromise;
28
+ }
29
+ ```
30
+
31
+ ```javascript
32
+ // usage
33
+ import { getPool } from './db.js';
34
+
35
+ const pool = await getPool();
36
+ const userId = 42;
37
+ const status = 'active';
38
+
39
+ // Tagged template на request — це працює
40
+ const result = await pool.request().query`
41
+ SELECT * FROM users
42
+ WHERE id = ${userId} AND status = ${status}
43
+ `;
44
+
45
+ ```
46
+
47
+ Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
48
+
49
+ Що НЕ робити
50
+ javascript// ❌ Це не tagged template — це конкатенація рядка перед викликом
51
+ ```javascript
52
+ await pool.request().query(`SELECT * FROM users WHERE id = ${userId}`);
53
+ // ↑ круглі дужки замість бекті́ків = звичайна інтерполяція = SQL injection
54
+ ```
55
+
56
+ Різниця між query\...`іquery(`...`)` — критична. Перше безпечне, друге — діра.
57
+ Потрібно використовувати перше. Потрібно шукати в коді друге і заміняти на перше.
58
+
59
+ Перевірка: `npx @nitra/cursor check js-mssql`.
package/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.130",
3
+ "version": "1.8.132",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,10 +25,12 @@ export const AUTO_RULE_ORDER = Object.freeze([
25
25
  'ga',
26
26
  'graphql',
27
27
  'js-lint',
28
+ 'js-mssql',
28
29
  'js-pino',
29
30
  'k8s',
30
31
  'nginx-default-tpl',
31
32
  'npm-module',
33
+ 'php',
32
34
  'style-lint',
33
35
  'text',
34
36
  'vue'
@@ -41,10 +43,59 @@ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
41
43
  const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
42
44
  const STYLE_RE = /\.(?:css|vue)$/iu
43
45
  const VUE_RE = /\.vue$/iu
46
+ const PHP_RE = /\.php$/iu
44
47
  const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
45
48
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
46
49
  const DEFAULT_DISABLED_LIST = Object.freeze([])
47
50
 
51
+ /**
52
+ * Чи є `mssql` у `dependencies` хоча б одного `package.json` у репозиторії.
53
+ * @param {string} root абсолютний шлях до кореня репозиторію
54
+ * @returns {Promise<boolean>} true, якщо знайдено `dependencies.mssql`
55
+ */
56
+ async function hasMssqlDependencyInAnyPackageJson(root) {
57
+ let found = false
58
+
59
+ /**
60
+ * Рекурсивний обхід каталогу з пропуском службових директорій.
61
+ * @param {string} dir абсолютний шлях каталогу
62
+ * @returns {Promise<void>}
63
+ */
64
+ async function walk(dir) {
65
+ if (found) return
66
+ let entries
67
+ try {
68
+ entries = await readdir(dir, { withFileTypes: true })
69
+ } catch {
70
+ return
71
+ }
72
+ for (const entry of entries) {
73
+ if (found) return
74
+ const absPath = join(dir, entry.name)
75
+ if (entry.isDirectory()) {
76
+ const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
77
+ if (!isIgnoredDir) {
78
+ await walk(absPath)
79
+ }
80
+ } else if (entry.isFile() && entry.name === 'package.json') {
81
+ try {
82
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
83
+ const deps = parsed?.dependencies
84
+ if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
85
+ found = true
86
+ return
87
+ }
88
+ } catch {
89
+ /* ігноруємо пошкоджені/недоступні package.json */
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ await walk(root)
96
+ return found
97
+ }
98
+
48
99
  /**
49
100
  * Фіксує ознаки, що залежать лише від імені підкаталогу.
50
101
  * @param {string} dirName імʼя каталогу
@@ -71,6 +122,7 @@ function updateDirFacts(dirName, facts) {
71
122
  * hasDockerfile: boolean,
72
123
  * hasJsLikeSource: boolean,
73
124
  * hasNginxDefaultTplFile: boolean,
125
+ * hasPhpSource: boolean,
74
126
  * hasVueOrCssSource: boolean,
75
127
  * hasVueSource: boolean
76
128
  * }} facts агреговані факти
@@ -89,6 +141,9 @@ function updateFileFacts(fileName, relPath, facts) {
89
141
  if (VUE_RE.test(relPath)) {
90
142
  facts.hasVueSource = true
91
143
  }
144
+ if (PHP_RE.test(relPath)) {
145
+ facts.hasPhpSource = true
146
+ }
92
147
  if (STYLE_RE.test(relPath)) {
93
148
  facts.hasVueOrCssSource = true
94
149
  }
@@ -213,6 +268,7 @@ export function isMonorepoPackage(packageJson) {
213
268
  * hasK8sDir: boolean,
214
269
  * hasNginxDefaultTplFile: boolean,
215
270
  * hasTempoDir: boolean,
271
+ * hasPhpSource: boolean,
216
272
  * hasVueSource: boolean,
217
273
  * hasVueOrCssSource: boolean
218
274
  * }>} агреговані факти
@@ -226,6 +282,7 @@ export async function collectAutoRuleFacts(root) {
226
282
  hasK8sDir: false,
227
283
  hasNginxDefaultTplFile: false,
228
284
  hasTempoDir: false,
285
+ hasPhpSource: false,
229
286
  hasVueSource: false,
230
287
  hasVueOrCssSource: false
231
288
  }
@@ -295,6 +352,7 @@ export async function detectAutoRulesAndSkills({
295
352
  )
296
353
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
297
354
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
355
+ const hasMssqlDependency = await hasMssqlDependencyInAnyPackageJson(root)
298
356
 
299
357
  /** @type {string[]} */
300
358
  const detectedRules = []
@@ -332,10 +390,12 @@ export async function detectAutoRulesAndSkills({
332
390
  { enabled: facts.hasGaWorkflowsDir, id: 'ga' },
333
391
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
334
392
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
393
+ { enabled: hasMssqlDependency, id: 'js-mssql' },
335
394
  { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
336
395
  { enabled: facts.hasK8sDir, id: 'k8s' },
337
396
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
338
397
  { enabled: npmDirExists, id: 'npm-module' },
398
+ { enabled: facts.hasPhpSource, id: 'php' },
339
399
  { enabled: facts.hasVueOrCssSource, id: 'style-lint' }
340
400
  ]
341
401
  for (const item of autoRuleChecks) {
@@ -7,10 +7,14 @@
7
7
  * Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
8
8
  * використовує мінімальний runtime-образ:
9
9
  * - backend: `mirror.gcr.io/library/alpine:*`
10
- * - frontend: `mirror.gcr.io/library/nginx:*`
10
+ * - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
11
+ *
12
+ * Якщо в Dockerfile є крок `bun install` і це не frontend-образ (фінальний stage — alpine),
13
+ * то очікується компіляція в один бінарник через `bun build --compile` у build stage, а у
14
+ * фінальному stage не повинно залишатися build tooling (Bun/Node).
11
15
  *
12
16
  * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
13
- * runtime (alpine) або nginx.
17
+ * runtime (alpine), nginx або openresty.
14
18
  *
15
19
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
16
20
  * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
@@ -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 {{
@@ -74,12 +82,38 @@ export function parseFromStages(fileContent) {
74
82
  return out
75
83
  }
76
84
 
77
- const RUNTIME_IMAGES = /** @type {const} */ (['mirror.gcr.io/library/alpine', 'mirror.gcr.io/library/nginx'])
85
+ const RUNTIME_IMAGES = /** @type {const} */ ([
86
+ 'mirror.gcr.io/library/alpine',
87
+ 'mirror.gcr.io/library/nginx',
88
+ 'mirror.gcr.io/openresty/openresty'
89
+ ])
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
+ }
78
112
 
79
113
  /**
80
114
  * Перевіряє базові вимоги до структури Dockerfile:
81
115
  * - multistage: мінімум 2 FROM
82
- * - фінальний FROM: alpine або nginx з mirror.gcr.io
116
+ * - фінальний FROM: alpine/nginx/openresty з mirror.gcr.io
83
117
  * @param {string} fileContent вміст Dockerfile/Containerfile
84
118
  * @returns {string | null} повідомлення помилки або null
85
119
  */
@@ -103,6 +137,88 @@ export function getMultistageAndRuntimeHint(fileContent) {
103
137
  return null
104
138
  }
105
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
+
106
222
  /**
107
223
  * Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
108
224
  * @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
@@ -134,6 +250,16 @@ export async function check() {
134
250
  fail(`${rel} (multistage): ${multistageHint}`)
135
251
  }
136
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
+
137
263
  const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
138
264
  const tail = (stdout + stderr).trim()
139
265
  if (ok) {