@nitra/cursor 3.18.2 → 3.20.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/n-cursor.js +12 -0
  3. package/package.json +1 -1
  4. package/rules/docker/docker.mdc +3 -3
  5. package/rules/docker/js/lint.mjs +1 -1
  6. package/rules/docker/lib/docker-hadolint.mjs +27 -55
  7. package/rules/ga/lint/lint.mjs +18 -54
  8. package/rules/image-compress/meta.json +1 -1
  9. package/rules/k8s/lint/lint.mjs +3 -10
  10. package/rules/nginx-default-tpl/js/template.mjs +39 -1
  11. package/rules/nginx-default-tpl/nginx-default-tpl.mdc +3 -1
  12. package/rules/npm-module/js/package_structure.mjs +40 -9
  13. package/rules/npm-module/npm-module.mdc +1 -1
  14. package/rules/npm-module/policy/npm_publish_yml/target.json +1 -0
  15. package/rules/rego/lint/lint.mjs +10 -55
  16. package/rules/text/lint/lint.mjs +11 -40
  17. package/rules/worktree/policy/vscode_settings/target.json +5 -0
  18. package/rules/worktree/policy/vscode_settings/template/settings.json.snippet.json +8 -0
  19. package/rules/worktree/policy/zed_settings/target.json +5 -0
  20. package/rules/worktree/policy/zed_settings/template/settings.json.snippet.json +12 -0
  21. package/rules/worktree/worktree.mdc +52 -0
  22. package/schemas/target.json +5 -0
  23. package/scripts/lib/assert-project-root.mjs +74 -0
  24. package/scripts/lib/ensure-tool.mjs +352 -0
  25. package/scripts/lib/run-conftest-batch.mjs +6 -28
  26. package/scripts/lib/run-rule.mjs +61 -5
  27. package/scripts/lib/template.mjs +29 -3
  28. package/scripts/lib/worktree-notice.mjs +52 -1
  29. package/skills/fix/SKILL.md +4 -4
  30. package/types/bin/n-cursor.d.ts +1 -1
  31. package/rules/npm-module/policy/npm_publish_yml/npm_publish_yml.rego +0 -87
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Авто-встановлення зовнішніх CLI-залежностей пакету `@nitra/cursor`.
3
+ *
4
+ * `ensureTool(toolId)` — єдиний seam резолву зовнішніх бінарників: PATH → кеш → авто-install → hard-fail.
5
+ * Новий тул = один запис у реєстрі `TOOLS`, без дублювання install-логіки в кожному `lint.mjs`/`fix.mjs`.
6
+ *
7
+ * Per-platform matrix: macOS → brew, Windows → scoop (fallback: GitHub Release), Linux → GitHub Release binary.
8
+ * Бінарники кешуються у `~/.cache/@nitra/cursor/bin/` (Linux/Mac), `%LOCALAPPDATA%\@nitra\cursor\bin\` (Win).
9
+ *
10
+ * `ensureHkInstall(hkBin)` — реєструє git pre-commit hook через `hk install`; пропускається в CI.
11
+ */
12
+ import { spawnSync } from 'node:child_process'
13
+ import { chmodSync, existsSync, mkdirSync, renameSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { arch, env, platform } from 'node:process'
17
+
18
+ import { resolveCmd } from '../utils/resolve-cmd.mjs'
19
+
20
+ /** Префікс `v` у git-тегу релізу (`v1.2.3` → `1.2.3`). */
21
+ const TAG_V_PREFIX_RE = /^v/
22
+
23
+ /**
24
+ * Повертає каталог керованого кешу бінарників для поточного OS.
25
+ * @returns {string} абсолютний шлях
26
+ */
27
+ function getCacheDir() {
28
+ if (platform === 'win32') {
29
+ const localAppData = env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local')
30
+ return join(localAppData, '@nitra', 'cursor', 'bin')
31
+ }
32
+ return join(homedir(), '.cache', '@nitra', 'cursor', 'bin')
33
+ }
34
+
35
+ /**
36
+ * Мапить `process.arch` у формат, що вживається в назвах GitHub-release ресурсів.
37
+ * @param {'x64'|'arm64'|string} nodeArch значення `process.arch`
38
+ * @param {'hk'|'conftest'|'actionlint'} style стиль іменування платформи
39
+ * @returns {string} рядок архітектури для asset-шаблону
40
+ */
41
+ function mapArch(nodeArch, style) {
42
+ if (style === 'actionlint') {
43
+ return nodeArch === 'x64' ? 'amd64' : 'arm64'
44
+ }
45
+ if (style === 'conftest') {
46
+ return nodeArch === 'x64' ? 'x86_64' : 'arm64'
47
+ }
48
+ // hk / shellcheck / dotenv-linter: x64 → x86_64, arm64 → aarch64
49
+ return nodeArch === 'x64' ? 'x86_64' : 'aarch64'
50
+ }
51
+
52
+ /**
53
+ * @typedef {object} ToolEntry
54
+ * @property {string} brew формула brew (macOS)
55
+ * @property {string|null} scoop назва пакету scoop (Windows); null = недоступний
56
+ * @property {string} github репо у форматі `owner/repo`
57
+ * @property {(ver: string) => string} asset повертає назву release-ресурсу для Linux
58
+ * @property {string} archStyle стиль маппінгу архітектури: 'hk'|'conftest'|'actionlint'
59
+ * @property {boolean} [archive] чи є release-ресурс архівом (tar) — default `true`; `false` = сирий бінарник (download + chmod)
60
+ * @property {((ver: string) => string)|null} [binFinder] для архівів де бінарник не у корені; повертає відносний шлях
61
+ */
62
+
63
+ /** @type {Record<string, ToolEntry>} */
64
+ const TOOLS = {
65
+ hk: {
66
+ brew: 'hk',
67
+ scoop: 'hk',
68
+ github: 'jdx/hk',
69
+ archStyle: 'hk',
70
+ asset: _ver => `hk-${mapArch(arch, 'hk')}-unknown-linux-gnu.tar.gz`,
71
+ binFinder: null
72
+ },
73
+ conftest: {
74
+ brew: 'conftest',
75
+ scoop: 'conftest',
76
+ github: 'open-policy-agent/conftest',
77
+ archStyle: 'conftest',
78
+ asset: ver => `conftest_${ver}_Linux_${mapArch(arch, 'conftest')}.tar.gz`,
79
+ binFinder: null
80
+ },
81
+ shellcheck: {
82
+ brew: 'shellcheck',
83
+ scoop: 'shellcheck',
84
+ github: 'koalaman/shellcheck',
85
+ archStyle: 'hk',
86
+ asset: ver => `shellcheck-v${ver}.linux.${mapArch(arch, 'hk')}.tar.xz`,
87
+ binFinder: ver => `shellcheck-v${ver}/shellcheck`
88
+ },
89
+ actionlint: {
90
+ brew: 'actionlint',
91
+ scoop: 'actionlint',
92
+ github: 'rhysd/actionlint',
93
+ archStyle: 'actionlint',
94
+ asset: ver => `actionlint_${ver}_linux_${mapArch(arch, 'actionlint')}.tar.gz`,
95
+ binFinder: null
96
+ },
97
+ 'dotenv-linter': {
98
+ brew: 'dotenv-linter',
99
+ scoop: null,
100
+ github: 'dotenv-linter/dotenv-linter',
101
+ archStyle: 'hk',
102
+ asset: _ver => `dotenv-linter-linux-${mapArch(arch, 'hk')}.tar.gz`,
103
+ binFinder: null
104
+ },
105
+ opa: {
106
+ brew: 'opa',
107
+ scoop: 'opa',
108
+ github: 'open-policy-agent/opa',
109
+ archStyle: 'actionlint',
110
+ archive: false,
111
+ asset: _ver => `opa_linux_${mapArch(arch, 'actionlint')}`,
112
+ binFinder: null
113
+ },
114
+ regal: {
115
+ brew: 'regal',
116
+ scoop: null,
117
+ github: 'StyraInc/regal',
118
+ archStyle: 'conftest',
119
+ archive: false,
120
+ asset: _ver => `regal_Linux_${mapArch(arch, 'conftest')}`,
121
+ binFinder: null
122
+ },
123
+ hadolint: {
124
+ brew: 'hadolint',
125
+ scoop: 'hadolint',
126
+ github: 'hadolint/hadolint',
127
+ archStyle: 'conftest',
128
+ archive: false,
129
+ asset: _ver => `hadolint-linux-${mapArch(arch, 'conftest')}`,
130
+ binFinder: null
131
+ },
132
+ kubeconform: {
133
+ brew: 'kubeconform',
134
+ scoop: 'kubeconform',
135
+ github: 'yannh/kubeconform',
136
+ archStyle: 'actionlint',
137
+ asset: _ver => `kubeconform-linux-${mapArch(arch, 'actionlint')}.tar.gz`,
138
+ binFinder: null
139
+ },
140
+ kubescape: {
141
+ brew: 'kubescape',
142
+ scoop: 'kubescape',
143
+ github: 'kubescape/kubescape',
144
+ archStyle: 'actionlint',
145
+ archive: false,
146
+ asset: ver => `kubescape_${ver}_linux_${mapArch(arch, 'actionlint')}`,
147
+ binFinder: null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Отримує останній тег з GitHub Releases API через curl (sync).
153
+ * @param {string} repo репо у форматі `owner/repo`
154
+ * @param {string} curlBin абсолютний шлях до curl
155
+ * @returns {string} рядок версії без префікса `v`, наприклад `0.4.1`
156
+ */
157
+ function fetchLatestVersion(repo, curlBin) {
158
+ const url = `https://api.github.com/repos/${repo}/releases/latest`
159
+ const r = spawnSync(curlBin, ['-sSL', '-H', 'Accept: application/vnd.github+json', url], { encoding: 'utf8' })
160
+ if (r.error) throw new Error(`curl failed: ${r.error.message}`)
161
+ if (r.status !== 0) throw new Error(`curl exit ${r.status}: ${(r.stderr ?? '').slice(0, 300)}`)
162
+ let parsed
163
+ try {
164
+ parsed = JSON.parse(r.stdout)
165
+ } catch {
166
+ throw new Error(`GitHub API response is not JSON: ${r.stdout.slice(0, 200)}`)
167
+ }
168
+ const tag = parsed['tag_name']
169
+ if (!tag) throw new Error(`GitHub API: tag_name missing for ${repo}`)
170
+ return tag.replace(TAG_V_PREFIX_RE, '')
171
+ }
172
+
173
+ /**
174
+ * Завантажує та розпаковує GitHub Release бінарник у кеш-директорію.
175
+ * Повертає абсолютний шлях до бінарника.
176
+ * @param {string} toolId ключ у TOOLS
177
+ * @param {ToolEntry} entry опис тула
178
+ * @param {string} cacheDir абсолютний шлях до кешу
179
+ * @returns {string} абсолютний шлях до готового бінарника
180
+ */
181
+ function installFromGithub(toolId, entry, cacheDir) {
182
+ const curlBin = resolveCmd('curl')
183
+ if (!curlBin) throw new Error(`curl не знайдено в PATH — потрібен для завантаження ${toolId}`)
184
+ const tarBin = resolveCmd('tar')
185
+ if (!tarBin) throw new Error(`tar не знайдено в PATH — потрібен для встановлення ${toolId}`)
186
+
187
+ const ver = fetchLatestVersion(entry.github, curlBin)
188
+ const assetName = entry.asset(ver)
189
+ const downloadUrl = `https://github.com/${entry.github}/releases/download/v${ver}/${assetName}`
190
+
191
+ mkdirSync(cacheDir, { recursive: true })
192
+ const archivePath = join(cacheDir, assetName)
193
+
194
+ const dlResult = spawnSync(curlBin, ['-sSL', '-o', archivePath, downloadUrl], { encoding: 'utf8' })
195
+ if (dlResult.error) throw new Error(`Завантаження ${toolId} не вдалось: ${dlResult.error.message}`)
196
+ if (dlResult.status !== 0)
197
+ throw new Error(`curl exit ${dlResult.status} при завантаженні ${toolId}: ${(dlResult.stderr ?? '').slice(0, 300)}`)
198
+
199
+ // Сирий бінарник (archive: false) — завантажений файл і є бінарником: перейменовуємо у <toolId> + chmod.
200
+ if (entry.archive === false) {
201
+ const binPath = join(cacheDir, toolId)
202
+ renameSync(archivePath, binPath)
203
+ chmodSync(binPath, 0o755)
204
+ return binPath
205
+ }
206
+
207
+ // .tar.xz потребує -J замість -z
208
+ const isXz = assetName.endsWith('.tar.xz')
209
+ const tarFlags = isXz ? ['-xJf'] : ['-xzf']
210
+ const extractResult = spawnSync(tarBin, [...tarFlags, archivePath, '-C', cacheDir], { encoding: 'utf8' })
211
+ if (extractResult.error) throw new Error(`tar failed for ${toolId}: ${extractResult.error.message}`)
212
+ if (extractResult.status !== 0)
213
+ throw new Error(`tar exit ${extractResult.status} для ${toolId}: ${(extractResult.stderr ?? '').slice(0, 300)}`)
214
+
215
+ const binRelPath = entry.binFinder ? entry.binFinder(ver) : toolId
216
+ const binPath = join(cacheDir, binRelPath)
217
+ if (!existsSync(binPath)) {
218
+ throw new Error(`Бінарник ${toolId} не знайдено після розпакування: ${binPath}`)
219
+ }
220
+
221
+ const rmBin = resolveCmd('rm')
222
+ if (rmBin) spawnSync(rmBin, [archivePath])
223
+
224
+ return binPath
225
+ }
226
+
227
+ /**
228
+ * Встановлює тул через brew (macOS). Hard-fail на будь-яку помилку.
229
+ * @param {string} toolId ключ у TOOLS
230
+ * @param {ToolEntry} entry опис тула
231
+ * @returns {string} абсолютний шлях до встановленого бінарника
232
+ */
233
+ function installViaBrew(toolId, entry) {
234
+ const brewBin = resolveCmd('brew')
235
+ if (!brewBin) throw new Error(`brew не знайдено в PATH. Встанови Homebrew: https://brew.sh`)
236
+ const r = spawnSync(brewBin, ['install', entry.brew], { stdio: 'inherit', encoding: 'utf8' })
237
+ if (r.error) throw new Error(`brew install ${toolId} не вдалось: ${r.error.message}`)
238
+ if (r.status !== 0) throw new Error(`brew install ${toolId} завершився з кодом ${r.status}`)
239
+ const resolved = resolveCmd(toolId)
240
+ if (!resolved) throw new Error(`${toolId} не знайдено в PATH після brew install`)
241
+ return resolved
242
+ }
243
+
244
+ /**
245
+ * Встановлює тул через scoop (Windows). Кидає якщо scoop недоступний або пакет null.
246
+ * @param {string} toolId ключ у TOOLS
247
+ * @param {ToolEntry} entry опис тула
248
+ * @returns {string} абсолютний шлях до встановленого бінарника
249
+ */
250
+ function installViaScoop(toolId, entry) {
251
+ if (!entry.scoop) {
252
+ throw new Error(`${toolId} недоступний у Scoop. Встанови вручну:\n https://github.com/${entry.github}/releases`)
253
+ }
254
+ const scoopBin = resolveCmd('scoop')
255
+ if (!scoopBin) throw new Error(`scoop не знайдено в PATH. Встанови Scoop: https://scoop.sh`)
256
+ const r = spawnSync(scoopBin, ['install', entry.scoop], { stdio: 'inherit', encoding: 'utf8' })
257
+ if (r.error) throw new Error(`scoop install ${toolId} не вдалось: ${r.error.message}`)
258
+ if (r.status !== 0) throw new Error(`scoop install ${toolId} завершився з кодом ${r.status}`)
259
+ const resolved = resolveCmd(toolId)
260
+ if (!resolved) throw new Error(`${toolId} не знайдено в PATH після scoop install`)
261
+ return resolved
262
+ }
263
+
264
+ /**
265
+ * Виконує авто-встановлення тула відповідно до поточного OS.
266
+ * @param {string} toolId ключ у TOOLS
267
+ * @param {ToolEntry} entry опис тула
268
+ * @param {string} cacheDir каталог кешу для Linux-бінарників
269
+ * @returns {string} абсолютний шлях до бінарника
270
+ */
271
+ function autoInstall(toolId, entry, cacheDir) {
272
+ if (platform === 'darwin') return installViaBrew(toolId, entry)
273
+ if (platform === 'win32') {
274
+ try {
275
+ return installViaScoop(toolId, entry)
276
+ } catch {
277
+ // Scoop недоступний або тул не в Scoop → GitHub Release fallback
278
+ return installFromGithub(toolId, entry, cacheDir)
279
+ }
280
+ }
281
+ // Linux
282
+ return installFromGithub(toolId, entry, cacheDir)
283
+ }
284
+
285
+ /**
286
+ * Будує install-hint повідомлення для hard-fail.
287
+ * @param {string} toolId ключ у TOOLS
288
+ * @param {ToolEntry} entry опис тула
289
+ * @returns {string} рядок помилки з підказками
290
+ */
291
+ function buildHint(toolId, entry) {
292
+ const lines = [
293
+ `❌ ${toolId} не знайдено в PATH і авто-встановлення відключено (N_CURSOR_NO_AUTO_INSTALL).`,
294
+ ' Встанови:'
295
+ ]
296
+ if (platform === 'darwin') {
297
+ lines.push(` macOS: brew install ${entry.brew}`)
298
+ } else if (platform === 'win32') {
299
+ if (entry.scoop) lines.push(` Windows: scoop install ${entry.scoop}`)
300
+ lines.push(` або: https://github.com/${entry.github}/releases`)
301
+ } else {
302
+ lines.push(` Linux: https://github.com/${entry.github}/releases`)
303
+ }
304
+ return lines.join('\n')
305
+ }
306
+
307
+ /**
308
+ * Резолвить і за необхідності авто-встановлює зовнішній CLI-тул.
309
+ *
310
+ * Порядок: PATH → кеш → авто-install (якщо не N_CURSOR_NO_AUTO_INSTALL) → hard-fail.
311
+ * Повертає абсолютний шлях або кидає Error.
312
+ * @param {string} toolId ключ у реєстрі TOOLS (`'hk'`, `'conftest'`, `'shellcheck'`, `'actionlint'`, `'dotenv-linter'`, `'opa'`, `'regal'`, `'hadolint'`, `'kubeconform'`, `'kubescape'`)
313
+ * @returns {string} абсолютний шлях до бінарника
314
+ */
315
+ export function ensureTool(toolId) {
316
+ const entry = TOOLS[toolId]
317
+ if (!entry) throw new Error(`ensureTool: невідомий тул '${toolId}'`)
318
+
319
+ // 1. PATH
320
+ const fromPath = resolveCmd(toolId)
321
+ if (fromPath) return fromPath
322
+
323
+ // 2. Кеш
324
+ const cacheDir = getCacheDir()
325
+ const cachedBin = join(cacheDir, toolId)
326
+ if (existsSync(cachedBin)) return cachedBin
327
+
328
+ // 3. Авто-install (якщо не заблоковано)
329
+ if (!env['N_CURSOR_NO_AUTO_INSTALL']) {
330
+ return autoInstall(toolId, entry, cacheDir)
331
+ }
332
+
333
+ // 4. Hard-fail з per-OS підказкою
334
+ throw new Error(buildHint(toolId, entry))
335
+ }
336
+
337
+ /**
338
+ * Реєструє git pre-commit hook через `hk install`.
339
+ * Пропускається в CI (`process.env.CI`). Попереджає (не кидає) на помилку.
340
+ * @param {string} hkBin абсолютний шлях до бінарника hk
341
+ * @returns {void}
342
+ */
343
+ export function ensureHkInstall(hkBin) {
344
+ if (env['CI']) return
345
+
346
+ const r = spawnSync(hkBin, ['install'], { stdio: 'inherit', encoding: 'utf8' })
347
+ if (r.error) {
348
+ console.warn(`⚠️ hk install не вдалось: ${r.error.message}`)
349
+ } else if (r.status !== 0) {
350
+ console.warn(`⚠️ hk install завершився з кодом ${r.status}`)
351
+ }
352
+ }
@@ -4,15 +4,13 @@
4
4
  * пер-документні правила винесені у `npm/policy/<rule>/<name>/` як rego-полісі
