@nitra/cursor 1.8.132 → 1.8.138

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
@@ -0,0 +1,38 @@
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, виняток Podfile
15
+
16
+ - **Правило за замовчуванням:** не залишай `Podfile` (поза `Pods/`) у вихідному iOS-шарі, **якщо** уся потрібна
17
+ iOS-функціональність (нативні плагіни/модулі) може працювати **лише** через **SPM** (Swift Package Manager).
18
+
19
+ - **Плагіни зі скоупу @nitra/:** за політикою вони **підтримуюють SPM**; **перевіряти** їх на **SPM** **не
20
+ потрібно** (і **check** цього **не** робить — **немає** обходу `package.json` на предмет **@nitra/**).
21
+
22
+ - **Коли `Podfile` дозволений:** якщо **не** вся потрібна iOS-функціональність **поза** **@nitra/** (сторонні
23
+ **Capacitor**-плагіни, інша нативна залежність) **доступна** через **SPM** — `Podfile` **дозволяється**,
24
+ але це **обов’язково** треба явно задати в кореневому **`package.json`** або в
25
+ **`capacitor.config.json` / `capacitor.config.ts` / `capacitor.config.mjs`**
26
+
27
+ - **`"iosCocoaPodsBecausePluginsLackSpm": true`** (семантика: **не** **вся** потрібна нативна частина
28
+ **поза** **@nitra/** **на** **SPM**; **@nitra/** у це **не** входить);
29
+ - або **`"iosCocoaPodsAllowed": true`** (короткий **alias** для того самого **винятку**);
30
+
31
+ Без **одного** з цих прапорів `true` наявний **Podfile** поза **`Pods/`** вважається **порушенням** правила **«лише** **SPM**».
32
+
33
+ - Перевірка читає **лише** кореневі файли: **`package.json`**, потім **capacitor-конфіги** у **корені** (див. вище).
34
+ У **`.ts` / `.mjs`**: шукається блок **nitra** `{ ... }` і **на його тілі** перевіряються ці **boolean**-поля.
35
+
36
+ ## Перевірка
37
+
38
+ `npx @nitra/cursor check capacitor` (коли **check-скрипт** підключено до цієї **ruleset**).
package/mdc/docker.mdc CHANGED
@@ -13,10 +13,13 @@ alwaysApply: false
13
13
 
14
14
  Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
15
15
 
16
- - **backend**: `mirror.gcr.io/library/alpine:*`
17
- - **frontend**: `mirror.gcr.io/library/nginx:*` aбо `mirror.gcr.io/openresty/openresty:*`
16
+ - **backend (типово)**: `mirror.gcr.io/library/alpine:*`
17
+ - **ультра-легкі (glibc / одна статична збірка)**: `scratch` тільки як `FROM scratch` (офіційний порожній базовий шар), коли весь **runtime** уже в `COPY --from=…`
18
+ - **glibc, Debian (slim)**: `mirror.gcr.io/library/debian:*` **лише** з тегом, у якому є `slim` (наприклад `bookworm-slim`, `trixie-slim`), а не `bookworm` без `slim`
19
+ - **виняток (інтерпретовані стеки)**: `mirror.gcr.io/library/php:*` або `mirror.gcr.io/library/python:*` — якщо сервіс має крутитися в офіційному runtime PHP чи Python, а не як один бінарник на Alpine; інакше лишай **alpine** у фінальному stage
20
+ - **frontend**: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
18
21
 
19
- Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
22
+ Це стримує зайвий build tooling (Bun, **node_modules** зі збірки) у фінальному образі; для **alpine** / **nginx** / **openresty** у **runtime** лишаються лише відповідні вимоги, для **php** / **python** (виняток) цільовий інтерпретований **stack**; **scratch** і **debian** з тегом **`*slim*`** — коли glibc і мінімальне оточення Debian важливіші за musl в **alpine**.
20
23
 
21
24
  ## компіляція
22
25
 
@@ -182,7 +185,10 @@ jobs:
182
185
  ```yaml title=".hadolint.yaml"
183
186
  ignored:
184
187
  - DL3007
188
+ - DL3018
185
189
  ```
190
+ Де DL3007 - «Не використовуй тег latest у FROM»
191
+ Де DL3018 - «Піни версії пакетів у apk add»
186
192
 
187
193
  Якщо немає файлів у межах відповідного набору (**`lint-docker`** або **`check docker`**) — перевірка пропускається (exit 0).
188
194
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.132",
3
+ "version": "1.8.138",
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',
@@ -119,6 +120,7 @@ function updateDirFacts(dirName, facts) {
119
120
  * @param {string} fileName базове імʼя файлу
120
121
  * @param {string} relPath шлях відносно кореня
121
122
  * @param {{
123
+ * hasCapacitorConfig: boolean,
122
124
  * hasDockerfile: boolean,
123
125
  * hasJsLikeSource: boolean,
124
126
  * hasNginxDefaultTplFile: boolean,
@@ -129,6 +131,9 @@ function updateDirFacts(dirName, facts) {
129
131
  * @returns {void}
130
132
  */
131
133
  function updateFileFacts(fileName, relPath, facts) {
134
+ if (fileName === 'capacitor.config.json') {
135
+ facts.hasCapacitorConfig = true
136
+ }
132
137
  if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
133
138
  facts.hasDockerfile = true
134
139
  }
@@ -182,6 +187,7 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
182
187
  * @param {string} absPath абсолютний шлях до файлу
183
188
  * @param {string} root абсолютний шлях кореня
184
189
  * @param {{
190
+ * hasCapacitorConfig: boolean,
185
191
  * hasDockerfile: boolean,
186
192
  * hasGqlTaggedTemplates: boolean,
187
193
  * hasJsLikeSource: boolean,
@@ -261,6 +267,7 @@ export function isMonorepoPackage(packageJson) {
261
267
  * Обходить дерево проєкту, збираючи факти для автоувімкнення правил.
262
268
  * @param {string} root абсолютний шлях кореня репозиторію
263
269
  * @returns {Promise<{
270
+ * hasCapacitorConfig: boolean,
264
271
  * hasDockerfile: boolean,
265
272
  * hasGaWorkflowsDir: boolean,
266
273
  * hasGqlTaggedTemplates: boolean,
@@ -275,6 +282,7 @@ export function isMonorepoPackage(packageJson) {
275
282
  */
276
283
  export async function collectAutoRuleFacts(root) {
277
284
  const facts = {
285
+ hasCapacitorConfig: false,
278
286
  hasDockerfile: false,
279
287
  hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
280
288
  hasGqlTaggedTemplates: false,
@@ -386,6 +394,7 @@ export async function detectAutoRulesAndSkills({
386
394
  const autoRuleChecks = [
387
395
  { enabled: isAbie, id: 'abie' },
388
396
  { enabled: packageJsonExists, id: 'bun' },
397
+ { enabled: facts.hasCapacitorConfig, id: 'capacitor' },
389
398
  { enabled: facts.hasDockerfile, id: 'docker' },
390
399
  { enabled: facts.hasGaWorkflowsDir, id: 'ga' },
391
400
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
@@ -0,0 +1,480 @@
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:** зазвичай **без** **Podfile** поза **Pods** (тільки **SPM**). **@nitra/**-плагіни за політикою **SPM** —
18
+ * їх **не** перелічуємо й **не** перевіряємо. Якщо **Podfile** є, його можна зареєструвати як **виняток**
19
+ * (див. **capacitor.mdc**): у кореневому **`package.json`** або
20
+ * **`capacitor.config.json` / `capacitor.config.ts` / `capacitor.config.mjs`**, об’єкт **nitra** з
21
+ * **`iosCocoaPodsBecausePluginsLackSpm: true`** (або **`iosCocoaPodsAllowed: true`**); тоді **Podfile** **не** fail.
22
+ * Якщо **`ios/`** **немає** — iOS-умови не застосовуються.
23
+ */
24
+ import { existsSync } from 'node:fs'
25
+ import { readdir, readFile } from 'node:fs/promises'
26
+ import { join, relative } from 'node:path'
27
+
28
+ import { createCheckReporter } from './utils/check-reporter.mjs'
29
+
30
+ /** Мінімальна допустима мажорна версія Capacitor (capacitor.mdc) */
31
+ const MIN_CAPACITOR_MAJOR = 8
32
+
33
+ /** @type {Set<string>} */
34
+ const IGNORED_DIRS_FOR_PACKAGE_JSON = new Set([
35
+ 'node_modules',
36
+ '.git',
37
+ 'dist',
38
+ 'coverage',
39
+ 'Pods',
40
+ '.turbo',
41
+ '.next',
42
+ 'build'
43
+ ])
44
+
45
+ /** `||` у діапазоні npm-версій */
46
+ // eslint-disable-next-line sonarjs/slow-regex -- короткі **semver**-підрядки у **package.json**
47
+ const NPM_OR_PARTS_RE = /\s*\|\|\s*/
48
+
49
+ /** `a - b` (діапазон діапазонів) */
50
+ // eslint-disable-next-line sonarjs/slow-regex -- форма **X - Y** у **npm**-range
51
+ const NPM_HYPHEN_RANGE_RE = /^(.+?)\s+-\s+(.+)$/
52
+
53
+ const FIRST_VERSION_NUM_RE = /^(?:v)?(\d+)/i
54
+
55
+ const PREFIX_GEQ_RE = /^>=\s*/u
56
+ const PREFIX_GT_RE = /^>\s*/u
57
+ const STRIP_CARET_TILDE_EQ_RE = /^[=^~]+\s*/u
58
+
59
+ /**
60
+ * Початок блока **nitra: {** у **.ts** / **.mjs** (**capacitor.config**; ключ **nitra**).
61
+ */
62
+ const RE_NITRA_CONFIG_OBJECT_LEAD_IN = /\bnitra\b\s*:\s*\{|[\u0027"]nitra[\u0027"]\s*:\s*\{/
63
+
64
+ const RE_COCOAPODS_EXEMPT_SPM = /\biosCocoaPodsBecausePluginsLackSpm\s*:\s*true\b/
65
+ const RE_COCOAPODS_EXEMPT_ALLOW = /\biosCocoaPodsAllowed\s*:\s*true\b/
66
+
67
+ /**
68
+ * Мінімальний **major** (нижня межа) для **однієї** OR-частини діапазону npm (без `||` всередині).
69
+ * @param {string} segment одна частина після `||` або весь рядок
70
+ * @returns {number | null} null, якщо **`*` / `x` / `latest`**, або **major** **нижньої** межі
71
+ */
72
+ export function capacitorSegmentMinMajor(segment) {
73
+ if (typeof segment !== 'string') {
74
+ return null
75
+ }
76
+ const s0 = segment.trim()
77
+ if (!s0) {
78
+ return null
79
+ }
80
+ const low = s0.toLowerCase()
81
+ if (s0 === '*' || low === 'x' || low === 'latest') {
82
+ return null
83
+ }
84
+ if (s0.startsWith('<') || s0.startsWith('<=')) {
85
+ return 0
86
+ }
87
+ if (s0.startsWith('>') && !s0.startsWith('>=')) {
88
+ return firstVersionMajorFromNpmValue(s0.replace(PREFIX_GT_RE, ''))
89
+ }
90
+ const rangeHyphen = s0.match(NPM_HYPHEN_RANGE_RE)
91
+ if (rangeHyphen) {
92
+ return firstVersionMajorFromNpmValue(rangeHyphen[1].trim())
93
+ }
94
+ if (s0.startsWith('^') || s0.startsWith('~') || s0.startsWith('=')) {
95
+ return firstVersionMajorFromNpmValue(s0.replace(STRIP_CARET_TILDE_EQ_RE, ''))
96
+ }
97
+ if (s0.startsWith('>=')) {
98
+ return firstVersionMajorFromNpmValue(s0.replace(PREFIX_GEQ_RE, ''))
99
+ }
100
+ return firstVersionMajorFromNpmValue(s0)
101
+ }
102
+
103
+ /**
104
+ * Витягує **major** з першого числа у вигляді **X** або **X.Y** / **X.Y.Z** (опційно **v**).
105
+ * @param {string} t рядок ділянки **версії** (без префікса **операторів**)
106
+ * @returns {number | null} перше **ціле** (major) або **null**
107
+ */
108
+ function firstVersionMajorFromNpmValue(t) {
109
+ const s = t.trim()
110
+ if (!s) {
111
+ return null
112
+ }
113
+ const m = s.match(FIRST_VERSION_NUM_RE)
114
+ if (!m) {
115
+ return null
116
+ }
117
+ return Number.parseInt(m[1], 10)
118
+ }
119
+
120
+ /**
121
+ * Мінімальна можлива (нижня) **major**-версія для повного діапазону npm, у т. ч. з `||`.
122
+ * @param {string} versionRange повне поле `package.json` для **@capacitor/core**
123
+ * @returns {number | null} **null** якщо **`*` / latest** в одній з частин
124
+ */
125
+ export function capacitorVersionRangeMinMajor(versionRange) {
126
+ if (typeof versionRange !== 'string') {
127
+ return null
128
+ }
129
+ const parts = versionRange.split(NPM_OR_PARTS_RE)
130
+ let overallMin = /** @type {number | null} */ (null)
131
+ for (const p of parts) {
132
+ const m = capacitorSegmentMinMajor(p)
133
+ if (m === null) {
134
+ return null
135
+ }
136
+ if (overallMin === null || m < overallMin) {
137
+ overallMin = m
138
+ }
139
+ }
140
+ return overallMin
141
+ }
142
+
143
+ /**
144
+ * @param {string} versionRange рядок **версії** з `package.json`
145
+ * @param {number} [min] мінімальний **major** (за замовчуванням `MIN_CAPACITOR_MAJOR`)
146
+ * @returns {boolean} **true**, якщо нижня межа **≥** **min**
147
+ */
148
+ export function isCapacitorCoreVersionAtLeast8(versionRange, min = MIN_CAPACITOR_MAJOR) {
149
+ const low = capacitorVersionRangeMinMajor(versionRange)
150
+ if (low === null) {
151
+ return false
152
+ }
153
+ return low >= min
154
+ }
155
+
156
+ /**
157
+ * @param {(m: string) => void} fail друк помилки
158
+ * @param {(m: string) => void} pass друк успіху
159
+ * @param {string} rel відносний **posix**-шлях `package.json`
160
+ * @param {string} range поле `version` для **@capacitor/core**
161
+ * @returns {void}
162
+ */
163
+ function reportOneCapacitorCoreRange(fail, pass, rel, range) {
164
+ if (isCapacitorCoreVersionAtLeast8(range)) {
165
+ pass(`«${rel}»: @capacitor/core — діапазон сумісний з ${MIN_CAPACITOR_MAJOR}+`)
166
+ } else {
167
+ fail(
168
+ `«${rel}»: @capacitor/core «${range}» — мінімальна допустима мажорна версія Capacitor ${MIN_CAPACITOR_MAJOR} (capacitor.mdc). Вкажи, наприклад, ^${MIN_CAPACITOR_MAJOR}.0.0`
169
+ )
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @param {string} rel відносний шлях `package.json`
175
+ * @param {Record<string, unknown>} obj `dependencies` / `devDependencies` / **…**
176
+ * @param {{ byPath: Map<string, string>, anyCapacitor: boolean }} out накопичувач **@capacitor** у дереві
177
+ */
178
+ function recordCapacitorFromDependencyObject(rel, obj, out) {
179
+ for (const [name, val] of Object.entries(obj)) {
180
+ if (typeof name === 'string' && name.startsWith('@capacitor/')) {
181
+ out.anyCapacitor = true
182
+ }
183
+ if (name === '@capacitor/core' && typeof val === 'string' && val !== '') {
184
+ out.byPath.set(rel, val)
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * @param {string} absPath шлях до `package.json`
191
+ * @param {string} root корінь репозиторію
192
+ * @param {{ byPath: Map<string, string>, anyCapacitor: boolean }} out накопичувач **byPath** і **anyCapacitor**
193
+ * @returns {Promise<void>}
194
+ */
195
+ export async function recordCapacitorFromOnePackageJson(absPath, root, out) {
196
+ let raw
197
+ try {
198
+ raw = await readFile(absPath, 'utf8')
199
+ } catch {
200
+ return
201
+ }
202
+ let pkg
203
+ try {
204
+ pkg = JSON.parse(raw)
205
+ } catch {
206
+ return
207
+ }
208
+ const rel = (relative(root, absPath) || absPath).replaceAll('\\', '/')
209
+ for (const block of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
210
+ const rec = pkg?.[block]
211
+ if (rec !== null && rec !== undefined && typeof rec === 'object' && !Array.isArray(rec)) {
212
+ recordCapacitorFromDependencyObject(rel, /** @type {Record<string, unknown>} */ (rec), out)
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Зчитує всі `package.json` з дерева, накопичує `byPath` і `anyCapacitor`.
219
+ * @param {string} root корінь репозиторію
220
+ * @param {{ byPath: Map<string, string>, anyCapacitor: boolean }} out накопичувач
221
+ * @returns {Promise<void>}
222
+ */
223
+ export async function collectCapacitorDataFromAllPackageJson(root, out) {
224
+ out.anyCapacitor = false
225
+ if (out.byPath) {
226
+ out.byPath.clear()
227
+ } else {
228
+ out.byPath = new Map()
229
+ }
230
+
231
+ /**
232
+ * @param {string} dir абсолютний **каталог** для `readdir`
233
+ * @returns {Promise<void>}
234
+ */
235
+ async function walk(dir) {
236
+ let entries
237
+ try {
238
+ entries = await readdir(dir, { withFileTypes: true })
239
+ } catch {
240
+ return
241
+ }
242
+ for (const entry of entries) {
243
+ const absPath = join(dir, entry.name)
244
+ if (entry.isDirectory() && !IGNORED_DIRS_FOR_PACKAGE_JSON.has(entry.name)) {
245
+ await walk(absPath)
246
+ } else if (entry.isFile() && entry.name === 'package.json') {
247
+ await recordCapacitorFromOnePackageJson(absPath, root, out)
248
+ }
249
+ }
250
+ }
251
+
252
+ await walk(root)
253
+ }
254
+
255
+ /**
256
+ * @param {string} root абсолютний або **cwd**-відносний **корінь** репозиторію
257
+ * @returns {boolean} **true** якщо `capacitor.config.{json,ts,mjs}` існує
258
+ */
259
+ export function hasCapacitorConfigInRoot(root) {
260
+ return (
261
+ existsSync(join(root, 'capacitor.config.json')) ||
262
+ existsSync(join(root, 'capacitor.config.ts')) ||
263
+ existsSync(join(root, 'capacitor.config.mjs'))
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Чи варто застосовувати правила: конфіг **або** **@capacitor/** у залежностях.
269
+ * @param {string} root корінь
270
+ * @param {boolean} anyCapacitor чи зустрілось **@capacitor/** у **package.json**
271
+ * @returns {boolean} **true** якщо застосовуємо **check capacitor**
272
+ */
273
+ export function isCapacitorRelevantForCheck(root, anyCapacitor) {
274
+ return hasCapacitorConfigInRoot(root) || anyCapacitor
275
+ }
276
+
277
+ /**
278
+ * Рекурсивно шукає `Podfile` у **ios/**, **не** заходячи в **Pods** (кеш CocoaPods) і типові build-каталоги.
279
+ * @param {string} root корінь репозиторію
280
+ * @param {string} dir абсолютний каталог
281
+ * @param {(rel: string) => void} onPodfileRelative **callback** з **posix**-шляхом `Podfile` від **root**
282
+ * @returns {Promise<boolean>} **true** — знайдено **хоча б один** `Podfile`
283
+ */
284
+ export async function walkIosForPodfileSkipPods(root, dir, onPodfileRelative) {
285
+ let entries
286
+ try {
287
+ entries = await readdir(dir, { withFileTypes: true })
288
+ } catch {
289
+ return false
290
+ }
291
+ for (const e of entries) {
292
+ if (e.name === 'Pods' || e.name === 'build' || e.name === 'DerivedData') {
293
+ // skip
294
+ } else if (e.isFile() === true && e.name === 'Podfile') {
295
+ const abs = join(dir, e.name)
296
+ onPodfileRelative((relative(root, abs) || abs).replaceAll('\\', '/'))
297
+ return true
298
+ } else if (e.isDirectory() === true) {
299
+ const abs = join(dir, e.name)
300
+ const found = await walkIosForPodfileSkipPods(root, abs, onPodfileRelative)
301
+ if (found === true) {
302
+ return true
303
+ }
304
+ }
305
+ }
306
+ return false
307
+ }
308
+
309
+ /**
310
+ * @param {string} root корінь
311
+ * @returns {Promise<string | null>} **relative**-шлях `Podfile` (або **null**)
312
+ */
313
+ export async function findFirstPodfileUnderIosExcludingPods(root) {
314
+ const iosDir = join(root, 'ios')
315
+ if (!existsSync(iosDir)) {
316
+ return null
317
+ }
318
+ let first = /** @type {string | null} */ (null)
319
+ await walkIosForPodfileSkipPods(root, iosDir, rel => {
320
+ if (first === null || rel.length < first.length) {
321
+ first = rel
322
+ }
323
+ })
324
+ return first
325
+ }
326
+
327
+ /**
328
+ * Чи дозволяє об’єкт **nitra** використання **Podfile** (CocoaPods) на iOS (див. **capacitor.mdc**; **@nitra/**
329
+ * **SPM** **не** аналізуємо).
330
+ * @param {unknown} o об’єкт **nitra** з **package.json** / **capacitor.config**
331
+ * @returns {boolean} **true** якщо **Podfile** дозволено (один з прапорів **true**)
332
+ */
333
+ export function nitrAObjectAllowsIosCocoaPods(o) {
334
+ if (o === null || o === undefined || typeof o !== 'object' || Array.isArray(o)) {
335
+ return false
336
+ }
337
+ const r = /** @type {Record<string, unknown>} */ (o)
338
+ if (r.iosCocoaPodsBecausePluginsLackSpm === true) {
339
+ return true
340
+ }
341
+ if (r.iosCocoaPodsAllowed === true) {
342
+ return true
343
+ }
344
+ return false
345
+ }
346
+
347
+ /**
348
+ * Витягає вихідний текст тіла **{ ... }** після **nitra:** / **"nitra":** у **config**-файлі (**ts** / **mjs**)
349
+ * (перша відповідність, баланс фігурних дужок).
350
+ * @param {string} source вміст **.ts** / **.mjs** config
351
+ * @returns {string | null} фрагмент **`{...}`** після **nitra:** або **null**
352
+ */
353
+ function extractNitraObjectBodySource(source) {
354
+ const m = RE_NITRA_CONFIG_OBJECT_LEAD_IN.exec(source)
355
+ if (!m) {
356
+ return null
357
+ }
358
+ const openBrace = m.index + m[0].length - 1
359
+ let d = 0
360
+ for (let i = openBrace; i < source.length; i += 1) {
361
+ const c = source[i]
362
+ if (c === '{') {
363
+ d += 1
364
+ } else if (c === '}') {
365
+ d -= 1
366
+ if (d === 0) {
367
+ return source.slice(openBrace, i + 1)
368
+ }
369
+ }
370
+ }
371
+ return null
372
+ }
373
+
374
+ /**
375
+ * @param {string} objectBody фрагмент **`{ ... }`** (текст)
376
+ * @returns {boolean} **true**, якщо в тілі є **iosCocoaPods**…**:** **true**
377
+ */
378
+ function nitraObjectBodyStringAllowsCocoaPodsExempt(objectBody) {
379
+ return (
380
+ RE_COCOAPODS_EXEMPT_SPM.test(objectBody) === true || RE_COCOAPODS_EXEMPT_ALLOW.test(objectBody) === true
381
+ )
382
+ }
383
+
384
+ /**
385
+ * @param {string} absPath повний шлях до **JSON**-файла
386
+ * @returns {Promise<boolean>} **true**, якщо `obj.nitra` (ключ **nitra** у JSON) — виняток
387
+ */
388
+ async function pathJsonShowsNitraCocoapodsExempt(absPath) {
389
+ if (existsSync(absPath) !== true) {
390
+ return false
391
+ }
392
+ try {
393
+ const t = await readFile(absPath, 'utf8')
394
+ const j = /** @type {Record<string, unknown>} */ (JSON.parse(t))
395
+ return nitrAObjectAllowsIosCocoaPods(j['nitra']) === true
396
+ } catch {
397
+ return false
398
+ }
399
+ }
400
+
401
+ /**
402
+ * @param {string} root корінь репозиторію
403
+ * @returns {Promise<boolean>} **true**, якщо **.ts** / **.mjs** містить валідний виняток **nitra**
404
+ */
405
+ async function capacitorConfigTsMjsNitraCocoapodsExempt(root) {
406
+ for (const name of ['capacitor.config.ts', 'capacitor.config.mjs']) {
407
+ const p = join(root, name)
408
+ if (existsSync(p) === true) {
409
+ const text = await readFile(p, 'utf8')
410
+ const body = extractNitraObjectBodySource(text)
411
+ if (body !== null && nitraObjectBodyStringAllowsCocoaPodsExempt(body) === true) {
412
+ return true
413
+ }
414
+ }
415
+ }
416
+ return false
417
+ }
418
+
419
+ /**
420
+ * @param {string} root корінь репозиторію (**process.cwd()** у **check**)
421
+ * @returns {Promise<boolean>} **true** якщо **Podfile** виправдано **nitra**-конфігом
422
+ */
423
+ async function isIosCocoaPodsExemptByNitraConfig(root) {
424
+ if ((await pathJsonShowsNitraCocoapodsExempt(join(root, 'package.json'))) === true) {
425
+ return true
426
+ }
427
+ if ((await pathJsonShowsNitraCocoapodsExempt(join(root, 'capacitor.config.json'))) === true) {
428
+ return true
429
+ }
430
+ return capacitorConfigTsMjsNitraCocoapodsExempt(root)
431
+ }
432
+
433
+ /**
434
+ * @returns {Promise<number>} **0** — **ok**; **1** — **fail** (див. **capacitor.mdc**)
435
+ */
436
+ export async function check() {
437
+ const reporter = createCheckReporter()
438
+ const { pass, fail, getExitCode } = reporter
439
+ const root = process.cwd()
440
+
441
+ const acc = { byPath: new Map(), anyCapacitor: false }
442
+ await collectCapacitorDataFromAllPackageJson(root, acc)
443
+ const { byPath, anyCapacitor } = acc
444
+
445
+ if (!isCapacitorRelevantForCheck(root, anyCapacitor)) {
446
+ pass('Capacitor не виявлено (без capacitor.config у корені, без @capacitor/ у package.json) — check capacitor пропущено')
447
+ return getExitCode()
448
+ }
449
+
450
+ pass('Проєкт з ознаками Capacitor — застосовую capacitor.mdc')
451
+
452
+ if (byPath.size === 0) {
453
+ fail(
454
+ `додай залежність @capacitor/core з діапазоном ^${MIN_CAPACITOR_MAJOR}.0.0 (або іншим, сумісним лише з ${MIN_CAPACITOR_MAJOR}+) у package.json (capacitor.mdc)`
455
+ )
456
+ } else {
457
+ for (const [rel, range] of byPath) {
458
+ reportOneCapacitorCoreRange(fail, pass, rel, range)
459
+ }
460
+ }
461
+
462
+ const podfileRel = await findFirstPodfileUnderIosExcludingPods(root)
463
+ if (podfileRel === null) {
464
+ if (existsSync(join(root, 'ios'))) {
465
+ pass('ios/ без Podfile поза Pods/ (лише SPM, capacitor.mdc)')
466
+ } else {
467
+ pass('каталог ios/ не знайдено — вимогу iOS/SPM пропущено')
468
+ }
469
+ } else if ((await isIosCocoaPodsExemptByNitraConfig(root)) === true) {
470
+ pass(
471
+ `iOS: Podfile «${podfileRel}» — дозволено виняток (nitra.iosCocoaPodsBecausePluginsLackSpm / iosCocoaPodsAllowed, capacitor.mdc)`
472
+ )
473
+ } else {
474
+ fail(
475
+ `iOS: знайдено Podfile «${podfileRel}» — для Capacitor використовуй лише SPM, без CocoaPods, або додай виняток nitra (capacitor.mdc)`
476
+ )
477
+ }
478
+
479
+ return getExitCode()
480
+ }
@@ -5,8 +5,10 @@
5
5
  * вказуються через `mirror.gcr.io` (див. `utils/docker-mirror.mjs`).
6
6
  *
7
7
  * Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
8
- * використовує мінімальний runtime-образ:
9
- * - backend: `mirror.gcr.io/library/alpine:*`
8
+ * використовує дозволений runtime-образ (див. docker.mdc):
9
+ * - backend: `mirror.gcr.io/library/alpine:*`, `scratch`, `mirror.gcr.io/library/debian:` з тегом, що
10
+ * містить `slim` (не повний `debian:bookworm`), за винятком PHP/Python — `mirror.gcr.io/library/php:*` або
11
+ * `mirror.gcr.io/library/python:*`
10
12
  * - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
11
13
  *
12
14
  * Якщо в Dockerfile є крок `bun install` і це не frontend-образ (фінальний stage — alpine),
@@ -14,7 +16,7 @@
14
16
  * фінальному stage не повинно залишатися build tooling (Bun/Node).
15
17
  *
16
18
  * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
17
- * runtime (alpine), nginx або openresty.
19
+ * дозволений runtime (alpine, scratch, debian slim, за потреби php/python, nginx або openresty).
18
20
  *
19
21
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
20
22
  * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
@@ -84,10 +86,31 @@ export function parseFromStages(fileContent) {
84
86
 
85
87
  const RUNTIME_IMAGES = /** @type {const} */ ([
86
88
  'mirror.gcr.io/library/alpine',
89
+ 'mirror.gcr.io/library/php',
90
+ 'mirror.gcr.io/library/python',
87
91
  'mirror.gcr.io/library/nginx',
88
92
  'mirror.gcr.io/openresty/openresty'
89
93
  ])
90
94
 
95
+ /** @type {RegExp} */
96
+ const DEBIAN_VIA_MIRROR_RE = /^mirror\.gcr\.io\/library\/debian:(.+)$/i
97
+
98
+ /**
99
+ * Чи ref фінального `FROM` відповідає дозволеним у docker.mdc (multistage / runtime).
100
+ * @param {string} lastLower ref без digest, lower case
101
+ * @returns {boolean}
102
+ */
103
+ function isAllowedFinalRuntimeImage(lastLower) {
104
+ if (lastLower === 'scratch' || lastLower.startsWith('scratch:')) {
105
+ return true
106
+ }
107
+ const deb = lastLower.match(DEBIAN_VIA_MIRROR_RE)
108
+ if (deb) {
109
+ return deb[1].toLowerCase().includes('slim')
110
+ }
111
+ return RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
112
+ }
113
+
91
114
  /**
92
115
  * Розбиває Dockerfile на stages за `FROM` (порожній масив, якщо FROM немає).
93
116
  * @param {string} fileContent вміст Dockerfile/Containerfile
@@ -113,7 +136,7 @@ export function splitDockerfileStages(fileContent) {
113
136
  /**
114
137
  * Перевіряє базові вимоги до структури Dockerfile:
115
138
  * - multistage: мінімум 2 FROM
116
- * - фінальний FROM: alpine/nginx/openresty з mirror.gcr.io
139
+ * - фінальний FROM: дозволені образи в docker.mdc (alpine, scratch, debian slim, php, python, nginx, openresty, …)
117
140
  * @param {string} fileContent вміст Dockerfile/Containerfile
118
141
  * @returns {string | null} повідомлення помилки або null
119
142
  */
@@ -129,9 +152,8 @@ export function getMultistageAndRuntimeHint(fileContent) {
129
152
  const lastImage = (last?.image || '').split('@')[0] || ''
130
153
  const lastLower = lastImage.toLowerCase()
131
154
 
132
- const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
133
- if (!okRuntime) {
134
- return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
155
+ if (!isAllowedFinalRuntimeImage(lastLower)) {
156
+ return `фінальний FROM має бути дозволеним runtime-образом (див. docker.mdc: multistage), зараз: ${last?.image} (рядок ${last?.line})`
135
157
  }
136
158
 
137
159
  return null
@@ -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)
@@ -5,6 +5,7 @@
5
5
  * VSCode (formatOnSave, defaultFormatter для js/ts/json/vue/css/html),
6
6
  * відсутність Prettier у конфігах і залежностях.
7
7
  *
8
+ * cspell: `.cspell.json` з обовʼязковим набором `ignorePaths` (клон text.mdc: node_modules, vscode, git, report, svg, k8s yaml);
8
9
  * cspell, markdownlint через `bunx markdownlint-cli2` у `lint-text` (без оголошення пакета в package.json); у кореневих **`devDependencies`**
9
10
  * дозволені лише **`@nitra/*`** (як у bun.mdc), зокрема **`@nitra/cspell-dict` ^2.0.0+**; без імпорту **`@cspell/dict-*`** у `.cspell.json`, заборона
10
11
  * `markdownlint-cli2` у dependencies/devDependencies, v8r (`run-v8r.mjs` або чотири `bunx v8r`),
@@ -31,6 +32,17 @@ const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
31
32
  /** Мінімальні glob-и в `ignorePatterns` у `.oxfmtrc.json` (text.mdc). */
32
33
  const OXFMT_REQUIRED_IGNORE_PATTERNS = ['**/hasura/metadata/**', '**/schema.graphql']
33
34
 
35
+ /** Канонічні записи `ignorePaths` у `.cspell.json` (text.mdc) — кожен має бути присутнім. */
36
+ const CSPELL_REQUIRED_IGNORE_PATHS = [
37
+ '**/node_modules/**',
38
+ '**/vscode-extension/**',
39
+ '**/.git/**',
40
+ '.vscode',
41
+ 'report',
42
+ '*.svg',
43
+ '**/k8s/**/*.yaml',
44
+ ]
45
+
34
46
  /**
35
47
  * Чи діапазон версії `@nitra/cspell-dict` у package.json означає лінію 2.0.0+ (з цієї версії словники входять у пакет).
36
48
  * @param {string|undefined} range наприклад "^2.0.0"
@@ -384,6 +396,14 @@ async function checkCspellConfig(pass, fail) {
384
396
  } else {
385
397
  fail('.cspell.json не містить ignorePaths')
386
398
  }
399
+ if (Array.isArray(cfg.ignorePaths)) {
400
+ const missing = CSPELL_REQUIRED_IGNORE_PATHS.filter(p => !cfg.ignorePaths.includes(p))
401
+ if (missing.length === 0) {
402
+ pass(`.cspell.json ignorePaths містить усі обовʼязкові glob-и з text.mdc`)
403
+ } else {
404
+ fail(`.cspell.json ignorePaths бракує за замовчанням: ${missing.join(', ')} (див. text.mdc)`)
405
+ }
406
+ }
387
407
  }
388
408
 
389
409
  /**