@nitra/cursor 3.28.0 → 4.0.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 +13 -0
- package/package.json +1 -3
- package/scripts/coverage-classify/index.mjs +60 -72
- package/scripts/coverage-fix.mjs +26 -23
- package/scripts/dispatcher/index.mjs +20 -61
- package/scripts/dispatcher/lib/flow-plan.mjs +153 -0
- package/scripts/dispatcher/lib/flow-signals.mjs +235 -0
- package/scripts/dispatcher/lib/flow-verify.mjs +127 -0
- package/scripts/dispatcher/lib/subagent-runner.mjs +33 -102
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +54 -178
- package/skills/fix/js/llm-worker.mjs +4 -4
- package/scripts/dispatcher/lib/active.mjs +0 -222
- package/scripts/dispatcher/lib/artifact.mjs +0 -79
- package/scripts/dispatcher/lib/budget.mjs +0 -36
- package/scripts/dispatcher/lib/capability.mjs +0 -59
- package/scripts/dispatcher/lib/commands.mjs +0 -296
- package/scripts/dispatcher/lib/flow-lock.mjs +0 -39
- package/scripts/dispatcher/lib/gate.mjs +0 -91
- package/scripts/dispatcher/lib/level.mjs +0 -135
- package/scripts/dispatcher/lib/plan.mjs +0 -88
- package/scripts/dispatcher/lib/planner.mjs +0 -73
- package/scripts/dispatcher/lib/review.mjs +0 -176
- package/scripts/dispatcher/lib/reviewer.mjs +0 -44
- package/scripts/dispatcher/lib/snapshot.mjs +0 -58
- package/scripts/dispatcher/lib/spec.mjs +0 -97
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Budget guard для автономного режиму (spec §9.4): обгортає SubagentRunner
|
|
3
|
-
* лічильником викликів і кидає `BudgetExceeded` при перевищенні `maxApiCalls`.
|
|
4
|
-
* Це запобіжник проти неконтрольованих витрат на сервері (де нема людини).
|
|
5
|
-
*
|
|
6
|
-
* (`maxCostUsd` — коли runner повертатиме tokens/cost; наразі рахуємо виклики.)
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Помилка перевищення бюджету (ловиться в `run`, §9.4). */
|
|
10
|
-
export class BudgetExceeded extends Error {}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Обгортає runner лічильником API-викликів.
|
|
14
|
-
* @param {{ backend?: string, runStep: (prompt: string, opts?: object) => object }} runner базовий runner
|
|
15
|
-
* @param {{ maxApiCalls?: number, log?: (m: string) => void }} [opts] ліміт і лог
|
|
16
|
-
* @returns {{ backend: string, runStep: (prompt: string, opts?: object) => Promise<object>, readonly calls: number }} обгорнутий runner
|
|
17
|
-
*/
|
|
18
|
-
export function withBudget(runner, opts = {}) {
|
|
19
|
-
const maxApiCalls = opts.maxApiCalls ?? Number.POSITIVE_INFINITY
|
|
20
|
-
const log = opts.log ?? (() => {})
|
|
21
|
-
let calls = 0
|
|
22
|
-
return {
|
|
23
|
-
backend: runner.backend,
|
|
24
|
-
get calls() {
|
|
25
|
-
return calls
|
|
26
|
-
},
|
|
27
|
-
async runStep(prompt, stepOpts) {
|
|
28
|
-
if (calls >= maxApiCalls) {
|
|
29
|
-
throw new BudgetExceeded(`budget: вичерпано maxApiCalls=${maxApiCalls}`)
|
|
30
|
-
}
|
|
31
|
-
calls += 1
|
|
32
|
-
log(`budget: API-виклик ${calls}/${maxApiCalls}`)
|
|
33
|
-
return runner.runStep(prompt, stepOpts)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capability Router — резолвер режиму оркестрації (`native` vs `polyfill`)
|
|
3
|
-
* за **явною декларацією моделі** (spec §2.2).
|
|
4
|
-
*
|
|
5
|
-
* Рантайм-детекції моделі в кодобазі немає — тому модель НЕ вгадуємо, а
|
|
6
|
-
* оголошуємо за пріоритетом: CLI `--model` > env `N_CURSOR_FLOW_MODEL` >
|
|
7
|
-
* config `flow.model`. Default-режим (`polyfill`) дозволений ЛИШЕ за наявного
|
|
8
|
-
* `SubagentRunner` (§15.1); інакше — fail (caller кидає помилку), бо polyfill
|
|
9
|
-
* без runner-а не «працює з будь-якою моделлю».
|
|
10
|
-
*
|
|
11
|
-
* Усі функції чисті (без I/O) — джерела (`args`/`env`/`config`/`matrix`/
|
|
12
|
-
* `hasRunner`) передаються ззовні, що робить модуль тривіально тестованим.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const DEFAULT_ORCHESTRATION = 'polyfill'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Витягує значення `--model <value>` з argv. Не мутує вхід.
|
|
19
|
-
* @param {string[]} args аргументи підкоманди flow
|
|
20
|
-
* @returns {string | null} оголошена модель або null
|
|
21
|
-
*/
|
|
22
|
-
export function parseModelFlag(args) {
|
|
23
|
-
const i = args.indexOf('--model')
|
|
24
|
-
return i !== -1 && i + 1 < args.length ? args[i + 1] : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Оголошена модель за пріоритетом CLI > env > config.
|
|
29
|
-
* @param {{ cliModel?: string | null, envModel?: string | null, configModel?: string | null }} sources джерела декларації
|
|
30
|
-
* @returns {string | null} модель або null, якщо ніде не оголошено
|
|
31
|
-
*/
|
|
32
|
-
export function declaredModel({ cliModel = null, envModel = null, configModel = null } = {}) {
|
|
33
|
-
return cliModel || envModel || configModel || null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Режим оркестрації для оголошеної моделі за `capability-matrix`.
|
|
38
|
-
* Невідома/неоголошена модель → `matrix.default` → `DEFAULT_ORCHESTRATION`.
|
|
39
|
-
* @param {string | null} model оголошена модель
|
|
40
|
-
* @param {{ models?: Record<string, { orchestration?: string }>, default?: { orchestration?: string } }} matrix матриця можливостей
|
|
41
|
-
* @returns {'native' | 'polyfill'} режим
|
|
42
|
-
*/
|
|
43
|
-
export function orchestrationFor(model, matrix) {
|
|
44
|
-
const entry = model && matrix && matrix.models ? matrix.models[model] : null
|
|
45
|
-
return (
|
|
46
|
-
(entry && entry.orchestration) ||
|
|
47
|
-
(matrix && matrix.default && matrix.default.orchestration) ||
|
|
48
|
-
DEFAULT_ORCHESTRATION
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Чи стартує polyfill: потрібен доступний `SubagentRunner`.
|
|
54
|
-
* @param {{ hasRunner: boolean }} ctx контекст середовища
|
|
55
|
-
* @returns {boolean} true, якщо runner у наявності
|
|
56
|
-
*/
|
|
57
|
-
export function polyfillStartable({ hasRunner }) {
|
|
58
|
-
return hasRunner === true
|
|
59
|
-
}
|
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Handler-и підкоманд `flow` (spec §8). Уся IO — через ін'єктовані `run`/`log`/
|
|
3
|
-
* `fingerprint`/`now`, щоб логіку тестувати без реальних процесів.
|
|
4
|
-
*
|
|
5
|
-
* Ф2 (Пасивний Турнікет): `init` (worktree + стан), `verify` (Суддя), `release`
|
|
6
|
-
* (change + completion snapshot). `run`/`resume`/`cancel`/`repair` — Ф4.
|
|
7
|
-
*/
|
|
8
|
-
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { isAbsolute, join } from 'node:path'
|
|
10
|
-
import { cwd as processCwd } from 'node:process'
|
|
11
|
-
|
|
12
|
-
import { worktreePaths } from '../../lib/worktree.mjs'
|
|
13
|
-
import { collectChangedFilesSince } from '../../lib/changed-files.mjs'
|
|
14
|
-
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
15
|
-
import { getMonorepoProjectRootDirs } from '../../../rules/changelog/lib/package-manifest.mjs'
|
|
16
|
-
import { flowEventsPath } from './events.mjs'
|
|
17
|
-
import { detectLevel, detectRisk } from './level.mjs'
|
|
18
|
-
import { runReview } from './reviewer.mjs'
|
|
19
|
-
import { buildCompletionSnapshot, writeSummaryToTaskRecord } from './snapshot.mjs'
|
|
20
|
-
import { flowStatePath, readState, recordTransition, writeState } from './state-store.mjs'
|
|
21
|
-
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Реальний sync-runner із захопленням виводу.
|
|
25
|
-
* @param {string} cmd виконуваний
|
|
26
|
-
* @param {string[]} args аргументи
|
|
27
|
-
* @param {object} [opts] додаткові опції spawnSync (напр. `cwd`)
|
|
28
|
-
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
29
|
-
*/
|
|
30
|
-
export function realRun(cmd, args, opts = {}) {
|
|
31
|
-
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
32
|
-
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Чи `cwd` — уже linked worktree (не основний checkout і не submodule).
|
|
37
|
-
* @param {(cmd: string, args: string[], opts: object) => { status: number, stdout: string }} run runner
|
|
38
|
-
* @param {string} cwd робочий каталог
|
|
39
|
-
* @returns {boolean} true, якщо вже в worktree
|
|
40
|
-
*/
|
|
41
|
-
function inLinkedWorktree(run, cwd) {
|
|
42
|
-
const gitDir = run('git', ['rev-parse', '--git-dir'], { cwd })
|
|
43
|
-
const gitCommon = run('git', ['rev-parse', '--git-common-dir'], { cwd })
|
|
44
|
-
if ((gitDir.status ?? 1) !== 0 || (gitCommon.status ?? 1) !== 0) return false
|
|
45
|
-
const superproject = run('git', ['rev-parse', '--show-superproject-working-tree'], { cwd })
|
|
46
|
-
const isSubmodule = (superproject.stdout ?? '').trim() !== ''
|
|
47
|
-
return !isSubmodule && (gitDir.stdout ?? '').trim() !== (gitCommon.stdout ?? '').trim()
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1). Якщо вже
|
|
52
|
-
* в worktree — не вкладає новий (detect existing isolation).
|
|
53
|
-
* @param {string[]} rest аргументи: `<branch> <опис...>`
|
|
54
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
55
|
-
* @returns {Promise<number>} exit code
|
|
56
|
-
*/
|
|
57
|
-
/**
|
|
58
|
-
* Гарантує worktree для задачі: парсить `<branch> <опис>`, детектить існуючу
|
|
59
|
-
* ізоляцію (§8.1) або створює новий worktree, читає `base_commit`. Спільне для
|
|
60
|
-
* `init` (Фасад A) і `run` (Фасад B).
|
|
61
|
-
* @param {string[]} rest аргументи `<branch> <опис...>`
|
|
62
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
|
|
63
|
-
* @returns {{ code: number, worktreeDir?: string, branch?: string, desc?: string, baseCommit?: string | null }} результат
|
|
64
|
-
*/
|
|
65
|
-
export function ensureWorktree(rest, deps = {}) {
|
|
66
|
-
const run = deps.run ?? realRun
|
|
67
|
-
const cwd = deps.cwd ?? processCwd()
|
|
68
|
-
const log = deps.log ?? console.error
|
|
69
|
-
|
|
70
|
-
const branch = rest[0]
|
|
71
|
-
const desc = rest.slice(1).join(' ').trim()
|
|
72
|
-
if (!branch || !desc) {
|
|
73
|
-
log('Usage: n-cursor flow <init|run> <branch> "<опис>"')
|
|
74
|
-
return { code: 1 }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let worktreeDir
|
|
78
|
-
if (inLinkedWorktree(run, cwd)) {
|
|
79
|
-
worktreeDir = cwd
|
|
80
|
-
log(`flow: уже в worktree (${cwd}) — не вкладаю новий`)
|
|
81
|
-
} else {
|
|
82
|
-
const add = run('npx', ['@nitra/cursor', 'worktree', 'add', branch, desc], { cwd })
|
|
83
|
-
if ((add.status ?? 1) !== 0) {
|
|
84
|
-
const detail = add.stderr ? `: ${add.stderr.trim()}` : ''
|
|
85
|
-
log(`flow: worktree add не вдався${detail}`)
|
|
86
|
-
return { code: 1 }
|
|
87
|
-
}
|
|
88
|
-
worktreeDir = worktreePaths(cwd, branch).checkout
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const head = run('git', ['rev-parse', 'HEAD'], { cwd: worktreeDir })
|
|
92
|
-
const baseCommit = (head.status ?? 1) === 0 ? (head.stdout ?? '').trim() : null
|
|
93
|
-
return { code: 0, worktreeDir, branch, desc, baseCommit }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* `flow init <branch> "<опис>"` — ізоляція + ініціалізація стану (§8.1).
|
|
98
|
-
* @param {string[]} rest аргументи `<branch> <опис...>`
|
|
99
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
100
|
-
* @returns {Promise<number>} exit code
|
|
101
|
-
*/
|
|
102
|
-
export async function init(rest, deps = {}) {
|
|
103
|
-
const ew = ensureWorktree(rest, deps)
|
|
104
|
-
if (ew.code !== 0) return ew.code
|
|
105
|
-
const now = deps.now ?? Date.now
|
|
106
|
-
const log = deps.log ?? console.error
|
|
107
|
-
const statePath = flowStatePath(ew.worktreeDir)
|
|
108
|
-
const level = detectLevel(ew.desc)
|
|
109
|
-
const risk = detectRisk(ew.desc)
|
|
110
|
-
writeState(statePath, {
|
|
111
|
-
branch: ew.branch,
|
|
112
|
-
status: 'in_progress',
|
|
113
|
-
started_at: new Date(now()).toISOString(),
|
|
114
|
-
metadata: { base_commit: ew.baseCommit },
|
|
115
|
-
level,
|
|
116
|
-
risk,
|
|
117
|
-
plan: []
|
|
118
|
-
})
|
|
119
|
-
log(`init: ${ew.branch} (level ${level}, risk ${risk}) → ${statePath}`)
|
|
120
|
-
return 0
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* `flow verify` — проганяє Quality Gates у поточному worktree (Турнікет, §8.1).
|
|
125
|
-
* На фейл друкує вивід проваленого gate. Якщо поряд є стан — записує
|
|
126
|
-
* gate-результати + fingerprint. Read-only щодо коду.
|
|
127
|
-
* @param {string[]} _rest аргументи (не використовуються)
|
|
128
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, fingerprint?: () => string | null }} [deps] ін'єкції
|
|
129
|
-
* @returns {Promise<number>} exit code (0 — pass, 1 — fail)
|
|
130
|
-
*/
|
|
131
|
-
export async function verify(_rest, deps = {}) {
|
|
132
|
-
const run = deps.run ?? realRun
|
|
133
|
-
const cwd0 = deps.cwd ?? processCwd()
|
|
134
|
-
const log = deps.log ?? console.error
|
|
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
|
-
}
|
|
161
|
-
// М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
|
|
162
|
-
if (state && !state.plan?.length) {
|
|
163
|
-
log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const verdict = runReview({ run, cwd, fingerprint })
|
|
167
|
-
|
|
168
|
-
for (const g of verdict.gates) {
|
|
169
|
-
log(`${g.ok ? '✅' : '❌'} gate: ${g.name}`)
|
|
170
|
-
}
|
|
171
|
-
if (!verdict.pass && verdict.failedOutput) log(verdict.failedOutput)
|
|
172
|
-
log(verdict.pass ? '✅ verify: усі gate-и пройдено' : '❌ verify: провалено')
|
|
173
|
-
|
|
174
|
-
if (state) {
|
|
175
|
-
recordTransition({ statePath, eventsPath: flowEventsPath(cwd) }, { type: 'verify', pass: verdict.pass }, state => ({
|
|
176
|
-
...state,
|
|
177
|
-
gates: verdict.gates,
|
|
178
|
-
fingerprint: verdict.fingerprint,
|
|
179
|
-
status: verdict.pass ? state.status : 'failed'
|
|
180
|
-
}))
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return verdict.pass ? 0 : 1
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Які з subworkspace-тек мають змінені файли — для авто-`--ws` у `release`.
|
|
188
|
-
* Кожен файл відноситься до НАЙГЛИБШОГО воркспейсу-збігу, тож вкладені воркспейси
|
|
189
|
-
* (`apps` + `apps/web`) не дають хибного «кілька воркспейсів» для `apps/web/x`.
|
|
190
|
-
* @param {string[]} subWorkspaces теки воркспейсів без кореня (`.`)
|
|
191
|
-
* @param {string[]} changedFiles змінені шляхи відносно кореня репо (posix)
|
|
192
|
-
* @returns {string[]} підмножина `subWorkspaces`, під якими є зміни (у вхідному порядку)
|
|
193
|
-
*/
|
|
194
|
-
export function matchChangedWorkspaces(subWorkspaces, changedFiles) {
|
|
195
|
-
const byDepthDesc = subWorkspaces.toSorted((a, b) => b.length - a.length)
|
|
196
|
-
const hit = new Set()
|
|
197
|
-
for (const f of changedFiles) {
|
|
198
|
-
const ws = byDepthDesc.find(w => f === w || f.startsWith(`${w}/`))
|
|
199
|
-
if (ws) hit.add(ws)
|
|
200
|
-
}
|
|
201
|
-
return subWorkspaces.filter(w => hit.has(w))
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Додає `--ws <шлях>` до аргументів `change`, інферячи воркспейс зі змін від
|
|
206
|
-
* `base_commit`, якщо `--ws` не задано явно. Один змінений subworkspace → авто-`--ws`;
|
|
207
|
-
* кілька → `{ error: true }` (fail-hard, exit 1 у release); нуль / без subworkspace →
|
|
208
|
-
* лишаємо як є (change дефолтиться на `.`). Помилку самого інференсу (недосяжний base,
|
|
209
|
-
* збій `listWorkspaces`) трактуємо fail-soft — не блокуємо, лишаємо дефолт.
|
|
210
|
-
* @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 ін'єкції
|
|
211
|
-
* @returns {Promise<{ args: string[], error?: boolean }>} аргументи для `change` або `{ error: true }`
|
|
212
|
-
*/
|
|
213
|
-
async function resolveChangeWsArgs({ rest, baseCommit, cwd, listWorkspaces, changedFilesSince, log }) {
|
|
214
|
-
// Поважаємо явно заданий воркспейс в обох формах (`--ws x` і `--ws=x`).
|
|
215
|
-
if (rest.includes('--ws') || rest.some(a => a.startsWith('--ws='))) return { args: rest }
|
|
216
|
-
try {
|
|
217
|
-
const workspaces = await listWorkspaces(cwd)
|
|
218
|
-
const subWs = workspaces.filter(w => w !== '.')
|
|
219
|
-
if (subWs.length === 0) return { args: rest }
|
|
220
|
-
const hits = matchChangedWorkspaces(subWs, changedFilesSince(baseCommit, cwd))
|
|
221
|
-
if (hits.length > 1) {
|
|
222
|
-
log(`release: зміни у кількох воркспейсах (${hits.join(', ')}) — вкажи --ws явно`)
|
|
223
|
-
return { args: rest, error: true }
|
|
224
|
-
}
|
|
225
|
-
if (hits.length === 1) {
|
|
226
|
-
log(`release: change → воркспейс «${hits[0]}» (інферено з diff від base)`)
|
|
227
|
-
return { args: [...rest, '--ws', hits[0]] }
|
|
228
|
-
}
|
|
229
|
-
return { args: rest }
|
|
230
|
-
} catch (error) {
|
|
231
|
-
log(`⚠️ release: інференс воркспейсу пропущено (${error instanceof Error ? error.message : String(error)})`)
|
|
232
|
-
return { args: rest }
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* `flow release [--bump … --section … --message …]` — генерує `.changes` і пише
|
|
238
|
-
* completion snapshot (§3 Ф5, §7). Потребує наявного стану (`init`).
|
|
239
|
-
* @param {string[]} rest аргументи, що прокидаються у `n-cursor change`
|
|
240
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => { status: number, stdout: string, stderr: string }, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
241
|
-
* @returns {Promise<number>} exit code
|
|
242
|
-
*/
|
|
243
|
-
export async function release(rest, deps = {}) {
|
|
244
|
-
const run = deps.run ?? realRun
|
|
245
|
-
const cwd = deps.cwd ?? processCwd()
|
|
246
|
-
const log = deps.log ?? console.error
|
|
247
|
-
const now = deps.now ?? Date.now
|
|
248
|
-
|
|
249
|
-
const resolved = resolveActiveFlowState({ cwd, branch: deps.branch }, deps)
|
|
250
|
-
if (!resolved.statePath) {
|
|
251
|
-
log(`release: ${resolved.error}`)
|
|
252
|
-
return 1
|
|
253
|
-
}
|
|
254
|
-
if (resolved.autoResolved) log(`flow: авторезолвлено активний flow «${resolved.label}» (cwd поза worktree)`)
|
|
255
|
-
const effectiveCwd = resolved.worktreeDir ?? cwd
|
|
256
|
-
const statePath = resolved.statePath
|
|
257
|
-
const state = readState(statePath)
|
|
258
|
-
if (!state) {
|
|
259
|
-
log('release: стану нема — спершу `flow init`')
|
|
260
|
-
return 1
|
|
261
|
-
}
|
|
262
|
-
// М'які ворота: FAIL-гейт — лише попередження, рішення за людиною.
|
|
263
|
-
if (state.gate?.verdict === 'FAIL') {
|
|
264
|
-
log(`⚠️ release: gate = FAIL (score ${state.gate.score}) — релізиш свідомо? (див. flow gate)`)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const wsResolved = await resolveChangeWsArgs({
|
|
268
|
-
rest,
|
|
269
|
-
baseCommit: state.metadata?.base_commit ?? null,
|
|
270
|
-
cwd: effectiveCwd,
|
|
271
|
-
listWorkspaces: deps.listWorkspaces ?? getMonorepoProjectRootDirs,
|
|
272
|
-
changedFilesSince: deps.changedFilesSince ?? collectChangedFilesSince,
|
|
273
|
-
log
|
|
274
|
-
})
|
|
275
|
-
if (wsResolved.error) return 1
|
|
276
|
-
|
|
277
|
-
const ch = run('npx', ['@nitra/cursor', 'change', ...wsResolved.args], { cwd: effectiveCwd })
|
|
278
|
-
if ((ch.status ?? 1) !== 0) {
|
|
279
|
-
const detail = ch.stderr ? `: ${ch.stderr.trim()}` : ''
|
|
280
|
-
log(`release: change не вдався${detail}`)
|
|
281
|
-
return 1
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const snapshot = buildCompletionSnapshot({ ...state, status: 'done' }, now)
|
|
285
|
-
recordTransition(
|
|
286
|
-
{ statePath, eventsPath: flowEventsPath(effectiveCwd) },
|
|
287
|
-
{ type: 'release' },
|
|
288
|
-
state_ => ({ ...state_, status: 'done', completion: snapshot }),
|
|
289
|
-
now
|
|
290
|
-
)
|
|
291
|
-
if (state.task) {
|
|
292
|
-
writeSummaryToTaskRecord(isAbsolute(state.task) ? state.task : join(effectiveCwd, state.task), snapshot)
|
|
293
|
-
}
|
|
294
|
-
log('release: done')
|
|
295
|
-
return 0
|
|
296
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Серіалізація мутацій стану `flow` через **reuse** спільного `withLock`
|
|
3
|
-
* (spec §4.1.3). `withLock` уже коректно чистить stale-локи (TTL +
|
|
4
|
-
* `process.kill(pid,0)`) і релізить на SIGINT/SIGTERM — не дублюємо це.
|
|
5
|
-
*
|
|
6
|
-
* **Override для flow:** `onWaitTimeout: 'fail'` — на відміну від lint (де
|
|
7
|
-
* прийнятно «після таймауту запустити без локу»), мутацію стану двома writer-ами
|
|
8
|
-
* не допускаємо → fail-closed. Dedup за fingerprint вимкнено (`getFingerprint:
|
|
9
|
-
* () => null`): flow завжди має виконатись, а не пропуститись за «тим самим
|
|
10
|
-
* деревом».
|
|
11
|
-
*
|
|
12
|
-
* Лок-кеш — sibling `.worktrees/.flow-lock-<branch>/` (поряд зі станом), щоб не
|
|
13
|
-
* залежати від глобального кеш-каталогу.
|
|
14
|
-
*/
|
|
15
|
-
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
16
|
-
|
|
17
|
-
import { withLock } from '../../utils/with-lock.mjs'
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Виконує `runFn` під per-branch локом flow. Кидає (fail-closed), якщо лок не
|
|
21
|
-
* вдалося взяти за `waitTimeout`.
|
|
22
|
-
* @param {string} worktreeDir абсолютний шлях checkout (`…/.worktrees/feat-x`)
|
|
23
|
-
* @param {() => unknown | Promise<unknown>} runFn критична секція
|
|
24
|
-
* @param {object} [opts] прокидається у `withLock` (напр. `waitTimeout`, `pollInterval`)
|
|
25
|
-
* @returns {Promise<unknown>} результат `runFn`
|
|
26
|
-
*/
|
|
27
|
-
export function withFlowLock(worktreeDir, runFn, opts = {}) {
|
|
28
|
-
if (!isAbsolute(worktreeDir)) {
|
|
29
|
-
throw new Error(`withFlowLock: очікується абсолютний шлях (отримано: ${worktreeDir})`)
|
|
30
|
-
}
|
|
31
|
-
const base = basename(worktreeDir)
|
|
32
|
-
const cacheDir = join(dirname(worktreeDir), `.flow-lock-${base}`)
|
|
33
|
-
return withLock(`flow-${base}`, runFn, {
|
|
34
|
-
onWaitTimeout: 'fail',
|
|
35
|
-
cacheDir,
|
|
36
|
-
getFingerprint: () => null,
|
|
37
|
-
...opts
|
|
38
|
-
})
|
|
39
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `flow gate` — структурований вердикт релізної готовності (ідея BMAD qa-gate, у
|
|
3
|
-
* нашому стані). Синтезує механічні гейти `verify` (`state.gates`) і adversarial
|
|
4
|
-
* findings `review` (`state.review.findings`) у єдине PASS/CONCERNS/FAIL + score
|
|
5
|
-
* + причини. Дає traceability «чому готово/не готово». `gate` лише агрегує —
|
|
6
|
-
* рішення verify/review не дублює.
|
|
7
|
-
*
|
|
8
|
-
* Уся IO (`now`) ін'єктується; `computeGate` — чиста (тестується без стану на диску).
|
|
9
|
-
*/
|
|
10
|
-
import { cwd as processCwd } from 'node:process'
|
|
11
|
-
|
|
12
|
-
import { flowEventsPath } from './events.mjs'
|
|
13
|
-
import { readState, recordTransition } from './state-store.mjs'
|
|
14
|
-
import { resolveActiveFlowState } from './flow-resolve.mjs'
|
|
15
|
-
|
|
16
|
-
/** Штрафи score за кожен тип проблеми. */
|
|
17
|
-
const PENALTY = { failedGate: 40, high: 25, med: 8, noVerify: 15 }
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Чистий синтез вердикту з наявного стану.
|
|
21
|
-
* @param {{ gates?: { name: string, ok: boolean }[], review?: { findings?: { severity?: string }[] } }} state стан flow
|
|
22
|
-
* @returns {{ verdict: 'PASS' | 'CONCERNS' | 'FAIL', score: number, reasons: string[] }} вердикт
|
|
23
|
-
*/
|
|
24
|
-
export function computeGate(state) {
|
|
25
|
-
const gates = state.gates ?? []
|
|
26
|
-
const findings = state.review?.findings ?? []
|
|
27
|
-
const failedGates = gates.filter(g => !g.ok)
|
|
28
|
-
const high = findings.filter(f => f.severity === 'high')
|
|
29
|
-
const med = findings.filter(f => f.severity === 'med')
|
|
30
|
-
const noVerify = gates.length === 0
|
|
31
|
-
|
|
32
|
-
const reasons = []
|
|
33
|
-
for (const g of failedGates) reasons.push(`gate «${g.name}» провалено`)
|
|
34
|
-
if (high.length > 0) reasons.push(`${high.length} high-severity review finding(s)`)
|
|
35
|
-
if (med.length > 0) reasons.push(`${med.length} med-severity review finding(s)`)
|
|
36
|
-
if (noVerify) reasons.push('verify ще не запускався')
|
|
37
|
-
|
|
38
|
-
let verdict = 'PASS'
|
|
39
|
-
if (failedGates.length > 0 || high.length > 0) {
|
|
40
|
-
verdict = 'FAIL'
|
|
41
|
-
} else if (med.length > 0 || noVerify) {
|
|
42
|
-
verdict = 'CONCERNS'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const penalty =
|
|
46
|
-
PENALTY.failedGate * failedGates.length +
|
|
47
|
-
PENALTY.high * high.length +
|
|
48
|
-
PENALTY.med * med.length +
|
|
49
|
-
(noVerify ? PENALTY.noVerify : 0)
|
|
50
|
-
const score = Math.max(0, Math.min(100, 100 - penalty))
|
|
51
|
-
|
|
52
|
-
return { verdict, score, reasons }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* `flow gate` — обчислює й фіксує вердикт у `.flow.json`.
|
|
57
|
-
* @param {string[]} _rest аргументи (не використовуються)
|
|
58
|
-
* @param {{ cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
59
|
-
* @returns {Promise<number>} exit code (FAIL → 1; PASS/CONCERNS → 0)
|
|
60
|
-
*/
|
|
61
|
-
export async function gate(_rest, deps = {}) {
|
|
62
|
-
const cwd0 = deps.cwd ?? processCwd()
|
|
63
|
-
const log = deps.log ?? console.error
|
|
64
|
-
const now = deps.now ?? Date.now
|
|
65
|
-
|
|
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
|
|
74
|
-
const state = readState(statePath)
|
|
75
|
-
if (!state) {
|
|
76
|
-
log('gate: стану нема — спершу `flow init`')
|
|
77
|
-
return 1
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const result = computeGate(state)
|
|
81
|
-
recordTransition(
|
|
82
|
-
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
83
|
-
{ type: 'gate', verdict: result.verdict },
|
|
84
|
-
s => ({ ...s, gate: { ...result, at: new Date(now()).toISOString() } }),
|
|
85
|
-
now
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
log(`gate: ${result.verdict} (score ${result.score})`)
|
|
89
|
-
for (const r of result.reasons) log(` · ${r}`)
|
|
90
|
-
return result.verdict === 'FAIL' ? 1 : 0
|
|
91
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scale-adaptive рівень + ризик задачі (ідея з BMAD project-levels/risk-profile,
|
|
3
|
-
* у наших термінах). `init` визначає рівень і ризик за описом; разом вони
|
|
4
|
-
* right-size'ять, скільки adversarial-рецензентів спавнить `flow review` (і його
|
|
5
|
-
* фокус), і які фази рекомендовані (контракт).
|
|
6
|
-
*
|
|
7
|
-
* Детекція — підрядками (case-insensitive), без regex (уникаємо slow-regex і
|
|
8
|
-
* проблем зі словомежами для кирилиці).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** L3 — велике/архітектурне. */
|
|
12
|
-
const L3_KEYS = ['platform', 'migration', 'rewrite', 'architecture', 'enterprise', 'редизайн', 'міграц', 'переписат']
|
|
13
|
-
/** L0 — тривіальне. ASCII-дієслова: матч цілим словом (щоб `fix` не ловило `prefix`/`fixture`). */
|
|
14
|
-
const L0_WORD_KEYS = ['fix', 'typo', 'bump', 'rename', 'hotfix']
|
|
15
|
-
/** L0 — кириличні ключі: підрядком (стемінг: `перейменув` ловить `перейменування`). */
|
|
16
|
-
const L0_SUBSTR_KEYS = ['опечат', 'перейменув']
|
|
17
|
-
/** L2 — багатофайлова фіча/рефактор. */
|
|
18
|
-
const L2_KEYS = ['feature', 'epic', 'refactor', 'рефактор', 'фіча']
|
|
19
|
-
/**
|
|
20
|
-
* Сигнали складності (cross-cutting rules/checks/correctness): разом із L0-дієсловом
|
|
21
|
-
* задача НЕ тривіальна. Перекривають L0 (мінімум L2) — щоб rules/checks-роботу не
|
|
22
|
-
* класифікувати як trivial і не пропускати spec (беклог #2). Підрядком (case-insensitive).
|
|
23
|
-
*/
|
|
24
|
-
const COMPLEXITY_KEYS = [
|
|
25
|
-
'mdc',
|
|
26
|
-
'policy',
|
|
27
|
-
'політик',
|
|
28
|
-
'rego',
|
|
29
|
-
'checker',
|
|
30
|
-
'чекер',
|
|
31
|
-
'правило',
|
|
32
|
-
'правила',
|
|
33
|
-
'rules',
|
|
34
|
-
'суперечн',
|
|
35
|
-
'інваріант',
|
|
36
|
-
'invariant',
|
|
37
|
-
'порушен',
|
|
38
|
-
'violation',
|
|
39
|
-
'кілька файл',
|
|
40
|
-
'декілька',
|
|
41
|
-
'meta-'
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Чи символ — ASCII-літера/цифра (межа слова). `undefined` (край рядка) — не alnum.
|
|
46
|
-
* @param {string | undefined} ch символ
|
|
47
|
-
* @returns {boolean} результат
|
|
48
|
-
*/
|
|
49
|
-
function isAsciiAlnum(ch) {
|
|
50
|
-
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Чи містить `text` слово `word` із межами, що не є ASCII-alnum (без regex —
|
|
55
|
-
* конвенція файлу). Для ASCII L0-дієслів: `fix` у `prefix`/`fixture` не рахується.
|
|
56
|
-
* @param {string} text текст (lowercase)
|
|
57
|
-
* @param {string} word шукане ASCII-слово (lowercase)
|
|
58
|
-
* @returns {boolean} результат
|
|
59
|
-
*/
|
|
60
|
-
function hasWord(text, word) {
|
|
61
|
-
let i = text.indexOf(word)
|
|
62
|
-
while (i !== -1) {
|
|
63
|
-
if (!isAsciiAlnum(text[i - 1]) && !isAsciiAlnum(text[i + word.length])) return true
|
|
64
|
-
i = text.indexOf(word, i + 1)
|
|
65
|
-
}
|
|
66
|
-
return false
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Рівень складності задачі за описом: 0 (тривіальне) … 3 (архітектурне).
|
|
71
|
-
* Пріоритет: L3 > L0 (якщо без сигналів складності) > L2/складність > дефолт L1.
|
|
72
|
-
* Лише сигнал складності перекриває L0-дієслово (`fix mdc checker` → L2, не L0);
|
|
73
|
-
* L2-ключі порядок L0 не змінюють (`rename feature` лишається L0, як і раніше).
|
|
74
|
-
* @param {string} desc опис задачі
|
|
75
|
-
* @returns {0 | 1 | 2 | 3} рівень
|
|
76
|
-
*/
|
|
77
|
-
export function detectLevel(desc) {
|
|
78
|
-
const d = String(desc ?? '').toLowerCase()
|
|
79
|
-
const has = keys => keys.some(k => d.includes(k))
|
|
80
|
-
const isL0 = L0_WORD_KEYS.some(k => hasWord(d, k)) || L0_SUBSTR_KEYS.some(k => d.includes(k))
|
|
81
|
-
if (has(L3_KEYS)) return 3
|
|
82
|
-
if (isL0 && !has(COMPLEXITY_KEYS)) return 0
|
|
83
|
-
if (has(L2_KEYS) || has(COMPLEXITY_KEYS)) return 2
|
|
84
|
-
return 1
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Скільки adversarial-рецензентів спавнити для рівня (глибина review за розміром).
|
|
89
|
-
* @param {number} level рівень 0..3
|
|
90
|
-
* @returns {number} кількість рецензентів (1..3)
|
|
91
|
-
*/
|
|
92
|
-
export function reviewersForLevel(level) {
|
|
93
|
-
if (level >= 3) return 3
|
|
94
|
-
if (level === 2) return 2
|
|
95
|
-
return 1
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Ключові слова високого ризику (безпека/гроші/доступи). */
|
|
99
|
-
const HIGH_RISK_KEYS = ['security', 'auth', 'crypto', 'payment', 'secret', 'token', 'permission', 'password', 'безпек']
|
|
100
|
-
/** Ключові слова середнього ризику (дані/незворотність). */
|
|
101
|
-
const MED_RISK_KEYS = ['data', ' db', 'database', 'migration', 'delete', 'gateway', 'міграц', 'видален']
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Рівень ризику задачі за описом: low | med | high.
|
|
105
|
-
* @param {string} desc опис задачі
|
|
106
|
-
* @returns {'low' | 'med' | 'high'} ризик
|
|
107
|
-
*/
|
|
108
|
-
export function detectRisk(desc) {
|
|
109
|
-
const d = String(desc ?? '').toLowerCase()
|
|
110
|
-
const has = keys => keys.some(k => d.includes(k))
|
|
111
|
-
if (has(HIGH_RISK_KEYS)) return 'high'
|
|
112
|
-
if (has(MED_RISK_KEYS)) return 'med'
|
|
113
|
-
return 'low'
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Скільки рецензентів диктує сам ризик.
|
|
118
|
-
* @param {string} risk low|med|high
|
|
119
|
-
* @returns {number} 1..3
|
|
120
|
-
*/
|
|
121
|
-
export function reviewersForRisk(risk) {
|
|
122
|
-
if (risk === 'high') return 3
|
|
123
|
-
if (risk === 'med') return 2
|
|
124
|
-
return 1
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Підсумкова глибина review: максимум вимог за рівнем і за ризиком (кап 3).
|
|
129
|
-
* @param {number} level рівень 0..3
|
|
130
|
-
* @param {string} [risk] low|med|high
|
|
131
|
-
* @returns {number} кількість рецензентів (1..3)
|
|
132
|
-
*/
|
|
133
|
-
export function reviewersFor(level, risk) {
|
|
134
|
-
return Math.min(3, Math.max(reviewersForLevel(level), reviewersForRisk(risk)))
|
|
135
|
-
}
|