5
5
  * (Rego-authoritative). JS у `check-*.mjs` робить cross-file частину (walking
6
6
  * дерева, парність, kustomize-резолюція), а пер-документне валідаційне ядро
7
- * делегується сюди — один спавн `conftest` на (`namespace`, `policyDir`),
7
+ * делегується сюді — один спавн `conftest` на (`namespace`, `policyDir`),
8
8
  * незалежно від кількості файлів. Це закриває дублювання JS↔rego і прибирає
9
9
  * ризик дрифту (типу `spec.config` vs `spec.default.config` у
10
10
  * `health_check_policy.rego`, що ми ловили cross-check тестами).
11
11
  *
12
- * Hard-fail на відсутність `conftest` у PATH узгоджено з рішенням Plan B:
13
- * якщо правило делегує свою логіку до Rego, а інструмент відсутній, тиха
14
- * відмова приховує реальні порушення. Друкуємо install-hint (як `lint-rego.mjs`
15
- * робить для opa/regal).
12
+ * Hard-fail на відсутність `conftest` — через `ensureTool`, що спочатку
13
+ * намагається авто-встановити, і лише після невдачі кидає виняток.
16
14
  */
17
15
  import { spawnSync } from 'node:child_process'
18
16
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -20,7 +18,7 @@ import { tmpdir } from 'node:os'
20
18
  import { dirname, join } from 'node:path'
