@7n/rules 1.32.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.32.1] - 2026-07-20
4
+
5
+ ### Changed
6
+
7
+ - fix(n-rules/n-taze): не забруднювати дерево перед worktree-гейтом і не губити untracked-директорії при поверненні змін (#160)
8
+
3
9
  ## [1.32.0] - 2026-07-19
4
10
 
5
11
  ### Changed
package/bin/n-rules.js CHANGED
@@ -101,7 +101,7 @@ import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
101
101
  import { syncGitignoreWorktree } from '../scripts/lib/sync-gitignore-worktree.mjs'
102
102
  import { upgradeNRulesToLatestAndBunInstall } from '../scripts/upgrade-n-rules-and-install.mjs'
103
103
  import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
104
- import { runSkillsCli } from '../scripts/skills-cli.mjs'
104
+ import { isTazeOrchestratorSkillArgs, runSkillsCli } from '../scripts/skills-cli.mjs'
105
105
  import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
106
106
 
107
107
  const PACKAGE_NAME = '@7n/rules'
@@ -1729,7 +1729,14 @@ try {
1729
1729
  }
1730
1730
  // `ci` (plan) — read-only гейт-команда для CI-джоб: не мутує package.json
1731
1731
  // (ensure дописав би devDependency прямо в чекауті pipeline-агента).
1732
- if (command !== 'ci') await ensureNRulesInRootDevDependencies(effectiveRoot)
1732
+ // `skill <runner> taze` JS-оркестрований worktree-only шлях
1733
+ // (skills/taze/js/orchestrate.mjs): сам створює worktree і гейтить на
1734
+ // чистоту дерева ДО checkout. Мутація тут забруднила б дерево прямо
1735
+ // перед тим гейтом і провалювала б auto-create на інакше чистому дереві —
1736
+ // оркестратор сам робить self-upgrade devDependency вже ВСЕРЕДИНІ
1737
+ // щойно створеного worktree, після власного гейту чистоти.
1738
+ const skipDevDepsEnsure = command === 'skill' && isTazeOrchestratorSkillArgs(args)
1739
+ if (command !== 'ci' && !skipDevDepsEnsure) await ensureNRulesInRootDevDependencies(effectiveRoot)
1733
1740
  // Підкоманди-оркестратори (hook/lint/skill/adr-normalize-local/taze/release тощо)
1734
1741
  // можуть спавнити внутрішню agent/LLM-сесію — ADR Stop-hooks (capture/normalize)
1735
1742
  // мають пропустити її як технічний шум, не людську думку (spec 2026-06-30).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.32.0",
3
+ "version": "1.32.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: skills-cli.mjs
4
4
  resource: npm/scripts/skills-cli.mjs
5
5
  docgen:
6
- crc: 12d3bf6d
6
+ crc: 3e5781e6
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -1,10 +1,11 @@
1
1
  /** @see ./docs/auto-worktree.md */
2
- import { existsSync } from 'node:fs'
3
- import { copyFile, mkdir, rm } from 'node:fs/promises'
4
- import { dirname, join } from 'node:path'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'
4
+ import { dirname, join, relative } from 'node:path'
5
5
  import { createInterface } from 'node:readline/promises'
6
6
 
7
7
  const YES_RE = /^y(es)?$/i
8
+ const TRAILING_SLASH_RE = /\/$/
8
9
 
9
10
  /**
10
11
  * Питає y/N у терміналі. Поза TTY (CI, неінтерактивний виклик) — одразу `false`,
@@ -112,6 +113,31 @@ export async function ensureRunningInWorktree(
112
113
  return { cwd: newCwd, autoCreated: true, branchArg }
113
114
  }
114
115
 
116
+ /**
117
+ * Рекурсивно копіює вміст директорії (лише файли-листки; підкаталоги
118
+ * створюються по дорозі через `makeDir`). `listDir` — інжектований `readdir`
119
+ * з `{ recursive: true, withFileTypes: true }` (Node ≥20.1): кожен `Dirent`
120
+ * несе `parentPath`/`path` — реальний каталог, у якому файл лежить.
121
+ * @param {string} srcDir директорія-джерело (у worktree)
122
+ * @param {string} destDir директорія-призначення (у оригінальному дереві)
123
+ * @param {{ copy: (src: string, dest: string) => Promise<void>, makeDir: (path: string, opts?: object) => Promise<void>, listDir: (path: string, opts?: object) => Promise<Array<{ name: string, parentPath?: string, path?: string, isFile: () => boolean }>> }} io інжекти
124
+ * @returns {Promise<string[]>} шляхи скопійованих файлів відносно `srcDir` (forward-slash)
125
+ */
126
+ async function copyDirectoryRecursive(srcDir, destDir, { copy, makeDir, listDir }) {
127
+ const entries = await listDir(srcDir, { recursive: true, withFileTypes: true })
128
+ const copiedRelPaths = []
129
+ for (const entry of entries) {
130
+ if (!entry.isFile()) continue
131
+ const entryDir = entry.parentPath ?? entry.path ?? srcDir
132
+ const relFromSrc = relative(srcDir, join(entryDir, entry.name)).replaceAll('\\', '/')
133
+ const dest = join(destDir, relFromSrc)
134
+ await makeDir(dirname(dest), { recursive: true })
135
+ await copy(join(entryDir, entry.name), dest)
136
+ copiedRelPaths.push(relFromSrc)
137
+ }
138
+ return copiedRelPaths
139
+ }
140
+
115
141
  /**
116
142
  * Переносить зміни з автоствореного worktree назад у вихідне дерево як
117
143
  * **untracked/незакомічені** правки (просте копіювання файлів, без git
@@ -122,48 +148,79 @@ export async function ensureRunningInWorktree(
122
148
  * лише нову назву — стара лишається як була, прийнятний компроміс для
123
149
  * інструментів, що самі файли не перейменовують (ефект можливий лише як
124
150
  * побічний результат LLM-рефакторингу чи форматера).
151
+ *
152
+ * **Untracked-директорія цілком.** Git схлопує щойно створену untracked
153
+ * директорію в один porcelain-рядок із `/` на кінці (якщо в ній нема жодного
154
+ * файлу, вже відомого git) — `copyFile` на такий шлях впав би (`EISDIR`/`ENOENT`)
155
+ * і обривав би цикл, гублячи все, що йшло по порядку ітерації ДАЛІ. Такий
156
+ * рядок (суфікс `/` або реальний каталог на диску) копіюється рекурсивно
157
+ * (`copyDirectoryRecursive`) — весь вміст, а не один `copyFile`.
125
158
  * @param {string} worktreeCwd автостворений worktree, з якого переносимо
126
159
  * @param {string} originalCwd вихідне дерево, куди переносимо
127
160
  * @param {typeof import('node:child_process').spawnSync} spawnFn інжект для тестів
128
161
  * @param {(line: string) => void} log колбек прогресу
129
- * @param {{ copyFile?: (src: string, dest: string) => Promise<void>, rm?: (path: string, opts?: object) => Promise<void>, mkdir?: (path: string, opts?: object) => Promise<void> }} [deps] інжекти для тестів
130
- * @returns {Promise<string[]>} відносні шляхи перенесених файлів
162
+ * @param {{ copyFile?: (src: string, dest: string) => Promise<void>, rm?: (path: string, opts?: object) => Promise<void>, mkdir?: (path: string, opts?: object) => Promise<void>, readdir?: (path: string, opts?: object) => Promise<Array<object>> }} [deps] інжекти для тестів
163
+ * @returns {Promise<{ brought: string[], failed: boolean }>} відносні шляхи перенесених файлів і чи стався провал (частковий чи повний)
131
164
  */
132
165
  export async function bringChangesBackToOriginal(worktreeCwd, originalCwd, spawnFn, log, deps = {}) {
133
166
  const copy = deps.copyFile ?? copyFile
134
167
  const removeFile = deps.rm ?? rm
135
168
  const makeDir = deps.mkdir ?? mkdir
169
+ const listDir = deps.readdir ?? readdir
136
170
 
137
171
  const statusResult = spawnFn('git', ['status', '--porcelain'], { cwd: worktreeCwd, encoding: 'utf8' })
138
172
  if (statusResult.status !== 0) {
139
173
  log(
140
174
  `⚠️ Не вдалось прочитати git status у "${worktreeCwd}" — зміни НЕ перенесені назад, worktree лишиться для ручного розбору.`
141
175
  )
142
- return []
176
+ return { brought: [], failed: true }
143
177
  }
144
178
 
145
179
  const lines = statusResult.stdout.split('\n').filter(Boolean)
146
180
  if (lines.length === 0) {
147
181
  log('ℹ️ Worktree без змін — нічого переносити назад.')
148
- return []
182
+ return { brought: [], failed: false }
149
183
  }
150
184
 
151
185
  const brought = []
186
+ let failed = false
152
187
  for (const line of lines) {
153
188
  const rest = line.slice(3)
154
189
  const relPath = rest.includes(' -> ') ? rest.split(' -> ', 2)[1] : rest
155
190
  const srcPath = join(worktreeCwd, relPath)
156
191
  const destPath = join(originalCwd, relPath)
157
- if (existsSync(srcPath)) {
158
- await makeDir(dirname(destPath), { recursive: true })
159
- await copy(srcPath, destPath)
160
- } else {
161
- await removeFile(destPath, { force: true })
192
+ try {
193
+ if (!existsSync(srcPath)) {
194
+ await removeFile(destPath, { force: true, recursive: true })
195
+ brought.push(relPath)
196
+ continue
197
+ }
198
+
199
+ const isDir = relPath.endsWith('/') || statSync(srcPath).isDirectory()
200
+ if (isDir) {
201
+ const trimmedRelPath = relPath.replace(TRAILING_SLASH_RE, '')
202
+ const copiedRelPaths = await copyDirectoryRecursive(srcPath, destPath, { copy, makeDir, listDir })
203
+ for (const nestedRelPath of copiedRelPaths) {
204
+ brought.push(`${trimmedRelPath}/${nestedRelPath}`)
205
+ }
206
+ } else {
207
+ await makeDir(dirname(destPath), { recursive: true })
208
+ await copy(srcPath, destPath)
209
+ brought.push(relPath)
210
+ }
211
+ } catch (error) {
212
+ failed = true
213
+ log(
214
+ `⚠️ Не вдалось перенести "${relPath}" назад у "${originalCwd}" — ${error instanceof Error ? error.message : String(error)}`
215
+ )
162
216
  }
163
- brought.push(relPath)
164
217
  }
165
- log(`📤 Перенесено назад у "${originalCwd}" як untracked: ${brought.join(', ')}`)
166
- return brought
218
+ log(
219
+ failed
220
+ ? `⚠️ Перенесення назад у "${originalCwd}" частково провалилось — перенесено: ${brought.join(', ') || '(нічого)'}`
221
+ : `📤 Перенесено назад у "${originalCwd}" як untracked: ${brought.join(', ')}`
222
+ )
223
+ return { brought, failed }
167
224
  }
168
225
 
169
226
  /**
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: auto-worktree.mjs
4
4
  resource: npm/scripts/lib/auto-worktree.mjs
5
5
  docgen:
6
- crc: 82081402
6
+ crc: bae09068
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -244,6 +244,23 @@ export function resolveBundledPackageRoot(fromModuleUrl = import.meta.url) {
244
244
  return join(dirname(fileURLToPath(fromModuleUrl)), '..')
245
245
  }
246
246
 
247
+ /**
248
+ * Чи `argv` (аргументи після `skill`) резолвиться в JS-оркестрований
249
+ * worktree-only `taze`-шлях (`runTazeOrchestratorCli`) — той самий критерій,
250
+ * що й нижче в `runSkillsCli`. Використовується `n-rules.js`, щоб не мутувати
251
+ * root `package.json` (self-upgrade `@7n/rules`) ДО власного worktree-гейту
252
+ * оркестратора: той сам створює worktree і перевіряє чистоту дерева
253
+ * (`ensureRunningInWorktree`, `requireCleanTree: true`) — мутація package.json
254
+ * прямо перед цим викликом примусово провалила б auto-create там, де дерево
255
+ * інакше було б чисте.
256
+ * @param {string[]} argv аргументи після `skill`
257
+ * @returns {boolean} `true`, якщо запуск піде через `runTazeOrchestratorCli`
258
+ */
259
+ export function isTazeOrchestratorSkillArgs(argv) {
260
+ const [first, second] = argv
261
+ return RUNNERS.has(first) && first !== 'claude' && normalizeSkillId(second) === 'taze'
262
+ }
263
+
247
264
  /**
248
265
  * @param {string[]} argv аргументи після `skill` у `n-rules`
249
266
  * @param {{ packageRoot?: string, projectDir?: string, log?: (line: string) => void, logError?: (line: string) => void, deps?: { runPiAgentSkill?: (prompt: string, opts?: object) => Promise<{ ok: boolean, error: string|null }> } }} [options] перевизначення кореня пакета, каталогу проєкту, функцій виводу та інжектів (для тестів)
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/taze/js/orchestrate.mjs
5
5
  docgen:
6
- crc: 7bf5d0c0
6
+ crc: 63cee070
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -2,6 +2,7 @@
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { pathToFileURL } from 'node:url'
4
4
 
5
+ import { ensureNRulesInRootDevDependencies } from '../../../scripts/ensure-n-rules-dev-dependencies.mjs'
5
6
  import {
6
7
  bringChangesBackToOriginal,
7
8
  ensureRunningInWorktree,
@@ -138,7 +139,13 @@ async function runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call })
138
139
  eco.results.push({ ...entry, ...outcome })
139
140
  log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
140
141
  if (outcome.ok && outcome.text) {
141
- await writeCache(entry.pkg, entry.from, entry.to, { notes: outcome.text, sourceRepo: cwd, updatedAt: new Date().toISOString() }, deps)
142
+ await writeCache(
143
+ entry.pkg,
144
+ entry.from,
145
+ entry.to,
146
+ { notes: outcome.text, sourceRepo: cwd, updatedAt: new Date().toISOString() },
147
+ deps
148
+ )
142
149
  }
143
150
  }
144
151
 
@@ -241,22 +248,50 @@ export async function runTazeOrchestrator(options = {}) {
241
248
  })
242
249
  const cwd = worktree.cwd
243
250
 
251
+ // Self-upgrade root package.json devDependency (`@7n/rules`) — навмисно
252
+ // ВСЕРЕДИНІ worktree, ПІСЛЯ гейту `ensureRunningInWorktree` на чистоту
253
+ // дерева: та ж мутація у `originalCwd` ДО цього гейту забруднила б дерево
254
+ // прямо перед перевіркою, яку сам гейт щойно пройшов (n-rules.js навмисно
255
+ // пропускає її для цього шляху). Провал — не критичний для taze-прогону,
256
+ // лише лог.
257
+ try {
258
+ const ensureDevDeps = deps.ensureNRulesInRootDevDependencies ?? ensureNRulesInRootDevDependencies
259
+ await ensureDevDeps(cwd, { silent: true })
260
+ } catch (error) {
261
+ log(
262
+ `⚠️ Self-upgrade @7n/rules у "${cwd}" провалився — пропускаю (${error instanceof Error ? error.message : String(error)})`
263
+ )
264
+ }
265
+
244
266
  let cleanedUp = false
245
267
  /**
246
268
  * Переносить зміни назад і прибирає автостворений worktree — не більше
247
269
  * одного разу (idempotent), щоб і сигнальний обробник, і `finally` могли
248
270
  * безпечно кликати те саме без подвійного `bringChangesBackToOriginal`/
249
271
  * `removeAutoCreatedWorktree`.
272
+ *
273
+ * **Захист від втрати даних.** Якщо перенесення провалилось (частково чи
274
+ * повністю — `bringChangesBackToOriginal` кидає або повертає
275
+ * `failed: true`), worktree НЕ прибирається: джерело ще не перенесених
276
+ * файлів лишається на диску для ручного відновлення замість того, щоб
277
+ * зникнути разом з `npx \@7n/mt worktree remove`.
250
278
  * @returns {Promise<void>}
251
279
  */
252
280
  const cleanupAutoCreatedWorktree = async () => {
253
281
  if (cleanedUp) return
254
282
  cleanedUp = true
283
+ let failed = true
255
284
  try {
256
- await bringChangesBackToOriginal(cwd, originalCwd, spawnFn, log, deps)
285
+ ;({ failed } = await bringChangesBackToOriginal(cwd, originalCwd, spawnFn, log, deps))
257
286
  } catch (error) {
258
287
  log(`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`)
259
288
  }
289
+ if (failed) {
290
+ log(
291
+ `⚠️ Перенесення назад не вдалось повністю — worktree "${cwd}" (гілка "${worktree.branchArg}") НЕ прибирається, розберіться вручну.`
292
+ )
293
+ return
294
+ }
260
295
  removeAutoCreatedWorktree(worktree.branchArg, originalCwd, spawnFn, log)
261
296
  }
262
297