@nitra/cursor 1.19.2 → 1.21.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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PostToolUse hook для Claude Code: точкова маршрутизація `npx @nitra/cursor fix`
3
+ * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
4
+ * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
5
+ * turn-і.
6
+ *
7
+ * Контракт:
8
+ * - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
9
+ * - exit 0, якщо файл не маршрутизується (PostToolUse не блокує turn у будь-якому випадку,
10
+ * але ми лишаємо exit-код прозорим — для діагностики);
11
+ * - інакше spawn `npx --no @nitra/cursor fix <rules…>` із пробрасуванням exit-коду.
12
+ *
13
+ * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
14
+ * `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
15
+ * Stop-hook'ом `normalize-decisions.sh` — повторний `fix adr` тут лише сповільнював би turn.
16
+ */
17
+ import { spawn } from 'node:child_process'
18
+ import { once } from 'node:events'
19
+
20
+ import picomatch from 'picomatch'
21
+
22
+ /**
23
+ * @typedef {object} Route
24
+ * @property {string} pattern picomatch glob (з підтримкою `**` і `{a,b}`)
25
+ * @property {string[]} rules ID правил `npm/rules/<id>` (бо `fix.mjs` обов'язковий)
26
+ */
27
+
28
+ /** Порядок важливий: специфічні маршрути (`.github/workflows/*`, `**\/k8s/**`) — перед загальними. */
29
+ /** @type {readonly Route[]} */
30
+ const ROUTES = Object.freeze([
31
+ { pattern: 'docs/adr/**/*.md', rules: [] },
32
+ { pattern: '.github/workflows/*.{yml,yaml}', rules: ['ga'] },
33
+ { pattern: '**/k8s/**/*.{yaml,yml}', rules: ['k8s'] },
34
+ { pattern: '**/*.vue', rules: ['js-lint', 'style-lint', 'vue'] },
35
+ { pattern: '**/*.{mjs,js,cjs,ts,tsx,jsx}', rules: ['js-lint'] },
36
+ { pattern: '**/*.{css,scss,sass}', rules: ['style-lint'] },
37
+ { pattern: '**/*.rego', rules: ['rego'] },
38
+ { pattern: '{**/,}Dockerfile', rules: ['docker'] },
39
+ { pattern: '**/*.Dockerfile', rules: ['docker'] },
40
+ { pattern: '**/*.sh', rules: ['security'] },
41
+ { pattern: '{**/,}package.json', rules: ['npm-module', 'bun'] },
42
+ { pattern: '**/*.md', rules: ['text'] }
43
+ ])
44
+
45
+ /**
46
+ * Повертає список правил, які слід прогнати для зміненого `filePath`.
47
+ * Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
48
+ * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
49
+ * @returns {string[]} ID правил для `npx @nitra/cursor fix`
50
+ */
51
+ export function routeFilePathToRules(filePath) {
52
+ if (typeof filePath !== 'string' || filePath === '') {
53
+ return []
54
+ }
55
+ for (const { pattern, rules } of ROUTES) {
56
+ if (picomatch.isMatch(filePath, pattern, { dot: true })) {
57
+ return [...rules]
58
+ }
59
+ }
60
+ return []
61
+ }
62
+
63
+ /**
64
+ * Зчитує stdin до EOF як utf8 рядок. На TTY — повертає `''` одразу.
65
+ * @returns {Promise<string>} вміст stdin
66
+ */
67
+ async function readStdin() {
68
+ if (process.stdin.isTTY) {
69
+ return ''
70
+ }
71
+ process.stdin.setEncoding('utf8')
72
+ const chunks = []
73
+ process.stdin.on('data', chunk => {
74
+ chunks.push(chunk)
75
+ })
76
+ try {
77
+ await once(process.stdin, 'end')
78
+ } catch {
79
+ // 'error' на stdin — повертаємо те, що встигли зібрати
80
+ }
81
+ return chunks.join('')
82
+ }
83
+
84
+ /**
85
+ * Дістає `tool_input.file_path` зі stdin JSON Claude Code. Невалідний JSON
86
+ * або відсутнє поле → `null` (не помилка: дехто з тулів — напр. Bash — не пише `file_path`).
87
+ * @param {string} stdinJson сирий вміст stdin
88
+ * @returns {string | null} відносний шлях або `null`
89
+ */
90
+ function extractFilePath(stdinJson) {
91
+ if (!stdinJson) {
92
+ return null
93
+ }
94
+ try {
95
+ const obj = JSON.parse(stdinJson)
96
+ const fp = obj?.tool_input?.file_path
97
+ return typeof fp === 'string' && fp !== '' ? fp : null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
105
+ * Параметри ін'єктовні для тестів: `stdinJson` обходить read від `process.stdin`,
106
+ * `spawnFn` — заміна `node:child_process.spawn` (повертає EventEmitter-сумісний об'єкт).
107
+ * @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options]
108
+ * @returns {Promise<number>} exit code (0 — пропущено / fix ОК; інше — exit-код `fix`)
109
+ */
110
+ export async function runPostToolUseFixCli(options = {}) {
111
+ const stdinJson = options.stdinJson ?? (await readStdin())
112
+ const filePath = extractFilePath(stdinJson)
113
+ if (filePath === null) {
114
+ return 0
115
+ }
116
+ const rules = routeFilePathToRules(filePath)
117
+ if (rules.length === 0) {
118
+ return 0
119
+ }
120
+ const spawnFn = options.spawnFn ?? spawn
121
+ const child = spawnFn('npx', ['--no', '@nitra/cursor', 'fix', ...rules], { stdio: 'inherit' })
122
+ try {
123
+ const [code] = await once(child, 'exit')
124
+ return code ?? 1
125
+ } catch (error) {
126
+ process.stderr.write(`post-tool-use-fix: не вдалося запустити npx @nitra/cursor fix — ${error.message}\n`)
127
+ return 1
128
+ }
129
+ }
@@ -30,8 +30,10 @@ import { existsSync } from 'node:fs'
30
30
  import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
31
31
  import { join } from 'node:path'
32
32
 
33
- /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
34
- export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
33
+ /** Маркер PostToolUse fix-hook'а (`npx --no \@nitra/cursor post-tool-use-fix`). */
34
+ export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor post-tool-use-fix'
35
+ /** Legacy-маркер старого Stop-hook'а — лишаємо для cleanup-у при оновленні існуючих інсталяцій. */
36
+ export const LEGACY_STOP_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
35
37
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта capture-decisions. */
36
38
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
37
39
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта normalize-decisions. */
@@ -40,9 +42,13 @@ export const ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisi
40
42
  export const CURSOR_ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
41
43
  /** Маркер Cursor ADR Normalize Stop-hook'а — той самий script path, але в `.cursor/hooks.json`. */
42
44
  export const CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisions.sh'
43
- /** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
45
+ /**
46
+ * Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких.
47
+ * Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у.
48
+ */
44
49
  export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([
45
50
  MANAGED_HOOK_COMMAND_MARKER,
51
+ LEGACY_STOP_HOOK_COMMAND_MARKER,
46
52
  ADR_HOOK_COMMAND_MARKER,
47
53
  ADR_NORMALIZE_HOOK_COMMAND_MARKER
48
54
  ])
@@ -189,9 +195,13 @@ export function mergeAllowList(existing, fromTemplate) {
189
195
  }
190
196
 
191
197
  /**
192
- * Зливає hooks-секцію: для кожної події в темплейті видаляємо managed-групи
193
- * з існуючої конфігурації і додаємо актуальні з темплейту. Немені події в
194
- * темплейті не чіпаються.
198
+ * Зливає hooks-секцію. Для **кожної події** з обох сторін:
199
+ * 1) видаляємо managed-групи з існуючої конфігурації (їх ідентифікують маркери з
200
+ * `MANAGED_HOOK_COMMAND_MARKERS`, включно з legacy-маркерами — це автоматично
201
+ * прибирає застарілі hook'и при переході на нову версію темплейту);
202
+ * 2) дописуємо managed-групи з темплейту.
203
+ * Перебір union-у подій важливий: коли пакет переносить hook між подіями (напр. `Stop`
204
+ * → `PostToolUse`), старі managed entries у вже-непотрібній події теж мають піти.
195
205
  * @param {Record<string, HookGroup[]> | undefined} existing поточна `hooks`-секція з .claude/settings.json
196
206
  * @param {Record<string, HookGroup[]> | undefined} fromTemplate цільова `hooks`-секція з темплейту
197
207
  * @returns {Record<string, HookGroup[]>} результат злиття (порожні події видаляються)
@@ -199,14 +209,13 @@ export function mergeAllowList(existing, fromTemplate) {
199
209
  export function mergeHooks(existing, fromTemplate) {
200
210
  /** @type {Record<string, HookGroup[]>} */
201
211
  const out = {}
202
- for (const [event, groups] of Object.entries(existing ?? {})) {
203
- out[event] = Array.isArray(groups) ? [...groups] : []
204
- }
205
- for (const [event, templateGroups] of Object.entries(fromTemplate ?? {})) {
206
- const existingGroups = (out[event] ?? []).filter(g => !isManagedHookGroup(g))
207
- out[event] = [...existingGroups, ...(templateGroups ?? [])]
208
- if (out[event].length === 0) {
209
- delete out[event]
212
+ const allEvents = new Set([...Object.keys(existing ?? {}), ...Object.keys(fromTemplate ?? {})])
213
+ for (const event of allEvents) {
214
+ const existingClean = (existing?.[event] ?? []).filter(g => !isManagedHookGroup(g))
215
+ const templateGroups = fromTemplate?.[event] ?? []
216
+ const combined = [...existingClean, ...templateGroups]
217
+ if (combined.length > 0) {
218
+ out[event] = combined
210
219
  }
211
220
  }
212
221
  return out
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: n-fix-tests
3
+ description: >-
4
+ Ітеративно дописати тести щоб підвищити mutation score — читає вижилі мутанти з COVERAGE.md і запускає агент до конвергенції
5
+ ---
6
+
7
+ # n-fix-tests — підвищення mutation score
8
+
9
+ ## Мета
10
+
11
+ Читає структурований JSON-блок вижилих мутантів з `COVERAGE.md` і ітеративно дописує тести що їх вловлюють. Зупиняється коли score перестає покращуватись (конвергенція).
12
+
13
+ ## Передумови
14
+
15
+ - У `COVERAGE.md` є секція `## Вижилі мутанти` з JSON-блоком
16
+ - Залежності встановлені (`bun i`)
17
+ - `bun run coverage` (або `n-cursor coverage`) доступний
18
+
19
+ ## Workflow
20
+
21
+ ### Крок 1: Зчитай вижилих мутантів
22
+
23
+ Прочитай `COVERAGE.md`. Знайди секцію `## Вижилі мутанти`. Знайди фенсований блок ` ```json ` у цій секції і розпарси JSON-масив.
24
+
25
+ Якщо секція відсутня або масив порожній — зупинись з повідомленням:
26
+ `✓ Жодних вижилих мутантів — mutation score повний`
27
+
28
+ Запамʼятай поточну кількість вижилих: `prevCount = масив.length`
29
+
30
+ ### Крок 2: Знайди test-команду і coverage-команду
31
+
32
+ Прочитай `package.json` у кореневій директорії.
33
+
34
+ **test-команда** (перша що існує):
35
+ 1. `scripts["test"]` з `package.json`
36
+ 2. fallback: `bun test`
37
+
38
+ **coverage-команда** (перша що існує):
39
+ 1. `scripts["coverage"]` з `package.json` → виклик: `bun run coverage`
40
+ 2. fallback: `n-cursor coverage`
41
+
42
+ ### Крок 3: Для кожного файлу — спауни Agent
43
+
44
+ Згрупуй мутанти по полю `file`. Для кожної групи виконай:
45
+
46
+ **3a. Знайди файли:**
47
+ - Source: `<cwd>/<file>` (прочитай вміст)
48
+ - Test файл (перший що існує):
49
+ - `<dir>/<basename>.test.<ext>` — поруч із source
50
+ - `<dir>/tests/<basename>.test.<ext>`
51
+ - `tests/<basename>.test.<ext>` від кореня
52
+ - Якщо жоден не знайдено — буде створено поруч із source
53
+
54
+ **3b. Сформуй промпт для Agent:**
55
+
56
+ ```
57
+ Тобі дані вижилі мутанти зі Stryker для файлу `<file>`.
58
+ Ці мутанти вижили тому що наявні тести НЕ вловили конкретні зміни коду.
59
+
60
+ **Вихідний код** (`<file>`):
61
+ \`\`\`
62
+ <зміст source-файлу>
63
+ \`\`\`
64
+
65
+ **Наявні тести** (`<test-file>`):
66
+ \`\`\`
67
+ <зміст test-файлу або "файл ще не існує">
68
+ \`\`\`
69
+
70
+ **Вижилі мутанти** (кожен — зміна коду що НЕ вловлена):
71
+ <для кожного мутанта:>
72
+ - Рядок <line>, колонка <col>: `<original>` → `<replacement>` (тип мутації: <mutantType>)
73
+
74
+ **Завдання:**
75
+ Допиши мінімальні test-cases у файл `<test-file>` які б вловили кожен із перелічених мутантів.
76
+ Правила:
77
+ - НЕ видаляй і НЕ змінюй наявні тести
78
+ - Стиль тестів — відповідно до наявного файлу (той самий фреймворк, той самий стиль describe/test)
79
+ - Якщо файл ще не існує — створи його з правильними імпортами відповідно до файлів у тому самому каталозі
80
+ - Після написання запусти: `bun test <test-file>` і переконайся що всі тести проходять (виправ якщо падають)
81
+ ```
82
+
83
+ **3c. Запусти Agent** з цим промптом і дочекайся завершення.
84
+
85
+ ### Крок 4: Перевір що всі тести проходять
86
+
87
+ ```bash
88
+ bun test # або test-команда з кроку 2
89
+ ```
90
+
91
+ Якщо тести падають — поверни конкретний Agent (для того файлу) з помилкою і попроси виправити.
92
+
93
+ ### Крок 5: Запусти coverage і порівняй
94
+
95
+ ```bash
96
+ bun run coverage # або coverage-команда з кроку 2
97
+ ```
98
+
99
+ Прочитай новий `COVERAGE.md`, знайди і розпарси JSON-масив вижилих.
100
+ `newCount = новий масив.length`
101
+
102
+ **Рішення:**
103
+ - Якщо `newCount < prevCount` → повтор з Кроку 1 з оновленим масивом
104
+ - Якщо `newCount >= prevCount` → зупинись:
105
+ `✓ Конвергенція: mutation score більше не покращується. Вижило: <newCount> мутантів.`
106
+
107
+ ## Зупинка після конвергенції
108
+
109
+ Конвергенція — нормальний результат. Деякі мутанти не можна вбити (захищений зовнішнім станом, недетермінована логіка тощо). Не намагайся виправити те що не змінилось після ітерації.
@@ -0,0 +1 @@
1
+ [js-lint]
@@ -1,74 +0,0 @@
1
- /**
2
- * Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
3
- * як агент сигналізує завершення ходу. Прозоро прокидає `npx \@nitra/cursor fix`
4
- * і повертає його exit code, щоб помилки правил блокували завершення.
5
- *
6
- * Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
7
- * (Claude Code позначає цей прапорець, коли hook сам спричинив повторний Stop),
8
- * виходимо з кодом 0 без повторного запуску перевірок.
9
- *
10
- * Виклик з `bin/n-cursor.js`:
11
- * `npx --no \@nitra/cursor stop-hook`
12
- */
13
- import { spawn } from 'node:child_process'
14
- import { once } from 'node:events'
15
-
16
- /**
17
- * Зчитує stdin до EOF як utf8 рядок. Якщо stdin порожній (TTY) — повертає '' одразу.
18
- * @returns {Promise<string>} вміст stdin
19
- */
20
- async function readStdin() {
21
- if (process.stdin.isTTY) {
22
- return ''
23
- }
24
- process.stdin.setEncoding('utf8')
25
- const chunks = []
26
- process.stdin.on('data', chunk => {
27
- chunks.push(chunk)
28
- })
29
- try {
30
- await once(process.stdin, 'end')
31
- } catch {
32
- // 'error' на stdin — повертаємо те, що встигли зібрати
33
- }
34
- return chunks.join('')
35
- }
36
-
37
- /**
38
- * Чи stdin вказує, що поточний Stop вже виник через попередній Stop hook
39
- * (Claude Code передає `stop_hook_active: true`). У такому випадку повторний
40
- * запуск перевірок створив би нескінченний цикл — пропускаємо.
41
- * @param {string} stdin сирий вміст stdin
42
- * @returns {boolean} true, якщо рекурсивний виклик
43
- */
44
- export function isRecursiveStopHookCall(stdin) {
45
- if (!stdin) {
46
- return false
47
- }
48
- try {
49
- const obj = JSON.parse(stdin)
50
- return obj?.stop_hook_active === true
51
- } catch {
52
- return false
53
- }
54
- }
55
-
56
- /**
57
- * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === 'stop-hook'.
58
- * @returns {Promise<number>} exit code (0 — OK / пропуск, 1 — помилки правил)
59
- */
60
- export async function runStopHookCli() {
61
- const stdin = await readStdin()
62
- if (isRecursiveStopHookCall(stdin)) {
63
- return 0
64
- }
65
-
66
- const child = spawn('npx', ['--no', '@nitra/cursor', 'fix'], { stdio: 'inherit' })
67
- try {
68
- const [code] = await once(child, 'exit')
69
- return code ?? 1
70
- } catch (error) {
71
- process.stderr.write(`stop-hook: не вдалося запустити npx @nitra/cursor fix — ${error.message}\n`)
72
- return 1
73
- }
74
- }