21
19
  import { fileURLToPath } from 'node:url'
22
20
 
23
- import { resolveCmd } from '../utils/resolve-cmd.mjs'
21
+ import { ensureTool } from './ensure-tool.mjs'
24
22
 
25
23
  /**
26
24
  Каталог пакета `@nitra/cursor`, від якого ресолвимо вшиті директорії правил.
@@ -30,23 +28,6 @@ const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
30
28
  /** Шлях до кореня правил. У npm-tarball публікується через `files: ["rules"]`. Кожне правило: `rules/<id>/policy/<name>/`. */
31
29
  const RULES_ROOT = join(PACKAGE_ROOT, 'rules')
32
30
 
33
- /**
34
- * Друкує install-hint для conftest і кидає виняток, щоб викликана `check-*`
35
- * команда ясно завершилась з кодом 1.
36
- * @returns {never} завжди кидає; для точки виклику — non-returning
37
- */
38
- function failConftestMissing() {
39
- throw new Error(
40
- [
41
- '❌ conftest не знайдено в PATH.',
42
- ' Без нього не запускається пер-документна валідація через rego-полісі (npm/policy/).',
43
- ' Встанови:',
44
- ' macOS: brew install conftest',
45
- ' Universal: https://www.conftest.dev/install/'
46
- ].join('\n')
47
- )
48
- }
49
-
50
31
  /**
51
32
  * @typedef {object} ConftestViolation
52
33
  * @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
@@ -80,16 +61,13 @@ export function buildConftestArgs(p) {
80
61
  /**
81
62
  * Виконує `conftest test` для всіх файлів одним спавном і повертає масив
82
63
  * порушень. Якщо `files` порожній — повертає `[]` без спавна. Якщо `conftest`
83
- * не у PATH — кидає виняток (hard fail, див. модульний docstring).
64
+ * не у PATH і авто-встановлення не вдалось — кидає виняток (hard fail).
84
65
  * @param {ConftestBatchOptions} opts параметри запуску
85
66
  * @returns {ConftestViolation[]} масив порушень (порожній — все ок)
86
67
  */
