@nitra/cursor 3.10.0 → 3.12.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.
- package/CHANGELOG.md +18 -0
- package/bin/n-cursor.js +3 -2
- package/package.json +1 -1
- package/rules/changelog/changelog.mdc +7 -1
- package/rules/flow/flow.mdc +6 -3
- package/rules/js-lint/coverage/coverage.mjs +89 -22
- package/rules/npm-module/js/package_structure.mjs +5 -147
- package/rules/npm-module/npm-module.mdc +4 -14
- package/rules/rust/coverage/coverage.mjs +13 -2
- package/rules/test/coverage/coverage.mjs +50 -6
- package/rules/test/test.mdc +2 -0
- package/scripts/dispatcher/index.mjs +36 -3
- package/scripts/dispatcher/lib/commands.mjs +100 -8
- package/scripts/dispatcher/lib/flow-resolve.mjs +154 -0
- package/scripts/dispatcher/lib/gate.mjs +11 -3
- package/scripts/dispatcher/lib/plan.mjs +11 -3
- package/scripts/dispatcher/lib/review.mjs +11 -3
- package/scripts/dispatcher/lib/reviewer.mjs +9 -5
- package/scripts/dispatcher/lib/spec.mjs +11 -3
- package/scripts/dispatcher/trace.mjs +50 -11
- package/scripts/lib/changed-files.mjs +28 -0
|
@@ -19,6 +19,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
19
19
|
|
|
20
20
|
import { applyVerdicts } from '../../../scripts/coverage-classify/apply.mjs'
|
|
21
21
|
import { classify } from '../../../scripts/coverage-classify/index.mjs'
|
|
22
|
+
import { flowStatePath, readState } from '../../../scripts/dispatcher/lib/state-store.mjs'
|
|
23
|
+
import { collectChangedFilesSince } from '../../../scripts/lib/changed-files.mjs'
|
|
22
24
|
import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
23
25
|
import { withLock } from '../../../scripts/utils/with-lock.mjs'
|
|
24
26
|
|
|
@@ -194,18 +196,46 @@ async function readClassifyThreshold(cwd) {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Резолвить scope змінених файлів для `--changed`-режиму.
|
|
201
|
+
*
|
|
202
|
+
* Base — `metadata.base_commit` зі стану flow (sibling-файл `.flow.json` поруч із
|
|
203
|
+
* worktree-checkout). `git diff <base>` проти робочого дерева ловить committed і
|
|
204
|
+
* uncommitted однаково — тож scope не залежить від того, чи executor уже закомітив
|
|
205
|
+
* крок. Поза flow (нема/пошкоджений стан) — fallback на робоче дерево vs HEAD.
|
|
206
|
+
* @param {string} cwd корінь проєкту (= worktree-checkout у межах flow)
|
|
207
|
+
* @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
|
|
208
|
+
*/
|
|
209
|
+
function resolveChangedScope(cwd) {
|
|
210
|
+
let base = null
|
|
211
|
+
try {
|
|
212
|
+
const state = readState(flowStatePath(cwd))
|
|
213
|
+
base = state?.metadata?.base_commit ?? null
|
|
214
|
+
} catch {
|
|
215
|
+
// пошкоджений/несумісний стан — попереджаємо й падаємо на HEAD (working-tree scope).
|
|
216
|
+
console.error('coverage --changed: стан flow нечитабельний — scope визначається від HEAD робочого дерева')
|
|
217
|
+
base = null
|
|
218
|
+
}
|
|
219
|
+
return { base, files: collectChangedFilesSince(base, cwd) }
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
/**
|
|
198
223
|
* Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
|
|
199
224
|
* detect+collect для кожного, агрегація, запис COVERAGE.md.
|
|
200
225
|
* При `opts.fix === true` після запису COVERAGE.md запускає агента (coverage-fix.mjs)
|
|
201
226
|
* для написання тестів по вцілілих мутантах.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
227
|
+
* При `opts.changed === true` провайдери звужують scope до змінених від base файлів
|
|
228
|
+
* (для flow-турнікета). Порожній scope (нема релевантних змін) — це pass (exit 0)
|
|
229
|
+
* без перезапису наявного COVERAGE.md, а НЕ помилка «жодного провайдера».
|
|
230
|
+
* @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix — --fix режим; changed — scope лише змінених
|
|
231
|
+
* @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних у full-режимі)
|
|
204
232
|
*/
|
|
205
233
|
export async function runCoverageSteps(opts = {}) {
|
|
206
234
|
const cwd = opts.cwd ?? process.cwd()
|
|
207
235
|
const rulesDir = opts.rulesDir ?? RULES_DIR
|
|
208
236
|
const config = await readNCursorConfigLite(cwd)
|
|
237
|
+
const scope = opts.changed ? resolveChangedScope(cwd) : null
|
|
238
|
+
const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
|
|
209
239
|
const rows = []
|
|
210
240
|
|
|
211
241
|
for (const ruleId of config.rules) {
|
|
@@ -214,14 +244,27 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
214
244
|
if (!provider) continue
|
|
215
245
|
if (!(await provider.detect(cwd))) continue
|
|
216
246
|
console.log(`→ ${ruleId} coverage…`)
|
|
217
|
-
rows.push(...(await provider.collect(cwd)))
|
|
247
|
+
rows.push(...(await provider.collect(cwd, collectOpts)))
|
|
218
248
|
}
|
|
219
249
|
|
|
220
250
|
if (rows.length === 0) {
|
|
251
|
+
// --changed: порожній scope = «нема релевантних змін» → pass, не чіпаємо COVERAGE.md.
|
|
252
|
+
if (opts.changed) {
|
|
253
|
+
console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
221
256
|
console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
|
|
222
257
|
return 1
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
// --changed (турнікет): рішення гейту визначає лише exit-код — vitest/Stryker кинули б помилку
|
|
261
|
+
// під час collect, якби тести/прогін упали. Тут НЕ перезаписуємо повний COVERAGE.md частковим
|
|
262
|
+
// scoped-звітом і не ганяємо LLM-класифікацію (зайвий кошт у per-step циклі).
|
|
263
|
+
if (opts.changed) {
|
|
264
|
+
console.log('✓ coverage --changed: змінені файли перевірено')
|
|
265
|
+
return 0
|
|
266
|
+
}
|
|
267
|
+
|
|
225
268
|
// LLM-класифікація survived мутантів (graceful skip без API key)
|
|
226
269
|
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
227
270
|
let augmentedRows = rows
|
|
@@ -256,18 +299,19 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
256
299
|
}
|
|
257
300
|
|
|
258
301
|
/**
|
|
259
|
-
* CLI entrypoint для `n-cursor coverage [--fix]`.
|
|
302
|
+
* CLI entrypoint для `n-cursor coverage [--fix] [--changed]`.
|
|
260
303
|
* Із `--fix`: збирає метрики → запускає агента → повторно збирає метрики.
|
|
261
304
|
* Без `--fix`: лише збирає метрики.
|
|
305
|
+
* Із `--changed`: звужує scope до змінених від base файлів (flow-турнікет).
|
|
262
306
|
* Лок охоплює кожен coverage-прогін окремо.
|
|
263
|
-
* @param {{fix?:boolean}} [opts]
|
|
307
|
+
* @param {{fix?:boolean, changed?:boolean}} [opts] прапори --fix / --changed
|
|
264
308
|
* @returns {Promise<number>} exit code
|
|
265
309
|
*/
|
|
266
310
|
export async function runCoverageCli(opts = {}) {
|
|
267
311
|
const code = await withLock('coverage', () => runCoverageSteps(opts))
|
|
268
312
|
if (code === 0 && opts.fix) {
|
|
269
313
|
console.log('\n♻️ Повторний coverage після агента…\n')
|
|
270
|
-
return withLock('coverage', () => runCoverageSteps({ fix: false }))
|
|
314
|
+
return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed }))
|
|
271
315
|
}
|
|
272
316
|
return code
|
|
273
317
|
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -130,6 +130,8 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
|
|
|
130
130
|
|
|
131
131
|
Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`vitest run --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker з vitest-runner + `coverageAnalysis: 'perTest'`, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
|
|
132
132
|
|
|
133
|
+
**Scoped-режим `--changed`** (для flow-турнікета): `n-cursor coverage --changed` звужує scope до файлів, змінених від `base_commit` (зі стану flow `.flow.json`; поза flow — робоче дерево vs HEAD). `git diff <base>` проти робочого дерева ловить committed і uncommitted однаково, тож результат не залежить від того, чи крок уже закомічено. Недосяжний `base` (rebase/force-update) — fail-closed (помилка, не тихий pass). JS-провайдер ганяє `vitest --changed <base>` (лише зачеплені тести) і Stryker `--mutate` по змінених production-файлах (тест-файли відкидаються); roots без змінених JS пропускаються. Rust-провайдер пропускається, якщо не змінено `.rs`/`Cargo.*` (інакше — повний crate-прогін; per-file scoping cargo-mutants — окремий крок). Порожній scope (нема релевантних змін) — pass. У changed-режимі `COVERAGE.md` **не** перезаписується (рішення гейту — лише exit-код) і LLM-класифікація не запускається. `DEFAULT_GATES` турнікета викликає саме `coverage --changed`; повний coverage (увесь проєкт, запис `COVERAGE.md`) лишається для `bun run coverage` / `/n-coverage-fix`.
|
|
134
|
+
|
|
133
135
|
Провайдери живуть у `npm/rules/<rule>/coverage/coverage.mjs` (постачаються правилами мови/рантайму: `js-lint`, `rust`, у майбутньому `python` тощо). Оркестратор — у `npm/rules/test/coverage/coverage.mjs`.
|
|
134
136
|
|
|
135
137
|
У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
|
|
@@ -35,19 +35,52 @@ const USAGE = [
|
|
|
35
35
|
*/
|
|
36
36
|
export const DEFAULT_HANDLERS = { init, spec, plan, verify, review, gate, release, run, resume, cancel, repair }
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Витягує опційний `--branch <гілка>` з аргументів (для cwd-незалежного резолву
|
|
40
|
+
* стану — беклог #1). Повертає очищені аргументи й значення гілки.
|
|
41
|
+
* @param {string[]} args аргументи після підкоманди
|
|
42
|
+
* @returns {{ rest: string[], branch: string | undefined }} очищені аргументи + гілка
|
|
43
|
+
*/
|
|
44
|
+
export function extractBranchFlag(args) {
|
|
45
|
+
const rest = []
|
|
46
|
+
let branch
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
if (args[i] === '--branch') {
|
|
49
|
+
const val = args[i + 1]
|
|
50
|
+
// Поглинаємо наступний аргумент як значення лише якщо це справді значення,
|
|
51
|
+
// а не інший прапорець / кінець аргументів (інакше `--branch` був би no-op,
|
|
52
|
+
// що тихо ковтав би сусідній прапорець).
|
|
53
|
+
if (val !== undefined && !val.startsWith('-')) {
|
|
54
|
+
branch = val
|
|
55
|
+
i++
|
|
56
|
+
}
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const inline = args[i].startsWith('--branch=') ? args[i].slice('--branch='.length) : null
|
|
60
|
+
if (inline !== null) {
|
|
61
|
+
if (inline !== '') branch = inline
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
rest.push(args[i])
|
|
65
|
+
}
|
|
66
|
+
return { rest, branch }
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
|
|
40
71
|
* маршрутизує до handler-а. Невідома/відсутня підкоманда → usage + код 1.
|
|
72
|
+
* Опційний `--branch <гілка>` прокидається в `deps.branch` (резолв стану поза worktree).
|
|
41
73
|
* @param {string[]} args аргументи після `flow`
|
|
42
|
-
* @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number
|
|
74
|
+
* @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>>, branch?: string }} [deps] ін'єкція handler-ів (для тестів)
|
|
43
75
|
* @returns {Promise<number>} exit code
|
|
44
76
|
*/
|
|
45
77
|
export async function runFlowCli(args, deps = {}) {
|
|
46
|
-
const [sub, ...
|
|
78
|
+
const [sub, ...raw] = args
|
|
47
79
|
const handlers = deps.handlers ?? DEFAULT_HANDLERS
|
|
48
80
|
if (!sub || ! Object.hasOwn(handlers, sub)) {
|
|
49
81
|
console.error(USAGE)
|
|
50
82
|
return 1
|
|
51
83
|
}
|
|
52
|
-
|
|
84
|
+
const { rest, branch } = extractBranchFlag(raw)
|
|
85
|
+
return await handlers[sub](rest, { ...deps, branch: deps.branch ?? branch })
|
|
53
86
|
}
|
|
@@ -10,12 +10,15 @@ import { isAbsolute, join } from 'node:path'
|
|
|
10
10
|
import { cwd as processCwd } from 'node:process'
|
|
11
11
|
|
|
12
12
|
import { worktreePaths } from '../../lib/worktree.mjs'
|
|
13
|
+
import { collectChangedFilesSince } from '../../lib/changed-files.mjs'
|
|
13
14
|
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
15
|
+
import { getMonorepoProjectRootDirs } from '../../../rules/changelog/lib/package-manifest.mjs'
|
|
14
16
|
import { flowEventsPath } from './events.mjs'
|
|
15
17
|
import { detectLevel, detectRisk } from './level.mjs'
|
|
16
18
|
import { runReview } from './reviewer.mjs'
|
|
17
19
|
import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
|
|
18
20
|
import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
|
|
21
|
+
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Реальний sync-runner із захопленням виводу.
|
|
@@ -127,12 +130,34 @@ export async function init(rest, deps = {}) {
|
|
|
127
130
|
*/
|
|
128
131
|
export async function verify(_rest, deps = {}) {
|
|
129
132
|
const run = deps.run ?? realRun
|
|
130
|
-
const
|
|
133
|
+
const cwd0 = deps.cwd ?? processCwd()
|
|
131
134
|
const log = deps.log ?? console.error
|
|
132
|
-
const fingerprint = deps.fingerprint ?? (() => worktreeFingerprint())
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
// cwd-незалежний резолв активного flow. verify толерантний: без активного flow
|
|
137
|
+
// гейти все одно прогоняються (standalone) у поточному cwd, лише без запису стану.
|
|
138
|
+
const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
|
|
139
|
+
if (resolved.statePath && resolved.autoResolved) {
|
|
140
|
+
log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
141
|
+
}
|
|
142
|
+
// Явний `--branch`, що не резолвиться, — це помилка наміру: не деградуємо тихо
|
|
143
|
+
// на поточний cwd (інакше `flow verify --branch typo` міг би «зеленіти» в CI).
|
|
144
|
+
if (deps.branch && !resolved.statePath) {
|
|
145
|
+
log(`❌ verify: ${resolved.error}`)
|
|
146
|
+
return 1
|
|
147
|
+
}
|
|
148
|
+
const cwd = resolved.worktreeDir ?? cwd0
|
|
149
|
+
const fingerprint =
|
|
150
|
+
deps.fingerprint ?? (() => worktreeFingerprint((cmd, args, opts) => spawnSync(cmd, args, { ...opts, cwd })))
|
|
151
|
+
|
|
152
|
+
const statePath = resolved.statePath
|
|
153
|
+
const state = statePath ? readState(statePath) : null
|
|
154
|
+
if (!state) {
|
|
155
|
+
// statePath null → resolved.error пояснює (нема/кілька активних); statePath є,
|
|
156
|
+
// але стан не читається → пошкоджений .flow.json. В обох випадках verify
|
|
157
|
+
// толерантний: гейти прогоняються standalone у `cwd`, без запису стану.
|
|
158
|
+
if (resolved.error) log(`⚠️ verify: ${resolved.error}`)
|
|
159
|
+
log(`⚠️ verify: активного flow не визначено — гейти прогнано у ${cwd} без запису стану`)
|
|
160
|
+
}
|
|
136
161
|
// М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
|
|
137
162
|
if (state && !(state.plan?.length)) {
|
|
138
163
|
log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
|
|
@@ -162,6 +187,56 @@ export async function verify(_rest, deps = {}) {
|
|
|
162
187
|
return verdict.pass ? 0 : 1
|
|
163
188
|
}
|
|
164
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Які з subworkspace-тек мають змінені файли — для авто-`--ws` у `release`.
|
|
192
|
+
* Кожен файл відноситься до НАЙГЛИБШОГО воркспейсу-збігу, тож вкладені воркспейси
|
|
193
|
+
* (`apps` + `apps/web`) не дають хибного «кілька воркспейсів» для `apps/web/x`.
|
|
194
|
+
* @param {string[]} subWorkspaces теки воркспейсів без кореня (`.`)
|
|
195
|
+
* @param {string[]} changedFiles змінені шляхи відносно кореня репо (posix)
|
|
196
|
+
* @returns {string[]} підмножина `subWorkspaces`, під якими є зміни (у вхідному порядку)
|
|
197
|
+
*/
|
|
198
|
+
export function matchChangedWorkspaces(subWorkspaces, changedFiles) {
|
|
199
|
+
const byDepthDesc = subWorkspaces.toSorted((a, b) => b.length - a.length)
|
|
200
|
+
const hit = new Set()
|
|
201
|
+
for (const f of changedFiles) {
|
|
202
|
+
const ws = byDepthDesc.find(w => f === w || f.startsWith(`${w}/`))
|
|
203
|
+
if (ws) hit.add(ws)
|
|
204
|
+
}
|
|
205
|
+
return subWorkspaces.filter(w => hit.has(w))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Додає `--ws <шлях>` до аргументів `change`, інферячи воркспейс зі змін від
|
|
210
|
+
* `base_commit`, якщо `--ws` не задано явно. Один змінений subworkspace → авто-`--ws`;
|
|
211
|
+
* кілька → `{ error: true }` (fail-hard, exit 1 у release); нуль / без subworkspace →
|
|
212
|
+
* лишаємо як є (change дефолтиться на `.`). Помилку самого інференсу (недосяжний base,
|
|
213
|
+
* збій `listWorkspaces`) трактуємо fail-soft — не блокуємо, лишаємо дефолт.
|
|
214
|
+
* @param {{ rest: string[], baseCommit: string | null, cwd: string, listWorkspaces: (cwd: string) => Promise<string[]>, changedFilesSince: (base: string | null, cwd: string) => string[], log: (m: string) => void }} input ін'єкції
|
|
215
|
+
* @returns {Promise<{ args: string[], error?: boolean }>} аргументи для `change` або `{ error: true }`
|
|
216
|
+
*/
|
|
217
|
+
async function resolveChangeWsArgs({ rest, baseCommit, cwd, listWorkspaces, changedFilesSince, log }) {
|
|
218
|
+
// Поважаємо явно заданий воркспейс в обох формах (`--ws x` і `--ws=x`).
|
|
219
|
+
if (rest.includes('--ws') || rest.some(a => a.startsWith('--ws='))) return { args: rest }
|
|
220
|
+
try {
|
|
221
|
+
const workspaces = await listWorkspaces(cwd)
|
|
222
|
+
const subWs = workspaces.filter(w => w !== '.')
|
|
223
|
+
if (subWs.length === 0) return { args: rest }
|
|
224
|
+
const hits = matchChangedWorkspaces(subWs, changedFilesSince(baseCommit, cwd))
|
|
225
|
+
if (hits.length > 1) {
|
|
226
|
+
log(`release: зміни у кількох воркспейсах (${hits.join(', ')}) — вкажи --ws явно`)
|
|
227
|
+
return { args: rest, error: true }
|
|
228
|
+
}
|
|
229
|
+
if (hits.length === 1) {
|
|
230
|
+
log(`release: change → воркспейс «${hits[0]}» (інферено з diff від base)`)
|
|
231
|
+
return { args: [...rest, '--ws', hits[0]] }
|
|
232
|
+
}
|
|
233
|
+
return { args: rest }
|
|
234
|
+
} catch (error) {
|
|
235
|
+
log(`⚠️ release: інференс воркспейсу пропущено (${error instanceof Error ? error.message : String(error)})`)
|
|
236
|
+
return { args: rest }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
165
240
|
/**
|
|
166
241
|
* `flow release [--bump … --section … --message …]` — генерує `.changes` і пише
|
|
167
242
|
* completion snapshot (§3 Ф5, §7). Потребує наявного стану (`init`).
|
|
@@ -175,7 +250,14 @@ export async function release(rest, deps = {}) {
|
|
|
175
250
|
const log = deps.log ?? console.error
|
|
176
251
|
const now = deps.now ?? Date.now
|
|
177
252
|
|
|
178
|
-
const
|
|
253
|
+
const resolved = resolveActiveFlowState({ cwd, branch: deps.branch }, deps)
|
|
254
|
+
if (!resolved.statePath) {
|
|
255
|
+
log(`release: ${resolved.error}`)
|
|
256
|
+
return 1
|
|
257
|
+
}
|
|
258
|
+
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
259
|
+
const effectiveCwd = resolved.worktreeDir ?? cwd
|
|
260
|
+
const statePath = resolved.statePath
|
|
179
261
|
const state = readState(statePath)
|
|
180
262
|
if (!state) {
|
|
181
263
|
log('release: стану нема — спершу `flow init`')
|
|
@@ -186,7 +268,17 @@ export async function release(rest, deps = {}) {
|
|
|
186
268
|
log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
|
|
187
269
|
}
|
|
188
270
|
|
|
189
|
-
const
|
|
271
|
+
const wsResolved = await resolveChangeWsArgs({
|
|
272
|
+
rest,
|
|
273
|
+
baseCommit: state.metadata?.base_commit ?? null,
|
|
274
|
+
cwd: effectiveCwd,
|
|
275
|
+
listWorkspaces: deps.listWorkspaces ?? getMonorepoProjectRootDirs,
|
|
276
|
+
changedFilesSince: deps.changedFilesSince ?? collectChangedFilesSince,
|
|
277
|
+
log
|
|
278
|
+
})
|
|
279
|
+
if (wsResolved.error) return 1
|
|
280
|
+
|
|
281
|
+
const ch = run('npx', ['@nitra/cursor', 'change', ...wsResolved.args], { cwd: effectiveCwd })
|
|
190
282
|
if ((ch.status ?? 1) !== 0) {
|
|
191
283
|
const detail = ch.stderr ? `: ${ch.stderr.trim()}` : ''
|
|
192
284
|
log(`release: change не вдався${detail}`)
|
|
@@ -195,13 +287,13 @@ export async function release(rest, deps = {}) {
|
|
|
195
287
|
|
|
196
288
|
const snapshot = buildCompletionSnapshot({ ...state, status: 'done' }, now)
|
|
197
289
|
recordTransition(
|
|
198
|
-
{ statePath, eventsPath: flowEventsPath(
|
|
290
|
+
{ statePath, eventsPath: flowEventsPath(effectiveCwd) },
|
|
199
291
|
{ type: 'release' },
|
|
200
292
|
state_ => ({ ...state_, status: 'done', completion: snapshot }),
|
|
201
293
|
now
|
|
202
294
|
)
|
|
203
295
|
if (state.task) {
|
|
204
|
-
writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(
|
|
296
|
+
writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(effectiveCwd, state.task), snapshot)
|
|
205
297
|
}
|
|
206
298
|
log('release: done')
|
|
207
299
|
return 0
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cwd-незалежний резолвер активного flow (беклог адаптації #1).
|
|
3
|
+
*
|
|
4
|
+
* Команди `spec/plan/verify/review/gate/release` мають знаходити `.flow.json`
|
|
5
|
+
* поточної задачі навіть коли їх запущено НЕ з кореня worktree (напр. з головного
|
|
6
|
+
* дерева чи з підтеки worktree) — інакше `flowStatePath(cwd)` обчислює хибний шлях
|
|
7
|
+
* і видає «стану нема», хоча flow активний.
|
|
8
|
+
*
|
|
9
|
+
* Порядок (spec 2026-06-01-flow-cwd-state-resolution):
|
|
10
|
+
* 1. явний `branch` → `.worktrees/<sanitizeBranch>.flow.json`;
|
|
11
|
+
* 2. toplevel-резолвинг: `git rev-parse --show-toplevel` від `cwd`; якщо toplevel
|
|
12
|
+
* лежить безпосередньо під `<repoRoot>/.worktrees/` і для нього є стан — беремо;
|
|
13
|
+
* 3. скан `<repoRoot>/.worktrees/*.flow.json` зі `status: in_progress`: рівно один →
|
|
14
|
+
* авторезолв; кілька → помилка зі списком; нуль → «стану нема».
|
|
15
|
+
*
|
|
16
|
+
* Резолвер не пише на диск. `git`/FS ін'єктуються — тестується без репозиторію.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
19
|
+
import { spawnSync } from 'node:child_process'
|
|
20
|
+
import { basename, dirname, join } from 'node:path'
|
|
21
|
+
import { cwd as processCwd } from 'node:process'
|
|
22
|
+
|
|
23
|
+
import { sanitizeBranch, worktreePaths } from '../../lib/worktree.mjs'
|
|
24
|
+
import { flowStatePath, readState as defaultReadState } from './state-store.mjs'
|
|
25
|
+
|
|
26
|
+
const FLOW_STATE_SUFFIX = '.flow.json'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Реальний sync git-runner у заданому `cwd`.
|
|
30
|
+
* @param {string[]} args аргументи git
|
|
31
|
+
* @param {string} cwd робочий каталог
|
|
32
|
+
* @returns {{ status: number, stdout: string }} результат
|
|
33
|
+
*/
|
|
34
|
+
function realGit(args, cwd) {
|
|
35
|
+
const r = spawnSync('git', args, { encoding: 'utf8', cwd })
|
|
36
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Корінь головного worktree через `git worktree list --porcelain` (перший запис).
|
|
41
|
+
* @param {(args: string[]) => { status: number, stdout: string }} git git-runner
|
|
42
|
+
* @returns {string | null} абсолютний шлях кореня репо або `null`
|
|
43
|
+
*/
|
|
44
|
+
function mainRepoRoot(git) {
|
|
45
|
+
const r = git(['worktree', 'list', '--porcelain'])
|
|
46
|
+
if ((r.status ?? 1) !== 0) return null
|
|
47
|
+
const line = r.stdout.split('\n').find(l => l.startsWith('worktree '))
|
|
48
|
+
const root = line ? line.slice('worktree '.length).trim() : ''
|
|
49
|
+
return root.length > 0 ? root : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Корінь поточного worktree (`git rev-parse --show-toplevel`).
|
|
54
|
+
* @param {(args: string[]) => { status: number, stdout: string }} git git-runner
|
|
55
|
+
* @returns {string | null} абсолютний шлях або `null`
|
|
56
|
+
*/
|
|
57
|
+
function currentToplevel(git) {
|
|
58
|
+
const r = git(['rev-parse', '--show-toplevel'])
|
|
59
|
+
return (r.status ?? 1) === 0 && r.stdout.trim().length > 0 ? r.stdout.trim() : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} ResolvedFlow
|
|
64
|
+
* @property {string | null} statePath абсолютний шлях `.flow.json` або `null`
|
|
65
|
+
* @property {string | null} worktreeDir тека worktree (ефективний cwd для гейтів) або `null`
|
|
66
|
+
* @property {string | null} label мітка flow (sanitized branch) або `null`
|
|
67
|
+
* @property {boolean} autoResolved `true`, якщо знайдено скануванням (cwd поза worktree)
|
|
68
|
+
* @property {string | null} error повідомлення для логу, якщо `statePath === null`
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Резолвить активний flow незалежно від `cwd`.
|
|
73
|
+
* @param {{ cwd?: string, branch?: string }} [params] параметри
|
|
74
|
+
* @param {{ git?: (args: string[]) => { status: number, stdout: string }, exists?: (p: string) => boolean, readState?: (p: string) => object | null, readdir?: (d: string) => string[], repoRoot?: string }} [deps] ін'єкції
|
|
75
|
+
* @returns {ResolvedFlow} результат
|
|
76
|
+
*/
|
|
77
|
+
export function resolveActiveFlowState({ cwd = processCwd(), branch } = {}, deps = {}) {
|
|
78
|
+
const git = deps.git ?? (args => realGit(args, cwd))
|
|
79
|
+
const exists = deps.exists ?? existsSync
|
|
80
|
+
const readState = deps.readState ?? defaultReadState
|
|
81
|
+
const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
|
|
82
|
+
|
|
83
|
+
const resolveRoot = () => deps.repoRoot ?? mainRepoRoot(git)
|
|
84
|
+
|
|
85
|
+
// 1. Явний --branch завжди перемагає. Валідуємо існування теки worktree, щоб
|
|
86
|
+
// команда не пішла виконувати гейти в неіснуючому каталозі (ENOENT).
|
|
87
|
+
if (branch) {
|
|
88
|
+
const repoRoot = resolveRoot()
|
|
89
|
+
if (!repoRoot) return notFound('стану нема — спершу `flow init`')
|
|
90
|
+
const label = sanitizeBranch(branch)
|
|
91
|
+
const worktreeDir = worktreePaths(repoRoot, branch).checkout
|
|
92
|
+
if (!exists(worktreeDir)) {
|
|
93
|
+
return notFound(`worktree для гілки «${branch}» не знайдено (${worktreeDir}) — перевір назву або зроби \`flow init\``)
|
|
94
|
+
}
|
|
95
|
+
return { statePath: flowStatePath(worktreeDir), worktreeDir, label, autoResolved: false, error: null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Швидкий шлях без git: `cwd` уже є текою worktree зі станом-sibling
|
|
99
|
+
// (звичайний запуск із кореня worktree).
|
|
100
|
+
const direct = flowStatePath(cwd)
|
|
101
|
+
if (exists(direct)) {
|
|
102
|
+
return { statePath: direct, worktreeDir: cwd, label: basename(cwd), autoResolved: false, error: null }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Далі потрібен корінь репо (git). Якщо недоступний — трактуємо як «стану нема».
|
|
106
|
+
const repoRoot = resolveRoot()
|
|
107
|
+
if (!repoRoot) return notFound('стану нема — спершу `flow init`')
|
|
108
|
+
const worktreesDir = join(repoRoot, '.worktrees')
|
|
109
|
+
|
|
110
|
+
// 3. Якщо ми ВСЕРЕДИНІ worktree (toplevel під .worktrees/, у т.ч. з підтеки) —
|
|
111
|
+
// беремо стан саме цього worktree. Якщо його нема — це проблема цього worktree
|
|
112
|
+
// (`flow init` не зроблено); чужий активний flow НЕ підтягуємо.
|
|
113
|
+
const top = currentToplevel(git)
|
|
114
|
+
if (top && dirname(top) === worktreesDir) {
|
|
115
|
+
const statePath = flowStatePath(top)
|
|
116
|
+
if (exists(statePath)) {
|
|
117
|
+
return { statePath, worktreeDir: top, label: basename(top), autoResolved: false, error: null }
|
|
118
|
+
}
|
|
119
|
+
return notFound('стану нема — спершу `flow init`')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Поза worktree (головне дерево) — скан активних flow.
|
|
123
|
+
const active = []
|
|
124
|
+
for (const name of readdir(worktreesDir)) {
|
|
125
|
+
if (!name.endsWith(FLOW_STATE_SUFFIX)) continue
|
|
126
|
+
const statePath = join(worktreesDir, name)
|
|
127
|
+
let state
|
|
128
|
+
try {
|
|
129
|
+
state = readState(statePath)
|
|
130
|
+
} catch {
|
|
131
|
+
continue // пошкоджений стан — пропускаємо при скануванні
|
|
132
|
+
}
|
|
133
|
+
if (state?.status === 'in_progress') {
|
|
134
|
+
const label = name.slice(0, -FLOW_STATE_SUFFIX.length)
|
|
135
|
+
active.push({ statePath, worktreeDir: join(worktreesDir, label), label })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (active.length === 1) {
|
|
139
|
+
return { ...active[0], autoResolved: true, error: null }
|
|
140
|
+
}
|
|
141
|
+
if (active.length > 1) {
|
|
142
|
+
const list = active.map(a => ` - ${a.label}`).join('\n')
|
|
143
|
+
return notFound(`кілька активних flow — уточни \`--branch <гілка>\` або \`cd\` у потрібний worktree:\n${list}`)
|
|
144
|
+
}
|
|
145
|
+
return notFound('стану нема — спершу `flow init`')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} error повідомлення
|
|
150
|
+
* @returns {ResolvedFlow} результат без statePath
|
|
151
|
+
*/
|
|
152
|
+
function notFound(error) {
|
|
153
|
+
return { statePath: null, worktreeDir: null, label: null, autoResolved: false, error }
|
|
154
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { cwd as processCwd } from 'node:process'
|
|
11
11
|
|
|
12
12
|
import { flowEventsPath } from './events.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import { readState, recordTransition } from './state-store.mjs'
|
|
14
|
+
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
14
15
|
|
|
15
16
|
/** Штрафи score за кожен тип проблеми. */
|
|
16
17
|
const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
|
|
@@ -58,11 +59,18 @@ export function computeGate(state) {
|
|
|
58
59
|
* @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
|
|
59
60
|
*/
|
|
60
61
|
export async function gate(_rest, deps = {}) {
|
|
61
|
-
const
|
|
62
|
+
const cwd0 = deps.cwd ?? processCwd()
|
|
62
63
|
const log = deps.log ?? console.error
|
|
63
64
|
const now = deps.now ?? Date.now
|
|
64
65
|
|
|
65
|
-
const
|
|
66
|
+
const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
|
|
67
|
+
if (!resolved.statePath) {
|
|
68
|
+
log(`gate: ${resolved.error}`)
|
|
69
|
+
return 1
|
|
70
|
+
}
|
|
71
|
+
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
72
|
+
const cwd = resolved.worktreeDir ?? cwd0
|
|
73
|
+
const statePath = resolved.statePath
|
|
66
74
|
const state = readState(statePath)
|
|
67
75
|
if (!state) {
|
|
68
76
|
log('gate: стану нема — спершу `flow init`')
|
|
@@ -15,7 +15,8 @@ import { flowEventsPath } from './events.mjs'
|
|
|
15
15
|
import { parsePlan } from './planner.mjs'
|
|
16
16
|
import { runPanel } from './plan-panel.mjs'
|
|
17
17
|
import { createRunner } from './subagent-runner.mjs'
|
|
18
|
-
import {
|
|
18
|
+
import { readState, recordTransition } from './state-store.mjs'
|
|
19
|
+
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* @param {string[]} rest аргументи (`--panel`, опц. `<plan.md>`)
|
|
@@ -23,9 +24,16 @@ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
|
|
|
23
24
|
* @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку/невалідний план)
|
|
24
25
|
*/
|
|
25
26
|
export async function plan(rest, deps = {}) {
|
|
26
|
-
const
|
|
27
|
+
const cwd0 = deps.cwd ?? processCwd()
|
|
27
28
|
const log = deps.log ?? console.error
|
|
28
|
-
const
|
|
29
|
+
const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
|
|
30
|
+
if (!resolved.statePath) {
|
|
31
|
+
log(`plan: ${resolved.error}`)
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
35
|
+
const cwd = resolved.worktreeDir ?? cwd0
|
|
36
|
+
const statePath = resolved.statePath
|
|
29
37
|
const state = readState(statePath)
|
|
30
38
|
if (!state) {
|
|
31
39
|
log('plan: стану нема — спершу `flow init`')
|
|
@@ -12,7 +12,8 @@ import { cwd as processCwd } from 'node:process'
|
|
|
12
12
|
import { realRun } from './commands.mjs'
|
|
13
13
|
import { flowEventsPath } from './events.mjs'
|
|
14
14
|
import { reviewersFor } from './level.mjs'
|
|
15
|
-
import {
|
|
15
|
+
import { readState, recordTransition } from './state-store.mjs'
|
|
16
|
+
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
16
17
|
import { createRunner } from './subagent-runner.mjs'
|
|
17
18
|
|
|
18
19
|
/** Ліміт diff у промпті (символів) — щоб не роздувати контекст рецензента. */
|
|
@@ -108,12 +109,19 @@ function severityIcon(severity) {
|
|
|
108
109
|
* @returns {Promise<number>} exit code (0 завжди — інформативна; 1 лише якщо нема стану/runner)
|
|
109
110
|
*/
|
|
110
111
|
export async function review(_rest, deps = {}) {
|
|
111
|
-
const
|
|
112
|
+
const cwd0 = deps.cwd ?? processCwd()
|
|
112
113
|
const log = deps.log ?? console.error
|
|
113
114
|
const run = deps.run ?? realRun
|
|
114
115
|
const now = deps.now ?? Date.now
|
|
115
116
|
|
|
116
|
-
const
|
|
117
|
+
const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
|
|
118
|
+
if (!resolved.statePath) {
|
|
119
|
+
log(`review: ${resolved.error}`)
|
|
120
|
+
return 1
|
|
121
|
+
}
|
|
122
|
+
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
123
|
+
const cwd = resolved.worktreeDir ?? cwd0
|
|
124
|
+
const statePath = resolved.statePath
|
|
117
125
|
const state = readState(statePath)
|
|
118
126
|
if (!state) {
|
|
119
127
|
log('review: стану нема — спершу `flow init`')
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Канонічні gate-и verify (lint + coverage; coverage включає тести+мутації).
|
|
15
|
+
* Обидва — scoped до змінених файлів: `lint` через quick-режим (`changed-files.mjs`),
|
|
16
|
+
* `coverage --changed` через vitest `--changed`/Stryker `--mutate` по diff від base.
|
|
17
|
+
* Турнікет (`flow verify`/per-step) перевіряє лише змінене; повний coverage —
|
|
18
|
+
* окремо (`bun run coverage`, `/n-coverage-fix`).
|
|
18
19
|
*/
|
|
19
|
-
export const DEFAULT_GATES = [
|
|
20
|
+
export const DEFAULT_GATES = [
|
|
21
|
+
{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
|
|
22
|
+
{ name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage', '--changed'] }
|
|
23
|
+
]
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Проганяє gate-и й повертає verdict.
|
|
@@ -14,7 +14,8 @@ import { resolveArtifact, verifyTrace } from './artifact.mjs'
|
|
|
14
14
|
import { flowEventsPath } from './events.mjs'
|
|
15
15
|
import { runPanel } from './plan-panel.mjs'
|
|
16
16
|
import { createRunner } from './subagent-runner.mjs'
|
|
17
|
-
import {
|
|
17
|
+
import { readState, recordTransition } from './state-store.mjs'
|
|
18
|
+
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
18
19
|
import { parseFrontMatter } from '../trace.mjs'
|
|
19
20
|
|
|
20
21
|
/** Допустимі значення ризику у spec-frontmatter. */
|
|
@@ -42,9 +43,16 @@ function riskFromSpec(doc, current) {
|
|
|
42
43
|
* @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку)
|
|
43
44
|
*/
|
|
44
45
|
export async function spec(rest, deps = {}) {
|
|
45
|
-
const
|
|
46
|
+
const cwd0 = deps.cwd ?? processCwd()
|
|
46
47
|
const log = deps.log ?? console.error
|
|
47
|
-
const
|
|
48
|
+
const resolved = resolveActiveFlowState({ cwd: cwd0, branch: deps.branch }, deps)
|
|
49
|
+
if (!resolved.statePath) {
|
|
50
|
+
log(`spec: ${resolved.error}`)
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
54
|
+
const cwd = resolved.worktreeDir ?? cwd0
|
|
55
|
+
const statePath = resolved.statePath
|
|
48
56
|
const state = readState(statePath)
|
|
49
57
|
if (!state) {
|
|
50
58
|
log('spec: стану нема — спершу `flow init`')
|