@nitra/cursor 2.0.0 → 3.1.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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.1.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - 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
8
+
9
+ ### Changed
10
+
11
+ - worktree-only skills: банер у SKILL.md став жорстким fail-fast preflight (Крок 0, STOP/ABORT) замість поради; CLAUDE.md отримав секцію-правило про worktree:true скіли
12
+ - vue: увесь стек auto-import (заборона value-імпортів з vue, вимога 'vue' у AutoImport.imports та наявності VueMacros/AutoImport у vite.config) не застосовується до бібліотек компонентів — пакетів із vue у peerDependencies (їхні джерела не проходять через unplugin-auto-import споживача); інші перевірки (esbuild, npm_lifecycle_event) лишаються; додано isVueComponentLibraryPkg
13
+
14
+ ## [3.0.0] - 2026-06-01
15
+
16
+ ### Added
17
+
18
+ - n-cursor flow (v2.0-a Ф0-Ф1.1): каркас dispatcher + CLI `case 'flow'` (init/verify/release/run/resume/cancel/repair — поки stub-и), Capability Router з явною декларацією моделі (native/polyfill, default→polyfill лише за наявного runner-а), crash-safe state-store (.flow.json sibling, atomic temp+fsync+rename, fail-closed на corruption). 29 unit-тестів (withTmpDir).
19
+ - n-cursor flow v2.0-a Ф1.2-Ф1.4: WAL-журнал .events.jsonl (append-only, торований останній рядок толерується), per-branch lock через reuse withLock із fail-closed override (додано опцію onWaitTimeout у спільний with-lock, back-compat), cleanupFlowSiblings (.flow.json/.events.jsonl/lock). recordTransition (WAL: подія до зміни статусу). +22 тести.
20
+ - n-cursor flow v2.0-a Ф2 (verify): reviewer.mjs — Level-1 «Суддя» проганяє lint+coverage gates через ін'єктований runner (fail-fast, fingerprint на повному pass через reuse worktree-fingerprint); flow verify — Пасивний Турнікет: запускає gates у поточному worktree, записує результати+fingerprint у наявний стан (recordTransition), exit 0/1. +11 тестів.
21
+ - n-cursor flow v2.0-a Ф2·2: flow init (n-cursor worktree add + detect-existing-isolation через git rev-parse, init .flow.json з base_commit; reuse worktreePaths/sanitizeBranch) + flow release (n-cursor change + completion snapshot у стан і task record) + snapshot.mjs (buildCompletionSnapshot/upsertSummaryBlock/writeSummaryToTaskRecord). reviewer тепер захоплює вивід проваленого gate. Пасивний Турнікет (init/verify/release) завершено. +18 тестів.
22
+ - n-cursor flow Ф2·4: bundled-правило flow (матеріалізується як .cursor/rules/n-flow.mdc) — контракт Пасивного Турнікета для IDE-агентів (Cursor/Claude Code): init -> сам пишеш код (TDD) -> verify (3 спроби на фейл) -> release. alwaysApply; pure-doc + стандартний fix.mjs (runStandardRule). Авто-дискавериться, активується при sync. Завершує Фасад A повністю.
23
+ - n-cursor flow v2.0-a Ф3 (двигун-блоки): SubagentRunner (§15.1) — selectBackend (sdk>claude>cursor за ANTHROPIC_API_KEY/PATH, fail коли нічого нема), cliRunner (claude/cursor-agent -p, CLI-auth), sdkRunner (claude-agent-sdk, dynamic import); planner — parsePlan (валідація+нормалізація, fail-closed на невалідному) + generatePlan. Усе з мок-ін'єкцією (нуль реальних API/SDK у тестах). +24 тести.
24
+ - n-cursor flow v2.0-a Ф4·a: executor.mjs — серце Активного Раннера. Виконує план покроково: мікропромпт зі стану (не історія) -> спавн субагента -> verify -> commit ЛИШЕ після зеленого (commit-інваріант §4.1.7) -> repair (per-step retry) -> на вичерпанні HITL (blocked-on-human + structured question). microprompt/patchStep — pure. Усе з ін'єкцією runner/verify/commit (нуль реальних LLM/git у тестах). +6 тестів.
25
+ - n-cursor flow v2.0-a Ф4·b: Активний Раннер end-to-end. flow run (ensureWorktree -> planner -> executor; exit 0 done / 1 fail / 2 blocked-on-human), flow resume (safe-resume git reset + HITL-відповіді як hint + свіжі спроби), flow cancel (cleanup sibling-ів), flow repair (--discard-step-work / діагностика). ensureWorktree витягнуто як спільне для init/run. Усі 7 підкоманд реальні. +12 тестів (103 у dispatcher). v2.0-a (Фасади A+B) функціонально завершено.
26
+ - n-cursor flow v2.0-a: (1) budget guard для flow run --autonomous (withBudget обгортає runner, BudgetExceeded при перевищенні maxApiCalls з .n-cursor.json flow.autonomous; на abort -> status failed, exit 1). (2) Ф1.4 борг закрито: worktree remove тепер reuse-кличе cleanupFlowSiblings (flow-sibling-и не осиротіють). Заодно виправлено передіснуючі switch-case-braces у worktree-cli. +4 тести.
27
+ - n-cursor flow v2.0-b: команда n-cursor trace — наскрізна простежуваність (§5.4/§7). Читає front-matter артефактів у docs/{tasks,specs,plans,adr}, будує ланцюг за лінками adr/spec/plan/change/task, флагує розриви (лінк на неіснуючий файл) з exit 1; --json для machine-readable. parseFrontMatter/analyze/render — pure, FS ін'єктовний. Підтверджено на власних spec<->plan. +10 тестів.
28
+
29
+ ### Changed
30
+
31
+ - n-cursor flow v2.0.0 (major): Dual-Mode Dispatcher — Пасивний Турнікет (flow init/verify/release) + Активний Раннер (flow run/resume/cancel/repair) + n-cursor trace + docs/{specs,plans} міграція
32
+
3
33
  ## [2.0.0] - 2026-05-31
