@nitra/cursor 3.1.0 → 3.2.1
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 +22 -0
- package/bin/n-cursor.js +1 -1
- package/package.json +1 -1
- package/rules/docker/docker.mdc +46 -2
- package/rules/docker/js/lint.mjs +61 -8
- package/rules/docker/lib/docker-native-addon.mjs +93 -0
- package/rules/js-lint/js-lint.mdc +3 -9
- package/rules/js-lint-ci/js-lint-ci.mdc +35 -2
- package/scripts/lib/worktree-notice.mjs +124 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.2.1] - 2026-06-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- worktree-only skills: прибрано shell expansion з preflight, щоб агент створював worktree literal-командами без confirmation prompt
|
|
8
|
+
|
|
9
|
+
## [3.2.0] - 2026-06-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 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
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- CLI sync: блоки Skills/Commands/Pi skills (разом із рядками ⬇) друкуються лише за наявності помилок — за чистого прогону вивід коротший
|
|
18
|
+
- js-lint-ci правило стало Auto Attached (glob як у js-lint) замість alwaysApply — не висить у кожному контексті, тригериться на JS/конфіги лінтерів
|
|
19
|
+
- правила js-lint: knip/dependency-аналіз перенесено з js-lint у js-lint-ci (його декларована зона); додано заборону @nitra/as-integrations-fastify з міграцією на @as-integrations/fastify ^3.1.0 (specifier-only, код незмінний)
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- worktree-only skills: preflight сам створює worktree від поточної гілки з коротким суфіксом без запиту назви
|
|
24
|
+
|
|
3
25
|
## [3.1.0] - 2026-06-01
|
|
4
26
|
|
|
5
27
|
### 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** і
|
|
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>` і без shell expansion (без command substitution, variable expansion чи backticks). Чисте робоче дерево — **не** привід пропустити preflight.',
|
|
655
655
|
''
|
|
656
656
|
]
|
|
657
657
|
}
|
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 принцип, наприклад:
|
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,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
|
-
|
|
332
|
-
|
|
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.
|
|
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
|
+
```
|
|
@@ -8,32 +8,133 @@
|
|
|
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-only skill.** Виконується **виключно** в окремому git-worktree (\`.worktrees/<branch>/\`) і **не** паралелиться — один інстанс за раз.
|
|
15
|
+
const FALLBACK_SUFFIX = "task";
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
/** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
|
|
18
|
+
const BLOCK_RE =
|
|
19
|
+
/\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u;
|
|
20
|
+
|
|
21
|
+
/** Закриття YAML-frontmatter на початку файла. */
|
|
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 не завершився успіхом.
|
|
19
115
|
|
|
20
116
|
\`\`\`bash
|
|
21
|
-
git rev-parse --show-toplevel
|
|
22
|
-
|
|
23
|
-
|
|
117
|
+
git rev-parse --show-toplevel
|
|
118
|
+
git branch --show-current
|
|
119
|
+
\`\`\`
|
|
24
120
|
|
|
25
|
-
|
|
26
|
-
const BLOCK_RE = /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u
|
|
121
|
+
Якщо перша команда показала, що ти **не** в \`.worktrees/\`, візьми вивід другої команди як \`<current-branch>\` і виконай **literal-команди без shell expansion** (без command substitution, variable expansion чи backticks). Наприклад, якщо поточна гілка \`feature/x\`:
|
|
27
122
|
|
|
28
|
-
|
|
29
|
-
|
|
123
|
+
\`\`\`bash
|
|
124
|
+
npx @nitra/cursor worktree add "feature/x-${suffix}" "n-${suffix}: worktree-only skill"
|
|
125
|
+
cd ".worktrees/feature-x-${suffix}"
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
Тобто branch-argument лишає slash як у git-гілці, а шлях для \`cd\` бере sanitized форму: slash → \`-\`.`;
|
|
129
|
+
}
|
|
30
130
|
|
|
31
131
|
/**
|
|
32
132
|
* Канонічний блок worktree-інструкції.
|
|
133
|
+
* @param {string} content вміст `SKILL.md`
|
|
33
134
|
* @returns {string} текст блоку від START до END
|
|
34
135
|
*/
|
|
35
|
-
function buildBlock() {
|
|
36
|
-
return `${WORKTREE_START}\n${
|
|
136
|
+
function buildBlock(content) {
|
|
137
|
+
return `${WORKTREE_START}\n${buildNoticeBody(deriveSuffix(content))}\n${WORKTREE_END}`;
|
|
37
138
|
}
|
|
38
139
|
|
|
39
140
|
/**
|
|
@@ -43,19 +144,19 @@ function buildBlock() {
|
|
|
43
144
|
* @returns {string} оновлений вміст (ідемпотентно)
|
|
44
145
|
*/
|
|
45
146
|
export function injectWorktreeNotice(content, enabled) {
|
|
46
|
-
const hadBlock = content.includes(WORKTREE_START)
|
|
47
|
-
const withoutBlock = content.replace(BLOCK_RE,
|
|
147
|
+
const hadBlock = content.includes(WORKTREE_START);
|
|
148
|
+
const withoutBlock = content.replace(BLOCK_RE, "\n\n");
|
|
48
149
|
|
|
49
150
|
if (!enabled) {
|
|
50
|
-
return hadBlock ? withoutBlock : content
|
|
151
|
+
return hadBlock ? withoutBlock : content;
|
|
51
152
|
}
|
|
52
153
|
|
|
53
|
-
const block = buildBlock()
|
|
54
|
-
const fm = withoutBlock.match(FRONTMATTER_RE)
|
|
154
|
+
const block = buildBlock(withoutBlock);
|
|
155
|
+
const fm = withoutBlock.match(FRONTMATTER_RE);
|
|
55
156
|
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}
|
|
157
|
+
const head = fm[1];
|
|
158
|
+
const rest = withoutBlock.slice(head.length).replace(/^\n+/u, "");
|
|
159
|
+
return `${head}\n${block}\n\n${rest}`;
|
|
59
160
|
}
|
|
60
|
-
return `${block}\n\n${withoutBlock.replace(/^\n+/u,
|
|
161
|
+
return `${block}\n\n${withoutBlock.replace(/^\n+/u, "")}`;
|
|
61
162
|
}
|