@nitra/cursor 1.8.220 → 1.8.222

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.
Files changed (51) hide show
  1. package/.claude-template/npm-CLAUDE.md +4 -0
  2. package/CHANGELOG.md +21 -0
  3. package/bin/auto-rules.md +2 -0
  4. package/bin/n-cursor.js +25 -4
  5. package/mdc/ci4.mdc +51 -0
  6. package/mdc/tauri.mdc +20 -0
  7. package/package.json +1 -1
  8. package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
  9. package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
  10. package/policy/k8s/base_manifest/base_manifest.rego +154 -0
  11. package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
  12. package/policy/k8s/gateway/gateway.rego +151 -0
  13. package/policy/k8s/gateway/gateway_test.rego +122 -0
  14. package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
  15. package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
  16. package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
  17. package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
  18. package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
  19. package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
  20. package/policy/k8s/kustomization/kustomization.rego +220 -0
  21. package/policy/k8s/kustomization/kustomization_test.rego +128 -0
  22. package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
  23. package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
  24. package/policy/k8s/manifest/manifest.rego +151 -4
  25. package/policy/k8s/manifest/manifest_test.rego +309 -0
  26. package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
  27. package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
  28. package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
  29. package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
  30. package/scripts/auto-skills.mjs +8 -1
  31. package/scripts/check-bun.mjs +3 -3
  32. package/scripts/check-changelog.mjs +2 -3
  33. package/scripts/check-image-avif.mjs +14 -6
  34. package/scripts/check-image-compress.mjs +1 -1
  35. package/scripts/check-js-run.mjs +58 -47
  36. package/scripts/check-k8s.mjs +128 -51
  37. package/scripts/check-npm-module.mjs +1 -4
  38. package/scripts/check-php.mjs +5 -5
  39. package/scripts/claude-stop-hook.mjs +2 -2
  40. package/scripts/lint-conftest.mjs +88 -8
  41. package/scripts/lint-ga.mjs +1 -1
  42. package/scripts/lint-rego.mjs +19 -4
  43. package/scripts/run-shellcheck-text.mjs +94 -64
  44. package/scripts/sync-claude-config.mjs +1 -1
  45. package/scripts/utils/ast-scan-utils.mjs +28 -0
  46. package/scripts/utils/bun-sql-scan.mjs +53 -34
  47. package/scripts/utils/bunyan-imports.mjs +10 -61
  48. package/scripts/utils/conn-file-rules.mjs +76 -37
  49. package/scripts/utils/depcheck-workflow.mjs +27 -6
  50. package/scripts/utils/redis-imports.mjs +9 -51
  51. package/skills/llm-patch/SKILL.md +16 -5
@@ -12,7 +12,14 @@
12
12
  */
13
13
 
14
14
  /** Порядок автододавання skills відповідно до `auto-skills.md`. */
15
- export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint', 'llm-patch', 'publish-telegram', 'taze'])
15
+ export const AUTO_SKILL_ORDER = Object.freeze([
16
+ 'abie-kustomize',
17
+ 'fix',
18
+ 'lint',
19
+ 'llm-patch',
20
+ 'publish-telegram',
21
+ 'taze'
22
+ ])
16
23
 
17
24
  /**
18
25
  * Залежність скілів від правил (`auto-skills.md` синтаксис `skill - [rules]`).
@@ -104,10 +104,10 @@ export async function check() {
104
104
  fail('Відсутній bun.lock — запусти bun i')
105
105
  }
106
106
 
107
- if (!existsSync('bunfig.toml')) {
108
- fail('Відсутній bunfig.toml — створи з [install] linker = "hoisted" (bun.mdc)')
109
- } else {
107
+ if (existsSync('bunfig.toml')) {
110
108
  pass('bunfig.toml є (структуру перевіряє bun run lint-conftest → bun.bunfig)')
109
+ } else {
110
+ fail('Відсутній bunfig.toml — створи з [install] linker = "hoisted" (bun.mdc)')
111
111
  }
112
112
 
113
113
  const cursorRules = await loadNCursorRules()
@@ -160,9 +160,8 @@ async function readBaseVersion(baseRef, ws) {
160
160
  * @returns {boolean} `true`, якщо запис для `version` знайдено
161
161
  */
