@nitra/cursor 3.1.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,21 @@
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
+
3
19
  ## [3.1.0] - 2026-06-01
4
20
 
5
21
  ### Added
package/bin/n-cursor.js CHANGED
@@ -651,7 +651,7 @@ function buildClaudeWorktreeEnforcementSectionLines() {
651
651
  '',
652
652
  '## Worktree-only skills (`meta.json` → `worktree: true`)',
653
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.',
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
655
  ''
656
656
  ]
657
657
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.1.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.10'
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 принцип, наприклад:
@@ -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,10 @@
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'
34
40
  import { getNginxUnprivilegedUserHint } from '../lib/docker-nginx-user.mjs'
35
41
  import { lintDockerfileWithHadolint, posixRel } from '../lib/docker-hadolint.mjs'
36
42
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
@@ -109,15 +115,24 @@ const RUNTIME_IMAGES = /** @type {const} */ ([
109
115
  /** @type {RegExp} */
110
116
  const DEBIAN_VIA_MIRROR_RE = /^mirror\.gcr\.io\/library\/debian:(.+)$/i
111
117
 
118
+ /** Bun-рантайм як фінальний stage — легітимний лише за наявності нативного .node-аддона (див. docker.mdc). */
119
+ const BUN_RUNTIME_IMAGE = 'mirror.gcr.io/oven/bun'
120
+
112
121
  /**
113
122
  * Чи ref фінального `FROM` відповідає дозволеним у docker.mdc (multistage / runtime).
114
123
  * @param {string} lastLower ref без digest, lower case
124
+ * @param {boolean} [hasNativeAddon] чи проєкт залежить від нативного .node-аддона (sharp/@img/argon2)
115
125
  * @returns {boolean} true, якщо образ дозволений як фінальний runtime
116
126
  */
117
- function isAllowedFinalRuntimeImage(lastLower) {
127
+ function isAllowedFinalRuntimeImage(lastLower, hasNativeAddon = false) {
118
128
  if (lastLower === 'scratch' || lastLower.startsWith('scratch:')) {
119
129
  return true
120
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
+ }
121
136
  const deb = lastLower.match(DEBIAN_VIA_MIRROR_RE)
122
137
  if (deb) {
123
138
  return deb[1].toLowerCase().includes('slim')
@@ -150,11 +165,13 @@ export function splitDockerfileStages(fileContent) {
150
165
  /**
151
166
  * Перевіряє базові вимоги до структури Dockerfile:
152
167
  * - multistage: мінімум 2 FROM
153
- * - фінальний 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-рантайм)
154
170
  * @param {string} fileContent вміст Dockerfile/Containerfile
171
+ * @param {{ hasNativeAddon?: boolean }} [opts] опції: hasNativeAddon — є нативний .node-аддон (sharp/@img/argon2)
155
172
  * @returns {string | null} повідомлення помилки або null
156
173
  */
157
- export function getMultistageAndRuntimeHint(fileContent) {
174
+ export function getMultistageAndRuntimeHint(fileContent, { hasNativeAddon = false } = {}) {
158
175
  const stages = parseFromStages(fileContent)
159
176
  if (stages.length === 0) return null
160
177
 
@@ -166,7 +183,7 @@ export function getMultistageAndRuntimeHint(fileContent) {
166
183
  const lastImage = (last?.image || '').split('@')[0] || ''
167
184
  const lastLower = lastImage.toLowerCase()
168
185
 
169
- if (!isAllowedFinalRuntimeImage(lastLower)) {
186
+ if (!isAllowedFinalRuntimeImage(lastLower, hasNativeAddon)) {
170
187
  return `фінальний FROM має бути дозволеним runtime-образом (див. docker.mdc: multistage), зараз: ${last?.image} (рядок ${last?.line})`
171
188
  }
172
189
 
@@ -310,6 +327,32 @@ export async function check(cwd = process.cwd()) {
310
327
  return reporter.getExitCode()
311
328
  }
312
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
+
313
356
  /**
314
357
  * Перевіряє один Dockerfile/Containerfile: mirror.gcr.io, multistage/runtime, compile/non-root/nginx tag і hadolint.
315
358
  * @param {ReturnType<typeof createCheckReporter>} reporter репортер перевірок
@@ -322,14 +365,24 @@ async function checkDockerfile(reporter, root, abs) {
322
365
  const rel = posixRel(root, abs) || basename(abs)
323
366
  const content = await readFile(abs, 'utf8')
324
367
 
368
+ const nativeAddons = getNativeAddonDeps(await readNearestDependencies(abs, root))
369
+ const hasNativeAddon = nativeAddons.length > 0
370
+
325
371
  const hint = getMirrorGcrHint(content)
326
372
  if (hint) fail(`${rel} (mirror.gcr.io): ${hint}`)
327
373
 
328
- const multistageHint = getMultistageAndRuntimeHint(content)
374
+ const multistageHint = getMultistageAndRuntimeHint(content, { hasNativeAddon })
329
375
  if (multistageHint) fail(`${rel} (multistage): ${multistageHint}`)
330
376
 
331
- const compileHint = getBunCompileHint(content)
332
- 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
+ }
333
386
 
334
387
  const nonRootHint = getNonRootRuntimeHint(content)
335
388
  if (nonRootHint) fail(`${rel} (non-root): ${nonRootHint}`)
@@ -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
+ }
@@ -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
+ ```
@@ -8,32 +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 = `> [!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
- \`\`\``
15
+ const FALLBACK_SUFFIX = "task";
24
16
 
25
17
  /** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
26
- 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;
27
20
 
28
21
  /** Закриття YAML-frontmatter на початку файла. */
29
- 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
+ }
30
126
 
31
127
  /**
32
128
  * Канонічний блок worktree-інструкції.
129
+ * @param {string} content вміст `SKILL.md`
33
130
  * @returns {string} текст блоку від START до END
34
131
  */
35
- function buildBlock() {
36
- return `${WORKTREE_START}\n${NOTICE_BODY}\n${WORKTREE_END}`
132
+ function buildBlock(content) {
133
+ return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`;
37
134
  }
38
135
 
39
136
  /**
@@ -43,19 +140,19 @@ function buildBlock() {
43
140
  * @returns {string} оновлений вміст (ідемпотентно)
44
141
  */
45
142
  export function injectWorktreeNotice(content, enabled) {
46
- const hadBlock = content.includes(WORKTREE_START)
47
- const withoutBlock = content.replace(BLOCK_RE, '\n\n')
143
+ const hadBlock = content.includes(WORKTREE_START);
144
+ const withoutBlock = content.replace(BLOCK_RE, "\n\n");
48
145
 
49
146
  if (!enabled) {
50
- return hadBlock ? withoutBlock : content
147
+ return hadBlock ? withoutBlock : content;
51
148
  }
52
149
 
53
- const block = buildBlock()
54
- const fm = withoutBlock.match(FRONTMATTER_RE)
150
+ const block = buildBlock(withoutBlock);
151
+ const fm = withoutBlock.match(FRONTMATTER_RE);
55
152
  if (fm) {
56
- const head = fm[1]
57
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
58
- 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}`;
59
156
  }
60
- return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
157
+ return `${block}\n\n${withoutBlock.replace(/^\n+/u, "")}`;
61
158
  }