@7n/rules 1.29.1 → 1.29.3

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.29.3] - 2026-07-19
4
+
5
+ ### Changed
6
+
7
+ - release: @7n/llm-lib@2.8.3, @7n/rules@1.29.2
8
+
9
+ ## [1.29.2] - 2026-07-19
10
+
11
+ ### Changed
12
+
13
+ - fix(auto-worktree): на брудному дереві перед auto-create — інтерактивний y/N-запит закомить і запушити (`npx @7n/n push`) замість одразу кидати
14
+ - chore(adr): батч фонової нормалізації — чернетки doc-files/worktree-lifecycle у канонічні ADR
15
+
3
16
  ## [1.29.1] - 2026-07-19
4
17
 
5
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.29.1",
3
+ "version": "1.29.3",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -2,6 +2,26 @@
2
2
  import { existsSync } from 'node:fs'
3
3
  import { copyFile, mkdir, rm } from 'node:fs/promises'
4
4
  import { dirname, join } from 'node:path'
5
+ import { createInterface } from 'node:readline/promises'
6
+
7
+ const YES_RE = /^y(es)?$/i
8
+
9
+ /**
10
+ * Питає y/N у терміналі. Поза TTY (CI, неінтерактивний виклик) — одразу `false`,
11
+ * той самий безпечний дефолт, що й раніше (throw), без зависання на порожньому stdin.
12
+ * @param {string} message текст питання (без "[y/N]" — додається тут)
13
+ * @returns {Promise<boolean>} `true` лише на явне "y"/"yes"
14
+ */
15
+ async function defaultConfirm(message) {
16
+ if (!process.stdin.isTTY) return false
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
18
+ try {
19
+ const answer = await rl.question(`${message} [y/N] `)
20
+ return YES_RE.test(answer.trim())
21
+ } finally {
22
+ rl.close()
23
+ }
24
+ }
5
25
 
6
26
  /**
7
27
  * Гарантує, що подальші кроки виконуються в ізольованому worktree
@@ -16,18 +36,29 @@ import { dirname, join } from 'node:path'
16
36
  * Якщо у вихідному `cwd` вже є незакомічені зміни, вони НЕ потраплять у щойно
17
37
  * створений worktree (той — checkout HEAD), і перенесення назад мовчки
18
38
  * затерло б їх версією з worktree. Тому за замовчуванням (`requireCleanTree:
19
- * true`) auto-create кидає на брудному дереві замість ризикувати чужими
20
- * незакоміченими правками. Викликач, що гарантує чистоту дерева сам (наприклад
21
- * taze SKILL.md вимагає цього як передумову ще ДО виклику), може передати
22
- * `requireCleanTree: false`, щоб не платити за зайву git-команду.
39
+ * true`) на брудному дереві auto-create питає в терміналі (`deps.confirm`,
40
+ * дефолт y/N через stdin; поза TTY одразу "ні") дозвіл закомить і
41
+ * запушити зараз через `npx \@7n/n push` (сквош усього робочого дерева в один
42
+ * коміт + push у origin — сама команда підтвердження не питає, тому питаємо
43
+ * ми, ДО виклику). На "ні"/поза TTY — кидає, як і раніше. Викликач, що
44
+ * гарантує чистоту дерева сам (наприклад taze — SKILL.md вимагає цього як
45
+ * передумову ще ДО виклику), може передати `requireCleanTree: false`, щоб не
46
+ * платити за зайву git-команду і не питати підтвердження.
23
47
  * @param {string} cwd каталог для перевірки
24
48
  * @param {typeof import('node:child_process').spawnSync} spawnFn інжект для тестів
25
49
  * @param {(line: string) => void} log колбек прогресу
26
50
  * @param {{ suffix: string, description: string, requireCleanTree?: boolean }} opts `suffix` — коротка (до 10 символів) назва задачі для `<branch>-<suffix>`; `description` — текст для `npx \@7n/mt worktree create`
27
- * @returns {{ cwd: string, autoCreated: boolean, branchArg: string|null }} `autoCreated: false` — `cwd` без змін
51
+ * @param {{ confirm?: (message: string) => Promise<boolean> }} [deps] `confirm`інжект для тестів/альтернативного UX (дефолт — `defaultConfirm`, readline y/N)
52
+ * @returns {Promise<{ cwd: string, autoCreated: boolean, branchArg: string|null }>} `autoCreated: false` — `cwd` без змін
28
53
  * (вже worktree); `autoCreated: true` — `cwd` щойно створеного worktree і `branchArg`, з яким його створено
29
54
  */