162
162
  function changelogHasVersionEntry(text, version) {
163
- const escaped = version.replaceAll(/[.+*?^$()[\]{}|\\]/g, String.raw`\$&`)
164
- const re = new RegExp(String.raw`^##\s+\[${escaped}\]`, 'm')
165
- return re.test(text)
163
+ const needle = `## [${version}]`
164
+ return text.startsWith(needle) || text.includes(`\n${needle}`)
166
165
  }
167
166
 
168
167
  /**
@@ -3,9 +3,9 @@
3
3
  * ув'язування `.avif`-двійників з посиланнями у `.vue`/`.html`.
4
4
  *
5
5
  * Дії під час `check image-avif`:
6
- * 1. `npx @nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
6
+ * 1. `npx \@nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
7
7
  * 2. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
8
- * (де AVIF-двійник реально існує на диску). Pakety з `"@nitra/minify-image": {
8
+ * (де AVIF-двійник реально існує на диску). Pakety з `"\@nitra/minify-image": {
9
9
  * "disable-avif": true }` у `package.json` пропускаються.
10
10
  * 3. Прибирає AVIF-сироти — `<name>.<ext>.avif`, на які не лишилось жодного посилання
11
11
  * у `.vue`/`.html` репозиторію, видаляються (умова правила: «AVIF лишається лише
@@ -27,13 +27,14 @@ import { env } from 'node:process'
27
27
 
28
28
  import { createCheckReporter } from './utils/check-reporter.mjs'
29
29
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
30
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
30
31
  import { walkDir } from './utils/walkDir.mjs'
31
32
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
32
33
 
33
34
  /** Імʼя CLI-пакета, який генерує AVIF. */
34
35
  const MINIFY_PACKAGE_NAME = '@nitra/minify-image'
35
36
 
36
- /** Поле в `package.json` для конфігу @nitra/minify-image (наприклад, `disable-avif`). */
37
+ /** Поле в `package.json` для конфігу `\@nitra/minify-image` (наприклад, `disable-avif`). */
37
38
  const PKG_CONFIG_FIELD = '@nitra/minify-image'
38
39
 
39
40
  /**
@@ -279,7 +280,7 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
279
280
 
280
281
  /**
281
282
  * Чи є в репозиторії хоч один raster-файл, який мав би сенс конвертувати у AVIF.
282
- * Якщо немає — `npx @nitra/minify-image` нема що робити, тож зайвий запуск пропускаємо
283
+ * Якщо немає — `npx \@nitra/minify-image` нема що робити, тож зайвий запуск пропускаємо
283
284
  * (важливо у тестах: фікстурні `.png`-імпорти посилаються на неіснуючі файли, тож
284
285
  * minify-image все одно нічого не згенерує — а зайвий npx-спавн повільний і робить шум).
285
286
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
@@ -299,7 +300,7 @@ async function hasAnyRasterImage(ignorePaths) {
299
300
  }
300
301
 
301
302
  /**
302
- * Запускає `npx @nitra/minify-image --src=. --write --avif` для генерації AVIF-двійників.
303
+ * Запускає `npx \@nitra/minify-image --src=. --write --avif` для генерації AVIF-двійників.
303
304
  *
304
305
  * Виклик best-effort: якщо мережа/кеш недоступні чи бінарника нема — лог-варн без падіння
305
306
  * перевірки (валідації package.json і vue-refs все одно прогоняться, vue-refs на
@@ -309,7 +310,14 @@ async function hasAnyRasterImage(ignorePaths) {
309
310
  */
