@7n/rules 1.48.1 → 1.49.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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/n-rules-cli.mjs +2045 -0
  3. package/bin/n-rules.js +4 -2026
  4. package/package.json +1 -1
  5. package/rules/changelog/.changes/260724-1500.md +5 -0
  6. package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
  7. package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
  8. package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
  9. package/rules/doc-files/docgen-gen/docs/index.md +9 -0
  10. package/rules/doc-files/docgen-gen/docs/main.md +40 -29
  11. package/rules/doc-files/docgen-gen/main.mjs +79 -25
  12. package/rules/test/coverage/fix-worker.mjs +9 -1
  13. package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
  14. package/scripts/docs/skills-cli.md +18 -24
  15. package/scripts/lib/acp-runner.mjs +1 -1
  16. package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
  17. package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
  18. package/scripts/lib/lint-surface/docs/index.md +1 -0
  19. package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
  20. package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
  21. package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
  22. package/scripts/lib/lint-surface/run-fix.mjs +209 -38
  23. package/scripts/lib/lint-surface/snapshot.mjs +6 -0
  24. package/scripts/lib/lint-surface/test-gate.mjs +87 -0
  25. package/scripts/skills-cli.mjs +60 -10
  26. package/scripts/utils/docs/glob-compat.md +20 -14
  27. package/scripts/utils/glob-compat.mjs +18 -3
  28. package/skills/git-reconcile/SKILL.md +58 -0
  29. package/skills/git-reconcile/js/docs/index.md +9 -0
  30. package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
  31. package/skills/git-reconcile/js/orchestrate.mjs +776 -0
  32. package/skills/git-reconcile/main.json +1 -0
  33. package/skills/taze/js/docs/orchestrate.md +40 -18
  34. package/skills/taze/js/orchestrate.mjs +6 -3
package/bin/n-rules.js CHANGED
@@ -1,2030 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- /**
4
- * n-rules — CLI завантаження правил та перевірки проєкту
5
- *
6
- * Використання:
7
- * `npx \@7n/rules` — завантажити cursor-правила (синк); якщо в корені вже є `.n-rules.json`,
8
- * спочатку зчитується конфіг і за потреби дописується `$schema`
9
- * `npx \@7n/rules rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
10
- * `npx \@7n/rules hook --post-tool-use` — PostToolUse hook: per-file lint правила для зміненого файлу (stdin JSON `tool_input.file_path`). Прописується автоматично в `.claude/settings.json`.
11
- * `npx \@7n/rules hook --stop` — Stop hook: per-file lint по всіх змінених файлах (git diff HEAD + untracked).
12
- * `npx \@7n/rules lint` — data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
13
- * за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
14
- * весь репо (`per-file` ∪ `full`); `--no-fix` = без мутацій/LLM (CI); позиційні
15
- * (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`);
16
- * `--path <dir>` = перетин піддиректорії з git-дельтою (лише per-file правила; з `--full` —
17
- * все піддерево), корінь прогону (root-guard, `.n-rules.json`) лишається поточним
18
- * каталогом/`--cwd`; сумісний із rule-фільтром (`lint js --path run/nexus`);
19
- * `--repo-wide` = лише full-scope правила (knip/jscpd/dep-policy) по всьому репо.
20
- * CI = `lint --no-fix --full` (весь репо, нуль мутацій/LLM).
21
- * `npx \@7n/rules ci plan` — skip-логіка сервіс-орієнтованого CI-канону: перетин дельти з `--path` → job outputs
22
- * «які lint-домени запускати» (`--github` → $GITHUB_OUTPUT, `--azure` → ##vso).
23
- * `npx \@7n/rules skill list` — скіли пакета без синку в проєкт
24
- * `npx \@7n/rules skill taze` — промпт на stdout
25
- * `npx \@7n/rules skill cursor taze ["task"]` — Cursor CLI (`cursor-agent -p`)
26
- * `npx \@7n/rules skill claude taze ["task"]` — Claude Code CLI (`claude -p`)
27
- *
28
- * Agent інтеграція: під час синку, окрім `.cursor/rules` і `.claude/commands` (з skills), CLI ще раз
29
- * синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються)
30
- * і `.cursor/hooks.json` (Cursor Agent hooks; merge — користувацькі hooks зберігаються).
31
- * Опт-аут — поле `claude-config: false` у `.n-rules.json`.
32
- * Pi.dev інтеграція: для кожного skill у `.cursor/skills/<dir>/` CLI генерує
33
- * `.pi/skills/<dir>/SKILL.md` із frontmatter `name`+`description` (формат pi.dev). Тіло — делегат
34
- * на джерельний `.cursor/skills/<dir>/SKILL.md`. Always-on, симетрично до `.claude/commands/`.
35
- *
36
- * Якщо у корені репозиторію немає .n-rules.json, спочатку перейменовується за наявності nitra-cursor.json;
37
- * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
38
- * з усіма правилами з каталогу rules/ пакету (їх можна відредагувати після створення). У файлі завжди має бути
39
- * поле `$schema` з посиланням на JSON Schema пакету (публічний URL для IDE); при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
40
- * Масиви `rules`, `skills`, `disable-rules` і `disable-skills` при записі сортуються за алфавітом.
41
- *
42
- * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
43
- * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
44
- * Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
45
- *
46
- * Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
47
- * пакетом), яких немає у списку rules у .n-rules.json. Інші .mdc у цій директорії залишаються.
48
- *
49
- * Composite GitHub Action `.github/actions/setup-bun-deps/action.yml` копіюється з каталогу
50
- * `github-actions/` пакету при кожному успішному синку (workflows з правил ga / js-lint / text).
51
- *
52
- * Skills копіюються з npm/skills пакету лише для id з масиву «skills» у .n-rules.json
53
- * (у JSON — без префікса, як імена каталогів у rules/ без n-). У пакеті джерело — каталоги
54
- * skills/<id>/ (без префікса); у проєкті — .cursor/skills/n-<id>/ (префікс n-, як n-*.mdc).
55
- * Якщо ключа skills немає, за замовчуванням підтягуються всі підкаталоги skills/ (лише імена без префікса n-).
56
- * Зайві каталоги n-* у .cursor/skills, яких немає у списку, видаляються.
57
- * Файл `auto.md` у скілі — джерело правди для auto-skills у CLI (`scripts/auto-skills.mjs`)
58
- * і у проєкт не копіюється; раніше синхронізовані `auto.md` у `.cursor/skills/n-<id>/` CLI
59
- * не чіпає — їх потрібно прибрати вручну.
60
- *
61
- * Якщо в корені є package.json і в ньому ще немає \@7n/rules у devDependencies (і не оголошено
62
- * в dependencies), CLI дописує devDependencies з діапазоном ^<version> поточного пакету — зручно після npx.
63
- *
64
- * Перед копіюванням правил (режим без підкоманди): оновлення \@7n/rules у package.json до
65
- * останньої версії з npm (крім workspace:/file:/link: тощо), `bun i`, далі файли беруться з
66
- * `node_modules/@7n/rules`, якщо пакет з’явився після встановлення.
67
- */
3
+ import { isRunAsCli } from '../scripts/cli-entry.mjs'
4
+ import { runCli } from './n-rules-cli.mjs'
68
5
 
