@nitra/cursor 3.0.0 → 3.2.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.2.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - docker: правило «нативний .node-аддон не компілювати» — sharp/@img/*/argon2 у deps + `bun build --compile` прапорцюється (компілятор не вшиває динамічний require → краш у рантаймі); канон — node_modules + `bun <entry>` на mirror.gcr.io/oven/bun:alpine (легітимний bun-рантайм як фінальний stage). lib/docker-native-addon.mjs + інтеграція в js/lint.mjs
8
+
9
+ ### Changed
10
+
11
+ - CLI sync: блоки Skills/Commands/Pi skills (разом із рядками ⬇) друкуються лише за наявності помилок — за чистого прогону вивід коротший
12
+ - js-lint-ci правило стало Auto Attached (glob як у js-lint) замість alwaysApply — не висить у кожному контексті, тригериться на JS/конфіги лінтерів
13
+ - правила js-lint: knip/dependency-аналіз перенесено з js-lint у js-lint-ci (його декларована зона); додано заборону @nitra/as-integrations-fastify з міграцією на @as-integrations/fastify ^3.1.0 (specifier-only, код незмінний)
14
+
15
+ ### Fixed
16
+
17
+ - worktree-only skills: preflight сам створює worktree від поточної гілки з коротким суфіксом без запиту назви
18
+
19
+ ## [3.1.0] - 2026-06-01
20
+
21
+ ### Added
22
+
23
+ - docker: для фінального nginx-unprivileged stage — окрема non-root-перевірка (lib/docker-nginx-user.mjs): жодного USER root/switch-back USER 101|nginx, COPY/ADD лише з --chown=nginx:nginx; gzip під дефолтним uid=101
24
+
25
+ ### Changed
26
+
27
+ - worktree-only skills: банер у SKILL.md став жорстким fail-fast preflight (Крок 0, STOP/ABORT) замість поради; CLAUDE.md отримав секцію-правило про worktree:true скіли
28
+ - vue: увесь стек auto-import (заборона value-імпортів з vue, вимога 'vue' у AutoImport.imports та наявності VueMacros/AutoImport у vite.config) не застосовується до бібліотек компонентів — пакетів із vue у peerDependencies (їхні джерела не проходять через unplugin-auto-import споживача); інші перевірки (esbuild, npm_lifecycle_event) лишаються; додано isVueComponentLibraryPkg
29
+
3
30
  ## [3.0.0] - 2026-06-01
4
31
 
5
32
  ### Added
package/bin/n-cursor.js CHANGED
@@ -641,6 +641,21 @@ function buildClaudeLintParallelismSectionLines() {
641
641
  ]
642
642
  }
643
643
 
644
+ /**
645
+ * Рендерить секцію для CLAUDE.md: скіли з `meta.json.worktree === true` запускаються
646
+ * лише в окремому git-worktree (дублює fail-fast банер `SKILL.md` як завжди-читане правило).
647
+ * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
648
+ */
649
+ function buildClaudeWorktreeEnforcementSectionLines() {
650
+ return [
651
+ '',
652
+ '## Worktree-only skills (`meta.json` → `worktree: true`)',
653
+ '',
654
+ 'Скіл із **`worktree: true`** у `meta.json` запускається **виключно** в окремому git-worktree (`.worktrees/<current-branch>-<suffix>/`) — **не** в основному дереві й **не** паралельно. Перший крок такого скіла (блок `n-cursor:worktree:start` у його `SKILL.md`) — **preflight**: якщо `git rev-parse --show-toplevel` не вказує під `.worktrees/`, **STOP** і не питай користувача про назву гілки; створи worktree від поточної гілки готовим snippet з `SKILL.md` за конвенцією `<current-branch>-<suffix>`. Чисте робоче дерево — **не** привід пропустити preflight.',
655
+ ''
656
+ ]
657
+ }
658
+
644
659
  /**
645
660
  * Рендерить секцію Skills для CLAUDE.md з урахуванням наявних slash-команд.
646
661
  * @returns {Promise<string[]>} готові рядки секції (або порожній масив)
@@ -701,6 +716,7 @@ async function syncClaudeMd(ignore) {
701
716
  }
702
717
 
703
718
  lines.push(...buildClaudeLintParallelismSectionLines())
719
+ lines.push(...buildClaudeWorktreeEnforcementSectionLines())
704
720
 
705
721
  const skillsSectionLines = await buildClaudeSkillsSectionLines()
706
722
  lines.push(...skillsSectionLines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Dockerfile — lint-docker / hadolint; перевірка check-docker
3
- version: '1.9'
3
+ version: '1.11'
4
4
  globs: "**/Dockerfile*"
5
5
  alwaysApply: false
6
6
  ---
@@ -25,7 +25,7 @@ alwaysApply: false
25
25
 
26
26
  ## компіляція
27
27
 
28
- Якщо проект має bun install крок, та не є фронтенд проектом (тобто не має bun build крок), то потрібно щоб була компіляція коду, і далі у фінальному образі був тільки бінарник і Цей образ не містив компілятора, npm, Bun — тільки runtime libs. Наприклад:
28
+ Якщо проект має bun install крок, та не є фронтенд проектом (тобто не має bun build крок), **і не має нативного `.node`-аддона з динамічним завантаженням** (див. наступну секцію), то потрібно щоб була компіляція коду, і далі у фінальному образі був тільки бінарник і Цей образ не містив компілятора, npm, Bun — тільки runtime libs. Наприклад:
29
29
 
30
30
  ```dockerfile
31
31
  FROM mirror.gcr.io/oven/bun:alpine AS build-env
@@ -56,6 +56,50 @@ COPY --from=build-env /app/app ./app
56
56
  CMD ["./app"]
57
57
  ```
58
58
 
59
+ ### виняток: нативний `.node`-аддон (sharp / @img/* / argon2)
60
+
61
+ Якщо в `package.json#dependencies` є нативний `.node`-аддон, який вантажиться через **динамічний `require`** — передусім **`sharp`**; той самий клас — **`@img/*`**, **`argon2`** — його **не можна** пакувати через `bun build --compile`.
62
+
63
+ `bun build --compile` не трейсить `require(\`@img/sharp-${platform}/sharp.node\`)` і не вшиває нативний біндинг → компільований бінарник падає в рантаймі: `Could not load the "sharp" module using the linuxmusl-arm64 runtime`. Доведено реальними docker-збірками (bun 1.3.14, sharp 0.34.5); відтворюється і на darwin-arm64 (тобто не musl/glibc-залежне). `apk add vips` **НЕ лікує** — він дає системний libvips, а бракує саме `sharp.node`.
64
+
65
+ Канон — ship `node_modules` і запускати через `bun <entry>` на базі `mirror.gcr.io/oven/bun:alpine` (це легітимний виняток до правила «лише alpine/scratch у фінальному stage» — тут потрібен саме bun-рантайм). База `oven/bun` уже має non-root користувача `bun` (uid/gid 1000). Entry бери з наявного `--outfile`-таргета / `package.json#main` / `scripts.start`; якщо не визначити — лиши TODO-маркер, не вгадуй.
66
+
67
+ Перевіряє **`npm/rules/docker/lib/docker-native-addon.mjs`** (підключено в **`npm/rules/docker/js/lint.mjs`**, точка входу обходу — **`npm/rules/docker/fix.mjs`**). Список нативних аддонів — розширювана константа `NATIVE_ADDON_PACKAGES` / `NATIVE_ADDON_SCOPES` у чек-модулі.
68
+
69
+ #### Антипатерн (це правило ловить)
70
+
71
+ ```dockerfile
72
+ RUN bun build --compile --outfile app ./src/index.js # ← з sharp у deps
73
+ FROM mirror.gcr.io/library/alpine:latest
74
+ RUN apk add --no-cache ... vips # системний vips не рятує
75
+ COPY --from=build-env --chown=app:app /app/app ./app
76
+ USER app
77
+ CMD ["./app"]
78
+ ```
79
+
80
+ #### Канон (привести до цього)
81
+
82
+ ```dockerfile
83
+ FROM mirror.gcr.io/oven/bun:alpine AS build-env
84
+ WORKDIR /app
85
+ ENV NODE_ENV=production
86
+ COPY package.json .
87
+ RUN bun install --production
88
+ COPY ./src ./src
89
+
90
+ FROM mirror.gcr.io/oven/bun:alpine
91
+ RUN apk add --no-cache tzdata
92
+ WORKDIR /app
93
+ # база oven/bun має non-root користувача bun (uid/gid 1000)
94
+ COPY --from=build-env --chown=bun:bun /app/node_modules ./node_modules
95
+ COPY --from=build-env --chown=bun:bun /app/src ./src
96
+ COPY --from=build-env --chown=bun:bun /app/package.json ./package.json
97
+ USER bun
98
+ CMD ["bun", "src/index.js"]
99
+ ```
100
+
101
+ Для проєктів **без** нативних аддонів standalone-бінарник на alpine лишається каноном (секція «компіляція» вище).
102
+
59
103
  ## не превілейований образ
