@nitra/cursor 3.11.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 +12 -0
- package/package.json +1 -1
- package/rules/changelog/changelog.mdc +7 -1
- package/rules/npm-module/js/package_structure.mjs +5 -147
- package/rules/npm-module/npm-module.mdc +4 -14
- 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/spec.mjs +11 -3
- package/scripts/dispatcher/trace.mjs +50 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.12.0] - 2026-06-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- flow: cwd-незалежний резолвинг активного стану — spec/plan/verify/review/gate/release знаходять .flow.json поточної гілки навіть із головного дерева (швидкий шлях без git, toplevel-резолв, авторезолв єдиного активного flow), + опційний --branch <гілка>. Гейти виконуються у теці worktree.
|
|
8
|
+
- flow release: інференс зміненого воркспейсу з diff від base_commit — авто-додає --ws у change, якщо не задано явно (один змінений subworkspace → його .changes/; кілька → fail з підказкою явного --ws; лише корінь → дефолт). Усуває потрапляння change-файлу в корінь монорепо при змінах під підпакетом.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Усунуто суперечність n-changelog.mdc ↔ n-npm-module.mdc: прибрано перевірки version/CHANGELOG у package_structure.mjs, що штовхали до ручного bump (єдиний артефакт змін — change-файл; узгодженість валідує changelog/consistency.mjs); npm-module.mdc делегує bump/CHANGELOG у changelog.mdc, який отримав post-release-інваріант.
|
|
13
|
+
- trace: резолв лінків front-matter відносно теки артефакту (+ root-relative fallback) — file-relative spec/plan лінки (`../specs/…`) більше не дають хибний «розрив ланцюга»; поле `flow` (runtime `.flow.json`) показується, але не рахується розривом. Розрив визначають лише chain-поля (adr/spec/plan/change/task).
|
|
14
|
+
|
|
3
15
|
## [3.11.0] - 2026-06-02
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
|
|
3
|
-
version: '3.
|
|
3
|
+
version: '3.2'
|
|
4
4
|
alwaysApply: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -97,3 +97,9 @@ alwaysApply: true
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
Секції — підмножина `### Added`, `### Changed`, `### Fixed`, `### Removed` (одна або кілька).
|
|
100
|
+
|
|
101
|
+
## Post-release інваріант (гарантує CI)
|
|
102
|
+
|
|
103
|
+
Перша (верхня) секція `## [version]` у `CHANGELOG.md` дорівнює полю `version` у маніфесті — але це **post-release** твердження, яке забезпечує `n-cursor release` у CI, агрегуючи change-файли (bump `version` + генерація секції + git-тег `<name>@<version>`). **Локально цю рівність руками не підтримують**: у feature-флоу `version`/`CHANGELOG.md` не чіпають, тож верхня секція може відставати від майбутньої версії — це нормально. Drift `version` поза CI (vs реєстр / vs git-база) ловить `check changelog` як заборонений ручний bump.
|
|
104
|
+
|
|
105
|
+
Інструкції щодо bump `version` і редагування `CHANGELOG.md` живуть **лише** в цьому правилі — джерелі істини. Інші правила (зокрема `n-npm-module.mdc`) їй підпорядковані й власних інструкцій bump/CHANGELOG не дублюють.
|
|
@@ -20,16 +20,15 @@
|
|
|
20
20
|
* test-фреймворків (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, `jest`, `ava`, …).
|
|
21
21
|
* Виняток: `*_test.rego` дозволені поруч з полісі — це конвенція conftest.
|
|
22
22
|
*
|
|
23
|
-
* Версія та CHANGELOG
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
23
|
+
* Версія та CHANGELOG тут НЕ перевіряються: єдиний артефакт зміни — change-файл, а узгодженість
|
|
24
|
+
* `version`/`CHANGELOG.md` (включно з drift від ручного bump) валідує `changelog/js/consistency.mjs`
|
|
25
|
+
* за моделлю `n-changelog.mdc`. Інваріант «верхня секція CHANGELOG == package.json.version» істинний
|
|
26
|
+
* лише post-release і його гарантує `n-cursor release` у CI — локально його не підтримують руками.
|
|
26
27
|
* @param {string} cwd корінь репозиторію
|
|
27
28
|
*/
|
|
28
|
-
import { execFile } from 'node:child_process'
|
|
29
29
|
import { existsSync } from 'node:fs'
|
|
30
30
|
import { readFile, stat } from 'node:fs/promises'
|
|
31
31
|
import { join, sep } from 'node:path'
|
|
32
|
-
import { promisify } from 'node:util'
|
|
33
32
|
|
|
34
33
|
import { parseSync } from 'oxc-parser'
|
|
35
34
|
|
|
@@ -43,14 +42,6 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
|
43
42
|
import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
|
|
44
43
|
import { walkDir } from '../../../scripts/utils/walkDir.mjs'
|
|
45
44
|
|
|
46
|
-
const execFileAsync = promisify(execFile)
|
|
47
|
-
|
|
48
|
-
/** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
|
|
49
|
-
const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
|
|
50
|
-
|
|
51
|
-
/** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
|
|
52
|
-
const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
|
|
53
|
-
|
|
54
45
|
/** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
|
|
55
46
|
const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
|
|
56
47
|
|
|
@@ -91,9 +82,9 @@ const GLOBSTAR_TRAILING_RE = /\/__GLOBSTAR__$/u
|
|
|
91
82
|
|
|
92
83
|
/**
|
|
93
84
|
* Чи є під `npm/src` хоча б один `.js` (рекурсивно).
|
|
85
|
+
* @param {string} cwd корінь репозиторію
|
|
94
86
|
* @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
|
|
95
87
|
* @returns {Promise<boolean>} `true`, якщо знайдено хоча б один `.js`
|
|
96
|
-
* @param {string} cwd корінь репозиторію
|
|
97
88
|
*/
|
|
98
89
|
async function npmSrcTreeHasJsFile(cwd, ignorePaths = []) {
|
|
99
90
|
const root = join(cwd, 'npm/src')
|
|
@@ -215,136 +206,6 @@ function checkEmitTypesConfig(passFn, failFn, cwd) {
|
|
|
215
206
|
passFn(`${EMIT_TYPES_CONFIG} є (структуру перевіряє npx @nitra/cursor fix → npm_module.emit_types_config)`)
|
|
216
207
|
}
|
|
217
208
|
|
|
218
|
-
/**
|
|
219
|
-
* Перевіряє npm-publish.yml workflow.
|
|
220
|
-
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
221
|
-
* @param {(msg: string) => void} failFn callback при помилці
|
|
222
|
-
* @param {string} cwd корінь репозиторію
|
|
223
|
-
*/
|
|
224
|
-
/**
|
|
225
|
-
* Чи виконано `git` у корені робочого дерева.
|
|
226
|
-
* @returns {Promise<boolean>} true, якщо процес запущено в межах git work tree
|
|
227
|
-
* @param {string} cwd корінь репозиторію
|
|
228
|
-
*/
|
|
229
|
-
async function gitInsideWorkTree(cwd) {
|
|
230
|
-
try {
|
|
231
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', cwd })
|
|
232
|
-
return stdout.trim() === 'true'
|
|
233
|
-
} catch {
|
|
234
|
-
return false
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Список незакомічених шляхів під `npm/` відносно `HEAD`.
|
|
240
|
-
* @param {string} cwd корінь репозиторію
|
|
241
|
-
* @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
|
|
242
|
-
*/
|
|
243
|
-
async function gitDiffNameOnlyNpm(cwd) {
|
|
244
|
-
try {
|
|
245
|
-
const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], {
|
|
246
|
-
encoding: 'utf8',
|
|
247
|
-
cwd
|
|
248
|
-
})
|
|
249
|
-
return stdout.trim().split('\n').filter(Boolean)
|
|
250
|
-
} catch {
|
|
251
|
-
return null
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
|
|
257
|
-
* @param {string} refPath на кшталт `HEAD:npm/package.json`
|
|
258
|
-
* @param {string} cwd корінь репозиторію
|
|
259
|
-
* @returns {Promise<string | null>} значення поля `version` або `null`, якщо ref недоступний
|
|
260
|
-
*/
|
|
261
|
-
async function gitShowNpmPackageVersionAt(refPath, cwd) {
|
|
262
|
-
try {
|
|
263
|
-
const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8', cwd })
|
|
264
|
-
const m = stdout.match(PACKAGE_JSON_VERSION_RE)
|
|
265
|
-
return m ? m[1] : null
|
|
266
|
-
} catch {
|
|
267
|
-
return null
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Версія з першого заголовка `## […]` у тексті CHANGELOG.
|
|
273
|
-
* @param {string} changelogText вміст файлу CHANGELOG.md
|
|
274
|
-
* @returns {string | null} версія з першої секції або `null`, якщо заголовка немає
|
|
275
|
-
*/
|
|
276
|
-
function firstChangelogSectionVersion(changelogText) {
|
|
277
|
-
const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
|
|
278
|
-
return m ? m[1] : null
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
|
|
283
|
-
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
284
|
-
* @param {(msg: string) => void} failFn callback при виявленому порушенні
|
|
285
|
-
* @returns {Promise<void>}
|
|
286
|
-
* @param {string} cwd корінь репозиторію
|
|
287
|
-
*/
|
|
288
|
-
async function checkChangelogTopMatchesPackageVersion(passFn, failFn, cwd) {
|
|
289
|
-
if (!existsSync(join(cwd, 'npm/CHANGELOG.md')) || !existsSync(join(cwd, 'npm/package.json'))) return
|
|
290
|
-
const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
|
|
291
|
-
const ver = typeof pkg.version === 'string' ? pkg.version : null
|
|
292
|
-
if (!ver) {
|
|
293
|
-
failFn('npm/package.json: відсутнє поле version')
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
const cl = await readFile(join(cwd, 'npm/CHANGELOG.md'), 'utf8')
|
|
297
|
-
const first = firstChangelogSectionVersion(cl)
|
|
298
|
-
if (!first) {
|
|
299
|
-
failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
|
|
300
|
-
return
|
|
301
|
-
}
|
|
302
|
-
if (first !== ver) {
|
|
303
|
-
failFn(
|
|
304
|
-
`npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
|
|
305
|
-
'(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
|
|
306
|
-
)
|
|
307
|
-
return
|
|
308
|
-
}
|
|
309
|
-
passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
|
|
314
|
-
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
315
|
-
* @param {(msg: string) => void} failFn callback при виявленому порушенні
|
|
316
|
-
* @returns {Promise<void>}
|
|
317
|
-
* @param {string} cwd корінь репозиторію
|
|
318
|
-
*/
|
|
319
|
-
async function checkDirtyNpmRequiresVersionBump(passFn, failFn, cwd) {
|
|
320
|
-
if (!(await gitInsideWorkTree(cwd))) {
|
|
321
|
-
passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
const changed = await gitDiffNameOnlyNpm(cwd)
|
|
325
|
-
if (changed === null) {
|
|
326
|
-
passFn('npm-module: git diff під npm/ недоступний — пропущено')
|
|
327
|
-
return
|
|
328
|
-
}
|
|
329
|
-
if (changed.length === 0) return
|
|
330
|
-
|
|
331
|
-
const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json', cwd)
|
|
332
|
-
if (headVer === null) return
|
|
333
|
-
|
|
334
|
-
const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
|
|
335
|
-
const cur = typeof pkg.version === 'string' ? pkg.version : null
|
|
336
|
-
if (!cur) return
|
|
337
|
-
|
|
338
|
-
if (cur === headVer) {
|
|
339
|
-
failFn(
|
|
340
|
-
`Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
|
|
341
|
-
'(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
|
|
342
|
-
)
|
|
343
|
-
return
|
|
344
|
-
}
|
|
345
|
-
passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
209
|
/**
|
|
349
210
|
* FS-existence для `npm-publish.yml` workflow. Поля workflow (`on.push.paths`,
|
|
350
211
|
* `branches`, `id-token: write`, JS-DevTools/npm-publish step) валідує
|
|
@@ -614,8 +475,5 @@ export async function check(cwd = process.cwd()) {
|
|
|
614
475
|
|
|
615
476
|
await checkPublishWorkflow(pass, fail, cwd)
|
|
616
477
|
|
|
617
|
-
await checkChangelogTopMatchesPackageVersion(pass, fail, cwd)
|
|
618
|
-
await checkDirtyNpmRequiresVersionBump(pass, fail, cwd)
|
|
619
|
-
|
|
620
478
|
return reporter.getExitCode()
|
|
621
479
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Оформлення репозиторію для npm модуля
|
|
3
3
|
globs: "npm/**,**/package.json,**/hk.pkl,.github/workflows/npm-publish.yml,**/tsconfig*.json"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.14'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
|
|
@@ -55,21 +55,11 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
|
|
|
55
55
|
|
|
56
56
|
Після додавання **`hk.pkl`**: **`hk install`**.
|
|
57
57
|
|
|
58
|
-
##
|
|
58
|
+
## Версія та CHANGELOG
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Версію (`version` у **`npm/package.json`**) і **`npm/CHANGELOG.md`** **не редагуй вручну** — навіть для hotfix. Єдиний артефакт зміни — **change-файл** (`npx @nitra/cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<…>"`); bump `version` і генерацію секції CHANGELOG робить `n-cursor release` у CI на `main`. Будь-який ручний bump `version` поза CI завалює `check changelog` — навіть із change-файлом.
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
**Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
|
|
65
|
-
|
|
66
|
-
**Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
|
|
67
|
-
|
|
68
|
-
**Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
|
|
69
|
-
|
|
70
|
-
## CHANGELOG
|
|
71
|
-
|
|
72
|
-
Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`**.
|
|
62
|
+
Повна модель (база порівняння, інверсія шляхів, формат CHANGELOG, post-release-інваріант «верхня секція CHANGELOG == `version`») — у **`n-changelog.mdc`** (джерело істини). Це правило їй підпорядковане й власних інструкцій bump/CHANGELOG не дублює.
|
|
73
63
|
|
|
74
64
|
## npm publish
|
|
75
65
|
|
|
@@ -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`')
|
|
@@ -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`')
|
|
@@ -7,12 +7,20 @@
|
|
|
7
7
|
* FS-доступ (`readdir`/`readFile`/`exists`) ін'єктується — тестується без диска.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
-
import { join } from 'node:path'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
11
|
import { cwd as processCwd } from 'node:process'
|
|
12
12
|
|
|
13
|
-
/** Поля-лінки у front-matter
|
|
13
|
+
/** Поля-лінки у front-matter (порядок відображення). */
|
|
14
14
|
const LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Інформаційні лінк-поля: показуються, але їх відсутність НЕ є розривом ланцюга.
|
|
18
|
+
* `flow` вказує на runtime-стан `.worktrees/<branch>.flow.json` — gitignored, поза
|
|
19
|
+
* `docs/`, існує лише під час задачі; у чистому checkout/CI його нема ніколи, тож
|
|
20
|
+
* рахувати його розривом — хибний сигнал. Решта полів — ланки ланцюга (breaking).
|
|
21
|
+
*/
|
|
22
|
+
const INFO_LINK_FIELDS = new Set(['flow'])
|
|
23
|
+
|
|
16
24
|
/** Каталоги з traceable-артефактами. */
|
|
17
25
|
const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
|
|
18
26
|
|
|
@@ -52,20 +60,40 @@ function isSimpleKey(key) {
|
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
62
|
* Будує аналіз: для кожного артефакту — його лінки зі статусом ok/розрив.
|
|
63
|
+
* `breaking` — чи відсутність цього лінка рве ланцюг (chain-поля) чи лише
|
|
64
|
+
* інформаційна (`flow` → runtime-стан).
|
|
55
65
|
* @param {{ file: string, fm: Record<string, string | null> }[]} artifacts артефакти з front-matter
|
|
56
|
-
* @param {(target: string) => boolean}
|
|
57
|
-
* @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean }[] }[]} аналіз
|
|
66
|
+
* @param {(target: string, artifactFile: string) => boolean} resolve чи резолвиться лінк (відносно артефакту/кореня)
|
|
67
|
+
* @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean, breaking: boolean }[] }[]} аналіз
|
|
58
68
|
*/
|
|
59
|
-
export function analyze(artifacts,
|
|
69
|
+
export function analyze(artifacts, resolve) {
|
|
60
70
|
return artifacts.map(({ file, fm }) => ({
|
|
61
71
|
file,
|
|
62
72
|
kind: fm.kind ?? null,
|
|
63
73
|
id: fm.id ?? null,
|
|
64
74
|
status: fm.status ?? null,
|
|
65
|
-
links: LINK_FIELDS.filter(f => fm[f]).map(f => ({
|
|
75
|
+
links: LINK_FIELDS.filter(f => fm[f]).map(f => ({
|
|
76
|
+
field: f,
|
|
77
|
+
target: fm[f],
|
|
78
|
+
ok: resolve(fm[f], file),
|
|
79
|
+
breaking: !INFO_LINK_FIELDS.has(f)
|
|
80
|
+
}))
|
|
66
81
|
}))
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Чи резолвиться лінк: спершу відносно теки артефакту (конвенція доків `../specs/…`),
|
|
86
|
+
* далі fallback на root-relative (`docs/specs/…`). Обидві форми вважаються валідними.
|
|
87
|
+
* @param {string} root корінь репо
|
|
88
|
+
* @param {string} artifactFile rel-шлях артефакту (напр. `docs/plans/x.md`)
|
|
89
|
+
* @param {string} target значення лінка
|
|
90
|
+
* @param {(absPath: string) => boolean} exists перевірка існування
|
|
91
|
+
* @returns {boolean} чи знайдено цільовий файл
|
|
92
|
+
*/
|
|
93
|
+
function resolveLink(root, artifactFile, target, exists) {
|
|
94
|
+
return exists(join(root, dirname(artifactFile), target)) || exists(join(root, target))
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
/**
|
|
70
98
|
* Текстовий рендер аналізу.
|
|
71
99
|
* @param {object[]} analysis результат `analyze`
|
|
@@ -77,14 +105,24 @@ export function render(analysis) {
|
|
|
77
105
|
for (const a of analysis) {
|
|
78
106
|
lines.push(`${a.kind ?? '?'} · ${a.id ?? a.file} [${a.status ?? '—'}]`)
|
|
79
107
|
for (const l of a.links) {
|
|
80
|
-
|
|
81
|
-
const note = l.ok ? '' : ' (РОЗРИВ — файл відсутній)'
|
|
82
|
-
lines.push(` ${mark} ${l.field}: ${l.target}${note}`)
|
|
108
|
+
lines.push(` ${renderLink(l)}`)
|
|
83
109
|
}
|
|
84
110
|
}
|
|
85
111
|
return lines.join('\n')
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Рядок одного лінка: `→` ok; `✗ … (РОЗРИВ)` — нерезолвлене chain-поле;
|
|
116
|
+
* `~ … (не рве ланцюг)` — нерезолвлене інформаційне поле (`flow`, runtime-стан).
|
|
117
|
+
* @param {{ field: string, target: string, ok: boolean, breaking: boolean }} l лінк
|
|
118
|
+
* @returns {string} рядок
|
|
119
|
+
*/
|
|
120
|
+
function renderLink(l) {
|
|
121
|
+
if (l.ok) return `→ ${l.field}: ${l.target}`
|
|
122
|
+
if (l.breaking) return `✗ ${l.field}: ${l.target} (РОЗРИВ — файл відсутній)`
|
|
123
|
+
return `~ ${l.field}: ${l.target} (runtime-стан — не рве ланцюг)`
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
/**
|
|
89
127
|
* CLI `n-cursor trace [--json]`. Повертає 1, якщо є розриви ланцюга.
|
|
90
128
|
* @param {string[]} args аргументи
|
|
@@ -108,7 +146,8 @@ export function runTraceCli(args, deps = {}) {
|
|
|
108
146
|
}
|
|
109
147
|
}
|
|
110
148
|
|
|
111
|
-
const analysis = analyze(artifacts, target =>
|
|
149
|
+
const analysis = analyze(artifacts, (target, file) => resolveLink(root, file, target, exists))
|
|
112
150
|
log(args.includes('--json') ? JSON.stringify(analysis, null, 2) : render(analysis))
|
|
113
|
-
|
|
151
|
+
// Розрив ланцюга — лише нерезолвлене chain-поле; нерезолвлений `flow` (runtime-стан) не рахуємо.
|
|
152
|
+
return analysis.some(a => a.links.some(l => l.breaking && !l.ok)) ? 1 : 0
|
|
114
153
|
}
|