69
- import { spawnSync } from 'node:child_process'
70
- import { existsSync } from 'node:fs'
71
- import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
72
- import { basename, dirname, join, resolve } from 'node:path'
73
- import { cwd, env } from 'node:process'
74
- import { fileURLToPath } from 'node:url'
75
-
76
- import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
77
- import { formatGeneratedMarkdownLines, renderAgentsTemplate } from '../scripts/lib/generated-markdown.mjs'
78
- import { appendDiscoveredMdcFiles, inlineTemplateLinks } from '../scripts/lib/inline-template-links.mjs'
79
- import {
80
- detectAutoRules,
81
- detectLegacyRuleIds,
82
- mergeConfigWithAutoDetected,
83
- normalizeIdList,
84
- RULE_MIGRATIONS
85
- } from '../scripts/auto-rules.mjs'
86
- import { detectAutoSkills } from '../scripts/auto-skills.mjs'
87
- import {
88
- bringChangesBackToOriginal,
89
- ensureRunningInWorktree,
90
- removeAutoCreatedWorktree
91
- } from '../scripts/lib/auto-worktree.mjs'
92
- import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
93
- import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
94
- import { collectSkillFragments, injectSkillFragments } from '../scripts/lib/skill-fragments.mjs'
95
- import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
96
- import { listProjectRulesMdcFiles } from '../scripts/lib/list-project-rules-mdc.mjs'
97
- import { ensureNRulesInRootDevDependencies } from '../scripts/ensure-n-rules-dev-dependencies.mjs'
98
- import { resolvePluginList, resolvePlugins, resolveRulesDirs } from '../scripts/lib/resolve-plugins.mjs'
99
- import { assertCwdIsProjectRoot } from '../scripts/lib/assert-project-root.mjs'
100
- import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
101
- import { syncGitignoreWorktree } from '../scripts/lib/sync-gitignore-worktree.mjs'
102
- import { upgradeNRulesToLatestAndBunInstall } from '../scripts/upgrade-n-rules-and-install.mjs'
103
- import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
104
- import { isTazeOrchestratorSkillArgs, runSkillsCli } from '../scripts/skills-cli.mjs'
105
- import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
106
-
107
- /**
108
- * Чи потребуватиме `lint <args>` worktree-ізоляції (той самий предикат, що й
109
- * `needsWorktreeIsolation` у `case 'lint'`, обчислений із сирих CLI-args ДО
110
- * парсингу `lintOpts`) — щоб self-upgrade devDependency не забруднив дерево
111
- * ПЕРЕД гейтом чистоти `ensureRunningInWorktree` (той самий клас проблеми,
112
- * що й `skill taze`, див. коментар над `skipDevDepsEnsure`).
113
- * @param {string[]} args сирі аргументи після `lint`
114
- * @returns {boolean} true, якщо `--full` без `--no-fix`/`--path`/`--repo-wide`
115
- */
116
- function isLintFullFixArgs(args) {
117
- return (
118
- args.includes('--full') && !args.includes('--path') && !args.includes('--repo-wide') && !args.includes('--no-fix')
119
- )
120
- }
121
-
122
- const PACKAGE_NAME = '@7n/rules'
123
- const CONFIG_FILE = '.n-rules.json'
124
- /** Публічний URL JSON Schema для поля `$schema` у `.n-rules.json` (IDE); вміст правил CLI читає лише з диска пакету */
125
- const CONFIG_SCHEMA_URL = 'https://unpkg.com/@7n/rules/schemas/n-rules.json'
126
- const AGENTS_FILE = 'AGENTS.md'
127
- const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
128
- const RULES_DIR = '.cursor/rules'
129
- const SKILLS_DIR = '.cursor/skills'
130
- const COMMANDS_DIR = '.claude/commands'
131
- const PI_SKILLS_DIR = '.pi/skills'
132
- const RULE_PREFIX = 'n-'
133
-
134
- const binDir = dirname(fileURLToPath(import.meta.url))
135
- const BUNDLED_RULES_DIR = join(binDir, '..', 'rules')
136
- const BUNDLED_SKILLS_DIR = join(binDir, '..', 'skills')
137
- const BUNDLED_AGENTS_TEMPLATE_PATH = join(binDir, '..', AGENTS_TEMPLATE_FILE)
138
- /** Корінь установленого пакету (каталог з `rules/`, `github-actions/`, …) */
139
- const BUNDLED_PACKAGE_ROOT = join(binDir, '..')
140
-
141
- const YAML_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
142
- const NEWLINE_RE = /\r?\n/
143
- const LEADING_SPACES_RE = /^\s+/
144
-
145
- /** Ключі `.n-rules.json`, де значення — масиви id; після запуску CLI сортуються за алфавітом */
146
- const CONFIG_SORTED_ARRAY_KEYS = /** @type {const} */ (['rules', 'skills', 'disable-rules', 'disable-skills'])
147
-
148
- /**
149
- * Сортує масиви id у конфігу за алфавітом (`localeCompare`), щоб порядок у файлі був стабільним після синку.
150
- * @param {Record<string, unknown>} config об'єкт конфігу перед записом на диск
151
- * @returns {Record<string, unknown>} копія з відсортованими масивами для відомих ключів
152
- */
153
- function sortConfigIdArrays(config) {
154
- const out = { ...config }
155
- for (const key of CONFIG_SORTED_ARRAY_KEYS) {
156
- const v = out[key]
157
- if (key in out && Array.isArray(v)) {
158
- out[key] = v.map(String).toSorted((a, b) => a.localeCompare(b))
159
- }
160
- }
161
- return out
162
- }
163
-
164
- /**
165
- * Імена правил з каталогу `rules/` поточної інсталяції пакету. Кожне правило — окремий
166
- * підкаталог `rules/<id>/`, у якому має бути `main.mdc`.
167
- * @param {string} [bundledRulesDir] каталог `rules/` у корені пакету
168
- * @returns {Promise<string[]>} відсортовані id правил (імена підкаталогів)
169
- */
170
- async function discoverBundledRuleNames(bundledRulesDir = BUNDLED_RULES_DIR) {
171
- if (!existsSync(bundledRulesDir)) {
172
- throw new Error(
173
- `Не знайдено каталог правил пакету.\n` +
174
- `Очікуваний шлях: ${bundledRulesDir}\n` +
175
- `Перевстановіть ${PACKAGE_NAME} або створіть ${CONFIG_FILE} вручну.`
176
- )
177
- }
178
- const entries = await readdir(bundledRulesDir, { withFileTypes: true })
179
- const rules = entries
180
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
181
- .filter(e => existsSync(join(bundledRulesDir, e.name, 'main.mdc')))
182
- .map(e => e.name)
183
- .toSorted((a, b) => a.localeCompare(b))
184
- if (rules.length === 0) {
185
- throw new Error(`У каталозі rules/ пакету немає підкаталогів з main.mdc. Створіть ${CONFIG_FILE} вручну.`)
186
- }
187
- return rules
188
- }
189
-
190
- /**
191
- * Імена skills (id без префікса n-) з каталогу skills пакету — лише підкаталоги `<id>/` без префікса n-
192
- * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету
193
- * @returns {Promise<string[]>} відсортовані id
194
- */
195
- async function discoverBundledSkillNames(bundledSkillsDir = BUNDLED_SKILLS_DIR) {
196
- if (!existsSync(bundledSkillsDir)) {
197
- return []
198
- }
199
- const entries = await readdir(bundledSkillsDir, { withFileTypes: true })
200
- return entries
201
- .filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith(RULE_PREFIX))
202
- .map(e => e.name)
203
- .toSorted((a, b) => a.localeCompare(b))
204
- }
205
-
206
- /**
207
- * Перейменовує у каталозі правил файли `nitra-*.mdc` → `n-*.mdc`. Якщо `n-*.mdc` уже є, застарілий файл видаляється.
208
- * @param {string} rulesDir абсолютний шлях до `.cursor/rules`
209
- * @returns {Promise<void>}
210
- */
211
- async function migrateLegacyManagedRuleFilenames(rulesDir) {
212
- if (!existsSync(rulesDir)) {
213
- return
214
- }
215
- const names = await readdir(rulesDir)
216
- for (const name of names) {
217
- if (!(name.endsWith('.mdc') && name.startsWith('nitra-'))) {
218
- continue
219
- }
220
-
221
- const rest = name.slice('nitra-'.length)
222
- const newName = `${RULE_PREFIX}${rest}`
223
- const from = join(rulesDir, name)
224
- const to = join(rulesDir, newName)
225
- if (existsSync(to)) {
226
- await unlink(from)
227
- console.log(`📝 Видалено застарілий ${RULES_DIR}/${name} (вже є ${newName})\n`)
228
- } else {
229
- await rename(from, to)
230
- console.log(`📝 Перейменовано ${RULES_DIR}/${name} → ${RULES_DIR}/${newName}\n`)
231
- }
232
- }
233
- }
234
-
235
- /**
236
- * Міграція legacy: `nitra-*.mdc` → `n-*.mdc` у `.cursor/rules`; якщо немає `.n-rules.json`, конфіг
237
- * береться з legacy-імен (`.n-rules.json`, потім `nitra-cursor.json`) з переписуванням `$schema` на новий URL
238
- * @returns {Promise<void>}
239
- */
240
- async function migrateLegacyConfigIfNeeded() {
241
- const root = cwd()
242
- await migrateLegacyManagedRuleFilenames(join(root, RULES_DIR))
243
-
244
- const target = join(root, CONFIG_FILE)
245
- if (existsSync(target)) {
246
- return
247
- }
248
- for (const legacyName of ['.n-cursor.json', 'nitra-cursor.json']) {
249
- const legacyPath = join(root, legacyName)
250
- if (!existsSync(legacyPath)) {
251
- continue
252
- }
253
- await rename(legacyPath, target)
254
- console.log(`📝 Перейменовано ${legacyName} → ${CONFIG_FILE}\n`)
255
- try {
256
- const parsed = JSON.parse(await readFile(target, 'utf8'))
257
- if (typeof parsed?.$schema === 'string' && parsed.$schema.includes('@nitra/cursor')) {
258
- parsed.$schema = CONFIG_SCHEMA_URL
259
- await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`)
260
- }
261
- } catch {
262
- /* некоректний JSON — $schema виправить подальший readConfig */
263
- }
264
- return
265
- }
266
- }
267
-
268
- /**
269
- * Повертає розпарсений package.json з кореня або null, якщо файл відсутній/некоректний.
270
- * @returns {Promise<unknown | null>} обʼєкт package.json або null
271
- */
272
- async function readRootPackageJsonSafe() {
273
- const packageJsonPath = join(cwd(), 'package.json')
274
- if (!existsSync(packageJsonPath)) {
275
- return null
276
- }
277
- try {
278
- return JSON.parse(await readFile(packageJsonPath, 'utf8'))
279
- } catch {
280
- return null
281
- }
282
- }
283
-
284
- /**
285
- * Агрегує імена правил по rules-каталогах (ядро перше, перший власник виграє)
286
- * і будує мапу `ruleId → rulesDir` для копіювання mdc.
287
- * @param {string[]} rulesDirs упорядковані rules-каталоги (ядро + плагіни)
288
- * @returns {Promise<{ names: string[], sources: Map<string, string>, extras: Map<string, string[]> }>} імена правил, їх джерела і mixin-теки
289
- */
290
- async function aggregateRuleSources(rulesDirs) {
291
- /** @type {Map<string, string>} */
292
- const sources = new Map()
293
- /** @type {Map<string, string[]>} каталоги `rules/<id>/` mixin-джерел (не власник main.mdc) */
294
- const extras = new Map()
295
- const perDirNames = await listRuleNamesPerDir(rulesDirs)
296
- for (const [i, dir] of rulesDirs.entries()) {
297
- for (const name of perDirNames[i]) {
298
- if (!sources.has(name)) sources.set(name, dir)
299
- }
300
- if (i > 0) await collectMixinDirs(dir, perDirNames[i], extras)
301
- }
302
- // Тека з main.mdc у плагіні для правила, яким володіє інше джерело, — теж mixin (дублікат id).
303
- for (const [i, dir] of rulesDirs.entries()) {
304
- if (i === 0) continue
305
- for (const name of perDirNames[i]) {
306
- if (sources.get(name) === dir) continue
307
- const list = extras.get(name) ?? []
308
- list.push(join(dir, name))
309
- extras.set(name, list)
310
- }
311
- }
312
- return { names: sources.keys().toArray(), sources, extras }
313
- }
314
-
315
- /**
316
- * Імена правил (теки з `main.mdc`) кожного rules-каталогу. Для ядра (перший елемент)
317
- * відсутність каталогу — фатальна помилка; для плагінів — порожній список
318
- * (уже повідомлено у resolve-plugins).
319
- * @param {string[]} rulesDirs упорядковані rules-каталоги
320
- * @returns {Promise<string[][]>} імена правил per-dir у тому ж порядку
321
- */
322
- async function listRuleNamesPerDir(rulesDirs) {
323
- /** @type {string[][]} */
324
- const out = []
325
- for (const [i, dir] of rulesDirs.entries()) {
326
- if (i === 0) {
327
- out.push(await discoverBundledRuleNames(dir))
328
- continue
329
- }
330
- try {
331
- out.push(await discoverBundledRuleNames(dir))
332
- } catch {
333
- out.push([])
334
- }
335
- }
336
- return out
337
- }
338
-
339
- /**
340
- * Додає у `extras` mixin-теки плагіна: підкаталоги `rules/<id>/` БЕЗ `main.mdc`
341
- * (плагін доповнює правило іншого джерела концернами).
342
- * @param {string} dir rules-каталог плагіна
343
- * @param {string[]} ownNames імена повних правил цього каталогу (з main.mdc)
344
- * @param {Map<string, string[]>} extras акумулятор mixin-тек (мутується)
345
- * @returns {Promise<void>}
346
- */
347
- async function collectMixinDirs(dir, ownNames, extras) {
348
- let entries
349
- try {
350
- entries = await readdir(dir, { withFileTypes: true })
351
- } catch {
352
- return // нечитабельний rules/ плагіна — вже пропущений вище
353
- }
354
- for (const e of entries) {
355
- if (!e.isDirectory() || e.name.startsWith('.') || ownNames.includes(e.name)) continue
356
- const list = extras.get(e.name) ?? []
357
- list.push(join(dir, e.name))
358
- extras.set(e.name, list)
359
- }
360
- }
361
-
362
- /**
363
- * Зчитує конфіг .n-rules.json з поточної директорії
364
- * @param {{ bundledRulesDir?: string, bundledSkillsDir?: string }} [paths] каталоги з пакету-джерела (після `bun i` — зазвичай `node_modules/@7n/rules`)
365
- * @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-); поле version у файлі за наявності ігнорується при синхронізації правил
366
- */
367
- async function readConfig(paths = {}) {
368
- const bundledRulesDir = paths.bundledRulesDir ?? BUNDLED_RULES_DIR
369
- const bundledSkillsDir = paths.bundledSkillsDir ?? BUNDLED_SKILLS_DIR
370
- await migrateLegacyConfigIfNeeded()
371
- const configPath = join(cwd(), CONFIG_FILE)
372
-
373
- /** @type {Record<string, unknown> | null} */
374
- let rawConfig = null
375
- if (existsSync(configPath)) {
376
- try {
377
- rawConfig = JSON.parse(await readFile(configPath, 'utf8'))
378
- } catch {
379
- throw new Error(`Невірний JSON у файлі ${CONFIG_FILE}`)
380
- }
381
- }
382
-
383
- // Плагіни: явне поле plugins конфігу (з per-категорійним backfill автодетекту — ADR
384
- // 260719-2154) або повний автодетект, коли поля нема; sync-контекст — з установкою devDep.
385
- // Результат записуємо у конфіг (нижче), щоб hook/lint не залежали від детекту й мережі.
386
- const declaredPlugins = Array.isArray(rawConfig?.plugins) ? rawConfig.plugins : null
387
- const resolvedPlugins = resolvePluginList(cwd(), rawConfig)
388
- const rulesDirs = resolveRulesDirs(cwd(), rawConfig, bundledRulesDir).map(d => d.rulesDir)
389
- const { names: availableRules } = await aggregateRuleSources(rulesDirs)
390
- const availableSkills = await discoverBundledSkillNames(bundledSkillsDir)
391
-
392
- /**
393
- * Автодописує правила/skills за `rules/<rule>/auto.md` і синхронізує `$schema`.
394
- * @param {Record<string, unknown>} parsedConfig сирий обʼєкт конфігу
395
- * @returns {Promise<Record<string, unknown>>} нормалізований конфіг
396
- */
397
- async function normalizeConfigWithAutoRules(parsedConfig) {
398
- const currentRules = parsedConfig.rules
399
- if (!Array.isArray(currentRules)) {
400
- throw new TypeError(`У ${CONFIG_FILE} поле "rules" має бути масивом рядків`)
401
- }
402
- if ('skills' in parsedConfig && !Array.isArray(parsedConfig.skills)) {
403
- throw new Error(`У ${CONFIG_FILE} поле "skills" має бути масивом рядків`)
404
- }
405
- const { ignore } = parsedConfig
406
- if (ignore !== undefined && (!Array.isArray(ignore) || ignore.some(p => typeof p !== 'string'))) {
407
- throw new Error(`У ${CONFIG_FILE} поле "ignore" має бути масивом рядків (шляхів до директорій)`)
408
- }
409
-
410
- const rootPkg = await readRootPackageJsonSafe()
411
- const disableRules = normalizeIdList(parsedConfig['disable-rules'])
412
- const disableSkills = normalizeIdList(parsedConfig['disable-skills'])
413
- const autoDetectedRules = await detectAutoRules({
414
- root: cwd(),
415
- availableRules,
416
- packageJsonParsed: rootPkg,
417
- disableRules,
418
- rulesDirs
419
- })
420
- // Skills залежать від ефективного списку правил, який буде у конфізі після merge:
421
- // вже існуючі (опт-ін вручну) + auto-detected, мінус `disable-rules`. Без цього
422
- // правило, додане вручну (напр. `adr` без auto.md-умови), не активувало б залежні
423
- // скіли (`adr-normalize`).
424
- const disableRulesSet = new Set(disableRules)
425
- const effectiveRulesForSkills = [
426
- ...new Set([...normalizeIdList(parsedConfig.rules), ...autoDetectedRules.rules]).difference(disableRulesSet)
427
- ]
428
- const autoDetectedSkills = detectAutoSkills({
429
- availableSkills,
430
- detectedRules: effectiveRulesForSkills,
431
- disableSkills
432
- })
433
-
434
- const merged = mergeConfigWithAutoDetected({
435
- config: parsedConfig,
436
- detectedRules: autoDetectedRules.rules,
437
- detectedSkills: autoDetectedSkills.skills,
438
- availableRules,
439
- availableSkills
440
- })
441
-
442
- if (merged.pruned) {
443
- const parts = []
444
- if (merged.pruned.rules.length > 0) parts.push(`rules: ${merged.pruned.rules.join(', ')}`)
445
- if (merged.pruned.skills.length > 0) parts.push(`skills: ${merged.pruned.skills.join(', ')}`)
446
- console.log(`🧹 Прибрано з ${CONFIG_FILE} неактуальні (немає у пакеті) — ${parts.join('; ')}\n`)
447
- }
448
-
449
- const rest = Object.fromEntries(Object.entries(parsedConfig).filter(([k]) => k !== '$schema'))
450
- const normalized = {
451
- $schema: CONFIG_SCHEMA_URL,
452
- ...rest,
453
- rules: merged.rules,
454
- skills: merged.skills
455
- }
456
- if (merged['disable-rules']?.length) {
457
- normalized['disable-rules'] = merged['disable-rules']
458
- }
459
- if (merged['disable-skills']?.length) {
460
- normalized['disable-skills'] = merged['disable-skills']
461
- }
462
- if (!('plugins' in parsedConfig)) {
463
- if (resolvedPlugins.length > 0) {
464
- normalized.plugins = resolvedPlugins
465
- console.log(`🔌 Автодетект плагінів: ${resolvedPlugins.join(', ')} — записано у ${CONFIG_FILE}\n`)
466
- }
467
- } else if (declaredPlugins && JSON.stringify(resolvedPlugins) !== JSON.stringify(declaredPlugins)) {
468
- // per-категорійний backfill (resolvePluginList, ADR 260719-2154) додав плагін
469
- // категорії, відсутньої в явному plugins — фіксуємо результат у конфізі.
470
- normalized.plugins = resolvedPlugins
471
- const added = resolvedPlugins.filter(p => !declaredPlugins.includes(p))
472
- console.log(`🔌 Доповнено plugins у ${CONFIG_FILE} (${added.join(', ')}) — категорія не була задекларована\n`)
473
- }
474
- return sortConfigIdArrays(normalized)
475
- }
476
-
477
- if (rawConfig === null) {
478
- const rootPkg = await readRootPackageJsonSafe()
479
- const autoDetectedRules = await detectAutoRules({
480
- root: cwd(),
481
- availableRules,
482
- packageJsonParsed: rootPkg,
483
- rulesDirs
484
- })
485
- const autoDetectedSkills = detectAutoSkills({
486
- availableSkills,
487
- detectedRules: autoDetectedRules.rules
488
- })
489
- const defaultConfig = sortConfigIdArrays({
490
- $schema: CONFIG_SCHEMA_URL,
491
- rules: autoDetectedRules.rules,
492
- skills: autoDetectedSkills.skills,
493
- ...(resolvedPlugins.length > 0 && { plugins: resolvedPlugins })
494
- })
495
- await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
496
- console.log(
497
- `📝 Створено ${CONFIG_FILE} з автоаналізом правил (${defaultConfig.rules.length}) і skills (${defaultConfig.skills.length}).\n`
498
- )
499
- return defaultConfig
500
- }
501
- const config = rawConfig
502
- logRuleMigrationsIfAny(config)
503
- const normalized = await normalizeConfigWithAutoRules(config)
504
- if (JSON.stringify(normalized) !== JSON.stringify(config)) {
505
- await writeFile(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
506
- console.log(`📝 Оновлено ${CONFIG_FILE}: синхронізовано $schema та авто-додані rules/skills\n`)
507
- }
508
- return normalized
509
- }
510
-
511
- /**
512
- * Якщо у `rules` чи `disable-rules` є застарілі rule-id з `RULE_MIGRATIONS`,
513
- * виводить пояснювальний лог про автоматичну заміну (саму заміну виконує
514
- * `migrateRuleIds` у `mergeConfigWithAutoDetected` — тут лише користувацька комунікація).
515
- * @param {Record<string, unknown>} parsedConfig сирий обʼєкт `.n-rules.json` після `JSON.parse`
516
- * @returns {void}
517
- */
518
- function logRuleMigrationsIfAny(parsedConfig) {
519
- /** @type {Set<string>} */
520
- const seen = new Set()
521
- for (const key of /** @type {const} */ (['rules', 'disable-rules'])) {
522
- const list = parsedConfig[key]
523
- if (!Array.isArray(list)) continue
524
- const legacy = detectLegacyRuleIds(normalizeIdList(list))
525
- for (const id of legacy) seen.add(id)
526
- }
527
- if (seen.size === 0) return
528
- console.log(`📦 Авто-міграція ${CONFIG_FILE}:`)
529
- for (const id of seen) {
530
- const replacement = RULE_MIGRATIONS[id].join(', ')
531
- console.log(` • ${id} → ${replacement}`)
532
- }
533
- console.log('')
534
- }
535
-
536
- /**
537
- * Витягує чистий id правила без шляху і без .mdc.
538
- * "npm/rules/text/text.mdc" → "text"
539
- * "text.mdc" → "text"
540
- * "text" → "text"
541
- * @param {string} ruleName шлях або базове ім'я, з суфіксом .mdc або без
542
- * @returns {string} id правила (без .mdc, без шляху)
543
- */
544
- function normalizeRuleName(ruleName) {
545
- const name = basename(String(ruleName).trim())
546
- return name.endsWith('.mdc') ? name.slice(0, -'.mdc'.length) : name
547
- }
548
-
549
- /**
550
- * Читає вміст правила з каталогу `rules/<id>/main.mdc` установленого пакету
551
- * (наприклад `node_modules/@7n/rules/rules/<id>/main.mdc` або кеш npx).
552
- * Mixin-концерни: `extraRuleDirs` — каталоги `rules/<id>/` того самого правила з ІНШИХ
553
- * джерел (плагінів); їхні concern-mdc доінлайнюються після концернів власника, тож
554
- * `.cursor/rules/n-<id>.mdc` містить і провайдер-специфічні розділи (напр. lint_js_yml
555
- * з `@7n/rules-ci-github` чи lint_pipeline_js з `@7n/rules-ci-azure`).
556
- * @param {string} rule елемент масиву rules з `.n-rules.json`
557
- * @param {string} [bundledRulesDir] каталог `rules/` у корені пакету-джерела (власник main.mdc)
558
- * @param {string[]} [extraRuleDirs] каталоги `rules/<id>/` mixin-джерел (без main.mdc)
559
- * @returns {Promise<string>} текст правила для запису в `.cursor/rules/n-*.mdc`
560
- */
561
- async function readBundledRuleContent(rule, bundledRulesDir = BUNDLED_RULES_DIR, extraRuleDirs = []) {
562
- const id = normalizeRuleName(rule)
563
- const bundledPath = join(bundledRulesDir, id, 'main.mdc')
564
- if (!existsSync(bundledPath)) {
565
- throw new Error(
566
- `Немає файлу ${id}/main.mdc у ${bundledRulesDir}. Оновіть ${PACKAGE_NAME} або приберіть "${rule}" з rules у ${CONFIG_FILE}.`
567
- )
568
- }
569
- const text = await readFile(bundledPath, 'utf8')
570
- let out = await inlineTemplateLinks(text, dirname(bundledPath))
571
- out = await appendDiscoveredMdcFiles(out, dirname(bundledPath))
572
- for (const extraDir of extraRuleDirs) {
573
- out = await appendDiscoveredMdcFiles(out, extraDir)
574
- }
575
- return out
576
- }
577
-
578
- /**
579
- * Нормалізує id skill з конфігу до форми без префікса n- (як «fix»)
580
- * @param {string} skillName елемент масиву skills або ім'я каталогу
581
- * @returns {string} id без префікса n-
582
- */
583
- function normalizeSkillId(skillName) {
584
- let s = basename(String(skillName).trim())
585
- if (s.startsWith(RULE_PREFIX)) {
586
- s = s.slice(RULE_PREFIX.length)
587
- }
588
- return s
589
- }
590
-
591
- /**
592
- * Ім'я керованого каталогу skill у .cursor/skills (префікс n-)
593
- * @param {string} skillId id без префікса (або з префіксом n- у конфігу — нормалізується)
594
- * @returns {string} наприклад n-fix
595
- */
596
- function managedSkillDirName(skillId) {
597
- return `${RULE_PREFIX}${normalizeSkillId(skillId)}`
598
- }
599
-
600
- /**
601
- * Витягує текст description з YAML frontmatter SKILL.md (формат description: >-)
602
- * @param {string} text повний вміст SKILL.md
603
- * @returns {string | null} один рядок опису або null
604
- */
605
- function extractSkillDescription(text) {
606
- const fm = text.match(YAML_FRONTMATTER_RE)
607
- if (!fm) {
608
- return null
609
- }
610
- const lines = fm[1].split(NEWLINE_RE)
611
- const start = lines.findIndex(line => line.trim() === 'description: >-')
612
- if (start === -1) {
613
- return null
614
- }
615
- const descLines = []
616
- for (const line of lines.slice(start + 1)) {
617
- if (!LEADING_SPACES_RE.test(line)) {
618
- break
619
- }
620
- descLines.push(line.replace(LEADING_SPACES_RE, '').trimEnd())
621
- }
622
- if (descLines.length === 0) {
623
- return null
624
- }
625
- return descLines.join(' ').trim()
626
- }
627
-
628
- /**
629
- * Підготовка опису skill для вставки в звичайний markdown (заголовок H1, bullet без code fence).
630
- * Послідовність `<id>` сприймається markdownlint (MD033) як inline HTML — замінюємо на `{id}`.
631
- * @param {string} desc один рядок з YAML frontmatter SKILL.md
632
- * @returns {string} той самий рядок після заміни літералу з кутовими дужками навколо id на плейсхолдер у фігурних дужках (MD033).
633
- */
634
- function skillDescriptionSafeForMarkdownInline(desc) {
635
- return desc.replaceAll('<id>', '{id}')
636
- }
637
-
638
- /**
639
- * YAML frontmatter для `.claude/commands/*.md`: поле `description` потрібне розширенню VSCode,
640
- * щоб команди з’являлись у списку. Текст збігається з полем `description` у frontmatter `SKILL.md`.
641
- * @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
642
- * @returns {string} блок `---` … `---` і порожній рядок після
643
- */
644
- function formatClaudeCommandFrontmatter(descriptionRaw) {
645
- let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
646
- if (!text) {
647
- text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
648
- }
649
- return `---\ndescription: >-\n ${text}\n---\n\n`
650
- }
651
-
652
- /**
653
- * YAML frontmatter для `.pi/skills/<dir>/SKILL.md` згідно зі специфікацією pi.dev:
654
- * обов'язкові поля `name` (1-64, `[a-z0-9-]`) і `description` (≤ 1024). Текст description збігається
655
- * з полем `description` у frontmatter джерельного `SKILL.md`.
656
- * @param {string} skillName ім'я скілу (наприклад `n-fix`); має бути валідним pi-name
657
- * @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
658
- * @returns {string} блок `---` … `---` і порожній рядок після
659
- */
660
- function formatPiSkillFrontmatter(skillName, descriptionRaw) {
661
- let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
662
- if (!text) {
663
- text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
664
- }
665
- return `---\nname: ${skillName}\ndescription: >-\n ${text}\n---\n\n`
666
- }
667
-
668
- /**
669
- * Базові імена файлів .mdc, які очікуються згідно з .n-rules.json (префікс n-).
670
- * @param {string[]} configRules елементи масиву rules з конфігу
671
- * @returns {Set<string>} множина очікуваних імен файлів (наприклад n-bun.mdc)
672
- */
673
- function expectedManagedRuleBasenames(configRules) {
674
- return new Set(configRules.map(rule => `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`))
675
- }
676
-
677
- /**
678
- * Видаляє з каталогу правил файли *.mdc з префіксом n-, яких немає у конфігурації.
679
- * Файли без префікса n- не змінює.
680
- * @param {string} rulesDir абсолютний шлях до .cursor/rules
681
- * @param {string[]} configRules елементи масиву rules з .n-rules.json
682
- * @returns {Promise<string[]>} відсортовані імена видалених файлів
683
- */
684
- async function removeOrphanManagedRuleFiles(rulesDir, configRules) {
685
- if (!existsSync(rulesDir)) {
686
- return []
687
- }
688
- const expected = expectedManagedRuleBasenames(configRules)
689
- const names = await readdir(rulesDir)
690
- const removed = []
691
- for (const name of names) {
692
- if (!(name.endsWith('.mdc') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
693
- continue
694
- }
695
-
696
- await unlink(join(rulesDir, name))
697
- removed.push(name)
698
- }
699
- return removed.toSorted((a, b) => a.localeCompare(b))
700
- }
701
-
702
- /**
703
- * Повертає відсортований список директорій skills у `.cursor/skills`.
704
- * Директорія вважається skill-каталогом, якщо це підкаталог (без префікса `.`).
705
- * @returns {Promise<string[]>} імена директорій (наприклад `n-fix`, `custom-skill`)
706
- */
707
- async function listProjectSkillDirNames() {
708
- const skillsRoot = join(cwd(), SKILLS_DIR)
709
- if (!existsSync(skillsRoot)) {
710
- return []
711
- }
712
- const entries = await readdir(skillsRoot, { withFileTypes: true })
713
- return entries
714
- .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
715
- .map(entry => entry.name)
716
- .toSorted((a, b) => a.localeCompare(b))
717
- }
718
-
719
- /**
720
- * Формує markdown-рядки для секції Skills у AGENTS.md з усіх skill-директорій на диску.
721
- * @returns {Promise<{ name: string }[]>} елементи з полем name для Mustache-секції skills
722
- */
723
- async function buildSkillBulletItems() {
724
- const skillsRoot = join(cwd(), SKILLS_DIR)
725
- const skillDirNames = await listProjectSkillDirNames()
726
- const items = []
727
- for (const dirName of skillDirNames) {
728
- const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
729
- let desc = ''
730
- if (existsSync(skillMdPath)) {
731
- const text = await readFile(skillMdPath, 'utf8')
732
- const parsed = extractSkillDescription(text)
733
- if (parsed) {
734
- desc = skillDescriptionSafeForMarkdownInline(parsed)
735
- }
736
- }
737
- const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
738
- const line = desc ? `${pathLine} — ${desc}` : pathLine
739
- items.push({ name: line })
740
- }
741
- return items
742
- }
743
-
744
- /**
745
- * Видаляє каталоги n-* у .cursor/skills, яких немає у конфігурації skills
746
- * @param {string} skillsRoot абсолютний шлях до .cursor/skills
747
- * @param {string[]} configSkills елементи масиву skills з .n-rules.json
748
- * @returns {Promise<string[]>} імена видалених каталогів
749
- */
750
- async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
751
- if (!existsSync(skillsRoot)) {
752
- return []
753
- }
754
- const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
755
- const entries = await readdir(skillsRoot, { withFileTypes: true })
756
- const removed = []
757
- for (const e of entries) {
758
- const isManagedDir = e.isDirectory() && e.name.startsWith(RULE_PREFIX)
759
- const isOrphan = isManagedDir && !expected.has(e.name)
760
- if (isOrphan) {
761
- await rm(join(skillsRoot, e.name), { recursive: true, force: true })
762
- removed.push(e.name)
763
- }
764
- }
765
- return removed.toSorted((a, b) => a.localeCompare(b))
766
- }
767
-
768
- /**
769
- * Рендерить коротку секцію для CLAUDE.md: full-прогони lint мають вбудовану
770
- * глобальну чергу з видимим прогресом, дельта-запуски йдуть паралельно без лока.
771
- * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
772
- */
773
- function buildClaudeLintParallelismSectionLines() {
774
- return [
775
- '',
776
- '## Лінт і ESLint (паралелізм)',
777
- '',
778
- 'Дельта-`lint` (типовий задачний прогін) — **без черги**, паралельні запуски по різних файлах дозволені. `npx @7n/rules lint --full` має **вбудовану глобальну чергу** (spec 2026-07-03): один full-прогін на машину; запуск у черзі показує свою позицію, решту черги і живий прогрес-бар активного прогону (`⏳ lint --full у черзі #… · працює pid … [██…] 5/12 · …` — штатна черга, не зависання; fail-closed таймаут 45 хв). Ідентичний повтор --full на незміненому дереві дедуплікується (`♻️ … пропускаю`). Координувати запуски вручну не треба. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
779
- ''
780
- ]
781
- }
782
-
783
- /**
784
- * Рендерить секцію для CLAUDE.md: скіли з `main.json.worktree === true` запускаються
785
- * лише в окремому git-worktree (дублює fail-fast банер `SKILL.md` як завжди-читане правило).
786
- * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
787
- */
788
- function buildClaudeWorktreeEnforcementSectionLines() {
789
- return [
790
- '',
791
- '## Worktree-only skills (`main.json` → `worktree: true`)',
792
- '',
793
- 'Скіл із **`worktree: true`** у `main.json` запускається **виключно** в окремому git-worktree (`.worktrees/<current-branch>-<suffix>/`) — **не** в основному дереві й **не** паралельно. Перший крок такого скіла (блок `n-rules: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.',
794
- ''
795
- ]
796
- }
797
-
798
- /**
799
- * Рендерить секцію для CLAUDE.md: doc-files — обовʼязковий крок задачі (як lint).
800
- * Після зміни кодового файлу його дока має бути перегенерована; застарілість
801
- * детермінується за CRC і контролюється Stop-hook'ом.
802
- * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
803
- */
804
- function buildClaudeDocFilesSectionLines() {
805
- return [
806
- '',
807
- '## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
808
- '',
809
- 'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`hook --post-tool-use`) **сигналить** про дрейф після правки через per-file lint правила. Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
810
- ''
811
- ]
812
- }
813
-
814
- /**
815
- * Рендерить секцію Skills для CLAUDE.md з урахуванням наявних slash-команд.
816
- * @returns {Promise<string[]>} готові рядки секції (або порожній масив)
817
- */
818
- async function buildClaudeSkillsSectionLines() {
819
- const skillDirNames = await listProjectSkillDirNames()
820
- if (skillDirNames.length === 0) {
821
- return []
822
- }
823
-
824
- const lines = ['', '## Skills', '']
825
- const skillsRoot = join(cwd(), SKILLS_DIR)
826
- const commandsRoot = join(cwd(), COMMANDS_DIR)
827
- for (const dirName of skillDirNames) {
828
- const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
829
- const commandPath = join(commandsRoot, `${dirName}.md`)
830
- let desc = ''
831
- if (existsSync(skillMdPath)) {
832
- const text = await readFile(skillMdPath, 'utf8')
833
- const parsed = extractSkillDescription(text)
834
- if (parsed) {
835
- desc = skillDescriptionSafeForMarkdownInline(parsed)
836
- }
837
- }
838
- const ref = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
839
- lines.push(desc ? `${ref} — ${desc}` : ref)
840
- if (existsSync(commandPath)) {
841
- lines.push(` Команда: \`/${dirName}\``)
842
- }
843
- }
844
- return lines
845
- }
846
-
847
- /**
848
- * Генерує CLAUDE.md у корені cwd з at-імпортами всіх .mdc-правил та посиланнями на skills.
849
- * Завдяки цьому Claude Code автоматично завантажує вміст кожного правила при старті.
850
- * @returns {Promise<void>}
851
- */
852
- /**
853
- * @param {string[]} [ignore] директорії заборонені для редагування
854
- */
855
- async function syncClaudeMd(ignore) {
856
- const lines = [`<!-- Цей файл генерується автоматично через \`npx ${PACKAGE_NAME}\`. Не редагуй вручну. -->`, '']
857
-
858
- if (Array.isArray(ignore) && ignore.length > 0) {
859
- lines.push('## Захищені директорії', '', 'Ніколи не змінюй, не видаляй і не створюй файли у цих директоріях:', '')
860
- for (const dir of ignore) {
861
- let d = dir
862
- while (d.endsWith('/')) d = d.slice(0, -1)
863
- lines.push(`- \`${d}/\``)
864
- }
865
- lines.push('')
866
- }
867
-
868
- const mdcFiles = await listProjectRulesMdcFiles()
869
- for (const mdcFile of mdcFiles) {
870
- lines.push(`@${RULES_DIR}/${mdcFile}`)
871
- }
872
-
873
- lines.push(
874
- ...buildClaudeLintParallelismSectionLines(),
875
- ...buildClaudeWorktreeEnforcementSectionLines(),
876
- ...buildClaudeDocFilesSectionLines()
877
- )
878
-
879
- const skillsSectionLines = await buildClaudeSkillsSectionLines()
880
- lines.push(...skillsSectionLines)
881
- const claudeMdPath = join(cwd(), 'CLAUDE.md')
882
- const hadFile = existsSync(claudeMdPath)
883
- await writeFile(claudeMdPath, formatGeneratedMarkdownLines(lines), 'utf8')
884
- console.log(hadFile ? `📝 Оновлено CLAUDE.md` : `📝 Створено CLAUDE.md`)
885
- }
886
-
887
- /**
888
- * Повністю перезаписує AGENTS.md у корені cwd з npm/AGENTS.template.md
889
- * @param {string} [agentsTemplatePath] шлях до AGENTS.template.md у корені пакету-джерела
890
- * @returns {Promise<void>} завершення запису файлу
891
- */
892
- async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
893
- if (!existsSync(agentsTemplatePath)) {
894
- throw new Error(
895
- `Не знайдено шаблон ${AGENTS_TEMPLATE_FILE} у пакеті.\n` +
896
- `Очікуваний шлях: ${agentsTemplatePath}\n` +
897
- `Перевстановіть ${PACKAGE_NAME}.`
898
- )
899
- }
900
- const templateText = await readFile(agentsTemplatePath, 'utf8')
901
- const mdcFiles = await listProjectRulesMdcFiles()
902
- const skillItems = await buildSkillBulletItems()
903
- const commandItems = await buildAgentsCommandBulletItems(cwd())
904
- const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
905
- const agentsPath = join(cwd(), AGENTS_FILE)
906
- const hadFile = existsSync(agentsPath)
907
- await writeFile(agentsPath, body.endsWith('\n') ? body : `${body}\n`, 'utf8')
908
- console.log(
909
- hadFile
910
- ? `📝 Оновлено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
911
- : `📝 Створено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
912
- )
913
- }
914
-
915
- /**
916
- * Копіює лише skills зі списку configSkills (джерело: skills/<id>/ у пакеті)
917
- * @param {string[]} configSkills id без префікса n-
918
- * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
919
- * @param {{ plugins?: unknown } | null} [config] конфіг `.n-rules.json` — активні плагіни для SKILL-фрагментів
920
- * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
921
- */
922
- async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR, config = null) {
923
- if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
924
- return { success: 0, fail: 0 }
925
- }
926
-
927
- const skillsRoot = join(cwd(), SKILLS_DIR)
928
- await mkdir(skillsRoot, { recursive: true })
929
- // Активні плагіни — джерело конвенційних фрагментів skills/<id>/SKILL.fragment.md
930
- // (фаза 4b spec lang-plugins-extraction): мовні гілки скіла їдуть з плагіном.
931
- const activePlugins = resolvePlugins(cwd(), config, { allowInstall: false, quiet: true })
932
-
933
- let success = 0
934
- let fail = 0
935
-
936
- for (const skillId of configSkills) {
937
- const id = normalizeSkillId(skillId)
938
- const srcDir = join(bundledSkillsDir, id)
939
- const destDirName = managedSkillDirName(skillId)
940
- const destDir = join(skillsRoot, destDirName)
941
-
942
- process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
943
- if (existsSync(srcDir)) {
944
- try {
945
- await mkdir(destDir, { recursive: true })
946
- const meta = readSkillMetaRaw(srcDir)
947
- const worktree = meta?.worktree === true
948
- // root-guard для in-place скілів (мутують CWD без worktree-ізоляції).
949
- // Worktree-скіли root-assert уже мають у worktree-блоці, тож тут лише !worktree.
950
- const rootOnly = !worktree && meta?.requireRoot === true
951
- const entries = await readdir(srcDir, { withFileTypes: true })
952
- for (const entry of entries) {
953
- // Лише top-level файли скіла. `main.json` — метадані (не для споживача);
954
- // підкаталоги (`js/` — скіл-специфічний код) виконуються з пакета через
955
- // `npx`, у проєкт не копіюються (як `npm/rules/<id>/js/`). Див. scripts.mdc.
956
- if (!entry.isFile() || entry.name === 'main.json') continue
957
- let content = await readFile(join(srcDir, entry.name), 'utf8')
958
- if (entry.name === 'SKILL.md') {
959
- content = injectWorktreeNotice(content, worktree)
960
- content = injectRootNotice(content, rootOnly)
961
- content = injectSkillFragments(content, collectSkillFragments(id, activePlugins))
962
- }
963
- await writeFile(join(destDir, entry.name), content, 'utf8')
964
- }
965
- console.log(`✅`)
966
- success++
967
- } catch (error) {
968
- console.log(`❌`)
969
- console.error(` Помилка: ${error.message}`)
970
- fail++
971
- }
972
- } else {
973
- console.log(`❌`)
974
- console.error(` Немає каталогу в пакеті: skills/${id}`)
975
- fail++
976
- }
977
- }
978
- return { success, fail }
979
- }
980
-
981
- /**
982
- * Синхронізує .claude/commands/n-<id>.md зі skills пакету.
983
- * У кожному файлі обов’язково YAML frontmatter з `description` (як у `SKILL.md`), інакше команди
984
- * не з’являються у розширенні VSCode; далі — заголовок H1 лише з імені команди (без повтору опису) і посилання на `.cursor/skills/…/SKILL.md`.
985
- * @param {string[]} configSkills id без префікса n-
986
- * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
987
- * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
988
- */
989
- async function syncCommands(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
990
- if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
991
- return { success: 0, fail: 0 }
992
- }
993
-
994
- const commandsDir = join(cwd(), COMMANDS_DIR)
995
- await mkdir(commandsDir, { recursive: true })
996
-
997
- let success = 0
998
- let fail = 0
999
-
1000
- for (const skillId of configSkills) {
1001
- const id = normalizeSkillId(skillId)
1002
- const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
1003
- const destDirName = managedSkillDirName(skillId)
1004
- const destFile = join(commandsDir, `${RULE_PREFIX}${id}.md`)
1005
-
1006
- process.stdout.write(` ⬇ ${id} → ${COMMANDS_DIR}/${RULE_PREFIX}${id}.md ... `)
1007
- if (existsSync(srcSkillMd)) {
1008
- try {
1009
- const raw = await readFile(srcSkillMd, 'utf8')
1010
- const descRaw = extractSkillDescription(raw)
1011
- const frontmatter = formatClaudeCommandFrontmatter(descRaw || '')
1012
- const header = `# ${RULE_PREFIX}${id}\n\n`
1013
- const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
1014
- await writeFile(destFile, body, 'utf8')
1015
- console.log(`✅`)
1016
- success++
1017
- } catch (error) {
1018
- console.log(`❌`)
1019
- console.error(` Помилка: ${error.message}`)
1020
- fail++
1021
- }
1022
- } else {
1023
- console.log(`❌`)
1024
- console.error(` Немає SKILL.md у пакеті: skills/${id}`)
1025
- fail++
1026
- }
1027
- }
1028
- return { success, fail }
1029
- }
1030
-
1031
- /**
1032
- * Видаляє файли n-*.md у .claude/commands, яких немає у конфігурації skills
1033
- * @param {string} commandsDir абсолютний шлях до .claude/commands
1034
- * @param {string[]} configSkills id без префікса n-
1035
- * @returns {Promise<string[]>} імена видалених файлів
1036
- */
1037
- async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
1038
- if (!existsSync(commandsDir)) {
1039
- return []
1040
- }
1041
- const expected = new Set(configSkills.map(s => `${RULE_PREFIX}${normalizeSkillId(s)}.md`))
1042
- const names = await readdir(commandsDir)
1043
- const removed = []
1044
- for (const name of names) {
1045
- if (!(name.endsWith('.md') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
1046
- continue
1047
- }
1048
-
1049
- await unlink(join(commandsDir, name))
1050
- removed.push(name)
1051
- }
1052
- return removed.toSorted((a, b) => a.localeCompare(b))
1053
- }
1054
-
1055
- /**
1056
- * Синхронізує .claude/commands/{dirName}.md для всіх локальних скілів з .cursor/skills/
1057
- * що не керуються пакетом (відсутні в configSkills). Frontmatter `description` — як у відповідному SKILL.md.
1058
- * @param {string[]} configSkills id керованих skills (вже оброблені syncCommands)
1059
- * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1060
- */
1061
- async function syncLocalOnlySkillCommands(configSkills) {
1062
- const skillsRoot = join(cwd(), SKILLS_DIR)
1063
- if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
1064
-
1065
- const commandsDir = join(cwd(), COMMANDS_DIR)
1066
- await mkdir(commandsDir, { recursive: true })
1067
-
1068
- const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1069
- const allDirNames = await listProjectSkillDirNames()
1070
- const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
1071
-
1072
- let success = 0
1073
- let fail = 0
1074
-
1075
- for (const dirName of localOnly) {
1076
- const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
1077
- const destFile = join(commandsDir, `${dirName}.md`)
1078
-
1079
- process.stdout.write(` ⬇ ${dirName} → ${COMMANDS_DIR}/${dirName}.md ... `)
1080
- try {
1081
- let descRaw = ''
1082
- if (existsSync(skillMdPath)) {
1083
- const raw = await readFile(skillMdPath, 'utf8')
1084
- const parsed = extractSkillDescription(raw)
1085
- if (parsed) descRaw = parsed
1086
- }
1087
- const frontmatter = formatClaudeCommandFrontmatter(descRaw)
1088
- const header = `# ${dirName}\n\n`
1089
- const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
1090
- await writeFile(destFile, body, 'utf8')
1091
- console.log(`✅`)
1092
- success++
1093
- } catch (error) {
1094
- console.log(`❌`)
1095
- console.error(` Помилка: ${errorMessage(error)}`)
1096
- fail++
1097
- }
1098
- }
1099
- return { success, fail }
1100
- }
1101
-
1102
- /**
1103
- * Видаляє .claude/commands/{dirName}.md файли локальних скілів, яких більше немає в .cursor/skills/
1104
- * @param {string} commandsDir абсолютний шлях до .claude/commands
1105
- * @param {string[]} configSkills id керованих skills
1106
- * @returns {Promise<string[]>} імена видалених файлів
1107
- */
1108
- async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
1109
- if (!existsSync(commandsDir)) return []
1110
-
1111
- const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1112
- const allDirNames = new Set(await listProjectSkillDirNames())
1113
- const names = await readdir(commandsDir)
1114
- const removed = []
1115
-
1116
- for (const name of names) {
1117
- if (!name.endsWith('.md') || name.startsWith(RULE_PREFIX)) continue
1118
- const dirName = name.slice(0, -3)
1119
- if (!managedDirNames.has(dirName) && !allDirNames.has(dirName)) {
1120
- await unlink(join(commandsDir, name))
1121
- removed.push(name)
1122
- }
1123
- }
1124
- return removed.toSorted((a, b) => a.localeCompare(b))
1125
- }
1126
-
1127
- /**
1128
- * Синхронізує .pi/skills/n-<id>/SKILL.md зі skills пакету для pi.dev-сумісності.
1129
- * Pi-skill — це директорія з SKILL.md (frontmatter `name`+`description`), тіло-делегат на джерельний
1130
- * `.cursor/skills/<dir>/SKILL.md`. Симетрично до `syncCommands`, але дир замість `.md`-файлу.
1131
- * @param {string[]} configSkills id без префікса n-
1132
- * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
1133
- * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1134
- */
1135
- async function syncPiSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
1136
- if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
1137
- return { success: 0, fail: 0 }
1138
- }
1139
-
1140
- const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
1141
- await mkdir(piSkillsRoot, { recursive: true })
1142
-
1143
- let success = 0
1144
- let fail = 0
1145
-
1146
- for (const skillId of configSkills) {
1147
- const id = normalizeSkillId(skillId)
1148
- const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
1149
- const destDirName = managedSkillDirName(skillId)
1150
- const destDir = join(piSkillsRoot, destDirName)
1151
- const destFile = join(destDir, 'SKILL.md')
1152
-
1153
- process.stdout.write(` ⬇ ${id} → ${PI_SKILLS_DIR}/${destDirName}/SKILL.md ... `)
1154
- if (existsSync(srcSkillMd)) {
1155
- try {
1156
- const raw = await readFile(srcSkillMd, 'utf8')
1157
- const descRaw = extractSkillDescription(raw)
1158
- await mkdir(destDir, { recursive: true })
1159
- const frontmatter = formatPiSkillFrontmatter(destDirName, descRaw || '')
1160
- const header = `# ${destDirName}\n\n`
1161
- const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
1162
- await writeFile(destFile, body, 'utf8')
1163
- console.log(`✅`)
1164
- success++
1165
- } catch (error) {
1166
- console.log(`❌`)
1167
- console.error(` Помилка: ${errorMessage(error)}`)
1168
- fail++
1169
- }
1170
- } else {
1171
- console.log(`❌`)
1172
- console.error(` Немає SKILL.md у пакеті: skills/${id}`)
1173
- fail++
1174
- }
1175
- }
1176
- return { success, fail }
1177
- }
1178
-
1179
- /**
1180
- * Синхронізує .pi/skills/{dirName}/SKILL.md для всіх локальних скілів з .cursor/skills/
1181
- * що не керуються пакетом. Симетрично до `syncLocalOnlySkillCommands`.
1182
- * @param {string[]} configSkills id керованих skills (уже оброблені syncPiSkills)
1183
- * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
1184
- */
1185
- async function syncLocalOnlyPiSkills(configSkills) {
1186
- const skillsRoot = join(cwd(), SKILLS_DIR)
1187
- if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
1188
-
1189
- const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
1190
- await mkdir(piSkillsRoot, { recursive: true })
1191
-
1192
- const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1193
- const allDirNames = await listProjectSkillDirNames()
1194
- const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
1195
-
1196
- let success = 0
1197
- let fail = 0
1198
-
1199
- for (const dirName of localOnly) {
1200
- const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
1201
- const destDir = join(piSkillsRoot, dirName)
1202
- const destFile = join(destDir, 'SKILL.md')
1203
-
1204
- process.stdout.write(` ⬇ ${dirName} → ${PI_SKILLS_DIR}/${dirName}/SKILL.md ... `)
1205
- try {
1206
- let descRaw = ''
1207
- if (existsSync(skillMdPath)) {
1208
- const raw = await readFile(skillMdPath, 'utf8')
1209
- const parsed = extractSkillDescription(raw)
1210
- if (parsed) descRaw = parsed
1211
- }
1212
- await mkdir(destDir, { recursive: true })
1213
- const frontmatter = formatPiSkillFrontmatter(dirName, descRaw)
1214
- const header = `# ${dirName}\n\n`
1215
- const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
1216
- await writeFile(destFile, body, 'utf8')
1217
- console.log(`✅`)
1218
- success++
1219
- } catch (error) {
1220
- console.log(`❌`)
1221
- console.error(` Помилка: ${errorMessage(error)}`)
1222
- fail++
1223
- }
1224
- }
1225
- return { success, fail }
1226
- }
1227
-
1228
- /**
1229
- * Видаляє n-* директорії у .pi/skills, яких немає у конфігурації skills.
1230
- * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1231
- * @param {string[]} configSkills id без префікса n-
1232
- * @returns {Promise<string[]>} імена видалених директорій
1233
- */
1234
- async function removeOrphanManagedPiSkillDirs(piSkillsDir, configSkills) {
1235
- if (!existsSync(piSkillsDir)) return []
1236
- const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
1237
- const entries = await readdir(piSkillsDir, { withFileTypes: true })
1238
- const removed = []
1239
- for (const entry of entries) {
1240
- if (!(entry.isDirectory() && entry.name.startsWith(RULE_PREFIX) && !expected.has(entry.name))) {
1241
- continue
1242
- }
1243
-
1244
- await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1245
- removed.push(entry.name)
1246
- }
1247
- return removed.toSorted((a, b) => a.localeCompare(b))
1248
- }
1249
-
1250
- /**
1251
- * Видаляє .pi/skills/{dirName} директорії локальних скілів, яких більше немає в .cursor/skills/.
1252
- * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1253
- * @param {string[]} configSkills id керованих skills
1254
- * @returns {Promise<string[]>} імена видалених директорій
1255
- */
1256
- async function removeOrphanLocalPiSkillDirs(piSkillsDir, configSkills) {
1257
- if (!existsSync(piSkillsDir)) return []
1258
- const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1259
- const allDirNames = new Set(await listProjectSkillDirNames())
1260
- const entries = await readdir(piSkillsDir, { withFileTypes: true })
1261
- const removed = []
1262
- for (const entry of entries) {
1263
- if (!entry.isDirectory() || entry.name.startsWith(RULE_PREFIX)) continue
1264
- if (!managedDirNames.has(entry.name) && !allDirNames.has(entry.name)) {
1265
- await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1266
- removed.push(entry.name)
1267
- }
1268
- }
1269
- return removed.toSorted((a, b) => a.localeCompare(b))
1270
- }
1271
-
1272
- /**
1273
- * Людинозрозумілий текст винятку для логів.
1274
- * @param {unknown} error виняток із catch
1275
- * @returns {string} текст повідомлення
1276
- */
1277
- function errorMessage(error) {
1278
- return error instanceof Error ? error.message : String(error)
1279
- }
1280
-
1281
- /**
1282
- * Виконує крок синхронізації з уніфікованим логуванням помилки.
1283
- * @template T
1284
- * @param {string} prefix префікс повідомлення про помилку
1285
- * @param {() => Promise<T>} action операція
1286
- * @returns {Promise<T>} результат операції
1287
- */
1288
- async function runSyncStep(prefix, action) {
1289
- try {
1290
- return await action()
1291
- } catch (error) {
1292
- console.error(`${prefix}${errorMessage(error)}`)
1293
- throw error
1294
- }
1295
- }
1296
-
1297
- /**
1298
- * Виконує `action`, буферизуючи весь його stdout/console-вивід.
1299
- *
1300
- * Мотивація: за успішного прогону sync-блоку рядки `⬇ … ✅` і підсумок
1301
- * (`🧩 Skills: N скопійовано, 0 з помилками`) не несуть користі й лише
1302
- * захаращують термінал. Тому буфер скидається в реальний stdout **лише**
1303
- * коли крок повернув `fail > 0` (або кинув виняток); за `fail === 0` —
1304
- * відкидається мовчки.
1305
- * @template T
1306
- * @param {() => Promise<T>} action крок синку, що повертає обʼєкт із лічильником помилок `fail`
1307
- * @returns {Promise<T>} результат `action` без змін
1308
- */
1309
- async function captureOutput(action) {
1310
- const buffer = []
1311
- const realStdoutWrite = process.stdout.write
1312
- const realLog = console.log
1313
- const realError = console.error
1314
- const flush = () => realStdoutWrite.call(process.stdout, buffer.join(''))
1315
- process.stdout.write = (...args) => {
1316
- const [chunk] = args
1317
- buffer.push(typeof chunk === 'string' ? chunk : chunk.toString())
1318
- const cb = args.find(arg => typeof arg === 'function')
1319
- if (cb) cb()
1320
- return true
1321
- }
1322
- console.log = (...args) => {
1323
- buffer.push(`${args.map(String).join(' ')}\n`)
1324
- }
1325
- console.error = (...args) => {
1326
- buffer.push(`${args.map(String).join(' ')}\n`)
1327
- }
1328
- try {
1329
- const result = await action()
1330
- if (result?.fail > 0) flush()
1331
- return result
1332
- } catch (error) {
1333
- flush()
1334
- throw error
1335
- } finally {
1336
- process.stdout.write = realStdoutWrite
1337
- console.log = realLog
1338
- console.error = realError
1339
- }
1340
- }
1341
-
1342
- /**
1343
- * Копіює керовані `.mdc` файли з пакету до `.cursor/rules`.
1344
- * @param {string[]} rules список rules з конфігу
1345
- * @param {string} bundledRulesDir каталог `rules` пакету-джерела (fallback для правил без явного джерела)
1346
- * @param {string} rulesDir абсолютний шлях до `.cursor/rules`
1347
- * @param {Map<string, string>} [ruleSources] мапа `ruleId → rulesDir` власника правила (ядро/плагін)
1348
- * @param {Map<string, string[]>} [ruleExtras] мапа `ruleId → rules/<id>-теки` mixin-джерел (concern-mdc доінлайнюються)
1349
- * @returns {Promise<{ successCount: number, failCount: number }>} статистика копіювання
1350
- */
1351
- async function syncManagedRuleFiles(rules, bundledRulesDir, rulesDir, ruleSources = new Map(), ruleExtras = new Map()) {
1352
- let successCount = 0
1353
- let failCount = 0
1354
- for (const rule of rules) {
1355
- const fileName = `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`
1356
- const destPath = join(rulesDir, fileName)
1357
- try {
1358
- process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
1359
- const id = normalizeRuleName(rule)
1360
- const sourceDir = ruleSources.get(id) ?? bundledRulesDir
1361
- const content = await readBundledRuleContent(rule, sourceDir, ruleExtras.get(id) ?? [])
1362
- await writeFile(destPath, content, 'utf8')
1363
- console.log(`✅`)
1364
- successCount++
1365
- } catch (error) {
1366
- console.log(`❌`)
1367
- console.error(` Помилка: ${errorMessage(error)}`)
1368
- failCount++
1369
- }
1370
- }
1371
- return { successCount, failCount }
1372
- }
1373
-
1374
- /**
1375
- * Логує видалені керовані правила/skills/commands у єдиному форматі.
1376
- * @param {string} title назва сутностей
1377
- * @param {string} basePath базовий шлях для виводу
1378
- * @param {string[]} names перелік елементів
1379
- * @returns {void}
1380
- */
1381
- function logRemovedManagedItems(title, basePath, names) {
1382
- if (names.length === 0) {
1383
- return
1384
- }
1385
- console.log(`\n🧹 Видалено ${title} поза списком ${CONFIG_FILE} (${names.length}):`)
1386
- for (const name of names) {
1387
- console.log(` − ${basePath}/${name}`)
1388
- }
1389
- }
1390
-
1391
- /**
1392
- * Читає поле `version` з `package.json` пакету за абсолютним шляхом до його кореня.
1393
- * @param {string} packageRoot корінь пакету (тека з `package.json`)
1394
- * @returns {Promise<string | null>} semver рядком або null, якщо файлу/поля немає або JSON некоректний
1395
- */
1396
- async function readBundledVersionAt(packageRoot) {
1397
- const p = join(packageRoot, 'package.json')
1398
- if (!existsSync(p)) {
1399
- return null
1400
- }
1401
- try {
1402
- const pkg = JSON.parse(await readFile(p, 'utf8'))
1403
- return typeof pkg.version === 'string' ? pkg.version : null
1404
- } catch {
1405
- return null
1406
- }
1407
- }
1408
-
1409
- /**
1410
- * Якщо `upgradeNRulesToLatestAndBunInstall` встановив у `node_modules/@7n/rules` версію,
1411
- * відмінну від тієї, з якої стартував поточний процес (наприклад, з npx-кешу), запускає бінар нової
1412
- * версії через `spawnSync` і завершує поточний процес із успадкованим exit-кодом. Re-exec потрібен,
1413
- * бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRules тощо) і нова логіка
1414
- * без повної заміни процесу не підхопиться. Захист від нескінченного циклу — env `NITRA_CURSOR_REEXEC=1`.
1415
- *
1416
- * Порівнює версії, **не** шляхи: коли `npx` резолвиться в локальний
1417
- * `<projectRoot>/node_modules/@7n/rules` (проєкт уже має пакет у devDependencies),
1418
- * `effectivePackageRoot` до і після `upgradeNRulesToLatestAndBunInstall` — той самий шлях,
1419
- * `bun i` лише перезаписує файли за ним in-place. Читати "поточну" версію з цього шляху
1420
- * ПІСЛЯ апгрейду означало б читати вже НОВУ версію — порівняння завжди збігалося б і
1421
- * re-exec ніколи не спрацьовував би, попри те що в памʼяті процесу лишається старий код.
1422
- * Тому `startVersion` фіксується викликачем ДО апгрейду.
1423
- * @param {string} effectivePackageRoot шлях, повернутий `upgradeNRulesToLatestAndBunInstall`
1424
- * @param {string | null} startVersion версія `@7n/rules`, з якою стартував процес (прочитана
1425
- * з `BUNDLED_PACKAGE_ROOT` до виклику `upgradeNRulesToLatestAndBunInstall`)
1426
- * @returns {Promise<void>} повертається лише якщо re-exec не потрібен; інакше кидає `ReexecHandoff`,
1427
- * який ловить top-level catch і прокидає exit-код у `process.exitCode`
1428
- */
1429
- async function reexecIfPackageVersionChanged(effectivePackageRoot, startVersion) {
1430
- if (env.NITRA_CURSOR_REEXEC === '1') {
1431
- return
1432
- }
1433
- const installedVersion = await readBundledVersionAt(effectivePackageRoot)
1434
- if (!startVersion || !installedVersion || startVersion === installedVersion) {
1435
- return
1436
- }
1437
- const newBinPath = join(effectivePackageRoot, 'bin', 'n-rules.js')
1438
- if (!existsSync(newBinPath)) {
1439
- return
1440
- }
1441
- console.log(
1442
- `🔁 Перезапуск ${PACKAGE_NAME}: процес стартував на ${startVersion}, ` +
1443
- `після self-upgrade встановлено ${installedVersion}.\n` +
1444
- ` Re-exec свіжого бінаря, щоб підхопити нову логіку (RULE_MIGRATIONS, auto-detect тощо).\n`
1445
- )
1446
- const result = spawnSync(process.execPath, [newBinPath, ...process.argv.slice(2)], {
1447
- stdio: 'inherit',
1448
- env: { ...env, NITRA_CURSOR_REEXEC: '1' }
1449
- })
1450
- if (result.error) {
1451
- throw result.error
1452
- }
1453
- throw new ReexecHandoff(typeof result.status === 'number' ? result.status : 1)
6
+ if (isRunAsCli(import.meta.url)) {
7
+ await runCli(process.argv.slice(2))
1454
8
  }
1455
-
1456
- /**
1457
- * Сентинельна помилка, яку кидає `reexecIfPackageVersionChanged` після успішного re-exec.
1458
- * Top-level catch розпізнає її й виставляє `process.exitCode = code` без stack-trace —
1459
- * процес тоді коректно завершується з тим самим кодом, що й child re-exec-у.
1460
- */
1461
- class ReexecHandoff extends Error {
1462
- /**
1463
- * @param {number} code exit-код, який повернув child-процес
1464
- */
1465
- constructor(code) {
1466
- super('reexec-handoff')
1467
- this.name = 'ReexecHandoff'
1468
- this.code = code
1469
- }
1470
- }
1471
-
1472
- /**
1473
- * Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
1474
- * @returns {Promise<void>}
1475
- */
1476
- async function runSync() {
1477
- console.log(`\n🔧 ${PACKAGE_NAME} — завантаження cursor-правил\n`)
1478
-
1479
- const projectRoot = cwd()
1480
- // Фіксуємо ДО апгрейду — див. коментар над reexecIfPackageVersionChanged.
1481
- const startVersion = await readBundledVersionAt(BUNDLED_PACKAGE_ROOT)
1482
- const effectivePackageRoot = await runSyncStep(`❌ Не вдалося оновити ${PACKAGE_NAME} або виконати bun i: `, () =>
1483
- upgradeNRulesToLatestAndBunInstall(projectRoot, BUNDLED_PACKAGE_ROOT)
1484
- )
1485
-
1486
- await reexecIfPackageVersionChanged(effectivePackageRoot, startVersion)
1487
-
1488
- const bundledRulesDir = join(effectivePackageRoot, 'rules')
1489
- const bundledSkillsDir = join(effectivePackageRoot, 'skills')
1490
- const bundledAgentsTemplatePath = join(effectivePackageRoot, AGENTS_TEMPLATE_FILE)
1491
-
1492
- const config = await runSyncStep('❌ ', () => readConfig({ bundledRulesDir, bundledSkillsDir }))
1493
-
1494
- const { rules, skills, version, ignore } = config
1495
- const claudeConfigEnabled = config['claude-config'] !== false
1496
- const bundledVer = await readBundledVersionAt(effectivePackageRoot)
1497
- if (bundledVer) {
1498
- const line =
1499
- effectivePackageRoot === BUNDLED_PACKAGE_ROOT
1500
- ? `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer}`
1501
- : `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer} (шлях: ${effectivePackageRoot})`
1502
- console.log(`${line}\n`)
1503
- }
1504
- if (version) {
1505
- console.log(`⚠️ Поле "version" у ${CONFIG_FILE} ігнорується; правила беруться з установленого пакету.\n`)
1506
- }
1507
- console.log(`📋 Правил до завантаження: ${rules.length}`)
1508
- console.log(`📋 Skills до синхронізації: ${skills.length}`)
1509
-
1510
- await runSyncStep('❌ Не вдалося записати setup-bun-deps action: ', () =>
1511
- captureOutput(async () => {
1512
- const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
1513
- console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
1514
- })
1515
- )
1516
-
1517
- const rulesDir = join(cwd(), RULES_DIR)
1518
- await mkdir(rulesDir, { recursive: true })
1519
- // Джерела правил: ядро + rules/ плагінів (резолв кешований після readConfig).
1520
- const allRulesDirs = resolveRulesDirs(projectRoot, config, bundledRulesDir).map(d => d.rulesDir)
1521
- const { sources: ruleSources, extras: ruleExtras } = await aggregateRuleSources(allRulesDirs)
1522
- const { successCount, failCount } = await captureOutput(async () => {
1523
- const stats = await syncManagedRuleFiles(rules, bundledRulesDir, rulesDir, ruleSources, ruleExtras)
1524
- return { ...stats, fail: stats.failCount }
1525
- })
1526
-
1527
- await runSyncStep(`❌ Не вдалося прибрати зайві файли в ${RULES_DIR}: `, async () => {
1528
- const removed = await removeOrphanManagedRuleFiles(rulesDir, rules)
1529
- logRemovedManagedItems('правила', RULES_DIR, removed)
1530
- })
1531
-
1532
- await runSyncStep('❌ Skills: ', async () => {
1533
- const { fail: skillFail } = await captureOutput(async () => {
1534
- const { success: skillOk, fail } = await syncSkills(skills, bundledSkillsDir, config)
1535
- if (skills.length > 0) {
1536
- console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${fail} з помилками`)
1537
- }
1538
- return { fail }
1539
- })
1540
- const removedSkills = await removeOrphanManagedSkillDirs(join(cwd(), SKILLS_DIR), skills)
1541
- logRemovedManagedItems('skills', SKILLS_DIR, removedSkills)
1542
- if (skillFail > 0) {
1543
- throw new Error(`Не вдалося скопіювати ${skillFail} з ${skills.length} skills`)
1544
- }
1545
- })
1546
-
1547
- await runSyncStep('❌ Commands: ', async () => {
1548
- const { fail: totalFail } = await captureOutput(async () => {
1549
- const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
1550
- const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
1551
- const totalOk = cmdOk + localOk
1552
- const fail = cmdFail + localFail
1553
- if (totalOk + fail > 0) {
1554
- console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${fail} з помилками`)
1555
- }
1556
- return { fail }
1557
- })
1558
- const commandsDir = join(cwd(), COMMANDS_DIR)
1559
- const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
1560
- logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
1561
- const removedLocalCmds = await removeOrphanLocalSkillCommandFiles(commandsDir, skills)
1562
- logRemovedManagedItems('commands (local)', COMMANDS_DIR, removedLocalCmds)
1563
- if (totalFail > 0) {
1564
- throw new Error(`Не вдалося скопіювати ${totalFail} commands`)
1565
- }
1566
- })
1567
-
1568
- await runSyncStep('❌ Pi skills: ', async () => {
1569
- const { fail: totalFail } = await captureOutput(async () => {
1570
- const { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
1571
- const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
1572
- const totalOk = piOk + piLocalOk
1573
- const fail = piFail + piLocalFail
1574
- if (totalOk + fail > 0) {
1575
- console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${fail} з помилками`)
1576
- }
1577
- return { fail }
1578
- })
1579
- const piSkillsDir = join(cwd(), PI_SKILLS_DIR)
1580
- const removedPi = await removeOrphanManagedPiSkillDirs(piSkillsDir, skills)
1581
- logRemovedManagedItems('pi skills', PI_SKILLS_DIR, removedPi)
1582
- const removedLocalPi = await removeOrphanLocalPiSkillDirs(piSkillsDir, skills)
1583
- logRemovedManagedItems('pi skills (local)', PI_SKILLS_DIR, removedLocalPi)
1584
- if (totalFail > 0) {
1585
- throw new Error(`Не вдалося скопіювати ${totalFail} pi skills`)
1586
- }
1587
- })
1588
-
1589
- await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () =>
1590
- captureOutput(() => syncAgentsMd(bundledAgentsTemplatePath))
1591
- )
1592
- await runSyncStep('❌ Не вдалося оновити CLAUDE.md: ', () =>
1593
- captureOutput(() => syncClaudeMd(/** @type {string[] | undefined} */ (ignore)))
1594
- )
1595
-
1596
- await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ', () =>
1597
- captureOutput(async () => {
1598
- const result = await syncClaudeConfig({
1599
- projectRoot: cwd(),
1600
- bundledPackageRoot: effectivePackageRoot,
1601
- enabled: claudeConfigEnabled,
1602
- rules
1603
- })
1604
- if (!claudeConfigEnabled) {
1605
- console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-rules.json)')
1606
- return
1607
- }
1608
- const parts = []
1609
- if (result.settings) parts.push('.claude/settings.json')
1610
- if (result.cursorHooks) parts.push('.cursor/hooks.json')
1611
- if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
1612
- if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1613
- if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
1614
- if (result.adrHookLib?.length > 0) {
1615
- for (const libPath of result.adrHookLib) {
1616
- parts.push(libPath)
1617
- }
1618
- }
1619
- if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1620
- if (result.piExtension) parts.push('.pi/extensions/n-rules-adr/')
1621
- if (result.rtkPiExtension) parts.push('.pi/extensions/rtk.ts')
1622
- if (parts.length > 0) {
1623
- console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1624
- }
1625
- })
1626
- )
1627
-
1628
- await runSyncStep('❌ Не вдалося оновити .gitignore (worktree): ', async () => {
1629
- const { written } = await syncGitignoreWorktree(cwd())
1630
- if (written) console.log('🌳 .gitignore (worktree): додано .worktrees/')
1631
- })
1632
-
1633
- console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
1634
- if (failCount > 0) {
1635
- throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
1636
- }
1637
- }
1638
-
1639
- /**
1640
- * Команди, що мутують проєкт у CWD і вимагають кореня репо. `undefined`/`''` —
1641
- * дефолтний sync; `check` — deprecated-alias `fix`. Решта (read-only,
1642
- * `--root`-команди `doc-aggregate`/`rename-yaml-extensions`,
1643
- * sub-лінтери) гард не зачіпає.
1644
- */
1645
- const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'release'])
1646
-
1647
- /**
1648
- * Короткий опис дії для тексту root-guard помилки за іменем команди.
1649
- * @param {string | undefined} cmd підкоманда CLI (або undefined для дефолтного sync)
1650
- * @returns {string} фраза «що саме мутує CWD»
1651
- */
1652
- function describeRootGuardedAction(cmd) {
1653
- switch (cmd) {
1654
- case undefined:
1655
- case '': {
1656
- return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-rules.json і робить bun install у поточному каталозі'
1657
- }
1658
- case 'lint': {
1659
- return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
1660
- }
1661
- case 'release': {
1662
- return '`release` бампає version і переписує CHANGELOG у поточному каталозі'
1663
- }
1664
- default: {
1665
- return 'Команда @7n/rules мутує проєкт у поточному каталозі'
1666
- }
1667
- }
1668
- }
1669
-
1670
- /**
1671
- * Довідка для `n-rules lint --help`: прапори unified lint surface
1672
- * (spec 2026-06-29 fix-by-default, spec 2026-07-03 глобальна черга --full).
1673
- */
1674
- function printLintHelp() {
1675
- console.log(`Використання: npx @7n/rules lint [rule|concern ...] [прапори]
1676
-
1677
- За замовчуванням лінт іде дельтою (змінені файли vs origin) і одразу
1678
- виправляє (auto-fix: oxfmt/eslint --fix/stylelint --fix + LLM-ladder).
1679
-
1680
- Прапори:
1681
- --full Прогін по всьому репозиторію (конформність), не лише дельта.
1682
- Один --full на машину: паралельні запуски стають у чергу
1683
- й бачать живий прогрес активного прогону.
1684
- --no-fix Лише детекція, без мутацій.
1685
- --cwd <path> Робочий каталог (корінь прогону) замість поточного —
1686
- root-guard, .n-rules.json і devDependencies читаються
1687
- з нього.
1688
- --path <dir> Звузити файловий набір до піддиректорії, лишивши корінь
1689
- прогону незмінним (config/root-guard — з поточного каталогу
1690
- чи --cwd). Дефолт — ПЕРЕТИН піддерева з git-дельтою
1691
- (vs merge-base main/origin/main або --base), лише per-file
1692
- правила (сервіс-орієнтований CI-канон). З --full — все
1693
- піддерево, full-scope правила при спрацюванні йдуть по
1694
- всьому репо (історична поведінка; без machine-wide локу).
1695
- Сумісний із позиційним rule/concern-фільтром
1696
- (lint js --path run/nexus).
1697
- --base <ref> Явна база дельти (merge-base HEAD <ref>) замість каскаду
1698
- main → origin/main — для CI-чекаутів.
1699
- --repo-wide ЛИШЕ full-scope правила (knip, jscpd, dep-policy тощо),
1700
- весь репозиторій — окремий CI-workflow, що не гейтить
1701
- деплой сервісів. Не поєднується з --path/фільтром.
1702
- --verbose Розширений вивід детекції та виправлення.
1703
- --help, -h Ця довідка.
1704
-
1705
- Позиційні аргументи — фільтр за назвою правила чи concern, наприклад:
1706
- npx @7n/rules lint ga rego k8s docker text
1707
-
1708
- Приклади:
1709
- npx @7n/rules lint
1710
- npx @7n/rules lint --full
1711
- npx @7n/rules lint --no-fix eslint
1712
- npx @7n/rules lint --cwd ./packages/foo --verbose
1713
- npx @7n/rules lint js --path run/nexus --no-fix --base origin/main
1714
- npx @7n/rules lint --repo-wide --no-fix
1715
-
1716
- Skip-логіка CI (яка джоба взагалі потрібна): npx @7n/rules ci plan
1717
- ci plan [--path <dir>] [--base <ref>] [--github|--azure|--json]
1718
- --github → outputs у $GITHUB_OUTPUT; --azure → ##vso-рядки в stdout.
1719
- `)
1720
- }
1721
-
1722
- // CLI: маршрутизація команд
1723
- let [command, ...args] = process.argv.slice(2)
1724
-
1725
- // Deprecated-аліаси до уніфікації lint-поверхні (spec 2026-06-29): старі
1726
- // підкоманди `lint-<scope>` замінені на `lint <scope>`. Аліас тут — щоб
1727
- // існуючі package.json/CI споживачів (lint-text/lint-ga тощо) не ламались
1728
- // мовчки з непрозорим "Невідома команда" при мажорному бампі пакету.
1729
- const LEGACY_LINT_COMMAND_ALIASES = {
1730
- 'lint-ga': 'ga',
1731
- 'lint-text': 'text',
1732
- 'lint-rego': 'rego',
1733
- 'lint-k8s': 'k8s',
1734
- 'lint-docker': 'docker'
1735
- }
1736
- if (command in LEGACY_LINT_COMMAND_ALIASES) {
1737
- const scope = LEGACY_LINT_COMMAND_ALIASES[command]
1738
- console.error(`⚠️ "${command}" застаріла назва команди — використовуйте "n-rules lint ${scope}"`)
1739
- args = [scope, ...args]
1740
- command = 'lint'
1741
- }
1742
-
1743
- // `lint --help`/`-h` — чиста довідка, без root-guard і без мутації devDependencies.
1744
- const isLintHelp = command === 'lint' && (args.includes('--help') || args.includes('-h'))
1745
-
1746
- try {
1747
- if (isLintHelp) {
1748
- printLintHelp()
1749
- } else {
1750
- // Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
1751
- // .n-rules.json + bun install, а lint/release переписують файли в CWD —
1752
- // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1753
- // `bun npm/bin/n-rules.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1754
- // `--root`-команди (doc-aggregate, rename-yaml-extensions) не зачіпаємо.
1755
- // `lint` з явним `--cwd` — свідомий вибір кореня (MT ганяє `## Check` вузла з
1756
- // node-dir усередині worktree, live e2e 2026-07-12): guard і devDeps-ensure
1757
- // цілять у ЦІЛЬОВИЙ каталог, а не процесний cwd.
1758
- const lintCwdIdx = command === 'lint' ? args.indexOf('--cwd') : -1
1759
- const effectiveRoot = lintCwdIdx !== -1 && args[lintCwdIdx + 1] ? resolve(args[lintCwdIdx + 1]) : cwd()
1760
- if (ROOT_GUARDED_COMMANDS.has(command)) {
1761
- assertCwdIsProjectRoot(effectiveRoot, describeRootGuardedAction(command))
1762
- }
1763
- // `ci` (plan) — read-only гейт-команда для CI-джоб: не мутує package.json
1764
- // (ensure дописав би devDependency прямо в чекауті pipeline-агента).
1765
- // `skill <runner> taze` — JS-оркестрований worktree-only шлях
1766
- // (skills/taze/js/orchestrate.mjs): сам створює worktree і гейтить на
1767
- // чистоту дерева ДО checkout. Мутація тут забруднила б дерево прямо
1768
- // перед тим гейтом і провалювала б auto-create на інакше чистому дереві —
1769
- // оркестратор сам робить self-upgrade devDependency вже ВСЕРЕДИНІ
1770
- // щойно створеного worktree, після власного гейту чистоти.
1771
- // `lint --full` (без --no-fix/--path/--repo-wide) — той самий клас ризику:
1772
- // `needsWorktreeIsolation` нижче в `case 'lint'` гейтить на чистоту дерева
1773
- // ЧЕРЕЗ `ensureRunningInWorktree`; self-upgrade тут забруднив би те саме
1774
- // дерево прямо перед тим гейтом. Відкладаємо ensure до ПІСЛЯ
1775
- // `ensureRunningInWorktree` (виклик у `case 'lint'`, спрямований на runCwd).
1776
- const skipDevDepsEnsure =
1777
- (command === 'skill' && isTazeOrchestratorSkillArgs(args)) || (command === 'lint' && isLintFullFixArgs(args))
1778
- if (command !== 'ci' && !skipDevDepsEnsure) await ensureNRulesInRootDevDependencies(effectiveRoot)
1779
- // Підкоманди-оркестратори (hook/lint/skill/adr-normalize-local/taze/release тощо)
1780
- // можуть спавнити внутрішню agent/LLM-сесію — ADR Stop-hooks (capture/normalize)
1781
- // мають пропустити її як технічний шум, не людську думку (spec 2026-06-30).
1782
- env.ADR_HOOKS_SKIP = '1'
1783
- switch (command) {
1784
- case 'rename-yaml-extensions': {
1785
- const code = await runRenameYamlExtensionsCli(args)
1786
- if (code !== 0) {
1787
- process.exitCode = 1
1788
- }
1789
-
1790
- break
1791
- }
1792
- case 'hook': {
1793
- // Thin hook entrypoint для Claude Code hooks. Делегує в runLint, перекодовує exit 1→2.
1794
- // --post-tool-use PostToolUse: file_path зі stdin JSON
1795
- // --stop Stop: робоче дерево vs HEAD
1796
- const { runHookCli } = await import('../scripts/hook.mjs')
1797
- process.exitCode = await runHookCli(args)
1798
-
1799
- break
1800
- }
1801
- case 'lint': {
1802
- // Unified lint surface (spec 2026-06-29). Осі: --full (весь репо vs дельта) ×
1803
- // --no-fix (detect-only). Позиційні аргументи — scoped rule/concern фільтр.
1804
- // Fix-by-default: detect → T0 → LLM-ladder (run-fix). --no-fix: лише detect.
1805
- const cwdIdx = args.indexOf('--cwd')
1806
- const cwdArg = cwdIdx === -1 ? cwd() : resolve(args[cwdIdx + 1])
1807
- // --path: на відміну від --cwd (підміняє корінь), лишає корінь/config
1808
- // незмінними й лише звужує файловий набір до заданої піддиректорії
1809
- // (той самий explicitFiles-шлях, що вже годує hook --post-tool-use/--stop).
1810
- const pathIdx = args.indexOf('--path')
1811
- const pathArg = pathIdx === -1 ? null : args[pathIdx + 1]
1812
- // --base <ref>: явна база дельти для CI (merge-base HEAD ↔ ref замість
1813
- // каскаду main→origin/main) — надійний diff у checkout-ах з fetch.
1814
- const baseIdx = args.indexOf('--base')
1815
- const baseRef = baseIdx === -1 ? null : args[baseIdx + 1]
1816
- const repoWide = args.includes('--repo-wide')
1817
- const rules = args.filter(
1818
- (a, i) =>
1819
- !a.startsWith('-') &&
1820
- !(cwdIdx !== -1 && i === cwdIdx + 1) &&
1821
- !(pathIdx !== -1 && i === pathIdx + 1) &&
1822
- !(baseIdx !== -1 && i === baseIdx + 1)
1823
- )
1824
- if (repoWide && (pathArg !== null || rules.length > 0)) {
1825
- throw new Error('--repo-wide не поєднується з --path чи scoped rule/concern фільтром — оберіть щось одне')
1826
- }
1827
- // --path (сервіс-канон): дефолт — перетин піддерева з git-дельтою, лише
1828
- // per-file concerns (pathMode). --path --full — історична поведінка: все
1829
- // піддерево, full-scope concerns при збігу glob ідуть whole-repo. База
1830
- // дельти не резолвиться → fail-open на повне піддерево (не мовчазний скіп).
1831
- let pathFiles = null
1832
- let pathMode = false
1833
- if (pathArg !== null) {
1834
- const { collectPathScopedFiles, collectPathScopedChangedFiles } =
1835
- await import('../scripts/lib/lint-surface/path-scope.mjs')
1836
- if (args.includes('--full')) {
1837
- pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1838
- } else {
1839
- const scoped = await collectPathScopedChangedFiles(cwdArg, pathArg, { baseRef })
1840
- if (scoped.baseResolved) {
1841
- pathFiles = scoped.files
1842
- pathMode = true
1843
- } else {
1844
- console.warn(
1845
- '⚠️ lint --path: база дельти не резолвиться (немає main/origin/main чи --base-ref) — fail-open: лінтиться все піддерево'
1846
- )
1847
- pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
1848
- }
1849
- }
1850
- }
1851
- // --full + --path: buildPlan ігнорує full, коли передано explicitFiles (той самий
1852
- // шлях), тож і тут full-вісь вважаємо неактивною — інакше глобальний machine-wide
1853
- // лок --full брався б для швидкого scoped-прогону без причини.
1854
- const full = args.includes('--full') && pathArg === null && !repoWide
1855
- const lintOpts = {
1856
- cwd: cwdArg,
1857
- full,
1858
- rules,
1859
- verbose: args.includes('--verbose'),
1860
- files: pathFiles,
1861
- pathMode,
1862
- repoWide,
1863
- baseRef
1864
- }
1865
- const noFix = args.includes('--no-fix')
1866
- // `--full` без `--no-fix` мутує ВЕСЬ репо (не лише дельту) — той самий клас
1867
- // ризику, що й taze: якщо запущено поза .worktrees/, треба ізолювати. `--no-fix`
1868
- // (detect-only, нуль мутацій) і не-full (дельта, за дизайном працює на живому
1869
- // дереві задачі, worktree-ізоляція зламала б саму суть дельти) — пропускаємо.
1870
- // (той самий предикат, що й `isLintFullFixArgs` вище — тримаємо єдиним джерелом,
1871
- // інакше `skipDevDepsEnsure` і цей гейт можуть розійтись.)
1872
- const needsWorktreeIsolation = full && !noFix
1873
- const worktree = needsWorktreeIsolation
1874
- ? await ensureRunningInWorktree(cwdArg, spawnSync, line => console.log(line), {
1875
- suffix: 'lint',
1876
- description: 'n-rules lint --full: worktree-only full-repo run'
1877
- })
1878
- : { cwd: cwdArg, autoCreated: false, branchArg: null }
1879
- const runCwd = worktree.cwd
1880
- // Ensure відкладений із root-guard-блоку (skipDevDepsEnsure вище) саме для
1881
- // цього шляху — self-upgrade тепер усередині (auto-created) worktree, ПІСЛЯ
1882
- // гейту чистоти, а не до нього.
1883
- if (needsWorktreeIsolation) await ensureNRulesInRootDevDependencies(runCwd)
1884
- // Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
1885
- // `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
1886
- // живий прогрес активного прогону; не-full запуски йдуть без лока
1887
- // (див. lint-lock.mjs). Publisher пише знімки прогресу для черги.
1888
- const { withGlobalLintLock, createProgressPublisher } =
1889
- await import('../scripts/lib/lint-surface/lint-lock.mjs')
1890
- try {
1891
- process.exitCode = await withGlobalLintLock({ ...lintOpts, cwd: runCwd, noFix }, async () => {
1892
- const runOpts = { ...lintOpts, cwd: runCwd }
1893
- const publisher = runOpts.full ? createProgressPublisher() : null
1894
- if (publisher) runOpts.onProgress = publisher.onUpdate
1895
- try {
1896
- if (noFix) {
1897
- const { detectAll } = await import('../scripts/lib/lint-surface/run-detectors.mjs')
1898
- // окрема змінна замість (await detectAll(...)).exitCode — no-await-expression-member (oxlint)
1899
- const detectResult = await detectAll(runOpts)
1900
- return detectResult.exitCode
1901
- }
1902
- const { runFixPipeline } = await import('../scripts/lib/lint-surface/run-fix.mjs')
1903
- return await runFixPipeline(runOpts)
1904
- } finally {
1905
- publisher?.stop()
1906
- }
1907
- })
1908
- } finally {
1909
- // Лише для АВТОстворених worktree (лінт уже сидів у своєму — не наш, не чіпаємо).
1910
- if (worktree.autoCreated) {
1911
- let bringBackFailed = true
1912
- try {
1913
- const bringBackResult = await bringChangesBackToOriginal(runCwd, cwdArg, spawnSync, line =>
1914
- console.log(line)
1915
- )
1916
- bringBackFailed = bringBackResult.failed
1917
- } catch (error) {
1918
- console.log(
1919
- `⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`
1920
- )
1921
- }
1922
- // Прибираємо worktree лише якщо перенесення точно вдалось — інакше
1923
- // не перенесені зміни згорять разом з деревом.
1924
- if (bringBackFailed) {
1925
- console.log(
1926
- `⚠️ Перенесення назад не підтверджено — worktree "${worktree.branchArg}" лишається для ручного розбору.`
1927
- )
1928
- } else {
1929
- removeAutoCreatedWorktree(worktree.branchArg, cwdArg, spawnSync, line => console.log(line))
1930
- }
1931
- }
1932
- }
1933
-
1934
- break
1935
- }
1936
- case 'ci': {
1937
- // n-rules ci plan — skip-логіка сервіс-орієнтованого CI-канону: рахує
1938
- // перетин git-дельти з --path (каталог сервісу) і віддає job outputs
1939
- // «які lint-домени запускати» для GitHub Actions (--github →
1940
- // $GITHUB_OUTPUT) та Azure Pipelines (--azure → ##vso-рядки).
1941
- const { runCiPlanCli } = await import('../scripts/lib/lint-surface/ci-plan.mjs')
1942
- process.exitCode = await runCiPlanCli(args)
1943
-
1944
- break
1945
- }
1946
- case 'taze': {
1947
- // n-rules taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
1948
- // (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
1949
- // агент отримує готовий список замість ручного порівняння бекапів.
1950
- // Живе у плагіні @7n/rules-lang-js (фаза 5a spec lang-plugins-extraction) —
1951
- // резолвимо його taze-handler і беремо named-експорт runTazeCli.
1952
- const { getHandlers } = await import('../scripts/lib/resolve-plugins.mjs')
1953
- const { readNRulesConfigLite } = await import('../scripts/lib/read-n-rules-config-lite.mjs')
1954
- const config = await readNRulesConfigLite(cwd())
1955
- const handler = getHandlers(cwd(), config, 'taze').find(h => h.pluginName === '@7n/rules-lang-js')
1956
- if (!handler) {
1957
- console.error(
1958
- '❌ taze diff потребує плагін @7n/rules-lang-js (npm/bun-гілка) — запусти npx @7n/rules для авто-встановлення'
1959
- )
1960
- process.exitCode = 1
1961
- break
1962
- }
1963
- const { pathToFileURL } = await import('node:url')
1964
- // eslint-disable-next-line no-unsanitized/method
1965
- const { runTazeCli } = await import(pathToFileURL(handler.modulePath).href)
1966
- process.exitCode = await runTazeCli(args)
1967
-
1968
- break
1969
- }
1970
- case 'release': {
1971
- const { runReleaseCli } = await import('../rules/release/release.mjs')
1972
- process.exitCode = await runReleaseCli(args)
1973
-
1974
- break
1975
- }
1976
- case 'skill': {
1977
- process.exitCode = await runSkillsCli(args)
1978
-
1979
- break
1980
- }
1981
- case 'adr-normalize-local': {
1982
- // Local-backend ADR-нормалізації: викликається з .claude/hooks/normalize-decisions.sh
1983
- // як заміна single-shot LLM-виклику. Проганяє конвеєр (retrieval→edge-judge→
1984
- // cluster→gen) на малій локальній моделі й друкує `{operations}` JSON у stdout.
1985
- const { runAdrNormalizeLocalCli } = await import('../scripts/lib/adr/normalize-cli.mjs')
1986
- process.exitCode = await runAdrNormalizeLocalCli(args)
1987
-
1988
- break
1989
- }
1990
- case undefined:
1991
- case '': {
1992
- await runSync()
1993
-
1994
- break
1995
- }
1996
- default: {
1997
- console.error(`❌ Невідома команда: ${command}`)
1998
- console.error(
1999
- ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), ci plan, taze, release, skill, doc-aggregate`
2000
- )
2001
- process.exitCode = 1
2002
- }
2003
- }
2004
- }
2005
- } catch (error) {
2006
- // TypeError/RangeError/ReferenceError сигналять баг у самому коді (не навмисне
2007
- // user-facing повідомлення) — друкуємо stack одразу, інакше діагностика вимагає
2008
- // патчити node_modules вручну (як під час діагностики цього ж класу вад).
2009
- const isProgrammerError = error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError
2010
- if (error instanceof ReexecHandoff) {
2011
- process.exitCode = error.code
2012
- } else if (isProgrammerError && error.stack) {
2013
- console.error(error.stack)
2014
- process.exitCode = 1
2015
- } else if (error instanceof Error && error.message) {
2016
- console.error(error.message)
2017
- process.exitCode = 1
2018
- } else {
2019
- console.error(error)
2020
- process.exitCode = 1
2021
- }
2022
- }
2023
-
2024
- // Pi-агент тримає TCP keep-alive сокет відкритим — явний вихід, щоб не висіти.
2025
- // Відтворюємо семантику process.exit() без самого виклику (n/no-process-exit):
2026
- // фіксуємо код, віддаємо 'exit'-слухачам, далі — примусове завершення через reallyExit.
2027
- const exitCode = process.exitCode ?? 0
2028
- process.exitCode = exitCode
2029
- process.emit('exit', exitCode)
2030
- process.reallyExit(exitCode)