@nitra/cursor 4.1.1 → 5.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/docs/n-cursor.md +1 -9
  3. package/bin/n-cursor.js +3 -25
  4. package/docs/stryker.config.md +37 -0
  5. package/docs/vitest.config.md +23 -0
  6. package/package.json +2 -1
  7. package/rules/docker/lib/docs/docker-mirror.md +1 -1
  8. package/rules/docker/lib/docs/docker-native-addon.md +1 -1
  9. package/rules/test/coverage/coverage.mjs +9 -19
  10. package/rules/test/test.mdc +1 -1
  11. package/scripts/dispatcher/trace.mjs +4 -16
  12. package/scripts/docs/build-agents-commands.md +1 -1
  13. package/scripts/docs/worktree-cli.md +1 -1
  14. package/scripts/lib/changed-files.mjs +19 -3
  15. package/scripts/lib/sync-gitignore-worktree.mjs +4 -5
  16. package/scripts/worktree-cli.mjs +1 -2
  17. package/skills/docgen/js/docgen-gen.mjs +7 -7
  18. package/scripts/dispatcher/docs/graph.md +0 -346
  19. package/scripts/dispatcher/docs/index.md +0 -236
  20. package/scripts/dispatcher/docs/trace.md +0 -296
  21. package/scripts/dispatcher/graph/lib/cmd-init.mjs +0 -112
  22. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +0 -96
  23. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +0 -141
  24. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +0 -142
  25. package/scripts/dispatcher/graph/lib/cmd-run.mjs +0 -328
  26. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +0 -115
  27. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +0 -111
  28. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +0 -328
  29. package/scripts/dispatcher/graph/lib/cmd-status.mjs +0 -131
  30. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +0 -100
  31. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +0 -128
  32. package/scripts/dispatcher/graph/lib/config.mjs +0 -103
  33. package/scripts/dispatcher/graph/lib/frontmatter.mjs +0 -224
  34. package/scripts/dispatcher/graph/lib/nnn.mjs +0 -127
  35. package/scripts/dispatcher/graph/lib/node-state.mjs +0 -157
  36. package/scripts/dispatcher/graph/lib/scanner.mjs +0 -235
  37. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +0 -193
  38. package/scripts/dispatcher/graph-tasks.mjs +0 -92
  39. package/scripts/dispatcher/graph.mjs +0 -212
  40. package/scripts/dispatcher/index.mjs +0 -45
  41. package/scripts/dispatcher/lib/docs/active.md +0 -348
  42. package/scripts/dispatcher/lib/docs/artifact.md +0 -232
  43. package/scripts/dispatcher/lib/docs/budget.md +0 -167
  44. package/scripts/dispatcher/lib/docs/capability.md +0 -196
  45. package/scripts/dispatcher/lib/docs/commands.md +0 -210
  46. package/scripts/dispatcher/lib/docs/events.md +0 -183
  47. package/scripts/dispatcher/lib/docs/executor.md +0 -190
  48. package/scripts/dispatcher/lib/docs/gate.md +0 -231
  49. package/scripts/dispatcher/lib/docs/level.md +0 -335
  50. package/scripts/dispatcher/lib/docs/plan-panel.md +0 -181
  51. package/scripts/dispatcher/lib/docs/plan.md +0 -200
  52. package/scripts/dispatcher/lib/docs/planner.md +0 -269
  53. package/scripts/dispatcher/lib/docs/review.md +0 -255
  54. package/scripts/dispatcher/lib/docs/reviewer.md +0 -240
  55. package/scripts/dispatcher/lib/docs/snapshot.md +0 -247
  56. package/scripts/dispatcher/lib/docs/spec.md +0 -203
  57. package/scripts/dispatcher/lib/docs/state-store.md +0 -303
  58. package/scripts/dispatcher/lib/docs/subagent-runner.md +0 -173
  59. package/scripts/dispatcher/lib/events.mjs +0 -67
  60. package/scripts/dispatcher/lib/executor.mjs +0 -107
  61. package/scripts/dispatcher/lib/plan-panel.mjs +0 -76
  62. package/scripts/dispatcher/lib/state-store.mjs +0 -173
  63. package/scripts/dispatcher/lib/subagent-runner.mjs +0 -53
  64. package/scripts/graph/index.mjs +0 -115
  65. package/scripts/graph/lib/config.mjs +0 -62
  66. package/scripts/graph/lib/dag.mjs +0 -161
  67. package/scripts/graph/lib/frontmatter.mjs +0 -70
  68. package/scripts/graph/lib/nnn.mjs +0 -77
  69. package/scripts/graph/lib/state.mjs +0 -110
  70. package/scripts/graph/scan.mjs +0 -64
  71. package/scripts/graph/status.mjs +0 -86
