@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 +27 -0
- package/bin/n-cursor.js +16 -0
- package/package.json +1 -1
- package/rules/docker/docker.mdc +84 -2
- package/rules/docker/js/lint.mjs +65 -8
- package/rules/docker/lib/docker-native-addon.mjs +93 -0
- package/rules/docker/lib/docker-nginx-user.mjs +122 -0
- package/rules/flow/meta.json +1 -1
- package/rules/js-lint/js-lint.mdc +3 -9
- package/rules/js-lint-ci/js-lint-ci.mdc +35 -2
- package/rules/vue/js/packages.mjs +87 -30
- package/rules/vue/vue.mdc +3 -1
- package/scripts/lib/worktree-notice.mjs +121 -18
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
package/rules/docker/docker.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Dockerfile — lint-docker / hadolint; перевірка check-docker
|
|
3
|
-
version: '1.
|
|
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`**.
|
package/rules/docker/js/lint.mjs
CHANGED
|
@@ -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
|
-
|
|
331
|
-
|
|
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
|
+
}
|
package/rules/flow/meta.json
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
491
|
-
await checkVuePackage(
|
|
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.
|
|
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 =
|
|
11
|
+
export const WORKTREE_START = "<!-- n-cursor:worktree:start -->";
|
|
12
12
|
/** Маркер кінця worktree-блоку. */
|
|
13
|
-
export const WORKTREE_END =
|
|
13
|
+
export const WORKTREE_END = "<!-- n-cursor:worktree:end -->";
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
'> **Worktree:** виконуй цей скіл в окремому git-worktree (`git worktree add`); ' +
|
|
17
|
-
'**не** запускай паралельно — один інстанс за раз.'
|
|
15
|
+
const FALLBACK_SUFFIX = "task";
|
|
18
16
|
|
|
19
17
|
/** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
|
|
20
|
-
const BLOCK_RE =
|
|
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${
|
|
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,
|
|
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
|
}
|