60
104
 
61
105
  Для всих образів потрібно щоб використовувся non root принцип, наприклад:
@@ -88,6 +132,44 @@ CMD ["./app"]
88
132
 
89
133
  ```
90
134
 
135
+ ### nginx-unprivileged — без USER, із --chown
136
+
137
+ Окрема гілка для фронтенду на базі **`nginxinc/nginx-unprivileged`** (будь-який тег, з/без `mirror.gcr.io/`-префікса). Цей образ **уже** оголошує `USER 101` і `EXPOSE 8080`, тож у фінальному stage **не потрібні** жодні явні `USER`-інструкції:
138
+
139
+ - **жодного `USER root` / `USER 0`** для білд-кроків: він перезатирає успадкований `USER 101`, і якщо потім не повернути non-root — фінальний образ лишається root, а k8s із `runAsNonRoot: true` падає з `CreateContainerConfigError`;
140
+ - **жодного switch-back** `USER 101` / `USER nginx` наприкінці stage — це лише симптом зайвого `USER root` на початку (повертати треба саме **числовим** UID, бо kubelet не підтверджує non-root за іменем `nginx`). Канон — взагалі не виходити з-під дефолтного 101;
141
+ - **`COPY`/`ADD` лише з `--chown`** (канон — `--chown=nginx:nginx`): без нього файли копіюються власником root і дефолтний non-root користувач (uid=101) не зможе читати статику.
142
+
143
+ Build-stage не чіпаємо — там root і tooling норма. Перевіряє **`npm/rules/docker/lib/docker-nginx-user.mjs`** (підключено в **`npm/rules/docker/js/lint.mjs`**, точка входу обходу — **`npm/rules/docker/fix.mjs`**).
144
+
145
+ #### Антипатерн (це правило ловить)
146
+
147
+ ```dockerfile
148
+ FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim
149
+ USER root
150
+ COPY ./k8s/nginx.conf /etc/nginx/conf.d/default.conf
151
+ COPY --from=build /app/dist ./
152
+ RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
153
+ USER 101 # повернення назад — симптом того, що був зайвий USER root
154
+ EXPOSE 8080
155
+ ```
156
+
157
+ #### Канон (привести до цього)
158
+
159
+ ```dockerfile
160
+ FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim
161
+
162
+ COPY --chown=nginx:nginx ./k8s/nginx.conf /etc/nginx/conf.d/default.conf
163
+
164
+ WORKDIR /usr/share/nginx/html
165
+
166
+ COPY --from=build --chown=nginx:nginx /app/dist ./
167
+
168
+ RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
169
+ ```
170
+
171
+ (без жодного `USER`, gzip під дефолтним користувачем 101; `EXPOSE 8080` теж зайвий — база вже його оголошує)
172
+
91
173
  ## Область
92
174
 
93
175
  - Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`rules/docker/fix.mjs`**.
@@ -15,6 +15,11 @@
15
15
  * то очікується компіляція в один бінарник через `bun build --compile` у build stage, а у
16
16
  * фінальному stage не повинно залишатися build tooling (Bun/Node).
17
17
  *
18
+ * Виняток — проєкти з нативним `.node`-аддоном (sharp/@img/argon2), який вантажиться через
19
+ * динамічний `require`: їх НЕ можна компілювати (`bun build --compile` не вшиває нативний
20
+ * біндинг → краш у рантаймі). Для них канон — node_modules + `bun <entry>` на базі
21
+ * `mirror.gcr.io/oven/bun:alpine` (див. `../lib/docker-native-addon.mjs`).
22
+ *
18
23
  * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
19
24
  * дозволений runtime (alpine, scratch, debian slim, за потреби php/python, nginx або openresty).
20
25
  *
@@ -28,9 +33,11 @@
28
33
  * Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
29
34
  */
30
35
  import { readFile } from 'node:fs/promises'
31
- import { basename } from 'node:path'
36
+ import { basename, dirname, join } from 'node:path'
32
37
 
33
38
  import { getMirrorGcrHint, getFromImageToken } from '../lib/docker-mirror.mjs'
39
+ import { getNativeAddonDeps, getNativeAddonNoCompileHint } from '../lib/docker-native-addon.mjs'
40
+ import { getNginxUnprivilegedUserHint } from '../lib/docker-nginx-user.mjs'
34
41
  import { lintDockerfileWithHadolint, posixRel } from '../lib/docker-hadolint.mjs'
35
42
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
36
43
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
@@ -108,15 +115,24 @@ const RUNTIME_IMAGES = /** @type {const} */ ([
108
115
  /** @type {RegExp} */
109
116
  const DEBIAN_VIA_MIRROR_RE = /^mirror\.gcr\.io\/library\/debian:(.+)$/i
110
117
 
118
+ /** Bun-рантайм як фінальний stage — легітимний лише за наявності нативного .node-аддона (див. docker.mdc). */
119
+ const BUN_RUNTIME_IMAGE = 'mirror.gcr.io/oven/bun'
120
+
111
121
  /**
112
122
  * Чи ref фінального `FROM` відповідає дозволеним у docker.mdc (multistage / runtime).
113
123
  * @param {string} lastLower ref без digest, lower case
124
+ * @param {boolean} [hasNativeAddon] чи проєкт залежить від нативного .node-аддона (sharp/@img/argon2)
114
125
  * @returns {boolean} true, якщо образ дозволений як фінальний runtime
115
126
  */
116
- function isAllowedFinalRuntimeImage(lastLower) {
127
+ function isAllowedFinalRuntimeImage(lastLower, hasNativeAddon = false) {
117
128
  if (lastLower === 'scratch' || lastLower.startsWith('scratch:')) {
118
129
  return true
119
130
  }
131
+ // Для нативних аддонів канон — ship node_modules + `bun <entry>`, тож фінальний stage на
132
+ // mirror.gcr.io/oven/bun:* легітимний (compile неможливий, див. docker-native-addon.mjs).
133
+ if (hasNativeAddon && (lastLower === BUN_RUNTIME_IMAGE || lastLower.startsWith(`${BUN_RUNTIME_IMAGE}:`))) {
134
+ return true
135
+ }
120
136
  const deb = lastLower.match(DEBIAN_VIA_MIRROR_RE)
121
137
  if (deb) {
122
138
  return deb[1].toLowerCase().includes('slim')
@@ -149,11 +165,13 @@ export function splitDockerfileStages(fileContent) {
149
165
  /**
150
166
  * Перевіряє базові вимоги до структури Dockerfile:
151
167
  * - multistage: мінімум 2 FROM
152
- * - фінальний FROM: дозволені образи в docker.mdc (alpine, scratch, debian slim, php, python, nginx, openresty, …)
168
+ * - фінальний FROM: дозволені образи в docker.mdc (alpine, scratch, debian slim, php, python, nginx, openresty, …);
169
+ * для проєктів із нативним .node-аддоном додатково дозволено mirror.gcr.io/oven/bun:* (bun-рантайм)
153
170
  * @param {string} fileContent вміст Dockerfile/Containerfile
171
+ * @param {{ hasNativeAddon?: boolean }} [opts] опції: hasNativeAddon — є нативний .node-аддон (sharp/@img/argon2)
154
172
  * @returns {string | null} повідомлення помилки або null
155
173
  */
156
- export function getMultistageAndRuntimeHint(fileContent) {
174
+ export function getMultistageAndRuntimeHint(fileContent, { hasNativeAddon = false } = {}) {
157
175
  const stages = parseFromStages(fileContent)
158
176
  if (stages.length === 0) return null
159
177
 
@@ -165,7 +183,7 @@ export function getMultistageAndRuntimeHint(fileContent) {
165
183
  const lastImage = (last?.image || '').split('@')[0] || ''
166
184
  const lastLower = lastImage.toLowerCase()
167
185
 
168
- if (!isAllowedFinalRuntimeImage(lastLower)) {
186
+ if (!isAllowedFinalRuntimeImage(lastLower, hasNativeAddon)) {
169
187
  return `фінальний FROM має бути дозволеним runtime-образом (див. docker.mdc: multistage), зараз: ${last?.image} (рядок ${last?.line})`
170
188
  }
171
189
 
@@ -309,6 +327,32 @@ export async function check(cwd = process.cwd()) {
309
327
  return reporter.getExitCode()
310
328
  }
311
329
 
330
+ /**
331
+ * Читає `dependencies` найближчого `package.json` (від каталогу Dockerfile вгору до кореня репо).
332
+ * Build-контекст Dockerfile — зазвичай його ж каталог, тож беремо найбільш специфічний package.json.
333
+ * @param {string} abs абсолютний шлях до Dockerfile/Containerfile
334
+ * @param {string} root корінь репозиторію
335
+ * @returns {Promise<Record<string, unknown>>} dependencies або порожній об'єкт
336
+ */
337
+ async function readNearestDependencies(abs, root) {
338
+ let dir = dirname(abs)
339
+ for (;;) {
340
+ try {
341
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'))
342
+ const deps = pkg?.dependencies
343
+ if (deps && typeof deps === 'object' && !Array.isArray(deps)) return deps
344
+ return {}
345
+ } catch {
346
+ /* немає package.json у цьому каталозі — піднімаємось вище */
347
+ }
348
+ if (dir === root) break
349
+ const parent = dirname(dir)
350
+ if (parent === dir) break
351
+ dir = parent
352
+ }
353
+ return {}
354
+ }
355
+
312
356
  /**
313
357
  * Перевіряє один Dockerfile/Containerfile: mirror.gcr.io, multistage/runtime, compile/non-root/nginx tag і hadolint.
314
358
  * @param {ReturnType<typeof createCheckReporter>} reporter репортер перевірок
@@ -321,14 +365,24 @@ async function checkDockerfile(reporter, root, abs) {
321
365
  const rel = posixRel(root, abs) || basename(abs)
322
366
  const content = await readFile(abs, 'utf8')
323
367
 
368
+ const nativeAddons = getNativeAddonDeps(await readNearestDependencies(abs, root))
369
+ const hasNativeAddon = nativeAddons.length > 0
370
+
324
371
  const hint = getMirrorGcrHint(content)
325
372
  if (hint) fail(`${rel} (mirror.gcr.io): ${hint}`)
326
373
 
327
- const multistageHint = getMultistageAndRuntimeHint(content)
374
+ const multistageHint = getMultistageAndRuntimeHint(content, { hasNativeAddon })
328
375
  if (multistageHint) fail(`${rel} (multistage): ${multistageHint}`)
329
376
 
330
- const compileHint = getBunCompileHint(content)
331
- if (compileHint) fail(`${rel} (compile): ${compileHint}`)
377
+ // Нативні .node-аддони (sharp/@img/argon2) керують окремо: compile заборонено (бінарник падає
378
+ // в рантаймі), канон — node_modules + `bun <entry>`. Генеричне compile-правило для них не діє.
379
+ if (hasNativeAddon) {
380
+ const nativeAddonHint = getNativeAddonNoCompileHint(content, nativeAddons)
381
+ if (nativeAddonHint) fail(`${rel} (native-addon): ${nativeAddonHint}`)
382
+ } else {
383
+ const compileHint = getBunCompileHint(content)
384
+ if (compileHint) fail(`${rel} (compile): ${compileHint}`)
385
+ }
332
386
 
333
387
  const nonRootHint = getNonRootRuntimeHint(content)
334
388
  if (nonRootHint) fail(`${rel} (non-root): ${nonRootHint}`)
@@ -336,6 +390,9 @@ async function checkDockerfile(reporter, root, abs) {
336
390
  const nginxSlimHint = getNginxAlpineSlimTagHint(content)
337
391
  if (nginxSlimHint) fail(`${rel} (nginx tag): ${nginxSlimHint}`)
338
392
 
393
+ const nginxUserHint = getNginxUnprivilegedUserHint(content)
394
+ if (nginxUserHint) fail(`${rel} (nginx non-root): ${nginxUserHint}`)
395
+
339
396
  const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
340
397
  const tail = (stdout + stderr).trim()
341
398
  if (ok) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Перевірка для проєктів, що залежать від нативного `.node`-аддона, який вантажиться через
3
+ * **динамічний `require`** (передусім **sharp**; той самий клас — `@img/*`, `argon2`).
4
+ *
5
+ * Такі аддони **не можна** пакувати через `bun build --compile`: компілятор не трейсить
6
+ * динамічний `require(\`@img/sharp-${platform}/sharp.node\`)` і не вшиває нативний біндинг, тож
7
+ * компільований бінарник падає в рантаймі (`Could not load the "sharp" module using the
8
+ * linuxmusl-arm64 runtime`). Доведено реальними docker-збірками (bun 1.3.14, sharp 0.34.5),
9
+ * відтворюється і на darwin-arm64 — тобто не musl/glibc-залежне. `apk add vips` НЕ лікує: він
10
+ * дає системний libvips, а бракує саме `sharp.node`.
11
+ *
12
+ * Канон для таких проєктів — НЕ компілювати, а ship `node_modules` і запускати через
13
+ * `bun <entry>` на базі `mirror.gcr.io/oven/bun:alpine` (див. docker.mdc: компіляція). Це
14
+ * легітимний виняток до правила «лише alpine/scratch у фінальному stage» — тут потрібен
15
+ * саме bun-рантайм.
16
+ *
17
+ * Це окрема гілка від генеричного compile-правила (`getBunCompileHint` у `../js/lint.mjs`):
18
+ * для проєктів БЕЗ нативних аддонів standalone-бінарник на alpine лишається каноном.
19
+ *
20
+ * Взірець структури dep-специфічного чек-модуля — сусідній `./docker-mirror.mjs`.
21
+ */
22
+
23
+ /**
24
+ * Розширюваний список нативних `.node`-аддонів із динамічним завантаженням біндингу.
25
+ * Точні імена пакетів.
26
+ */
27
+ export const NATIVE_ADDON_PACKAGES = /** @type {const} */ (['sharp', 'argon2'])
28
+
29
+ /**
30
+ * Scope-префікси нативних аддонів (будь-який пакет у scope трактуємо як нативний).
31
+ * `@img/*` — платформо-специфічні біндинги sharp (`@img/sharp-linuxmusl-arm64` тощо).
32
+ */
33
+ export const NATIVE_ADDON_SCOPES = /** @type {const} */ (['@img/'])
34
+
35
+ const BUN_BUILD_COMPILE_RE = /\bbun\s+build\b[^\n]*\s--compile\b/iu
36
+ const APK_ADD_VIPS_RE = /\bapk\s+add\b[^\n]*\bvips\b/iu
37
+
38
+ /**
39
+ * Чи ім'я пакета — нативний `.node`-аддон зі списку (точне ім'я або scope-префікс).
40
+ * @param {string} name — ім'я npm-пакета
41
+ * @returns {boolean} true, якщо це знаний нативний аддон
42
+ */
43
+ export function isNativeAddonPackage(name) {
44
+ if (NATIVE_ADDON_PACKAGES.includes(/** @type {never} */ (name))) return true
45
+ return NATIVE_ADDON_SCOPES.some(scope => name.startsWith(scope))
46
+ }
47
+
48
+ /**
49
+ * Повертає імена нативних аддонів, наявних у `dependencies` пакета.
50
+ * @param {unknown} dependencies — об'єкт `package.json#dependencies`
51
+ * @returns {string[]} відсортовані імена знайдених нативних аддонів (порожній — якщо немає)
52
+ */
53
+ export function getNativeAddonDeps(dependencies) {
54
+ if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) return []
55
+ return Object.keys(dependencies)
56
+ .filter(name => isNativeAddonPackage(name))
57
+ .toSorted((a, b) => a.localeCompare(b))
58
+ }
59
+
60
+ /**
61
+ * Перевіряє антипатерн «нативний аддон + `bun build --compile`».
62
+ *
63
+ * Тригер:
64
+ * - у `package.json#dependencies` є нативний аддон (`getNativeAddonDeps` непорожній);
65
+ * - **і** Dockerfile містить `bun build --compile`.
66
+ *
67
+ * Прапорцює compile-крок як помилку; додатково — зайвий `apk add ... vips`, доданий для
68
+ * компенсації (системний vips не рятує). Канон описано в docker.mdc.
69
+ * @param {string} fileContent — вміст Dockerfile/Containerfile
70
+ * @param {string[]} nativeAddons — знайдені нативні аддони (з `getNativeAddonDeps`)
71
+ * @returns {string | null} повідомлення помилки або null
72
+ */
73
+ export function getNativeAddonNoCompileHint(fileContent, nativeAddons) {
74
+ if (!Array.isArray(nativeAddons) || nativeAddons.length === 0) return null
75
+ if (!BUN_BUILD_COMPILE_RE.test(fileContent)) return null
76
+
77
+ /** @type {string[]} */
78
+ const problems = [
79
+ `проєкт залежить від нативного .node-аддона (${nativeAddons.join(', ')}) з динамічним require — ` +
80
+ '`bun build --compile` не вшиває нативний біндинг, тож бінарник падає в рантаймі. ' +
81
+ 'Прибери compile-крок: ship node_modules + `bun <entry>` на базі mirror.gcr.io/oven/bun:alpine ' +
82
+ '(docker.mdc: компіляція). Entry бери з наявного --outfile-таргета / package.json#main / ' +
83
+ 'scripts.start; якщо не визначити — лиши TODO-маркер, не вгадуй'
84
+ ]
85
+
86
+ if (APK_ADD_VIPS_RE.test(fileContent)) {
87
+ problems.push(
88
+ 'зайвий `apk add ... vips` — системний libvips не лікує брак `sharp.node`; прибери разом із compile-кроком'
89
+ )
90
+ }
91
+
92
+ return problems.join('\n - ')
93
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Перевірка для фінального (runtime) stage на базі `nginxinc/nginx-unprivileged`.
3
+ *
4
+ * Образ `nginx-unprivileged` уже оголошує `USER 101` і `EXPOSE 8080`, тож у Dockerfile
5
+ * **не повинно** бути жодних явних `USER`-інструкцій у цьому stage:
6
+ *
7
+ * - `USER root` (або `USER 0`) для білд-кроків перезатирає успадкований `USER 101`; якщо потім
8
+ * не повернути non-root — фінальний образ лишається root, і k8s із `runAsNonRoot: true` падає з
9
+ * `CreateContainerConfigError`. Повертати треба саме **числовим** UID (`USER 101`), бо kubelet не
10
+ * підтверджує non-root за іменем `nginx` — тож повернення `USER 101`/`USER nginx` у кінці stage
11
+ * є симптомом зайвого `USER root` на початку.
12
+ * - Найбезпечніший канон — взагалі не виходити з-під дефолтного 101: ні `USER root`, ні
13
+ * switch-back. Будь-який явний `USER` у такому stage прапорцюється як зайвий.
14
+ *
15
+ * Крім того, `COPY`/`ADD` без `--chown` копіює файли власником root — їх не зможе читати дефолтний
16
+ * non-root користувач (uid=101); тому в цьому stage кожен `COPY`/`ADD` має мати `--chown` (канон —
17
+ * `--chown=nginx:nginx`).
18
+ *
19
+ * Це окрема гілка від генеричного non-root-правила (`addgroup/adduser` + `USER app` для alpine-бекендів,
20
+ * див. `getNonRootRuntimeHint` у `../js/lint.mjs`): для nginx канон — навпаки, **відсутність** `USER`.
21
+ *
22
+ * Тригер — лише фінальний `FROM`, що базується на `nginxinc/nginx-unprivileged` (з урахуванням
23
+ * `mirror.gcr.io/…`-префікса й будь-якого тега). Build-stage-и не чіпаємо — там root і tooling норма.
24
+ *
25
+ * Взірець структури base-image-специфічного чек-модуля — сусідній `./docker-mirror.mjs`.
26
+ */
27
+ import { getFromImageToken } from './docker-mirror.mjs'
28
+
29
+ const NEWLINE_RE = /\r?\n/
30
+ const USER_LINE_RE = /^\s*USER\s+([^\s#]+)/iu
31
+ const COPY_ADD_RE = /^\s*(COPY|ADD)\b(.*)$/iu
32
+ const CHOWN_FLAG_RE = /(?:^|\s)--chown=/iu
33
+
34
+ /** Шлях репозиторію nginx-unprivileged (після зняття `mirror.gcr.io/`/`docker.io/`-префікса й тега/digest). */
35
+ const NGINX_UNPRIVILEGED_REPO_RE = /(?:^|\/)nginxinc\/nginx-unprivileged(?::|@|$)/iu
36
+
37
+ /**
38
+ * Чи базується ref `FROM` на образі `nginxinc/nginx-unprivileged` (будь-який тег, з/без `mirror.gcr.io/`).
39
+ * @param {string} image — токен образу після `FROM`
40
+ * @returns {boolean} true, якщо це nginx-unprivileged
41
+ */
42
+ export function isNginxUnprivilegedImage(image) {
43
+ return NGINX_UNPRIVILEGED_REPO_RE.test((image || '').trim())
44
+ }
45
+
46
+ /**
47
+ * @typedef {{ image: string, lines: Array<{ lineNo: number, text: string }> }} FinalStage
48
+ */
49
+
50
+ /**
51
+ * Виділяє фінальний (останній `FROM` … кінець файла) stage з номерами рядків.
52
+ * @param {string} fileContent — вміст Dockerfile/Containerfile
53
+ * @returns {FinalStage | null} фінальний stage або null, якщо `FROM` немає
54
+ */
55
+ function getFinalStage(fileContent) {
56
+ const lines = fileContent.split(NEWLINE_RE)
57
+ /** @type {{ image: string, idx: number } | null} */
58
+ let lastFrom = null
59
+ for (const [idx, line] of lines.entries()) {
60
+ const image = getFromImageToken(line)
61
+ if (image) lastFrom = { image, idx }
62
+ }
63
+ if (!lastFrom) return null
64
+ const stageLines = lines.slice(lastFrom.idx).map((text, i) => ({ lineNo: lastFrom.idx + i + 1, text }))
65
+ return { image: lastFrom.image, lines: stageLines }
66
+ }
67
+
68
+ /**
69
+ * Нормалізує токен `USER` (без лапок, lower-case, без зайвих пробілів).
70
+ * @param {string} token — захоплений токен після `USER`
71
+ * @returns {string} нормалізований токен
72
+ */
73
+ function normalizeUserToken(token) {
74
+ return token.replaceAll('"', '').replaceAll("'", '').trim().toLowerCase()
75
+ }
76
+
77
+ /**
78
+ * Перевіряє фінальний nginx-unprivileged stage на зайві `USER` і `COPY`/`ADD` без `--chown`.
79
+ *
80
+ * Збирає всі порушення в один рядок (по одному пункту на рядок) — щоб один прогін показав і
81
+ * `USER root`, і switch-back `USER 101`, і `COPY` без `--chown` одразу.
82
+ * @param {string} fileContent — вміст Dockerfile/Containerfile
83
+ * @returns {string | null} повідомлення помилки або null, якщо порушень немає / це не nginx-stage
84
+ */
85
+ export function getNginxUnprivilegedUserHint(fileContent) {
86
+ const stage = getFinalStage(fileContent)
87
+ if (!stage) return null
88
+ if (!isNginxUnprivilegedImage(stage.image)) return null
89
+
90
+ /** @type {string[]} */
91
+ const problems = []
92
+ // Перший рядок stage — це сам FROM; USER/COPY у ньому неможливі, але цикл лишаємо загальним.
93
+ for (const { lineNo, text } of stage.lines) {
94
+ const u = text.match(USER_LINE_RE)
95
+ if (u) {
96
+ const token = normalizeUserToken(u[1])
97
+ if (token === 'root' || token === '0') {
98
+ problems.push(
99
+ `рядок ${lineNo}: прибери \`USER ${u[1]}\` — у nginx-unprivileged не можна перемикатися на root (інакше фінальний образ лишиться root і k8s із runAsNonRoot впаде)`
100
+ )
101
+ } else if (token === '101' || token === 'nginx') {
102
+ problems.push(
103
+ `рядок ${lineNo}: прибери зайвий \`USER ${u[1]}\` — база nginx-unprivileged вже працює від uid=101 (повернення USER назад — симптом зайвого USER root)`
104
+ )
105
+ } else {
106
+ problems.push(
107
+ `рядок ${lineNo}: прибери явний \`USER ${u[1]}\` — база nginx-unprivileged вже працює від non-root (uid=101), окремий USER не потрібен`
108
+ )
109
+ }
110
+ continue
111
+ }
112
+ const c = text.match(COPY_ADD_RE)
113
+ if (c && !CHOWN_FLAG_RE.test(text)) {
114
+ problems.push(
115
+ `рядок ${lineNo}: додай \`--chown=nginx:nginx\` до \`${c[1].toUpperCase()}\` — статику має читати non-root користувач (uid=101)`
116
+ )
117
+ }
118
+ }
119
+
120
+ if (problems.length === 0) return null
121
+ return problems.join('\n - ')
122
+ }
@@ -1 +1 @@
1
- {}
1
+ { "auto": "завжди" }
@@ -2,10 +2,10 @@
2
2
  description: Перевірка JavaScript коду
3
3
  globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
4
4
  alwaysApply: false
5
- version: '1.28'
5
+ version: '1.29'
6
6
  ---
7
7
 
8
- **oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config`** — версія не нижче канонічного мінімуму зі snippet нижче (semver-поріг, єдине джерело істини) (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd/knip не додавай без потреби монорепо.
8
+ **oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config`** — версія не нижче канонічного мінімуму зі snippet нижче (semver-поріг, єдине джерело істини) (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint). Dependency-політику CI-етапу (`@e18e/eslint-plugin` і oxlint/eslint/jscpd/knip окремо не додавати) винесено в `js-lint-ci`.
9
9
 
10
10
  У кожному **`package.json`** проєкту (корінь і всі workspace-пакети) має бути **`"type": "module"`** — весь код у ESM.
11
11
 
@@ -62,13 +62,7 @@ version: '1.28'
62
62
 
63
63
  ## knip
64
64
 
65
- Перевірку невикористаних залежностей і експортів виконує **knip** (заміна `depcheck`). Викликається у скрипті `lint-js` і в CI разом з oxlint/eslint/jscpd — окремий крок у CI не потрібен.
66
-
67
- У корені проєкту має бути **`knip.json`**, який стартує з канонічного baseline з пакета `@nitra/cursor` — файл [`npm/rules/js-lint/js/tooling/knip-canonical.json`](./js/tooling/knip-canonical.json). Він покриває типові false-positives для наших правил: `entry` зі CLI-конфігами (eslint, stylelint, oxlint, jscpd, markdownlint-cli2, `commitlint`), `project` для `**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}`, `ignore` для `**/__fixtures__/**`, `ignoreDependencies` для пакетів, посилання на які є лише в не-JS-конфігах (`@nitra/cspell-dict`, `/@cspell\/dict-.+/`), і `ignoreBinaries` для CLI, які канон вимагає викликати через `npx`/`bunx` і яких заборонено додавати в `devDependencies` (`actionlint`, `cspell`, `depcheck`, `eslint`, `git-ai`, `jscpd`, `markdownlint-cli2`, `oxfmt`, `oxlint`, `shellcheck`, `uvx`, `v8r`, `zizmor`).
68
-
69
- Якщо `knip.json` відсутній — `npx @nitra/cursor fix js-lint` копіює канон у корінь проєкту (side effect). Після створення модифікуй файл під свій проєкт як завгодно: перевіряємо лише наявність, зміст подальших змін не валідується.
70
-
71
- Пакет `knip` окремо в `devDependencies` не додавай — `bunx knip` тягне його ad-hoc, як oxlint/eslint/jscpd.
65
+ Залежнісний аналіз (knip невикористані залежності/експорти, `knip.json` канон) крос-файловий, тож винесений у правило `js-lint-ci` (`lint: ci`). Див. `js-lint-ci`.
72
66
 
73
67
  ## jscpd: рефакторинг і структура
74
68
 
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у lint-ci, по всьому репо.
3
- globs:
4
- alwaysApply: true
3
+ globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
4
+ alwaysApply: false
5
+ version: '1.0'
5
6
  ---
6
7
 
7
8
  # js-lint-ci — крос-файловий ci-етап
@@ -10,3 +11,35 @@ alwaysApply: true
10
11
  `npx @nitra/cursor lint-ci` (не у швидкому `lint` по змінених файлах). Per-file режиму нема.
11
12
 
12
13
  Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: quick`).
14
+
15
+ ## Залежнісна політика (що не додавати)
16
+
17
+ Залежнісний аналіз — крос-файлова зона цього ci-етапу, тож і політика «що не додавати в залежності» живе тут.
18
+
19
+ `@e18e/eslint-plugin` окремо не додавай — він уже в залежностях `@nitra/eslint-config` (з **3.8.0**), oxlint підвантажує його з `node_modules`. Пакети oxlint/eslint/jscpd/knip теж не додавай у `devDependencies` без потреби монорепо — `bunx` тягне їх ad-hoc.
20
+
21
+ ## knip
22
+
23
+ Перевірку невикористаних залежностей і експортів виконує **knip** (заміна `depcheck`). Викликається у скрипті `lint-js` і в CI разом з oxlint/eslint/jscpd — окремий крок у CI не потрібен.
24
+
25
+ У корені проєкту має бути **`knip.json`**, який стартує з канонічного baseline з пакета `@nitra/cursor` — файл [`npm/rules/js-lint/js/tooling/knip-canonical.json`](../js-lint/js/tooling/knip-canonical.json). Він покриває типові false-positives для наших правил: `entry` зі CLI-конфігами (eslint, stylelint, oxlint, jscpd, markdownlint-cli2, `commitlint`), `project` для `**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}`, `ignore` для `**/__fixtures__/**`, `ignoreDependencies` для пакетів, посилання на які є лише в не-JS-конфігах (`@nitra/cspell-dict`, `/@cspell\/dict-.+/`, `graphql`), і `ignoreBinaries` для CLI, які канон вимагає викликати через `npx`/`bunx` і яких заборонено додавати в `devDependencies` (`actionlint`, `cspell`, `eslint`, `git-ai`, `jscpd`, `markdownlint-cli2`, `oxfmt`, `oxlint`, `shellcheck`, `uvx`, `v8r`, `zizmor`).
26
+
27
+ Якщо `knip.json` відсутній — `npx @nitra/cursor fix js-lint` копіює канон у корінь проєкту (side effect). Після створення модифікуй файл під свій проєкт як завгодно: перевіряємо лише наявність, зміст подальших змін не валідується.
28
+
29
+ Пакет `knip` окремо в `devDependencies` не додавай — `bunx knip` тягне його ad-hoc, як oxlint/eslint/jscpd.
30
+
31
+ ## Заборона `@nitra/as-integrations-fastify`
32
+
33
+ Пакет **`@nitra/as-integrations-fastify`** заборонений у **`dependencies`**, **`peerDependencies`** та в import-specifier-ах. Це чистий републіш upstream, застряглий на peer `@apollo/server: "^4.0.0"`, тож на Apollo 5 `bun install` дає `warn: incorrect peer dependency`. Заміна — upstream **`@as-integrations/fastify`** (**`^3.1.0`**): peer `@apollo/server: "^4.0.0 || ^5.0.0"` + `fastify: "^5.3.0"`.
34
+
35
+ Як і knip, це крос-файлова dependency-політика ci-етапу, а не per-file перевірка — інваріант «per-file режиму нема» зберігається.
36
+
37
+ Міграція — лише specifier у трьох місцях: залежність у `package.json`, `import`, та `vi.mock(...)` / `await import(...)` у тестах. **Код не міняється**, експорти ті самі: `default` → `fastifyApollo`, named → `fastifyApolloDrainPlugin`.
38
+
39
+ ```javascript title="❌ до"
40
+ import fastifyApollo, { fastifyApolloDrainPlugin } from '@nitra/as-integrations-fastify'
41
+ ```
42
+
43
+ ```javascript title="✅ після"
44
+ import fastifyApollo, { fastifyApolloDrainPlugin } from '@as-integrations/fastify'
45
+ ```
@@ -226,6 +226,20 @@ async function checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail,
226
226
  passFn(`${prefix}jsconfig.json присутній`)
227
227
  }
228
228
 
229
+ /**
230
+ * Чи є пакет бібліотекою компонентів Vue — `vue` оголошено в `peerDependencies`.
231
+ *
232
+ * Такі пакети споживаються Vite-проєктами як залежність; їхні власні джерела **не** проходять
233
+ * через `unplugin-auto-import` споживача (auto-import резолвиться лише в коді самого додатка, не в
234
+ * `node_modules`). Тому в бібліотеці компонентів явні `import { … } from 'vue'` обовʼязкові, і правило
235
+ * авто-імпорту (заборона value-імпортів з `'vue'`) до неї **не** застосовується.
236
+ * @param {{ peerDependencies?: Record<string, string> }} pkg розпарсений package.json
237
+ * @returns {boolean} true, якщо `vue` присутній у `peerDependencies`
238
+ */
239
+ export function isVueComponentLibraryPkg(pkg) {
240
+ return Boolean(pkg?.peerDependencies?.vue)
241
+ }
242
+
229
243
  /**
230
244
  * Витягує текст аргументів першого виклику `AutoImport(` з vite.config зі збалансованими дужками.
231
245
  * Повертає `null`, якщо виклик не знайдено або дужки не збалансовані (тоді перевірка `'vue'`
@@ -266,13 +280,14 @@ function viteConfigHasVueInAutoImports(content) {
266
280
  /**
267
281
  * Перевіряє vite.config на наявність VueMacros і AutoImport.
268
282
  * @param {string} rootDir параметр rootDir
283
+ * @param {boolean} isComponentLibrary чи це бібліотека компонентів (vue у peerDependencies) — тоді auto-import не застосовується
269
284
  * @param {string} prefix параметр prefix
270
285
  * @param {(msg: string) => void} passFn callback при успішній перевірці
271
286
  * @param {(msg: string) => void} fail callback при помилці
272
287
  * @returns {Promise<{ hasVueAutoImport: boolean }>} ознака успішно сконфігурованого vue-auto-import (для checkVueImportViolations)
273
288
  * @param {string} cwd корінь репозиторію
274
289
  */
275
- async function checkViteConfig(rootDir, prefix, passFn, fail, cwd) {
290
+ async function checkViteConfig(rootDir, isComponentLibrary, prefix, passFn, fail, cwd) {
276
291
  const configFiles = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']
277
292
  const viteConfig = configFiles.find(f => existsSync(join(cwd, rootDir, f)))
278
293
  if (!viteConfig) {
@@ -283,27 +298,36 @@ async function checkViteConfig(rootDir, prefix, passFn, fail, cwd) {
283
298
  if (ESBUILD_RE.test(content)) {
284
299
  fail(`${prefix}${viteConfig} містить 'esbuild' — заміни на 'rolldown'`)
285
300
  }
286
- const checks = [
287
- { token: 'VueMacros', ok: `${viteConfig} використовує VueMacros`, err: `${viteConfig} не містить VueMacros` },
288
- { token: 'AutoImport', ok: `${viteConfig} використовує AutoImport`, err: `${viteConfig} не містить AutoImport` }
289
- ]
290
- for (const { token, ok, err } of checks) {
291
- if (content.includes(token)) {
292
- passFn(`${prefix}${ok}`)
293
- } else {
294
- fail(`${prefix}${err}`)
301
+ // VueMacros + AutoImport (і 'vue' у його imports) — інструментарій auto-import Vite-додатка;
302
+ // бібліотека компонентів (vue у peerDependencies) споживається готовою і не потребує цього стеку.
303
+ // npm_lifecycle_event (Bun-compat) перевіряємо нижче незалежно це не auto-import.
304
+ const hasVueAutoImport = viteConfigHasVueInAutoImports(content)
305
+ if (isComponentLibrary) {
306
+ passFn(
307
+ `${prefix}${viteConfig}: бібліотека компонентів (vue у peerDependencies) — VueMacros/AutoImport не вимагаються`
308
+ )
309
+ } else {
310
+ const checks = [
311
+ { token: 'VueMacros', ok: `${viteConfig} використовує VueMacros`, err: `${viteConfig} не містить VueMacros` },
312
+ { token: 'AutoImport', ok: `${viteConfig} використовує AutoImport`, err: `${viteConfig} не містить AutoImport` }
313
+ ]
314
+ for (const { token, ok, err } of checks) {
315
+ if (content.includes(token)) {
316
+ passFn(`${prefix}${ok}`)
317
+ } else {
318
+ fail(`${prefix}${err}`)
319
+ }
295
320
  }
296
- }
297
321
 
298
- const hasVueAutoImport = viteConfigHasVueInAutoImports(content)
299
- if (content.includes('AutoImport(')) {
300
- if (hasVueAutoImport) {
301
- passFn(`${prefix}${viteConfig}: AutoImport({ imports: [..., 'vue', ...] }) — value-імпорти з 'vue' покриті`)
302
- } else {
303
- fail(
304
- `${prefix}${viteConfig}: AutoImport не містить 'vue' у imports додай 'vue' (інакше прибирати ` +
305
- `value-імпорти на кшталт \`import { ref } from 'vue'\` небезпечно: ref/createApp тощо нікому буде надати)`
306
- )
322
+ if (content.includes('AutoImport(')) {
323
+ if (hasVueAutoImport) {
324
+ passFn(`${prefix}${viteConfig}: AutoImport({ imports: [..., 'vue', ...] }) — value-імпорти з 'vue' покриті`)
325
+ } else {
326
+ fail(
327
+ `${prefix}${viteConfig}: AutoImport не містить 'vue' у imports — додай 'vue' (інакше прибирати ` +
328
+ `value-імпорти на кшталт \`import { ref } from 'vue'\` небезпечно: ref/createApp тощо нікому буде надати)`
329
+ )
330
+ }
307
331
  }
308
332
  }
309
333
 
@@ -367,12 +391,29 @@ async function checkVueNodeImportViolations(rootDir, absPackageRoot, ignorePaths
367
391
  * @param {string} rootDir відносний шлях до пакета
368
392
  * @param {string} absPackageRoot абсолютний шлях до кореня пакета
369
393
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
394
+ * @param {boolean} isComponentLibrary чи це бібліотека компонентів (vue у peerDependencies) — її джерела не проходять auto-import
370
395
  * @param {boolean} hasVueAutoImport чи `AutoImport({ imports: [..., 'vue', ...] })` сконфігуровано
371
396
  * @param {string} prefix префікс повідомлення `[<pkg>] `
372
397
  * @param {(msg: string) => void} passFn callback при успішній перевірці
373
398
  * @param {(msg: string) => void} fail callback при помилці
374
399
  */
375
- async function checkVueImportViolations(rootDir, absPackageRoot, ignorePaths, hasVueAutoImport, prefix, passFn, fail) {
400
+ async function checkVueImportViolations(
401
+ rootDir,
402
+ absPackageRoot,
403
+ ignorePaths,
404
+ isComponentLibrary,
405
+ hasVueAutoImport,
406
+ prefix,
407
+ passFn,
408
+ fail
409
+ ) {
410
+ if (isComponentLibrary) {
411
+ passFn(
412
+ `${prefix}бібліотека компонентів (vue у peerDependencies) — явні value-імпорти з 'vue' дозволені ` +
413
+ `(джерела не проходять через unplugin-auto-import споживача)`
414
+ )
415
+ return
416
+ }
376
417
  if (!hasVueAutoImport) {
377
418
  passFn(`${prefix}value-імпорти з 'vue' не заборонені — спершу додай 'vue' до AutoImport.imports у vite.config`)
378
419
  return
@@ -409,38 +450,54 @@ async function checkVueImportViolations(rootDir, absPackageRoot, ignorePaths, ha
409
450
  /**
410
451
  * Перевіряє залежності та vite.config одного Vue-пакета.
411
452
  * @param {string} rootDir відносний шлях до пакета
453
+ * @param {boolean} isComponentLibrary чи це бібліотека компонентів (vue у peerDependencies) — auto-import не застосовується
412
454
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
413
455
  * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
414
456
  * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
415
457
  * @returns {Promise<void>} завершується після перевірок залежностей, `vite.config` і сканування джерел на імпорти з `vue`
416
458
  * @param {string} cwd корінь репозиторію
417
459
  */
418
- async function checkVuePackage(rootDir, ignorePaths, fail, passFn, cwd) {
460
+ async function checkVuePackage(rootDir, isComponentLibrary, ignorePaths, fail, passFn, cwd) {
419
461
  const prefix = `[${packageLabel(rootDir)}] `
420
462
  passFn(`${prefix}package.json залежності перевіряє npx @nitra/cursor fix → vue.package_json`)
421
463
 
422
464
  await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail, cwd)
423
465
 
424
- const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail, cwd)
425
- await checkVueImportViolations(rootDir, join(cwd, rootDir), ignorePaths, hasVueAutoImport, prefix, passFn, fail)
466
+ const { hasVueAutoImport } = await checkViteConfig(rootDir, isComponentLibrary, prefix, passFn, fail, cwd)
467
+ await checkVueImportViolations(
468
+ rootDir,
469
+ join(cwd, rootDir),
470
+ ignorePaths,
471
+ isComponentLibrary,
472
+ hasVueAutoImport,
473
+ prefix,
474
+ passFn,
475
+ fail
476
+ )
426
477
  await checkVueNodeImportViolations(rootDir, join(cwd, rootDir), ignorePaths, prefix, passFn, fail)
427
478
  await checkEsbuildMentions(rootDir, join(cwd, rootDir), ignorePaths, prefix, passFn, fail)
428
479
  }
429
480
 
430
481
  /**
431
- * Збирає корені пакетів, у яких у `dependencies` є `vue`.
482
+ * Збирає корені пакетів, у яких у `dependencies` є `vue`, із ознакою «бібліотека компонентів».
483
+ *
484
+ * Пакети, де `vue` лише в `peerDependencies` (без `dependencies`), — це бібліотеки компонентів, які
485
+ * споживаються Vite-додатками; вони не є самостійними Vite-проєктами, тож app-перевірки (vite-env,
486
+ * VueMacros тощо) до них не застосовуються — їх не збираємо. Якщо ж пакет має `vue` і в
487
+ * `dependencies` (повноцінний проєкт), і в `peerDependencies` — позначаємо `isComponentLibrary`,
488
+ * щоб вимкнути саме правило auto-import (його джерела не проходять через unplugin-auto-import споживача).
432
489
  * @param {string[]} roots усі корені пакетів monorepo
433
- * @returns {Promise<string[]>} перелік пакетів з vue у dependencies
490
+ * @returns {Promise<Array<{ rootDir: string, isComponentLibrary: boolean }>>} пакети з vue у dependencies
434
491
  * @param {string} cwd корінь репозиторію
435
492
  */
436
493
  async function collectVueRoots(roots, cwd) {
437
- /** @type {string[]} */
494
+ /** @type {Array<{ rootDir: string, isComponentLibrary: boolean }>} */
438
495
  const vueRoots = []
439
496
  for (const r of roots) {
440
497
  const p = join(cwd, r, 'package.json')
441
498
  if (!existsSync(p)) continue
442
499
  const pkg = JSON.parse(await readFile(p, 'utf8'))
443
- if (pkg.dependencies?.vue) vueRoots.push(r)
500
+ if (pkg.dependencies?.vue) vueRoots.push({ rootDir: r, isComponentLibrary: isVueComponentLibraryPkg(pkg) })
444
501
  }
445
502
  return vueRoots
446
503
  }
@@ -487,8 +544,8 @@ export async function check(cwd = process.cwd()) {
487
544
  await checkVueVolarRecommendation(pass, fail, cwd)
488
545
 
489
546
  const ignorePaths = await loadCursorIgnorePaths(cwd)
490
- for (const r of vueRoots) {
491
- await checkVuePackage(r, ignorePaths, fail, pass, cwd)
547
+ for (const { rootDir, isComponentLibrary } of vueRoots) {
548
+ await checkVuePackage(rootDir, isComponentLibrary, ignorePaths, fail, pass, cwd)
492
549
  }
493
550
 
494
551
  return reporter.getExitCode()
package/rules/vue/vue.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Vue
3
- version: '2.0'
3
+ version: '2.1'
4
4
  globs: "**/*.vue"
5
5
  alwaysApply: false
6
6
  ---
@@ -290,6 +290,8 @@ export default defineConfig({
290
290
 
291
291
  Потрібно використовувати unplugin-auto-import для автоматичного імпортування компонентів, composables, utils та інших функцій і прибирати з файлів усередині Vite проектів відповідні ручні імпорти, зокрема рядки виду `import { … } from 'vue'` — API Vue (`ref`, `computed`, `watch` тощо) мають підставлятися через auto-import, а не дублюватися явним імпортом з модуля `vue`.
292
292
 
293
+ **Виняток — бібліотеки компонентів (`vue` у `peerDependencies`).** Увесь стек auto-import застосовується лише коли `vue` підключено як звичайну `dependencies`. Якщо ж пакет оголошує `vue` у `peerDependencies` — це проєкт-бібліотека компонентів: його джерела споживаються Vite-додатками і **не** проходять через `unplugin-auto-import` споживача (auto-import резолвиться лише в коді самого додатка, не в `node_modules`). Тому до таких пакетів **не** застосовуються: заборона явних `import { ref, computed, … } from 'vue'` (вони обовʼязкові), вимога `'vue'` у `AutoImport.imports`, а також вимоги наявності `VueMacros` / `AutoImport` у `vite.config`. Решта перевірок (заборона `esbuild`, `process.env.npm_lifecycle_event` тощо) лишаються. Тригер винятку — `isVueComponentLibraryPkg` у `npm/rules/vue/js/packages.mjs`.
294
+
293
295
  Потрібно використовувати vite-plugin-vue-layouts-next для автоматичного імпортування layout компонентів.
294
296
 
295
297
  ## npm_lifecycle_event
@@ -8,26 +8,129 @@
8
8
  */
9
9
 
10
10
  /** Маркер початку worktree-блоку (стабільний, не залежить від тексту всередині). */
11
- export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
11
+ export const WORKTREE_START = "<!-- n-cursor:worktree:start -->";
12
12
  /** Маркер кінця worktree-блоку. */
13
- export const WORKTREE_END = '<!-- n-cursor:worktree:end -->'
13
+ export const WORKTREE_END = "<!-- n-cursor:worktree:end -->";
14
14
 
15
- const NOTICE_BODY =
16
- '> **Worktree:** виконуй цей скіл в окремому git-worktree (`git worktree add`); ' +
17
- '**не** запускай паралельно — один інстанс за раз.'
15
+ const FALLBACK_SUFFIX = "task";
18
16
 
19
17
  /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
20
- const BLOCK_RE = /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u
18
+ const BLOCK_RE =
19
+ /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u;
21
20
 
22
21
  /** Закриття YAML-frontmatter на початку файла. */
23
- const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u
22
+ const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u;
23
+
24
+ /** Значення `name` з YAML-frontmatter. */
25
+ const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu;
26
+
27
+ /** Перший H1 як fallback, якщо frontmatter не містить `name`. */
28
+ const H1_RE = /^#\s+(.+)$/mu;
29
+
30
+ const CYRILLIC_TRANSLIT = new Map(
31
+ Object.entries({
32
+ а: "a",
33
+ б: "b",
34
+ в: "v",
35
+ г: "h",
36
+ ґ: "g",
37
+ д: "d",
38
+ е: "e",
39
+ є: "ye",
40
+ ж: "zh",
41
+ з: "z",
42
+ и: "y",
43
+ і: "i",
44
+ ї: "yi",
45
+ й: "y",
46
+ к: "k",
47
+ л: "l",
48
+ м: "m",
49
+ н: "n",
50
+ о: "o",
51
+ п: "p",
52
+ р: "r",
53
+ с: "s",
54
+ т: "t",
55
+ у: "u",
56
+ ф: "f",
57
+ х: "kh",
58
+ ц: "ts",
59
+ ч: "ch",
60
+ ш: "sh",
61
+ щ: "shch",
62
+ ь: "",
63
+ ю: "yu",
64
+ я: "ya",
65
+ ы: "y",
66
+ э: "e",
67
+ ё: "yo",
68
+ ъ: "",
69
+ }),
70
+ );
71
+
72
+ /**
73
+ * Транслітерує кирилицю в ASCII для короткого suffix.
74
+ * @param {string} value вхідний текст
75
+ * @returns {string} транслітерований текст
76
+ */
77
+ function transliterate(value) {
78
+ return [...value.toLowerCase()]
79
+ .map((char) => CYRILLIC_TRANSLIT.get(char) ?? char)
80
+ .join("");
81
+ }
82
+
83
+ /**
84
+ * Робить короткий безпечний suffix для worktree-гілки з назви скіла.
85
+ * @param {string} content вміст `SKILL.md`
86
+ * @returns {string} suffix до 10 символів
87
+ */
88
+ function deriveSuffix(content) {
89
+ const raw =
90
+ content.match(NAME_RE)?.[1] ?? content.match(H1_RE)?.[1] ?? FALLBACK_SUFFIX;
91
+ const slug = transliterate(raw)
92
+ .trim()
93
+ .replace(/^n-/u, "")
94
+ .normalize("NFKD")
95
+ .replace(/[\u0300-\u036f]/gu, "")
96
+ .replace(/[^a-z0-9]+/gu, "-")
97
+ .replace(/^-+|-+$/gu, "");
98
+
99
+ return (
100
+ (slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, "") ||
101
+ FALLBACK_SUFFIX
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Тіло worktree-інструкції з конкретним суфіксом, щоб агент не питав назву гілки.
107
+ * @param {string} suffix короткий suffix задачі
108
+ * @returns {string} markdown-блок без маркерів
109
+ */
110
+ function buildNoticeBody(suffix) {
111
+ return `> [!IMPORTANT]
112
+ > **Worktree-only skill.** Виконується **виключно** в окремому git-worktree (\`.worktrees/<current-branch>-${suffix}/\`) і **не** паралелиться — один інстанс за раз.
113
+
114
+ **Крок 0 — preflight (обовʼязковий, перед будь-якими іншими діями).** Якщо перевірка падає — **STOP**: не питай користувача про назву гілки, а сам створи worktree від поточної гілки за конвенцією \`<current-branch>-${suffix}\`. Суфікс \`${suffix}\` — коротка (до 10 символів) транслітерація задачі. Не виконуй **жоден** наступний крок скіла, поки preflight не завершився успіхом.
115
+
116
+ \`\`\`bash
117
+ if ! git rev-parse --show-toplevel | grep -q '/\\.worktrees/'; then
118
+ B=$(git branch --show-current)
119
+ W="\${B}-${suffix}"
120
+ P="\${W//\\//-}"
121
+ npx @nitra/cursor worktree add "$W" "n-${suffix}: worktree-only skill"
122
+ cd ".worktrees/\${P}"
123
+ fi
124
+ \`\`\``;
125
+ }
24
126
 
25
127
  /**
26
128
  * Канонічний блок worktree-інструкції.
129
+ * @param {string} content вміст `SKILL.md`
27
130
  * @returns {string} текст блоку від START до END
28
131
  */
29
- function buildBlock() {
30
- return `${WORKTREE_START}\n${NOTICE_BODY}\n${WORKTREE_END}`
132
+ function buildBlock(content) {
133
+ return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`;
31
134
  }
32
135
 
33
136
  /**
@@ -37,19 +140,19 @@ function buildBlock() {
37
140
  * @returns {string} оновлений вміст (ідемпотентно)
38
141
  */
39
142
  export function injectWorktreeNotice(content, enabled) {
40
- const hadBlock = content.includes(WORKTREE_START)
41
- const withoutBlock = content.replace(BLOCK_RE, '\n\n')
143
+ const hadBlock = content.includes(WORKTREE_START);
144
+ const withoutBlock = content.replace(BLOCK_RE, "\n\n");
42
145
 
43
146
  if (!enabled) {
44
- return hadBlock ? withoutBlock : content
147
+ return hadBlock ? withoutBlock : content;
45
148
  }
46
149
 
47
- const block = buildBlock()
48
- const fm = withoutBlock.match(FRONTMATTER_RE)
150
+ const block = buildBlock(withoutBlock);
151
+ const fm = withoutBlock.match(FRONTMATTER_RE);
49
152
  if (fm) {
50
- const head = fm[1]
51
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
52
- return `${head}\n${block}\n\n${rest}`
153
+ const head = fm[1];
154
+ const rest = withoutBlock.slice(head.length).replace(/^\n+/u, "");
155
+ return `${head}\n${block}\n\n${rest}`;
53
156
  }
54
- return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
157
+ return `${block}\n\n${withoutBlock.replace(/^\n+/u, "")}`;
55
158
  }