87
68
  export function runConftestBatch(opts) {
88
69
  if (opts.files.length === 0) return []
89
- const conftestBin = resolveCmd('conftest')
90
- if (!conftestBin) {
91
- failConftestMissing()
92
- }
70
+ const conftestBin = ensureTool('conftest')
93
71
  // policyDirRel — формат `<rule>/<name>` (наприклад `abie/base_deployment_preem`).
94
72
  // Реальний шлях у новій структурі: `rules/<rule>/policy/<name>`.
95
73
  const slash = opts.policyDirRel.indexOf('/')
@@ -13,13 +13,20 @@
13
13
  * Це дає той самий 0/1 контракт, що й попередня модель «один check.mjs на правило».
14
14
  */
15
15
  import { readFile } from 'node:fs/promises'
16
- import { join } from 'node:path'
16
+ import { join, relative } from 'node:path'
17
17
 
18
18
  import { findMissingMdcRefs } from './check-mdc-template-refs.mjs'
19
19
  import { createCheckReporter } from './check-reporter.mjs'
20
20
  import { resolveTargetFiles } from './resolve-target-files.mjs'
21
21
  import { runConftestBatch } from './run-conftest-batch.mjs'
22
- import { resolveConcernTemplateData } from './template.mjs'
22
+ import {
23
+ checkContains,
24
+ checkDeny,
25
+ checkSnippet,
26
+ checkTextSubset,
27
+ parseByExt,
28
+ resolveConcernTemplateData
29
+ } from './template.mjs'
23
30
 
24
31
  const APPLIES_CONCERN_NAME = 'applies'
25
32
 
@@ -52,6 +59,46 @@ async function evaluateAppliesGate(bundledRulesDir, rule) {
52
59
  return Boolean(await mod.applies())
53
60
  }
54
61
 
62
+ /**
63
+ * Snippet-driven перевірка концерну (`target.json:"check":"template"`): канон зі
64
+ * `template/<target>.snippet|deny|contains.<ext>` звіряється з actual-файлом
65
+ * generic deep-subset-ом, без `.rego`. Семантика — subset-of: усі канонічні
66
+ * поля/елементи обовʼязкові, зайві дозволені; масиви матчаться за наявністю
67
+ * (order-insensitive). Сніпет — єдине джерело істини: його зміна одразу змінює enforce.
68
+ * @param {string} concernAbsDir абсолютний `rules/<id>/policy/<concern>/`
69
+ * @param {object} target розпарсений `target.json`
70
+ * @param {string[]} files актуальні файли-таргети (resolveTargetFiles)
71
+ * @param {string} ruleId id правила (для `source` у повідомленнях)
72
+ * @param {string} concernName імʼя концерну (для summary)
73
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
74
+ */
75
+ export async function runTemplateSubsetConcern(concernAbsDir, target, files, ruleId, concernName) {
76
+ const reporter = createCheckReporter()
77
+ const data = await resolveConcernTemplateData(concernAbsDir, target)
78
+ if (!data) {
79
+ reporter.pass(`${concernName}: немає template-сніпета — пропущено`)
80
+ return reporter.getExitCode()
81
+ }
82
+ for (const file of files) {
83
+ const rel = relative(process.cwd(), file) || file
84
+ const actual = await parseByExt(file)
85
+ const opts = { targetPath: rel, source: `${ruleId}.mdc` }
86
+ const violations = [
87
+ ...(typeof data.snippet === 'string'
88
+ ? checkTextSubset(actual, data.snippet, opts)
89
+ : checkSnippet(actual, data.snippet, opts)),
90
+ ...checkDeny(actual, data.deny, opts),
91
+ ...checkContains(actual, data.contains, opts)
92
+ ]
93
+ if (violations.length === 0) {
94
+ reporter.pass(`${concernName}: ${rel} відповідає канону (template subset)`)
95
+ } else {
96
+ for (const v of violations) reporter.fail(v)
97
+ }
98
+ }
99
+ return reporter.getExitCode()
100
+ }
101
+
55
102
  /**
56
103
  * Запускає одну policy-полісі через `runConftestBatch`. Створює локальний репортер,
57
104
  * читає `target.json`, визначає файли, фіксує fail/pass — і повертає exit-код.
@@ -63,8 +110,9 @@ async function evaluateAppliesGate(bundledRulesDir, rule) {
63
110
  */
64
111
  async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache) {
65
112
  const reporter = createCheckReporter()
66
- const targetPath = join(bundledRulesDir, ruleId, 'policy', concernName, 'target.json')
67
- /** @type {{ files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
113
+ const concernAbsDir = join(bundledRulesDir, ruleId, 'policy', concernName)
114
+ const targetPath = join(concernAbsDir, 'target.json')
115
+ /** @type {{ check?: 'template', files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
68
116
  const target = JSON.parse(await readFile(targetPath, 'utf8'))
69
117
  const files = await resolveTargetFiles(target.files, process.cwd(), walkCache)
70
118
  if (files.length === 0) {
@@ -76,10 +124,18 @@ async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache)
76
124
  }
77
125
  return reporter.getExitCode()
78
126
  }
127
+
128
+ // `"check": "template"` — концерн без власного `.rego`: канон зі `template/`
129
+ // звіряється напряму через generic deep-subset (`checkSnippet`/`checkDeny`/
130
+ // `checkContains`/`checkTextSubset`). Редагування сніпета автоматично змінює
131
+ // enforce — без правок rego й без міграторів.
132
+ if (target.check === 'template') {
133
+ return runTemplateSubsetConcern(concernAbsDir, target, files, ruleId, concernName)
134
+ }
135
+
79
136
  // Rego не дозволяє '-' в імені пакета, тому kebab-id у `.n-cursor.json:rules`
80
137
  // мапиться на snake у namespace. Файлова структура `rules/<id>/policy/` лишається kebab.
81
138
  const regoNamespace = `${ruleId.replaceAll('-', '_')}.${concernName}`
82
- const concernAbsDir = join(bundledRulesDir, ruleId, 'policy', concernName)
83
139
  const templateData = await resolveConcernTemplateData(concernAbsDir, target)
84
140
  const violations = runConftestBatch({
85
141
  policyDirRel: `${ruleId}/${concernName}`,
@@ -26,7 +26,7 @@ const LEADING_BANG_RE = /^!/
26
26
  * @param {string} path шлях до файлу
27
27
  * @returns {Promise<unknown>} розпарсений вміст
28
28
  */
29
- async function parseByExt(path) {
29
+ export async function parseByExt(path) {
30
30
  const raw = await readFile(path, 'utf8')
31
31
  const ext = extname(path).toLowerCase()
32
32
  if (ext === '.json' || ext === '.jsonc') return JSON.parse(stripJsonComments(raw))
@@ -112,6 +112,27 @@ function quote(v) {
112
112
  return typeof v === 'string' ? JSON.stringify(v) : String(v)
113
113
  }
114
114
 
115
+ /** Ключі, за якими ідентифікуємо елемент масиву обʼєктів у повідомленні (напр. workflow-крок). */
116
+ const ELEMENT_ID_KEYS = ['uses', 'name', 'id', 'run']
117
+
118
+ /**
119
+ * Людинозрозумілий опис елемента масиву для повідомлення про відсутність.
120
+ * Для скаляра — `quote`; для обʼєкта — перший наявний ідентифікуючий ключ
121
+ * (`uses`/`name`/`id`/`run`), інакше компактний JSON.
122
+ * @param {unknown} needle елемент сніпета, якого бракує в actual
123
+ * @returns {string} опис для тексту порушення
124
+ */
125
+ function describeElement(needle) {
126
+ if (needle !== null && typeof needle === 'object' && !Array.isArray(needle)) {
127
+ const obj = /** @type {Record<string, unknown>} */ (needle)
128
+ for (const k of ELEMENT_ID_KEYS) {
129
+ if (typeof obj[k] === 'string') return `елемент з ${k}: ${quote(obj[k])}`
130
+ }
131
+ return `елемент ${JSON.stringify(needle)}`
132
+ }
133
+ return quote(needle)
134
+ }
135
+
115
136
  /**
116
137
  * Deep subset-of check. Every leaf in `snippet` must equal same path in `actual`.
117
138
  * Arrays in snippet: every element must be present in actual array.
@@ -131,10 +152,15 @@ export function checkSnippet(actual, snippet, opts, path = []) {
131
152
  violations.push(`${targetPath}: ${formatPath(path)} має бути масивом (${source})`)
132
153
  return violations
133
154
  }
155
+ // Subset-of, order-insensitive: кожен елемент сніпета має структурно міститись
156
+ // хоча б в одному елементі actual. Для обʼєктів — рекурсивний subset
157
+ // (`checkSnippet` без порушень), тож порядок ключів, зайві поля й зайві елементи
158
+ // не ламають збіг. Критично для впорядкованих масивів як `steps`, де елементи
159
+ // сортувати не можна (порядок кроків семантичний) — матч лишається за наявністю.
134
160
  for (const needle of snippet) {
135
- const found = actual.some(a => JSON.stringify(a) === JSON.stringify(needle))
161
+ const found = actual.some(a => checkSnippet(a, needle, opts, [...path, '[]']).length === 0)
136
162
  if (!found) {
137
- violations.push(`${targetPath}: ${formatPath(path)} має містити ${quote(needle)} (${source})`)
163
+ violations.push(`${targetPath}: ${formatPath(path)} має містити ${describeElement(needle)} (${source})`)
138
164
  }
139
165
  }
140
166
  return violations
@@ -5,6 +5,16 @@
5
5
  * і не паралелитись. Підказка адресована агенту, який читає `SKILL.md`, тож
6
6
  * вставляється в текст між стабільними маркерами — ре-синк ідемпотентний:
7
7
  * наявний блок замінюється, при `worktree:false` — видаляється.
8
+ *
9
+ * Крок 0.1 блоку додає `bun install` у щойно створеному дереві (локальна копія
10
+ * CLI усуває гонку з CDN) і shell-обгортку `n_cursor_npx` навколо bootstrap-виклику
11
+ * `npx`: на ETARGET/notarget та мережевих помилках npm падає ДО запуску бінарника,
12
+ * тож retry мусить жити на рівні shell-інструкції, а не в JS-хендлерах CLI.
13
+ * Обгортка ретраїть лише транзитні помилки реєстру/мережі (30с інтервал, дефолт
14
+ * 5 хв, env `N_CURSOR_NPX_RETRY_MAX_MIN`, ceiling 10 хв) і віддає реальний nonzero
15
+ * CLI одразу. Команди винесені окремим кроком ПІСЛЯ worktree-створення, бо
16
+ * вимагають command substitution, заборонену у «без-expansion» preflight-снипеті
17
+ * (узгоджено з worktree.mdc).
8
18
  */
9
19
 
10
20
  /** Маркер початку worktree-блоку (стабільний, не залежить від тексту всередині). */
@@ -124,7 +134,48 @@ npx @nitra/cursor worktree add "feature/x-${suffix}" "n-${suffix}: worktree-only
124
134
  cd ".worktrees/feature-x-${suffix}"
125
135
  \`\`\`
126
136
 
127
- Тобто branch-argument лишає slash як у git-гілці, а шлях для \`cd\` бере sanitized форму: slash → \`-\`.`;
137
+ Тобто branch-argument лишає slash як у git-гілці, а шлях для \`cd\` бере sanitized форму: slash → \`-\`.
138
+
139
+ **Крок 0.1 — bootstrap у новому дереві (після \`cd\`, окремий крок — поза «без-expansion» блоком вище).** Дерево щойно створене й **без** \`node_modules\`. Спершу постав залежності локально: тоді \`npx\` бере локальну копію \`@nitra/cursor\` і гонки з CDN немає взагалі. Retry-обгортка нижче — safety-net на випадок, коли версію щойно опубліковано, але edge-кеш CDN ще її не має: \`npm\` тоді падає з \`ETARGET\`/\`notarget\` **до** запуску бінарника (внутрішній JS-retry у \`n-cursor\` для цього кейсу марний — бінарник ще не стартував).
140
+
141
+ \`\`\`bash
142
+ # Локальна копія @nitra/cursor (девзалежність споживача) — npx бере її, без походу в реєстр.
143
+ bun install
144
+
145
+ # n_cursor_npx <args> — обгортка bootstrap-виклику "npx @nitra/cursor <args>".
146
+ # Ретраїмо ЛИШЕ транзитні помилки реєстру/мережі (CDN ще не пропагував щойно
147
+ # опубліковану версію). Реальний nonzero від CLI (fix повернув ❌, lint-помилка) —
148
+ # віддаємо одразу, без ретраю. Інтервал 30с; дефолт-ліміт 5 хв
149
+ # (env N_CURSOR_NPX_RETRY_MAX_MIN), hard-ceiling 10 хв.
150
+ # Чому 5 хв: CDN-пропагація npm зазвичай < 2 хв, 5 хв — запас; довше → ймовірно
151
+ # реальна проблема (невірна версія / аутейдж), краще віддати помилку, ніж висіти.
152
+ n_cursor_npx() {
153
+ max_min="\${N_CURSOR_NPX_RETRY_MAX_MIN:-5}"
154
+ case "$max_min" in '' | *[!0-9]*) max_min=5 ;; esac
155
+ [ "$max_min" -gt 10 ] && max_min=10
156
+ deadline=$(( $(date +%s) + max_min * 60 ))
157
+ attempt=1
158
+ transient='ETARGET|notarget|No matching version|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|50[0-9] |502 Bad Gateway|503 Service Unavailable|504 Gateway'
159
+ while :; do
160
+ err=$(mktemp)
161
+ npx @nitra/cursor "$@" 2>"$err"
162
+ code=$?
163
+ cat "$err" >&2
164
+ [ "$code" -eq 0 ] && { rm -f "$err"; return 0; }
165
+ if grep -Eq "$transient" "$err" && [ "$(date +%s)" -lt "$deadline" ]; then
166
+ rm -f "$err"
167
+ echo "n-cursor: очікую пропагації версії по CDN… спроба $attempt, повтор через 30с" >&2
168
+ attempt=$((attempt + 1))
169
+ sleep 30
170
+ else
171
+ rm -f "$err"
172
+ return "$code"
173
+ fi
174
+ done
175
+ }
176
+ \`\`\`
177
+
178
+ Усі подальші bootstrap-виклики \`npx @nitra/cursor <cmd>\` у цій сесії роби через \`n_cursor_npx <cmd>\`. Якщо опинився у свіжому shell без цієї функції — спершу повтори блок вище (\`bun install\` + визначення \`n_cursor_npx\`).`;
128
179
  }
129
180
 
130
181
  /**