30
- export function ensureRunningInWorktree(cwd, spawnFn, log, { suffix, description, requireCleanTree = true }) {
55
+ export async function ensureRunningInWorktree(
56
+ cwd,
57
+ spawnFn,
58
+ log,
59
+ { suffix, description, requireCleanTree = true },
60
+ deps = {}
61
+ ) {
31
62
  const toplevelResult = spawnFn('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
32
63
  const toplevel = toplevelResult.status === 0 ? toplevelResult.stdout.trim() : ''
33
64
  const segments = new Set(toplevel.replaceAll('\\', '/').split('/'))
@@ -45,11 +76,28 @@ export function ensureRunningInWorktree(cwd, spawnFn, log, { suffix, description
45
76
  if (requireCleanTree) {
46
77
  const statusResult = spawnFn('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' })
47
78
  if (statusResult.status === 0 && statusResult.stdout.trim().length > 0) {
48
- throw new Error(
49
- `"${cwd}" не в ізольованому worktree і має незакомічені зміни — auto-create worktree тут НЕБЕЗПЕЧНИЙ: ` +
50
- 'перенесення результату назад копіюванням файлів затерло б ці незакомічені правки версією зі свіжого ' +
51
- 'checkout (worktree = HEAD, без твоїх правок). Закомить/застеш зміни або створи worktree вручну.'
79
+ const dirtyTreeError = () =>
80
+ new Error(
81
+ `"${cwd}" не в ізольованому worktree і має незакомічені зміни auto-create worktree тут НЕБЕЗПЕЧНИЙ: ` +
82
+ 'перенесення результату назад копіюванням файлів затерло б ці незакомічені правки версією зі свіжого ' +
83
+ 'checkout (worktree = HEAD, без твоїх правок). Закомить/застеш зміни або створи worktree вручну.'
84
+ )
85
+
86
+ const confirm = deps.confirm ?? defaultConfirm
87
+ const wantsPush = await confirm(
88
+ `"${cwd}" не в ізольованому worktree і має незакомічені зміни — auto-create worktree тут НЕБЕЗПЕЧНИЙ ` +
89
+ '(перенесення назад копіюванням файлів затерло б їх версією зі свіжого checkout). ' +
90
+ 'Закомить і запушити зараз через `npx @7n/n push`?'
52
91
  )
92
+ if (!wantsPush) throw dirtyTreeError()
93
+
94
+ log(`📤 "${cwd}" брудне — запускаю \`npx @7n/n push\` перед auto-create worktree...`)
95
+ runCommand('npx', ['@7n/n', 'push'], cwd, spawnFn)
96
+
97
+ const recheckResult = spawnFn('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' })
98
+ if (recheckResult.status !== 0 || recheckResult.stdout.trim().length > 0) {
99
+ throw new Error(`\`npx @7n/n push\` відпрацював, але "${cwd}" усе ще не чисте — перевір вручну (git status).`)
100
+ }
53
101
  }
54
102
  }
55
103
 
@@ -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: 8d55dfc5
6
+ crc: 82081402
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -22,7 +22,7 @@ docgen:
22
22
 
23
23
  ## Публічний API
24
24
 
25
- - ensureRunningInWorktree — Гарантує ізольований worktree для наступних кроків; якщо вже працюєш у `.worktrees/`, лишає поточний каталог без змін, інакше створює окремий worktree та ставить залежності. За замовчуванням блокує запуск на брудному дереві, щоб не втратити незакомічені правки; це можна послабити лише там, де чистоту вже забезпечили іншим способом.
25
+ - ensureRunningInWorktree — Гарантує ізольований worktree для наступних кроків; якщо вже працюєш у `.worktrees/`, лишає поточний каталог без змін, інакше створює окремий worktree та ставить залежності. За замовчуванням на брудному дереві питає в терміналі (y/N) дозвіл закомить і запушити зараз через `npx @7n/n push`, і лише на "ні" (або поза TTY) кидає; це можна послабити (`requireCleanTree: false`) лише там, де чистоту вже забезпечили іншим способом.
26
26
  - bringChangesBackToOriginal — Повертає зміни з автоствореного worktree у початкове дерево простим копіюванням файлів: нові й змінені файли переносять, видалені — прибирають у вихідному дереві. Працює як перенесення фактичного стану, а не як merge.
27
27
  - removeAutoCreatedWorktree — Прибирає тимчасовий worktree та пов’язану з ним ephemeral branch після того, як зміни вже перенесено назад; якщо прибирання не вдалось, залишає worktree для ручного розбору.
28
28
 
@@ -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: c1bcfe63
6
+ crc: 9e3842cb
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -0,0 +1,90 @@
1
+ /** @see ./docs/migration-cache.md */
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ /**
8
+ * Каталог за замовчуванням для кешу міграцій — спільний для всіх репо на цій
9
+ * машині (не прив'язаний до конкретного worktree/репо), бо ключ кешу — сам
10
+ * пакет+діапазон версій, а не проєкт.
11
+ */
12
+ export const DEFAULT_CACHE_DIR = join(homedir(), '.cache', 'n-rules', 'taze-migrations')
13
+
14
+ /**
15
+ * Санітизує `(pkg, from, to)` у безпечне імʼя файлу — крос-репо ключ кешу.
16
+ * Той самий `(pkg, from, to)` у різних репо/воркспейсах дає той самий ключ.
17
+ * @param {string} pkg назва пакета (може містити `@scope/name`)
18
+ * @param {string} from стара версія
19
+ * @param {string} to нова версія
20
+ * @returns {string} ключ без розширення
21
+ */
22
+ export function migrationCacheKey(pkg, from, to) {
23
+ return `${pkg}@${from}__${to}`.replaceAll(/[^a-zA-Z0-9._@-]/g, '-')
24
+ }
25
+
26
+ /**
27
+ * Читає кешований запис міграції для `(pkg, from, to)`, якщо інший
28
+ * repo/worktree на цій машині вже проганяв через LLM ту саму пару версій.
29
+ * Відсутній/побитий файл — `null` (мовчки, не провал прогону: кеш —
30
+ * оптимізація, а не залежність, від якої залежить коректність).
31
+ * @param {string} pkg назва пакета
32
+ * @param {string} from стара версія
33
+ * @param {string} to нова версія
34
+ * @param {{ cacheDir?: string, existsSyncFn?: typeof existsSync, readFileFn?: typeof readFile }} [deps] інжекти для тестів
35
+ * @returns {Promise<{notes: string, sourceRepo: string, updatedAt: string}|null>} кешований запис або null
36
+ */
37
+ export async function readMigrationCache(pkg, from, to, deps = {}) {
38
+ const cacheDir = deps.cacheDir ?? DEFAULT_CACHE_DIR
39
+ const exists = deps.existsSyncFn ?? existsSync
40
+ const read = deps.readFileFn ?? readFile
41
+ const path = join(cacheDir, `${migrationCacheKey(pkg, from, to)}.json`)
42
+ if (!exists(path)) return null
43
+ try {
44
+ return JSON.parse(await read(path, 'utf8'))
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Зберігає результат ізольованого LLM-виклику для `(pkg, from, to)` — щоб
52
+ * наступний репо з тим самим bump-ом на цій машині не повторював
53
+ * CHANGELOG-дослідження з нуля (див. `readMigrationCache`).
54
+ * @param {string} pkg назва пакета
55
+ * @param {string} from стара версія
56
+ * @param {string} to нова версія
57
+ * @param {{notes: string, sourceRepo: string, updatedAt: string}} entry запис для збереження
58
+ * @param {{ cacheDir?: string, mkdirFn?: typeof mkdir, writeFileFn?: typeof writeFile }} [deps] інжекти для тестів
59
+ * @returns {Promise<void>}
60
+ */
61
+ export async function writeMigrationCache(pkg, from, to, entry, deps = {}) {
62
+ const cacheDir = deps.cacheDir ?? DEFAULT_CACHE_DIR
63
+ const makeDir = deps.mkdirFn ?? mkdir
64
+ const write = deps.writeFileFn ?? writeFile
65
+ await makeDir(cacheDir, { recursive: true })
66
+ const path = join(cacheDir, `${migrationCacheKey(pkg, from, to)}.json`)
67
+ await write(path, JSON.stringify(entry, null, 2))
68
+ }
69
+
70
+ /**
71
+ * Дописує до промпта `provider.promptFor(entry)` підсумок відомої міграції,
72
+ * якщо кеш її знайшов — каже раннеру пропустити крок 1 (CHANGELOG/diff-
73
+ * дослідження) і одразу шукати використання в поточному проєкті.
74
+ * @param {string} prompt базовий промпт з `provider.promptFor(entry)`
75
+ * @param {{notes: string, sourceRepo: string}} cached кешований запис
76
+ * @returns {string} доповнений промпт
77
+ */
78
+ export function withKnownMigrationNotes(prompt, cached) {
79
+ return [
80
+ prompt,
81
+ '',
82
+ '## Відома міграція (кеш з іншого репо на цій машині)',
83
+ '',
84
+ `Це саме оновлення (той самий пакет і діапазон версій) вже проаналізовано раніше в "${cached.sourceRepo}". Підсумок того прогону:`,
85
+ '',
86
+ cached.notes,
87
+ '',
88
+ 'Довірся цьому підсумку й пропусти крок 1 (повторне CHANGELOG/diff-дослідження) — одразу переходь до кроку 2 (використання API в ЦЬОМУ проєкті) і застосування міграції, якщо вона тут релевантна.'
89
+ ].join('\n')
90
+ }
@@ -10,6 +10,7 @@ import {
10
10
  import { assertEcosystemProvider } from '../../../scripts/lib/plugin-api.mjs'
11
11
  import { readNRulesConfigLite } from '../../../scripts/lib/read-n-rules-config-lite.mjs'
12
12
  import { getHandlers, resolvePlugins } from '../../../scripts/lib/resolve-plugins.mjs'
13
+ import { readMigrationCache, withKnownMigrationNotes, writeMigrationCache } from './migration-cache.mjs'
13
14
 
14
15
  export { bringChangesBackToOriginal, removeAutoCreatedWorktree } from '../../../scripts/lib/auto-worktree.mjs'
15
16
 
@@ -123,11 +124,22 @@ async function runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call })
123
124
  const diff = await provider.diff(cwd, eco.manifests, deps)
124
125
  log(`🔍 ${provider.title} diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
125
126
 
127
+ const readCache = deps.readMigrationCache ?? readMigrationCache
128
+ const writeCache = deps.writeMigrationCache ?? writeMigrationCache
126
129
  for (const entry of diff.major) {
127
130
  log(`🔧 [${provider.id}] ${entry.pkg} (${entry.manifest}): ${entry.from} → ${entry.to}...`)
128
- const outcome = await call(runner, provider.promptFor(entry), cwd, deps)
131
+ let prompt = provider.promptFor(entry)
132
+ const cached = await readCache(entry.pkg, entry.from, entry.to, deps)
133
+ if (cached) {
134
+ log(` ♻️ Кешована міграція з "${cached.sourceRepo}" — пропускаю повторне CHANGELOG-дослідження`)
135
+ prompt = withKnownMigrationNotes(prompt, cached)
136
+ }
137
+ const outcome = await call(runner, prompt, cwd, deps)
129
138
  eco.results.push({ ...entry, ...outcome })
130
139
  log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
140
+ 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
+ }
131
143
  }
132
144
 
133
145
  await provider.cleanup(cwd, eco.manifests, deps)
@@ -223,12 +235,46 @@ export async function runTazeOrchestrator(options = {}) {
223
235
  const call = deps.callRunner ?? callRunner
224
236
 
225
237
  const originalCwd = options.cwd ?? process.cwd()
226
- const worktree = ensureRunningInWorktree(originalCwd, spawnFn, log, {
238
+ const worktree = await ensureRunningInWorktree(originalCwd, spawnFn, log, {
227
239
  suffix: 'taze',
228
240
  description: 'n-taze: worktree-only skill'
229
241
  })
230
242
  const cwd = worktree.cwd
231
243
 
244
+ let cleanedUp = false
245
+ /**
246
+ * Переносить зміни назад і прибирає автостворений worktree — не більше
247
+ * одного разу (idempotent), щоб і сигнальний обробник, і `finally` могли
248
+ * безпечно кликати те саме без подвійного `bringChangesBackToOriginal`/
249
+ * `removeAutoCreatedWorktree`.
250
+ * @returns {Promise<void>}
251
+ */
252
+ const cleanupAutoCreatedWorktree = async () => {
253
+ if (cleanedUp) return
254
+ cleanedUp = true
255
+ try {
256
+ await bringChangesBackToOriginal(cwd, originalCwd, spawnFn, log, deps)
257
+ } catch (error) {
258
+ log(`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`)
259
+ }
260
+ removeAutoCreatedWorktree(worktree.branchArg, originalCwd, spawnFn, log)
261
+ }
262
+
263
+ // SIGINT/SIGTERM (Ctrl-C, таймаут зовнішнього раннера, `kill`) без обробника
264
+ // залишають автостворений worktree осиротілим — Node завершується одразу,
265
+ // `finally` нижче не встигає спрацювати. Ловимо сигнал, рятуємо прогрес
266
+ // (перенесення змін + видалення worktree) і лише тоді виходимо.
267
+ const exitProcess = deps.exitProcessFn ?? (code => (process.exitCode = code))
268
+ const onSignal = async signal => {
269
+ log(`⚠️ Отримано ${signal} — переношу прогрес автоствореного worktree назад перед виходом...`)
270
+ await cleanupAutoCreatedWorktree()
271
+ exitProcess(1)
272
+ }
273
+ if (worktree.autoCreated) {
274
+ process.on('SIGINT', onSignal)
275
+ process.on('SIGTERM', onSignal)
276
+ }
277
+
232
278
  try {
233
279
  const providers = deps.ecosystemProviders ?? (await loadPluginTazeProviders(cwd, log, deps))
234
280
  if (providers.length === 0) {
@@ -253,12 +299,9 @@ export async function runTazeOrchestrator(options = {}) {
253
299
  // сирітський worktree прибирався навіть при кинутому винятку всередині
254
300
  // try (падіння bunx/diff/провайдера), а не лише при успішному прогоні.
255
301
  if (worktree.autoCreated) {
256
- try {
257
- await bringChangesBackToOriginal(cwd, originalCwd, spawnFn, log, deps)
258
- } catch (error) {
259
- log(`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`)
260
- }
261
- removeAutoCreatedWorktree(worktree.branchArg, originalCwd, spawnFn, log)
302
+ process.removeListener('SIGINT', onSignal)
303
+ process.removeListener('SIGTERM', onSignal)
304
+ await cleanupAutoCreatedWorktree()
262
305
  }
263
306
  }
264
307
  }