@@ -1,269 +0,0 @@
1
- # planner.mjs
2
-
3
- ## Огляд
4
-
5
- Модуль `planner.mjs` — **декларативний планувальник** диспетчера субагентів (відповідає Ф1 зі spec §3). Його завдання — отримати від користувача опис фічі/задачі, сформулювати спеціалізований промпт, передати його зовнішньому субагенту (через ін'єкцію `runner`), а потім **строго валідувати** повернутий JSON-план реалізації та нормалізувати його кроки до уніфікованої структури з полями `step`, `task`, `status`, `retry_count` (плюс опційне `acceptance`).
6
-
7
- Поведінка модуля **fail-closed**: будь-яка невідповідність контракту (відсутність JSON-масиву, невалідний JSON, порожній масив, відсутність текстового `task` у кроці, плейсхолдер на кшталт `tbd`/`todo`/`fixme`/`...`/`placeholder`, помилка субагента) призводить до кидання `Error` без часткових результатів. Це гарантує, що подальші стадії (виконання плану) ніколи не отримають частково сформований/невалідований план.
8
-
9
- Файл написано як чистий ES-модуль (`.mjs`) без зовнішніх залежностей — лише вбудовані JS-конструкції (`JSON.parse`, `String`, регулярні вирази, `Array.prototype.map`). Це робить його легким для unit-тестування й мокування `runner` у тестах.
10
-
11
- ## Експорти / API
12
-
13
- Модуль експортує **три іменовані функції** (інших експортів немає, default-експорту немає):
14
-
15
- | Експорт | Тип | Призначення |
16
- | ------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------- |
17
- | `plannerPrompt(task)` | `(string) => string` | Формує текстовий промпт для субагента-планувальника. |
18
- | `parsePlan(text)` | `(string) => Step[]` | Парсить і валідує JSON-план із сирого тексту відповіді; повертає нормалізований масив кроків. |
19
- | `generatePlan({ runner, task, cwd })` | `async ({...}) => Promise<Step[]>` | Високорівнева оркестровка: будує промпт, викликає `runner.runStep`, парсить вихід. |
20
-
21
- Внутрішня (не експортована) константа:
22
-
23
- | Ім'я | Тип | Значення / опис |
24
- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `PLACEHOLDER` | `RegExp` | `^(tbd\|todo\|fixme\|\.\.\.\|placeholder)$` із прапором `i` — список «заборонених» текстів кроку, що сигналізують про несформований план. |
26
-
27
- ### Тип `Step` (нормалізований крок плану)
28
-
29
- Об'єкт, який повертають `parsePlan` і `generatePlan` (масивом):
30
-
31
- ```
32
- {
33
- step: number, // 0-індексований порядковий номер
34
- task: string, // оригінальний (не тримлений!) текст задачі
35
- status: 'pending', // початковий статус — завжди 'pending'
36
- retry_count: 0, // лічильник повторів — завжди 0
37
- acceptance?: string // опційний критерій приймання (як строка)
38
- }
39
- ```
40
-
41
- ## Функції
42
-
43
- ### `plannerPrompt(task)`
44
-
45
- **Сигнатура.** `plannerPrompt(task: string): string`
46
-
47
- **Параметри.**
48
-
49
- - `task` — текстовий опис фічі/задачі, який отримав диспетчер від виклику верхнього рівня.
50
-
51
- **Повертає.** Готовий **system-user** промпт у вигляді одного рядка, склеєного через `\n`. Зміст промпта:
52
-
53
- 1. Роль: «архітектор», який має розбити задачу.
54
- 2. Обмеження кроку: ≤ 5 хв розробки, чіткі критерії приймання.
55
- 3. Формат відповіді: **ЛИШЕ** JSON-масив без коментарів, кожен елемент — об'єкт `{ "task": "...", "acceptance": "..." }`.
56
- 4. Останній рядок — фактична задача: `Задача: <task>`.
57
-
58
- **Side effects.** Немає — функція суто детермінована й pure. Її вихід можна логувати, кешувати, передавати в тести як snapshot.
59
-
60
- **Особливості.**
61
-
62
- - Не екранує і не санітизує `task` — викликач відповідає за те, що рядок не зламає форматування промпта (хоча для рядкового рендеру це й не критично).
63
- - Не вставляє приклади чи додаткові few-shot блоки — мінімалістичний контракт.
64
-
65
- ### `parsePlan(text)`
66
-
67
- **Сигнатура.** `parsePlan(text: string): Step[]`
68
-
69
- **Параметри.**
70
-
71
- - `text` — сира відповідь субагента; може містити markdown-огорожі (наприклад, ` ```json ... ``` `), пояснювальний текст до/після масиву тощо.
72
-
73
- **Повертає.** Масив нормалізованих кроків (див. тип `Step` вище).
74
-
75
- **Алгоритм.**
76
-
77
- 1. Конвертує вхід у рядок через `String(text)` — захист від `null`/`undefined`/буферів.
78
- 2. Шукає **перший `[`** через `indexOf('[')` і **останній `]`** через `lastIndexOf(']')`. Це робить парсер толерантним до markdown-огорож, преамбули чи коментарів навколо JSON-масиву.
79
- 3. Якщо хоча б одна з дужок не знайдена або порядок інвертовано (`end < start`) — кидає `Error('planner: не знайдено JSON-масив плану — fail-closed')`.
80
- 4. Виокремлює зріз `str.slice(start, end + 1)` і парсить через `JSON.parse`. На SyntaxError кидає `Error('planner: невалідний JSON плану — fail-closed')`.
81
- 5. Перевіряє, що результат — **непорожній масив**; інакше — `Error('planner: план має бути непорожнім масивом — fail-closed')`.
82
- 6. Мапить кожен елемент:
83
- - Витягує `task`: якщо елемент сам є рядком — то він і є task; інакше `s?.task`.
84
- - Якщо `task` відсутній або не рядок — кидає `Error('planner: крок <i> без текстового поля task — fail-closed')`.
85
- - Тримить копію (`task.trim()`) і перевіряє: непорожній і **не** збігається з `PLACEHOLDER`. Інакше — `Error('planner: крок <i> — placeholder/порожній task (<task>) — fail-closed')`.
86
- - Формує об'єкт `{ step: i, task, status: 'pending', retry_count: 0 }`. Важливо: у поле `task` зберігається **оригінальний** (нетримлений) рядок, валідується лише тримлений варіант.
87
- - Якщо в сирому елементі є поле `acceptance` (truthy) — додає `step.acceptance = String(s.acceptance)`.
88
-
89
- **Повертає.** Масив об'єктів `Step` у тому ж порядку, що й у вхідному масиві; індекс `step` — 0-індексований, відповідає позиції в масиві.
90
-
91
- **Side effects.** Немає — pure-функція. Винятки кидаються синхронно; внутрішнього логування, мутацій глобального стану чи I/O немає.
92
-
93
- **Покриті граничні випадки.**
94
-
95
- - Markdown-огорожі / преамбула / postамбула навколо JSON.
96
- - Елемент-рядок (`"крок 1"`) замість `{ task: "крок 1" }`.
97
- - Цифрові `task` (`123`), `null`, відсутні — все відсіюється.
98
- - Регістронезалежні плейсхолдери (`TBD`, `Todo`, `FIXME`, `...`, `Placeholder`).
99
- - Порожній масив `[]` — відхиляється.
100
- - Будь-який `task` із пробілами навколо плейсхолдера (наприклад, `" tbd "`) також відсіюється завдяки `.trim()` перед регуляркою.
101
-
102
- ### `generatePlan({ runner, task, cwd })`
103
-
104
- **Сигнатура.**
105
-
106
- ```
107
- async generatePlan(input: {
108
- runner: { runStep: (prompt: string, opts?: { cwd?: string })
109
- => { ok: boolean, output: string } | Promise<{ ok: boolean, output: string }> },
110
- task: string,
111
- cwd?: string
112
- }): Promise<Step[]>
113
- ```
114
-
115
- **Параметри (деструктуровані).**
116
-
117
- - `runner` — обов'язкова ін'єкція «бігуна» субагента. Очікується об'єкт із методом `runStep(prompt, opts?)`, що повертає (або резолвить у Promise) `{ ok: boolean, output: string }`. Контракт навмисно мінімальний — це спрощує мокування в тестах.
118
- - `task` — текстовий опис фічі/задачі; передається у `plannerPrompt`.
119
- - `cwd` — опційний робочий каталог, який пробрасується у `runner.runStep` через `{ cwd }`.
120
-
121
- **Повертає.** `Promise<Step[]>` — нормалізований план від `parsePlan`.
122
-
123
- **Алгоритм.**
124
-
125
- 1. Формує промпт через `plannerPrompt(task)`.
126
- 2. Викликає `await runner.runStep(prompt, { cwd })` — підтримує і синхронний, і асинхронний `runStep` (await на не-promise — no-op).
127
- 3. Якщо `res.ok === false` — кидає `Error('planner: субагент-планувальник завершився помилкою[:\n<output>]')`. Якщо `res.output` truthy — він приєднується до повідомлення з префіксом `:\n` для зручної діагностики.
128
- 4. Передає `res.output` у `parsePlan` і повертає його результат (валідні винятки парсера пробулькують далі).
129
-
130
- **Side effects.**
131
-
132
- - Викликає `runner.runStep`, який у реальному середовищі стартує субагента (запис файлів, мережа, процеси — все на боці `runner`). Сам модуль `planner.mjs` ніяких I/O не виконує.
133
- - Може кидати `Error` як від власних перевірок, так і від `parsePlan`.
134
-
135
- **Обробка помилок.** Будь-яка помилка веде до **повного провалу** (без часткового плану) — fail-closed контракт, на який спираються наступні Ф2/Ф3 диспетчера.
136
-
137
- ## Залежності
138
-
139
- **Зовнішні npm-залежності:** немає.
140
-
141
- **Імпорти у файлі:** немає (`import`-ів нема — модуль самодостатній).
142
-
143
- **Що використовується (вбудоване в JS-runtime):**
144
-
145
- - `String(...)` — нормалізація вхідного значення в `parsePlan`.
146
- - `JSON.parse` — парсинг витягнутого JSON-зрізу.
147
- - `Array.isArray`, `Array.prototype.map` — перевірка типу й мапінг.
148
- - `RegExp` (`/^(tbd|todo|fixme|\.\.\.|placeholder)$/i`) — детектор плейсхолдерів.
149
- - `String.prototype.indexOf`/`lastIndexOf`/`slice`/`trim`.
150
-
151
- **Ін'єкції (контракт ззовні):**
152
-
153
- - `runner` із методом `runStep(prompt, opts?)` — інверсія залежностей; реалізація лежить за межами цього модуля (зазвичай — окремий модуль `dispatcher/lib/runner*.mjs`).
154
-
155
- **Хто залежить від модуля (типові споживачі).**
156
-
157
- - Оркестратор/диспетчер субагентів верхнього рівня — імпортує `generatePlan` для побудови плану перед виконанням.
158
- - Юніт-тести — як правило, ймовірно імпортують `parsePlan` і `plannerPrompt` прямо для перевірки контракту без `runner`.
159
-
160
- Точні шляхи споживачів у цьому документі **не фіксуються** (модуль документується ізольовано).
161
-
162
- ## Потік виконання / Використання
163
-
164
- ### Типовий happy-path (з боку диспетчера)
165
-
166
- 1. Диспетчер отримує `task` (опис фічі) і має `runner`-реалізацію.
167
- 2. Викликає `await generatePlan({ runner, task, cwd })`.
168
- 3. `generatePlan` будує промпт через `plannerPrompt(task)`.
169
- 4. `runner.runStep(prompt, { cwd })` стартує субагента-«архітектора», який повертає `{ ok: true, output: '[ ... JSON ... ]' }` (можливо з markdown-огорожею).
170
- 5. `parsePlan(output)` витягує JSON-зріз від `[` до `]`, парсить, валідує, нормалізує — повертає масив `Step`.
171
- 6. Диспетчер отримує `Step[]` і передає його наступній фазі (Ф2 — виконання кроків).
172
-
173
- ### Sad-path сценарії (fail-closed)
174
-
175
- | Сценарій | Точка падіння | Текст помилки (рівно) |
176
- | ------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------- |
177
- | `runner.runStep` повернув `{ ok: false, output: '' }` | `generatePlan` | `planner: субагент-планувальник завершився помилкою` |
178
- | `runner.runStep` повернув `{ ok: false, output: '<lorem>' }` | `generatePlan` | `planner: субагент-планувальник завершився помилкою:\n<lorem>` |
179
- | У відповіді немає `[` або `]` (або в зворотному порядку) | `parsePlan` | `planner: не знайдено JSON-масив плану — fail-closed` |
180
- | JSON всередині `[...]` неваліден | `parsePlan` | `planner: невалідний JSON плану — fail-closed` |
181
- | Результат — не масив або порожній | `parsePlan` | `planner: план має бути непорожнім масивом — fail-closed` |
182
- | Крок без `task` (або не-рядок) | `parsePlan` | `planner: крок <i> без текстового поля task — fail-closed` |
183
- | Крок з порожнім/плейсхолдер-`task` | `parsePlan` | `planner: крок <i> — placeholder/порожній task (<task>) — fail-closed` |
184
-
185
- ### Приклади використання
186
-
187
- **Лише парсинг (наприклад, у тесті):**
188
-
189
- ````
190
- import { parsePlan } from './planner.mjs'
191
-
192
- const text = '```json\n[\n { "task": "init repo", "acceptance": "git init ok" },\n "lint config"\n]\n```'
193
- const steps = parsePlan(text)
194
- // steps[0] === { step: 0, task: 'init repo', status: 'pending', retry_count: 0, acceptance: 'git init ok' }
195
- // steps[1] === { step: 1, task: 'lint config', status: 'pending', retry_count: 0 }
196
- ````
197
-
198
- **Повна оркестровка з мок-runner:**
199
-
200
- ```
201
- import { generatePlan } from './planner.mjs'
202
-
203
- const runner = {
204
- runStep: async (prompt) => ({
205
- ok: true,
206
- output: '[{ "task": "крок 1" }, { "task": "крок 2", "acceptance": "тести зелені" }]'
207
- })
208
- }
209
-
210
- const plan = await generatePlan({ runner, task: 'додати feature X', cwd: process.cwd() })
211
- // plan.length === 2; plan[1].acceptance === 'тести зелені'
212
- ```
213
-
214
- **Реакція на fail-closed:**
215
-
216
- ```
217
- try {
218
- await generatePlan({ runner, task: '...' })
219
- } catch (e) {
220
- // e.message починається з 'planner: ' — далі диспетчер може віддати помилку нагору
221
- }
222
- ```
223
-
224
- ### Інваріанти, на які можна спиратися
225
-
226
- - Поверне рівно стільки кроків, скільки було у вхідному JSON-масиві (mapping 1:1, порядок збережено).
227
- - `step` зростає монотонно від `0` до `arr.length - 1`.
228
- - `status === 'pending'`, `retry_count === 0` — для **кожного** свіжого кроку (новостворений план — завжди «незачеплений»).
229
- - Поле `acceptance` присутнє **лише** якщо в сирому елементі воно truthy; інакше відсутнє (не `undefined`, а немає ключа взагалі).
230
- - Поле `task` зберігається **в оригінальному вигляді** (з пробілами по краях), валідація використовує тримлений варіант — тобто можливі «візуально нетримлені» рядки в результаті, але вони гарантовано не плейсхолдери й не порожні.
231
-
232
- ## Rebuild Test
233
-
234
- Цей розділ описує контракт настільки повно, що його можна використати як специфікацію для повторної реалізації файлу з нуля.
235
-
236
- **Реалізаційні вимоги:**
237
-
238
- 1. ES-модуль `.mjs` без імпортів.
239
- 2. Внутрішня константа-регулярка `PLACEHOLDER = /^(tbd|todo|fixme|\.\.\.|placeholder)$/i`.
240
- 3. Експортувати рівно три функції: `plannerPrompt`, `parsePlan`, `generatePlan` (іменовані експорти, без default).
241
- 4. `plannerPrompt(task)` повертає рядок, склеєний через `\n` з 5 рядків (4 інструктивних + порожній + `Задача: <task>`).
242
- 5. `parsePlan(text)`:
243
- - Конвертувати в рядок через `String(text)`.
244
- - Знайти `start = indexOf('[')`, `end = lastIndexOf(']')`. Якщо `start === -1 || end === -1 || end < start` — кинути `'planner: не знайдено JSON-масив плану — fail-closed'`.
245
- - Спробувати `JSON.parse(slice(start, end+1))`; на catch — кинути `'planner: невалідний JSON плану — fail-closed'`.
246
- - Якщо результат не масив або порожній — кинути `'planner: план має бути непорожнім масивом — fail-closed'`.
247
- - Для кожного `(s, i)`: визначити `task = typeof s === 'string' ? s : s?.task`; якщо falsy або не string — кинути `'planner: крок <i> без текстового поля task — fail-closed'`; перевірити `trimmed = task.trim()` — якщо falsy або матчить `PLACEHOLDER` — кинути `'planner: крок <i> — placeholder/порожній task (<task>) — fail-closed'`.
248
- - Сформувати `{ step: i, task, status: 'pending', retry_count: 0 }`; якщо `s?.acceptance` — додати `acceptance: String(s.acceptance)`.
249
- 6. `generatePlan({ runner, task, cwd })` — `async`:
250
- - `const res = await runner.runStep(plannerPrompt(task), { cwd })`.
251
- - Якщо `!res.ok` — кинути `'planner: субагент-планувальник завершився помилкою'` плюс `':\n' + res.output` якщо `res.output` truthy.
252
- - Інакше `return parsePlan(res.output)`.
253
-
254
- **Контрольні приклади (повинні проходити після rebuild):**
255
-
256
- | Вхід `parsePlan` | Очікуваний результат |
257
- | -------------------------------------------------- | -------------------------------------------------------------------------- |
258
- | `'[{"task":"a"}]'` | `[{ step:0, task:'a', status:'pending', retry_count:0 }]` |
259
- | `'prefix [{"task":"a","acceptance":"ok"}] suffix'` | `[{ step:0, task:'a', status:'pending', retry_count:0, acceptance:'ok' }]` |
260
- | `'[\"a\",\"b\"]'` | `[{step:0,task:'a',...},{step:1,task:'b',...}]` |
261
- | `'[]'` | throw `'planner: план має бути непорожнім масивом — fail-closed'` |
262
- | `'no json here'` | throw `'planner: не знайдено JSON-масив плану — fail-closed'` |
263
- | `'[{"task":"tbd"}]'` | throw `'planner: крок 0 — placeholder/порожній task (tbd) — fail-closed'` |
264
- | `'[{"task":""}]'` | throw `'planner: крок 0 — placeholder/порожній task () — fail-closed'` |
265
- | `'[{"task":123}]'` | throw `'planner: крок 0 без текстового поля task — fail-closed'` |
266
- | `'[oops'` (нема `]`) | throw `'planner: не знайдено JSON-масив плану — fail-closed'` |
267
- | `'[invalid json]'` | throw `'planner: невалідний JSON плану — fail-closed'` |
268
-
269
- Якщо реалізація проходить усі ці кейси й тримає сигнатури/тексти помилок з таблиць — її можна вважати функціонально еквівалентною оригінальному `planner.mjs`.
@@ -1,255 +0,0 @@
1
- # review.mjs
2
-
3
- ## Огляд
4
-
5
- Модуль реалізує команду `flow review` — adversarial-перевірку коду **після** його написання (концепція з BMAD quick-dev: спершу self-check, потім adversarial-review). Незалежний субагент-рецензент читає **лише** `git diff` від базового комміту й шукає логічні баги, ризики та smells, які не ловлять механічні гейти (lint + coverage) у команді `verify`.
6
-
7
- Команда є **інформативною**: ворота м'які, тож exit code завжди `0`, якщо вдалося запустити рецензію. Код `1` повертається лише за технічної неможливості (немає стану flow, не вдалось створити runner). Кількість рецензентів визначається полем `level` стану flow (через `reviewersFor`), а додаткова security-лінза вмикається для `risk === 'high'`.
8
-
9
- Усі сторонні залежності (запуск процесів, runner субагента, годинник) **ін'єктуються** через об'єкт `deps`, тож модуль повністю тестується без реального git та LLM.
10
-
11
- Результати рецензії (`{ at, reviewers, findings }`) фіксуються в `.flow.json` через `recordTransition` і виводяться у лог зі зрозумілими емодзі-іконками за severity.
12
-
13
- ## Експорти / API
14
-
15
- Модуль експортує чотири іменовані функції:
16
-
17
- | Експорт | Тип | Призначення |
18
- | ------------------------------ | ---------------- | ------------------------------------------------------------------------------------ |
19
- | `diffFromBase(base, run, cwd)` | `function` | Будує текст diff: закомічене `base...HEAD` + working tree `git diff`. |
20
- | `reviewerPrompt(diff, risk)` | `function` | Формує промпт для adversarial-рецензента з фокусом на diff (опційно security-лінза). |
21
- | `parseFindings(text)` | `function` | Витягає JSON-масив findings з відповіді субагента (fail-soft). |
22
- | `dedupeFindings(findings)` | `function` | Дедуплікує findings за ключем `(file, issue)`. |
23
- | `review(_rest, deps)` | `async function` | Головна точка входу команди `flow review`. |
24
-
25
- Внутрішня (не експортується) функція `severityIcon(severity)` повертає емодзі-маркер.
26
-
27
- Константа модульного scope:
28
-
29
- - `DIFF_LIMIT = 12_000` — максимальна кількість символів diff, що потрапляє у промпт рецензента (захист від роздування контексту).
30
-
31
- ## Функції
32
-
33
- ### `diffFromBase(base, run, cwd)`
34
-
35
- **Сигнатура:** `(base: string, run: (cmd, args, opts) => { stdout: string }, cwd: string) => string`
36
-
37
- **Параметри:**
38
-
39
- - `base` — базовий комміт, від якого рахується diff (наприклад, `HEAD~1` або значення з `state.metadata.base_commit`).
40
- - `run` — ін'єктований git-раннер. Виклик `run('git', args, { cwd })` має повертати об'єкт із полем `stdout`.
41
- - `cwd` — шлях до worktree, у якому виконуються git-команди.
42
-
43
- **Повертає:** склеєний рядок з двох частин — `git diff base...HEAD` (закомічене) і `git diff` (робоче дерево), розділених `\n`, з обрізаними пробілами по краях.
44
-
45
- **Side effects:** виконує два процеси `git` через ін'єктований `run`. Без `run` — чиста функція.
46
-
47
- **Особливості:** `stdout` нормалізується через `?? ''`, тому якщо одна з команд не повернула вивід, інша частина не "забивається" `undefined`.
48
-
49
- ---
50
-
51
- ### `reviewerPrompt(diff, risk)`
52
-
53
- **Сигнатура:** `(diff: string, risk?: string) => string`
54
-
55
- **Параметри:**
56
-
57
- - `diff` — текст diff для рецензування (обрізається до `DIFF_LIMIT` символів).
58
- - `risk` — рівень ризику flow: `'low'`, `'med'`, `'high'`. За `risk === 'high'` додається security-лінза з акцентом на auth/секрети/ін'єкції/незворотні операції.
59
-
60
- **Повертає:** готовий текст промпта для adversarial-рецензента — рядки, склеєні через `\n` (порожні через `lens` або інші falsy-вставки відфільтровуються `.filter(Boolean)`).
61
-
62
- **Side effects:** немає (чиста функція).
63
-
64
- **Ключові вимоги в промпті:**
65
-
66
- 1. Рецензент шукає баги/ризики/smells, які **вносить або зачіпає** саме цей diff.
67
- 2. Якщо доступний інструмент `Read` — точково читає referenced-файли для верифікації cross-file тверджень; інакше працює лише з diff.
68
- 3. Сусідні файли — для контексту, **не** для пошуку преіснуючих багів.
69
- 4. Заборонено нефальсифіковні findings виду "з diff не видно / можливо" — або підтвердити читанням, або відкинути.
70
- 5. Формат відповіді — **лише** JSON-масив `[{ severity, file, issue, suggestion }]`; якщо проблем нема — `[]`.
71
-
72
- ---
73
-
74
- ### `parseFindings(text)`
75
-
76
- **Сигнатура:** `(text: string) => Array<{ severity?: string, file?: string, issue?: string, suggestion?: string }>`
77
-
78
- **Параметри:**
79
-
80
- - `text` — сирий вивід субагента-рецензента.
81
-
82
- **Повертає:** масив findings. Якщо JSON-масив не знайдено або парсинг впав — повертає `[]`.
83
-
84
- **Алгоритм:**
85
-
86
- 1. Знаходить індекси першого `[` та останнього `]` у тексті.
87
- 2. Якщо хоча б одного нема, або `end < start` — повертає `[]`.
88
- 3. Парсить підрядок `[...]` через `JSON.parse`.
89
- 4. Якщо результат — масив, повертає його; інакше або при exception — `[]`.
90
-
91
- **Side effects:** немає. Fail-soft: будь-яке сміття/невалідний JSON безпечно перетворюється на порожній масив.
92
-
93
- ---
94
-
95
- ### `dedupeFindings(findings)`
96
-
97
- **Сигнатура:** `(findings: object[]) => object[]`
98
-
99
- **Параметри:**
100
-
101
- - `findings` — масив findings (можливо з дублікатами).
102
-
103
- **Повертає:** новий масив без дублікатів за ключем `${file}::${issue}`, зі збереженням порядку першого входження.
104
-
105
- **Side effects:** немає (чиста функція, але створює новий масив).
106
-
107
- **Особливості:** `f?.file ?? ''` і `f?.issue ?? ''` — `undefined`/`null` нормалізуються до пустого рядка, тож два findings без обох полів вважаються дублікатами.
108
-
109
- ---
110
-
111
- ### `severityIcon(severity)` _(internal)_
112
-
113
- **Сигнатура:** `(severity: string) => string`
114
-
115
- **Параметри:**
116
-
117
- - `severity` — рівень: `'high'` | `'med'` | будь-що інше.
118
-
119
- **Повертає:** емодзі-іконку:
120
-
121
- - `'high'` → червоне коло;
122
- - `'med'` → жовте коло;
123
- - решта (включно з `'low'`, `undefined`) → біле коло.
124
-
125
- **Side effects:** немає.
126
-
127
- ---
128
-
129
- ### `review(_rest, deps)`
130
-
131
- **Сигнатура:** `(_rest: string[], deps?: object) => Promise<number>`
132
-
133
- **Параметри:**
134
-
135
- - `_rest` — позиційні аргументи CLI (не використовуються; передається для уніфікованої сигнатури команд диспатчера).
136
- - `deps` — об'єкт ін'єкцій:
137
- - `cwd` — стартова робоча тека (за замовчуванням `process.cwd()`);
138
- - `branch` — гілка для авторезолву flow-стану (необов'язково);
139
- - `log` — функція логування (за замовчуванням `console.error`);
140
- - `run` — git-раннер (за замовчуванням `realRun` з `./commands.mjs`);
141
- - `runner` — готовий runner субагента; якщо не передано — створюється через `createRunner(deps)`;
142
- - `now` — джерело часу (за замовчуванням `Date.now`).
143
-
144
- **Повертає:** `Promise<number>` — exit code:
145
-
146
- - `0` — нормальне завершення (включно з випадком "нема змін" та з будь-якою кількістю findings);
147
- - `1` — технічна неможливість виконати рецензію (нема активного flow-стану, нема `.flow.json`, не вдалось створити runner).
148
-
149
- **Side effects:**
150
-
151
- - Викликає `resolveActiveFlowState` для пошуку активного flow.
152
- - Читає файл стану через `readState(statePath)`.
153
- - Виконує git через `run` (всередині `diffFromBase`).
154
- - Створює runner субагента (`createRunner`) і запускає `runner.runStep(prompt, { cwd })` стільки разів, скільки повернув `reviewersFor`.
155
- - Записує транзицію в state-store через `recordTransition`, додаючи поле `review: { at, reviewers, findings }`.
156
- - Логує кожен finding з емодзі за severity та підсумок `review: N findings (рецензентів: M)`. Якщо є high-severity — додатковий warning.
157
-
158
- **Покроковий потік:**
159
-
160
- 1. Зчитує `cwd0`, `log`, `run`, `now` з `deps` із дефолтами.
161
- 2. Резолвить активний flow-стан через `resolveActiveFlowState({ cwd: cwd0, branch }, deps)`.
162
- - Якщо `statePath` пустий — лог помилки + `return 1`.
163
- - Якщо `autoResolved` — інформативний лог.
164
- 3. Робочий `cwd` = `resolved.worktreeDir ?? cwd0`.
165
- 4. Читає стан `state = readState(statePath)`. Якщо немає — лог `review: стану нема — спершу 'flow init'` + `return 1`.
166
- 5. Бере `base = state.metadata?.base_commit ?? 'HEAD~1'`.
167
- 6. Будує `diff = diffFromBase(base, run, cwd)`. Якщо пустий — лог `review: нема змін від base — нічого ревʼювити` + `return 0`.
168
- 7. Готує `runner`: бере з `deps.runner`, інакше пробує `await createRunner(deps)`. На exception — лог `review: ${error.message}` + `return 1`.
169
- 8. Обчислює `reviewers = reviewersFor(state.level ?? 1, state.risk)` — скільки паралельних рецензентів запустити.
170
- 9. Будує промпт `reviewerPrompt(diff, state.risk)`.
171
- 10. Запускає `reviewers` копій рецензента паралельно через `Promise.all` + `runner.runStep(prompt, { cwd })`.
172
- 11. Збирає findings: для кожного результату з `r.ok === true` парсить через `parseFindings(r.output)`, потім `flatMap` + `dedupeFindings`.
173
- 12. Викликає `recordTransition` з `type: 'review'`, кількістю findings, reducer-функцією, що додає поле `review`, і `now`.
174
- 13. Логує кожен finding: `${іконка} ${file ?? '?'}: ${issue ?? ''}`.
175
- 14. Якщо є high-severity findings — додатковий warning рядок.
176
- 15. Підсумковий лог `review: N findings (рецензентів: M)` і `return 0`.
177
-
178
- ## Залежності
179
-
180
- ### Зовнішні (Node.js standard)
181
-
182
- - `cwd as processCwd` з `node:process` — дефолтний CWD для команди.
183
-
184
- ### Внутрішні модулі
185
-
186
- - `./commands.mjs` — `realRun`: дефолтний раннер shell-команд (`git`).
187
- - `./events.mjs` — `flowEventsPath`: шлях до файла подій flow для `recordTransition`.
188
- - `./level.mjs` — `reviewersFor(level, risk)`: скільки adversarial-рецензентів запускати на даному рівні строгості та ризику.
189
- - `./state-store.mjs`:
190
- - `readState(statePath)` — читає `.flow.json`;
191
- - `recordTransition(paths, event, reducer, now)` — атомарно оновлює стан і дописує подію в events-лог.
192
- - `./flow-resolve.mjs` — `resolveActiveFlowState({ cwd, branch }, deps)`: знаходить активний flow за CWD або з авторезолвом по гілці; повертає `{ statePath, worktreeDir, label, autoResolved, error }`.
193
- - `./subagent-runner.mjs` — `createRunner(deps)`: фабрика об'єкта з методом `runStep(prompt, opts) → Promise<{ ok, output }>` для запуску LLM-субагента.
194
-
195
- ### Зовнішні fail-points
196
-
197
- - `git` (через `run`).
198
- - Запуск LLM-субагента (через `runner.runStep`), що, ймовірно, використовує Claude CLI або аналог.
199
-
200
- ## Потік виконання / Використання
201
-
202
- ### Типове використання як CLI-команди
203
-
204
- Команда `review` зареєстрована у dispatcher і викликається так:
205
-
206
- ```bash
207
- flow review
208
- ```
209
-
210
- Алгоритм:
211
-
212
- 1. Користувач має активний flow (створений через `flow init`) з `.flow.json` у поточному worktree або резолвиться авто.
213
- 2. У стані лежить `metadata.base_commit` (комміт-стартер flow).
214
- 3. Команда збирає diff від `base_commit` до HEAD + working tree, формує промпт і запускає рецензентів.
215
- 4. Findings виводяться в stderr і зберігаються в `.flow.json` під ключем `review`.
216
-
217
- ### Програмне використання (тести)
218
-
219
- ```javascript
220
- import { review, diffFromBase, reviewerPrompt, parseFindings, dedupeFindings } from './review.mjs'
221
-
222
- // Тест без git та LLM
223
- const fakeRun = (cmd, args) => ({ stdout: 'diff text' })
224
- const fakeRunner = {
225
- async runStep(prompt, { cwd }) {
226
- return { ok: true, output: '[{"severity":"high","file":"a.js","issue":"npe","suggestion":"check null"}]' }
227
- }
228
- }
229
- const exit = await review([], {
230
- cwd: '/tmp/repo',
231
- run: fakeRun,
232
- runner: fakeRunner,
233
- now: () => 0,
234
- log: () => {}
235
- })
236
- // exit === 0
237
- ```
238
-
239
- ### Інваріанти та контракти
240
-
241
- 1. **Exit-code контракт:** `0` за успішного запуску рецензії (включно з нульовою кількістю findings); `1` лише за відсутності стану або runner-а.
242
- 2. **Ідемпотентність:** повторний запуск `review` перезаписує поле `review` у стані поточним підсумком; події накопичуються в events-лозі.
243
- 3. **Fail-soft парсинг:** будь-яке "сміття" від рецензента → пустий список findings, а не exception.
244
- 4. **Дедуплікація** — по `(file, issue)`, **не** по `suggestion`/`severity`. Різні рецензенти, що знайшли одну проблему по-різному, схлопуються в один finding.
245
- 5. **Контекст-ліміт:** diff обрізається до 12 000 символів — рецензент бачить лише початок великих diff'ів.
246
-
247
- ### Rebuild Test
248
-
249
- Якщо файл видалити й переписати на основі цієї документації, відновлення має містити:
250
-
251
- 1. Імпорти: `cwd as processCwd` з `node:process`; `realRun` з `./commands.mjs`; `flowEventsPath` з `./events.mjs`; `reviewersFor` з `./level.mjs`; `readState`, `recordTransition` з `./state-store.mjs`; `resolveActiveFlowState` з `./flow-resolve.mjs`; `createRunner` з `./subagent-runner.mjs`.
252
- 2. Константу `DIFF_LIMIT = 12_000`.
253
- 3. Експорти `diffFromBase`, `reviewerPrompt`, `parseFindings`, `dedupeFindings`, `review` із сигнатурами та поведінкою, описаними вище.
254
- 4. Внутрішню `severityIcon` із трьома кейсами (`high` → червоний, `med` → жовтий, інше → білий).
255
- 5. У `review`: всі гілки `return 1` (нема `statePath`, нема стану, помилка `createRunner`); `return 0` за пустого diff; запуск `reviewers` копій рецензента через `Promise.all`; фільтрацію `r.ok` перед `parseFindings`; `dedupeFindings` після `flatMap`; виклик `recordTransition` з полем `review: { at, reviewers, findings }`; логування з іконками + warning для high-severity + підсумок.