@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
|
@@ -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`)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
}
|