4
34
 
5
35
  ### 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/<branch>/`) — **не** в основному дереві й **не** паралельно. Перший крок такого скіла (блок `n-cursor:worktree:start` у його `SKILL.md`) — **preflight**: якщо `git rev-parse --show-toplevel` не вказує під `.worktrees/`, **STOP** і спершу `npx @nitra/cursor worktree add <branch> "<навіщо>"` → `cd .worktrees/<branch>`. Чисте робоче дерево — **не** привід пропустити 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": "2.0.0",
3
+ "version": "3.1.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.10'
4
4
  globs: "**/Dockerfile*"
5
5
  alwaysApply: false
6
6
  ---
@@ -88,6 +88,44 @@ CMD ["./app"]
88
88
 
89
89
  ```
90
90
 
91
+ ### nginx-unprivileged — без USER, із --chown
92
+
93
+ Окрема гілка для фронтенду на базі **`nginxinc/nginx-unprivileged`** (будь-який тег, з/без `mirror.gcr.io/`-префікса). Цей образ **уже** оголошує `USER 101` і `EXPOSE 8080`, тож у фінальному stage **не потрібні** жодні явні `USER`-інструкції:
94
+
95
+ - **жодного `USER root` / `USER 0`** для білд-кроків: він перезатирає успадкований `USER 101`, і якщо потім не повернути non-root — фінальний образ лишається root, а k8s із `runAsNonRoot: true` падає з `CreateContainerConfigError`;
96
+ - **жодного switch-back** `USER 101` / `USER nginx` наприкінці stage — це лише симптом зайвого `USER root` на початку (повертати треба саме **числовим** UID, бо kubelet не підтверджує non-root за іменем `nginx`). Канон — взагалі не виходити з-під дефолтного 101;
97
+ - **`COPY`/`ADD` лише з `--chown`** (канон — `--chown=nginx:nginx`): без нього файли копіюються власником root і дефолтний non-root користувач (uid=101) не зможе читати статику.
98
+
99
+ Build-stage не чіпаємо — там root і tooling норма. Перевіряє **`npm/rules/docker/lib/docker-nginx-user.mjs`** (підключено в **`npm/rules/docker/js/lint.mjs`**, точка входу обходу — **`npm/rules/docker/fix.mjs`**).
100
+
101
+ #### Антипатерн (це правило ловить)
102
+
103
+ ```dockerfile
104
+ FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim
105
+ USER root
106
+ COPY ./k8s/nginx.conf /etc/nginx/conf.d/default.conf
107
+ COPY --from=build /app/dist ./
108
+ RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
109
+ USER 101 # повернення назад — симптом того, що був зайвий USER root
110
+ EXPOSE 8080
111
+ ```
112
+
113
+ #### Канон (привести до цього)
114
+
115
+ ```dockerfile
116
+ FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim
117
+
118
+ COPY --chown=nginx:nginx ./k8s/nginx.conf /etc/nginx/conf.d/default.conf
119
+
120
+ WORKDIR /usr/share/nginx/html
121
+
122
+ COPY --from=build --chown=nginx:nginx /app/dist ./
123
+
124
+ RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
125
+ ```
126
+
127
+ (без жодного `USER`, gzip під дефолтним користувачем 101; `EXPOSE 8080` теж зайвий — база вже його оголошує)
128
+
91
129
  ## Область
92
130
 
93
131
  - Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`rules/docker/fix.mjs`**.
@@ -31,6 +31,7 @@ import { readFile } from 'node:fs/promises'
31
31
  import { basename } from 'node:path'
32
32
 
33
33
  import { getMirrorGcrHint, getFromImageToken } from '../lib/docker-mirror.mjs'
34
+ import { getNginxUnprivilegedUserHint } from '../lib/docker-nginx-user.mjs'
34
35
  import { lintDockerfileWithHadolint, posixRel } from '../lib/docker-hadolint.mjs'
35
36
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
36
37
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
@@ -336,6 +337,9 @@ async function checkDockerfile(reporter, root, abs) {
336
337
  const nginxSlimHint = getNginxAlpineSlimTagHint(content)
337
338
  if (nginxSlimHint) fail(`${rel} (nginx tag): ${nginxSlimHint}`)
338
339
 
340
+ const nginxUserHint = getNginxUnprivilegedUserHint(content)
341
+ if (nginxUserHint) fail(`${rel} (nginx non-root): ${nginxUserHint}`)
342
+
339
343
  const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
340
344
  const tail = (stdout + stderr).trim()
341
345
  if (ok) {
@@ -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": "завжди" }
@@ -1 +1 @@
1
- { "lint": "ci" }
1
+ { "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "ci" }
@@ -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
@@ -12,9 +12,15 @@ export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
12
12
  /** Маркер кінця worktree-блоку. */
13
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 NOTICE_BODY = `> [!IMPORTANT]
16
+ > **Worktree-only skill.** Виконується **виключно** в окремому git-worktree (\`.worktrees/<branch>/\`) і **не** паралелиться — один інстанс за раз.
17
+
18
+ **Крок 0 — preflight (обовʼязковий, перед будь-якими іншими діями).** Якщо перевірка падає — **STOP**: створи worktree і лише тоді продовжуй. Не виконуй **жоден** наступний крок скіла, поки preflight не завершився успіхом.
19
+
20
+ \`\`\`bash
21
+ git rev-parse --show-toplevel | grep -q '/\\.worktrees/' \\
22
+ || { echo "ABORT: не у worktree. Спершу: npx @nitra/cursor worktree add <branch> \\"<навіщо>\\" && cd .worktrees/<branch>"; exit 1; }
23
+ \`\`\``
18
24
 
19
25
  /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
20
26
  const BLOCK_RE = /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u