310
311
  function runAvifGeneration() {
311
312
  if (env.NITRA_CURSOR_NO_AVIF_RUN === '1') return
312
- const result = spawnSync('npx', [MINIFY_PACKAGE_NAME, '--src=.', '--write', '--avif'], {
313
+ const npxPath = resolveCmd('npx')
314
+ if (!npxPath) {
315
+ console.log(
316
+ ` ⚠️ 'npx' не знайдено в PATH — пропускаємо генерацію AVIF; vue/html-перевірка покаже файли, для яких не вистачає .avif`
317
+ )
318
+ return
319
+ }
320
+ const result = spawnSync(npxPath, [MINIFY_PACKAGE_NAME, '--src=.', '--write', '--avif'], {
313
321
  stdio: 'inherit',
314
322
  env
315
323
  })
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * **Що покрила Rego** (`bun run lint-conftest`,
13
13
  * `npm/policy/image_compress/package_json/`):
14
- * - `scripts.lint-image` викликає `npx @nitra/minify-image --src=. --write`
14
+ * - `scripts.lint-image` викликає `npx \@nitra/minify-image --src=. --write`
15
15
  * без `--avif` (AVIF — окреме правило `image-avif`);
16
16
  * - агрегований `lint` (якщо є) містить `bun run lint-image`;
17
17
  * - `@nitra/minify-image` НЕ у `dependencies` / `devDependencies` (через `npx`).
@@ -207,35 +207,56 @@ async function checkConnFileNamingAndExports(absPackageRoot, sourcePaths, pkgJso
207
207
  let violations = 0
208
208
  for (const absPath of sourcePaths) {
209
209
  const rel = relPosix(absPackageRoot, absPath)
210
- if (!isInsideConnDir(rel, connDir)) continue
211
- if (!isConnFileRulesSourceFile(rel)) continue
212
- // пропускаємо реекспортний барель `index.*` (якщо знадобиться) і прихований .d.ts
213
- const base = rel.slice(rel.lastIndexOf('/') + 1)
214
- if (base.startsWith('index.')) continue
215
-
210
+ if (!isConnFileToCheck(rel, connDir)) continue
216
211
  const content = await readFile(absPath, 'utf8')
217
212
  for (const v of findConnFileRuleViolations(content, rel)) {
218
213
  violations++
219
- if (v.kind === 'name') {
220
- fail(
221
- `${label}${rel} — назва файла в '${connDir}/' не відповідає канону js-run: ` +
222
- `'ql-<id>', 'pg-{read|write}[-<id>]', 'mysql-{read|write}[-<id>]' або 'mssql-{read|write}[-<id>]' ` +
223
- `(kebab-case, [a-z0-9-])`
224
- )
225
- } else if (v.kind === 'default-export') {
226
- fail(`${label}${rel} — 'export default' заборонений у '${connDir}/'; зроби іменований експорт`)
227
- } else {
228
- const found = v.foundNames?.length ? v.foundNames.join(', ') : '—'
229
- fail(
230
- `${label}${rel} — очікується іменований експорт 'export const ${v.expectedName} = …' ` +
231
- `(camelCase від назви файла); знайдено: ${found}`
232
- )
233
- }
214
+ fail(formatConnFileViolation(v, label, rel, connDir))
234
215
  }
235
216
  }
236
217
  return violations
237
218
  }
238
219
 
220
+ /**
221
+ * Чи `rel` — це conn-файл, який треба валідувати: під `connDir/`, з JS/TS-розширенням,
222
+ * не `index.*` (який є реекспортним барелем).
223
+ * @param {string} rel відносний шлях у posix-форматі
224
+ * @param {string} connDir каталог conn-файлів (наприклад `src/conn`)
225
+ * @returns {boolean} true, якщо файл потрібно перевірити
226
+ */
227
+ function isConnFileToCheck(rel, connDir) {
228
+ if (!isInsideConnDir(rel, connDir)) return false
229
+ if (!isConnFileRulesSourceFile(rel)) return false
230
+ const base = rel.slice(rel.lastIndexOf('/') + 1)
231
+ return !base.startsWith('index.')
232
+ }
233
+
234
+ /**
235
+ * Будує повідомлення про конкретне порушення canon-у файла з `connDir/`.
236
+ * @param {{ kind: 'name' | 'default-export' | 'export-name', expectedName?: string, foundNames?: string[] }} v опис порушення
237
+ * @param {string} label префікс повідомлення `[<pkg>] `
238
+ * @param {string} rel відносний шлях файла
239
+ * @param {string} connDir каталог conn-файлів
240
+ * @returns {string} повний текст повідомлення для `fail(...)`
241
+ */
242
+ function formatConnFileViolation(v, label, rel, connDir) {
243
+ if (v.kind === 'name') {
244
+ return (
245
+ `${label}${rel} — назва файла в '${connDir}/' не відповідає канону js-run: ` +
246
+ `'ql-<id>', 'pg-{read|write}[-<id>]', 'mysql-{read|write}[-<id>]' або 'mssql-{read|write}[-<id>]' ` +
247
+ `(kebab-case, [a-z0-9-])`
248
+ )
249
+ }
250
+ if (v.kind === 'default-export') {
251
+ return `${label}${rel} — 'export default' заборонений у '${connDir}/'; зроби іменований експорт`
252
+ }
253
+ const found = v.foundNames?.length ? v.foundNames.join(', ') : '—'
254
+ return (
255
+ `${label}${rel} — очікується іменований експорт 'export const ${v.expectedName} = …' ` +
256
+ `(camelCase від назви файла); знайдено: ${found}`
257
+ )
258
+ }
259
+
239
260
  /**
240
261
  * Перевіряє правило «CheckEnv» для пакета.
241
262
  * @param {string} absPackageRoot абсолютний корінь пакета
@@ -297,12 +318,12 @@ async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, f
297
318
  async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, passFn) {
298
319
  const label = `[${rootDir}] `
299
320
  const absPackageRoot = join(process.cwd(), rootDir)
300
- const pkgJson = await loadPackageJsonAndCheckBunyanDeps(rootDir, label, fail)
321
+ const pkgJson = await loadPackageJson(rootDir)
301
322
 
302
323
  // Frontend-пакети (vite у devDependencies) виходять за межі js-run:
303
324
  // браузерний бандл не має `node:process`, а `process.env.*` бандлер
304
- // обробляє самостійно. Перевірку process.env / conn-аліасів пропускаємо,
305
- // bunyan-залежність уже звірено в `loadPackageJsonAndCheckBunyanDeps`.
325
+ // обробляє самостійно. Перевірку process.env / conn-аліасів пропускаємо;
326
+ // bunyan-залежність валідується в Rego (`bun run lint-conftest`).
306
327
  if (packageJsonHasViteDevDependency(pkgJson)) {
307
328
  passFn(`${label}vite-пакет (frontend) — js-run пропущено (process.env / conn-aliases / OTEL configmap)`)
308
329
  return
@@ -343,7 +364,7 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
343
364
  passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
344
365
  }
345
366
 
346
- await checkOtelConfigmap(rootDir, label, fail, passFn)
367
+ checkOtelConfigmap(rootDir, passFn)
347
368
 
348
369
  checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn)
349
370
  }
@@ -388,41 +409,31 @@ function packageJsonHasViteDevDependency(pkgJson) {
388
409
  }
389
410
 
390
411
  /**
391
- * Завантажує `package.json` пакета (якщо є) і реєструє порушення для bunyan-залежностей.
412
+ * Завантажує `package.json` пакета (якщо є). Заборону `@nitra/bunyan` / `bunyan`
413
+ * у dependencies/devDependencies перенесено в Rego (`npm/policy/js_run/package_json/`);
414
+ * `bun run lint-conftest` запускає її по всіх workspace `package.json`. Тут лишилася
415
+ * лише AST-перевірка імпортів.
392
416
  * @param {string} rootDir відносний шлях workspace
393
- * @param {string} label префікс повідомлення `[<pkg>] `
394
- * @param {(msg: string) => void} fail callback при помилці
395
417
  * @returns {Promise<unknown>} розпарсений package.json або null
396
418
  */
397
- async function loadPackageJsonAndCheckBunyanDeps(rootDir, label, fail) {
419
+ async function loadPackageJson(rootDir) {
398
420
  const pkgPath = join(rootDir, 'package.json')
399
421
  if (!existsSync(pkgPath)) return null
400
- const pkgJson = JSON.parse(await readFile(pkgPath, 'utf8'))
401
- // Заборону `@nitra/bunyan` / `bunyan` у dependencies/devDependencies перенесено
402
- // в Rego (`npm/policy/js_run/package_json/`); `bun run lint-conftest` запускає
403
- // її по всіх workspace `package.json`. Тут лишилася лише AST-перевірка імпортів.
404
- void label
405
- void fail
406
- return pkgJson
422
+ return JSON.parse(await readFile(pkgPath, 'utf8'))
407
423
  }
408
424
 
409
425
  /**
410
- * Перевіряє вміст `k8s/base/configmap.yaml` пакета на наявність OTEL_RESOURCE_ATTRIBUTES
411
- * з обов'язковими `service.name=` та `service.namespace=` всередині.
426
+ * Перевіряє наявність `k8s/base/configmap.yaml` пакета. Структуру (наявність
427
+ * `OTEL_RESOURCE_ATTRIBUTES` з обов'язковими `service.name=` / `service.namespace=`)
428
+ * перенесено в Rego (`npm/policy/js_run/configmap/`); `bun run lint-conftest`
429
+ * запускає її на всіх `k8s/base/configmap.yaml`.
412
430
  * @param {string} rootDir відносний шлях workspace
413
- * @param {string} label префікс повідомлення `[<pkg>] `
414
- * @param {(msg: string) => void} fail callback при помилці
415
431
  * @param {(msg: string) => void} passFn успішне повідомлення
416
- * @returns {Promise<void>} завершується після перевірки configmap
432
+ * @returns {void}
417
433
  */
418
- function checkOtelConfigmap(rootDir, label, fail, passFn) {
434
+ function checkOtelConfigmap(rootDir, passFn) {
419
435
  const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
420
436
  if (!existsSync(configmapPath)) return
421
- // Перевірку `OTEL_RESOURCE_ATTRIBUTES` має містити `service.name=` /
422
- // `service.namespace=` перенесено в Rego (`npm/policy/js_run/configmap/`);
423
- // `bun run lint-conftest` запускає її на всіх `k8s/base/configmap.yaml`.
424
- void label
425
- void fail
426
437
  passFn(`${rootDir}/k8s/base/configmap.yaml є (OTEL — bun run lint-conftest → js_run.configmap)`)
427
438
  }
428
439
 
@@ -325,6 +325,18 @@ const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
325
325
  const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
326
326
  const HTTPS_SCHEMA_RE = /^https:/iu
327
327
  const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
328
+ const BASE_CANON_MEMORY_RE = /^128Mi$/iu
329
+
330
+ /**
331
+ * Видаляє хвостові символи `\n` зі стрічки без regex (щоб не тригерити sonarjs/slow-regex).
332
+ * @param {string} s стрічка YAML/тексту
333
+ * @returns {string} стрічка без trailing newlines
334
+ */
335
+ function stripTrailingNewlines(s) {
336
+ let end = s.length
337
+ while (end > 0 && s.codePointAt(end - 1) === 10) end--
338
+ return end === s.length ? s : s.slice(0, end)
339
+ }
328
340
  const BATCH_V1BETA1_API_VERSION_LINE_RE = /^(\s*apiVersion:\s*)["']?batch\/v1beta1["']?(\s*)$/u
329
341
 
330
342
  /**
@@ -665,15 +677,25 @@ async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fa
665
677
  if (kust === null) continue
666
678
  const outer = kustomizationPatchesSortedViolation(kust)
667
679
  if (outer !== null) fail(`${rel}: ${outer}`)
668
- const patches = kust.patches
669
- if (!Array.isArray(patches)) continue
670
- for (const [i, p] of patches.entries()) {
671
- if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
672
- const rec = /** @type {Record<string, unknown>} */ (p)
673
- if (typeof rec.patch !== 'string') continue
674
- const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
675
- if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
676
- }
680
+ if (!Array.isArray(kust.patches)) continue
681
+ validateInlinePatchesSorted(rel, kust.patches, fail)
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Перевіряє, що inline-`patch:` (рядок YAML/JSON) у кожному `patches[i]` має ops у канонічному порядку
687
+ * (`add`/`replace` за `path`). Чужі форми (без `patch`-стрічки, з `target` без inline-блока) пропускаються.
688
+ * @param {string} rel відносний шлях `kustomization.yaml` для повідомлень
689
+ * @param {unknown[]} patches масив `kust.patches` (рекордів)
690
+ * @param {(msg: string) => void} fail callback при порушенні
691
+ */
692
+ function validateInlinePatchesSorted(rel, patches, fail) {
693
+ for (const [i, p] of patches.entries()) {
694
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
695
+ const rec = /** @type {Record<string, unknown>} */ (p)
696
+ if (typeof rec.patch !== 'string') continue
697
+ const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
698
+ if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
677
699
  }
678
700
  }
679
701
 
@@ -2582,7 +2604,7 @@ function isBaseCanonCpuValue(cpu) {
2582
2604
  */
2583
2605
  function isBaseCanonMemoryValue(mem) {
2584
2606
  if (typeof mem !== 'string' || mem.trim() === '') return false
2585
- return /^128Mi$/iu.test(mem.trim())
2607
+ return BASE_CANON_MEMORY_RE.test(mem.trim())
2586
2608
  }
2587
2609
 
2588
2610
  /**
@@ -5212,13 +5234,11 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
5212
5234
  ok = false
5213
5235
  }
5214
5236
  }
5215
- if (needs.needsPdbMinAvailablePatch) {
5216
- if (!pdbPaths.has('/spec/minAvailable')) {
5217
- fail(
5218
- `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5219
- )
5220
- ok = false
5221
- }
5237
+ if (needs.needsPdbMinAvailablePatch && !pdbPaths.has('/spec/minAvailable')) {
5238
+ fail(
5239
+ `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5240
+ )
5241
+ ok = false
5222
5242
  }
5223
5243
  if (ok) {
5224
5244
  passFn(`${rel}: прод-оверрайди HPA/PDB за потреби присутні (k8s.mdc)`)
@@ -5573,18 +5593,7 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5573
5593
  const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
5574
5594
  const deployRel = relDir === '' ? '.' : relDir
5575
5595
  if (isK8sBaseLayer && deployments.length > 0) {
5576
- const hpaAbs = join(dir, HPA_FILENAME)
5577
- if (existsSync(hpaAbs)) {
5578
- fail(
5579
- `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5580
- )
5581
- }
5582
- const pdbAbs = join(dir, PDB_FILENAME)
5583
- if (existsSync(pdbAbs)) {
5584
- fail(
5585
- `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5586
- )
5587
- }
5596
+ failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail)
5588
5597
  }
5589
5598
  const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5590
5599
  const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
@@ -5600,15 +5609,47 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5600
5609
  passFn
5601
5610
  )
5602
5611
  if (isK8sBaseLayer) {
5603
- const deployName = manifestMetadataName(deployment)
5604
- const appLabel = deploymentAppLabel(deployment)
5605
- if (deployName !== null && appLabel !== null) {
5606
- await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5607
- }
5612
+ await validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn)
5608
5613
  }
5609
5614
  }
5610
5615
  }
5611
5616
 
5617
+ /**
5618
+ * У шарі `…/k8s/…/base/` забороняє локальні `hpa.yaml` / `pdb.yaml` (вони мають жити у sibling `components/`).
5619
+ * @param {string} dir абсолютний каталог Deployment-маніфесту
5620
+ * @param {string} deployRel відносний шлях для повідомлень (`.` якщо корінь репо)
5621
+ * @param {(msg: string) => void} fail callback при порушенні
5622
+ */
5623
+ function failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail) {
5624
+ if (existsSync(join(dir, HPA_FILENAME))) {
5625
+ fail(
5626
+ `${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
5627
+ )
5628
+ }
5629
+ if (existsSync(join(dir, PDB_FILENAME))) {
5630
+ fail(
5631
+ `${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
5632
+ )
5633
+ }
5634
+ }
5635
+
5636
+ /**
5637
+ * Якщо у Deployment є `metadata.name` і `spec.selector.matchLabels.app` — викликає
5638
+ * `validateComponentsForBaseDeployment` для звірки sibling-`components/`. Без цих ключів
5639
+ * каталог `components/` неможливо звʼязати з конкретним Deployment, тож пропускаємо мовчки.
5640
+ * @param {Record<string, unknown>} deployment AST документа Deployment
5641
+ * @param {string} dir абсолютний каталог Deployment-маніфесту
5642
+ * @param {string} root абсолютний корінь репо
5643
+ * @param {(msg: string) => void} fail callback при порушенні
5644
+ * @param {(msg: string) => void} passFn callback при успіху
5645
+ */
5646
+ async function validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn) {
5647
+ const deployName = manifestMetadataName(deployment)
5648
+ const appLabel = deploymentAppLabel(deployment)
5649
+ if (deployName === null || appLabel === null) return
5650
+ await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
5651
+ }
5652
+
5612
5653
  /**
5613
5654
  * Витягує документи Deployment з YAML-файлу (повертає порожній масив, якщо файл недоступний або немає Deployment).
5614
5655
  * @param {string} filePath абсолютний шлях до YAML-файлу
@@ -6302,6 +6343,20 @@ function applyConversionsToDoc(doc, conversions) {
6302
6343
  const patchesNode = doc.get('patches', true)
6303
6344
  if (!isSeq(patchesNode)) return false
6304
6345
 
6346
+ applyPatchConversionsToPatchesNode(patchesNode, groupConversionsByPatchIndex(conversions))
6347
+ if (patchesNode.items.length === 0) {
6348
+ doc.delete('patches')
6349
+ }
6350
+ appendConvertedImagesNode(doc, conversions)
6351
+ return true
6352
+ }
6353
+
6354
+ /**
6355
+ * Згруповує конвертації за індексом `patches[i]` і збирає `opIdx`-список ops, які треба видалити.
6356
+ * @param {Array<{ index: number, opIndex: number, totalOps: number }>} conversions конвертації
6357
+ * @returns {Map<number, { totalOps: number, opIdx: number[] }>} згруповане
6358
+ */
6359
+ function groupConversionsByPatchIndex(conversions) {
6305
6360
  /** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
6306
6361
  const byPatch = new Map()
6307
6362
  for (const c of conversions) {
@@ -6309,39 +6364,61 @@ function applyConversionsToDoc(doc, conversions) {
6309
6364
  slot.opIdx.push(c.opIndex)
6310
6365
  byPatch.set(c.index, slot)
6311
6366
  }
6367
+ return byPatch
6368
+ }
6312
6369
 
6370
+ /**
6371
+ * Застосовує згруповані конвертації до `patches:` Sequence: видаляє повністю-конвертовані
6372
+ * patches або переписує inline `patch:` без конвертованих ops. Іде в порядку спадання
6373
+ * індексів, щоб зберегти стабільність вилучень з масиву.
6374
+ * @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
6375
+ * @param {Map<number, { totalOps: number, opIdx: number[] }>} byPatch згруповані конвертації
6376
+ */
6377
+ function applyPatchConversionsToPatchesNode(patchesNode, byPatch) {
6313
6378
  const sortedIdx = [...byPatch.keys()].toSorted((a, b) => b - a)
6314
6379
  for (const i of sortedIdx) {
6315
6380
  const slot = byPatch.get(i)
6316
6381
  if (slot === undefined) continue
6317
- const { totalOps, opIdx } = slot
6318
- if (opIdx.length === totalOps) {
6382
+ if (slot.opIdx.length === slot.totalOps) {
6319
6383
  patchesNode.delete(i)
6320
6384
  continue
6321
6385
  }
6322
- const patchEntry = patchesNode.get(i, true)
6323
- if (patchEntry === undefined || patchEntry === null) continue
6324
- const patchScalar = patchEntry.get('patch', true)
6325
- if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
6326
- const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6327
- if (rewritten === null) continue
6328
- patchScalar.value = rewritten
6386
+ rewriteInlinePatchAtIndex(patchesNode, i, slot.opIdx)
6329
6387
  }
6388
+ }
6330
6389
 
6331
- if (patchesNode.items.length === 0) {
6332
- doc.delete('patches')
6333
- }
6390
+ /**
6391
+ * Переписує inline `patch:` у `patches[i]`, видаляючи ops зі списку. Якщо вузол не знайдено
6392
+ * або переписування не вдалося — залишає Document без змін.
6393
+ * @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
6394
+ * @param {number} i індекс у `patches:`
6395
+ * @param {number[]} opIdx індекси ops для видалення
6396
+ */
6397
+ function rewriteInlinePatchAtIndex(patchesNode, i, opIdx) {
6398
+ const patchEntry = patchesNode.get(i, true)
6399
+ if (patchEntry === undefined || patchEntry === null) return
6400
+ const patchScalar = patchEntry.get('patch', true)
6401
+ if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') return
6402
+ const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6403
+ if (rewritten === null) return
6404
+ patchScalar.value = rewritten
6405
+ }
6334
6406
 
6335
- let imagesNode = doc.get('images', true)
6336
- if (!isSeq(imagesNode)) {
6337
- imagesNode = doc.createNode([])
6407
+ /**
6408
+ * Дописує `images:` Seq у Document результатами конвертацій (створює, якщо немає).
6409
+ * @param {import('yaml').Document} doc YAML Document
6410
+ * @param {Array<{ name: string, newName: string, newTag: string | null }>} conversions конвертації
6411
+ */
6412
+ function appendConvertedImagesNode(doc, conversions) {
6413
+ const existing = doc.get('images', true)
6414
+ const imagesNode = isSeq(existing) ? existing : doc.createNode([])
6415
+ if (existing !== imagesNode) {
6338
6416
  doc.set('images', imagesNode)
6339
6417
  }
6340
6418
  for (const { name, newName, newTag } of conversions) {
6341
6419
  const entry = newTag === null ? { name, newName } : { name, newName, newTag }
6342
6420
  imagesNode.add(doc.createNode(entry))
6343
6421
  }
6344
- return true
6345
6422
  }
6346
6423
 
6347
6424
  /**
@@ -6370,7 +6447,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6370
6447
  }
6371
6448
  if (seq.items.length === 0) return null
6372
6449
  seq.flow = false
6373
- return inner.toString().replace(/\n+$/u, '')
6450
+ return stripTrailingNewlines(inner.toString())
6374
6451
  }
6375
6452
 
6376
6453
  /**
@@ -33,9 +33,6 @@ const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
33
33
  /** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
34
34
  const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
35
35
 
36
- /** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
37
- const TYPES_INDEX = './types/index.d.ts'
38
-
39
36
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
40
37
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
41
38
 
@@ -198,7 +195,7 @@ async function gitDiffNameOnlyNpm() {
198
195
  async function gitShowNpmPackageVersionAt(refPath) {
199
196
  try {
200
197
  const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
201
- const m = stdout.match(/"version":\s*"([^"]+)"/)
198
+ const m = stdout.match(PACKAGE_JSON_VERSION_RE)
202
199
  return m ? m[1] : null
203
200
  } catch {
204
201
  return null
@@ -15,9 +15,9 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
15
15
 
16
16
  /**
17
17
  * Перевіряє відповідність проєкту правилам php.mdc.
18
- * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
18
+ * @returns {number} 0 — все OK, 1 — є проблеми
19
19
  */
20
- export async function check() {
20
+ export function check() {
21
21
  const reporter = createCheckReporter()
22
22
  const { pass, fail } = reporter
23
23
 
@@ -27,10 +27,10 @@ export async function check() {
27
27
  fail('composer.json не знайдено в корені — додай (php.mdc)')
28
28
  }
29
29
 
30
- if (!existsSync('package.json')) {
31
- fail('package.json не знайдено в корені — додай (php.mdc)')
32
- } else {
30
+ if (existsSync('package.json')) {
33
31
  pass('package.json є (наявність lint-php перевіряє bun run lint-conftest → php.package_json)')
32
+ } else {
33
+ fail('package.json не знайдено в корені — додай (php.mdc)')
34
34
  }
35
35
 
36
36
  const wfPath = '.github/workflows/lint-php.yml'
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
3
- * як агент сигналізує завершення ходу. Прозоро прокидає `npx @nitra/cursor check`
3
+ * як агент сигналізує завершення ходу. Прозоро прокидає `npx \@nitra/cursor check`
4
4
  * і повертає його exit code, щоб помилки правил блокували завершення.
5
5
  *
6
6
  * Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
@@ -8,7 +8,7 @@
8
8
  * виходимо з кодом 0 без повторного запуску перевірок.
9
9
  *
10
10
  * Виклик з `bin/n-cursor.js`:
11
- * `npx --no @nitra/cursor stop-hook`
11
+ * `npx --no \@nitra/cursor stop-hook`
12
12
  */
13
13
  import { spawn } from 'node:child_process'
14
14
  import { once } from 'node:events'